타겟을 진짜 “MAS용"으로 만들기

1편에서는 MAS 전용 Bundle ID를 등록하고 FocusTimer MAS 빌드 타겟을 복제했습니다. 하지만 그 타겟은 아직 직접 배포 타겟의 복사본일 뿐입니다.

MAS 빌드는 직접 배포 빌드와 세 가지가 달라야 합니다.

  1. 권한(entitlements) — App Store에 맞는 최소 권한만
  2. Info.plist — Sparkle 키는 빼고, App Store 메타데이터는 추가
  3. 코드 — Sparkle이 없어도 컴파일되도록 분기

이 글에서 이 세 가지를 모두 갈라 둡니다.

1편과 마찬가지로 FocusTimer, com.example.FocusTimer.mas 등은 모두 예시 값입니다.

1단계 — MAS 전용 entitlements 파일

entitlements는 앱이 어떤 시스템 권한을 요청하는지 적은 파일입니다. 직접 배포 빌드에서는 Sparkle이 업데이트를 설치하기 위해 추가 권한을 요구했지만, MAS 빌드에는 Sparkle이 없으므로 그 권한들이 불필요합니다.

프로젝트 루트에 FocusTimer-MAS.entitlements 파일을 새로 만듭니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
</dict>
</plist>

직접 배포용 FocusTimer.entitlements와 비교하면 이렇게 달라집니다.

  • Sparkle 관련 권한 없음 — 직접 배포 빌드에 있던 temporary-exception.mach-lookup.*(Sparkle이 설치 헬퍼와 통신하기 위한 예외)은 MAS 빌드에 둘 이유가 없습니다.
  • network.client 없음 — Sparkle이 빠지면 FocusTimer 본체는 어떤 네트워크 호출도 하지 않습니다. 쓰지 않는 권한은 빼는 것이 정직하고, 심사에서도 불필요한 권한은 지적 대상이 됩니다. 앱이 요청하는 권한 표면은 작을수록 좋습니다.

위 예시는 “사용자가 직접 고른 파일을 읽고 쓰는” 정도의 권한만 가진 가장 단순한 형태입니다. 본인 앱이 실제로 네트워크나 다른 자원을 쓴다면 그에 맞는 권한을 더하되, 쓰지 않는 권한은 절대 넣지 마세요.

2단계 — MAS 전용 Info.plist 파일

마찬가지로 프로젝트 루트에 FocusTimer-MAS-Info.plist를 새로 만듭니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>LSApplicationCategoryType</key>
    <string>public.app-category.productivity</string>
    <key>ITSAppUsesNonExemptEncryption</key>
    <false/>
</dict>
</plist>

직접 배포용 FocusTimer-Info.plist와의 차이는 두 가지입니다.

① Sparkle SU* 키가 전부 빠진다

직접 배포용 Info.plist에는 SUFeedURL, SUPublicEDKey 같은 Sparkle 설정 키가 들어 있었습니다. MAS용 Info.plist에는 이 키들이 하나도 없어야 합니다. App Store 심사는 자체 자동 업데이트를 금지하므로, 그런 기능을 암시하는 키가 plist에 남아 있는 것만으로도 문제가 될 수 있습니다. 키 자체를 두지 않는 것이 안전합니다.

② App Store 메타데이터 키가 추가된다

  • LSApplicationCategoryType — App Store에서 앱이 속할 카테고리입니다. 위 예시의 public.app-category.productivity는 “생산성” 카테고리를 뜻합니다. 이 값은 3편에서 App Store Connect에 설정할 카테고리와 일치해야 심사 단계에서 불일치 경고가 뜨지 않습니다.
  • ITSAppUsesNonExemptEncryption — 앱이 수출 규제 대상 암호화 기술을 쓰는지 여부입니다. 일반적인 암호화(예: HTTPS, 표준 시스템 API)만 쓰거나 암호화를 쓰지 않는다면 false로 둘 수 있습니다. 이 키를 미리 박아 두면 빌드를 업로드할 때마다 나오는 암호화 설문을 자동으로 통과합니다.

ITSAppUsesNonExemptEncryptionfalse로 두기 전에, 본인 앱이 정말 표준 외 암호화를 쓰지 않는지 확인하세요. 판단이 서지 않으면 Apple의 암호화 관련 문서를 참고하는 편이 안전합니다.

3단계 — MAS 타겟 빌드 설정

이제 1편 2-3절에서 미뤄 둔 빌드 설정 갱신입니다. TARGETS → FocusTimer MAS → Build Settings에서 다음을 맞춥니다.

빌드 설정 키
PRODUCT_BUNDLE_IDENTIFIERcom.example.FocusTimer.mas
INFOPLIST_FILEFocusTimer-MAS-Info.plist
CODE_SIGN_ENTITLEMENTSFocusTimer-MAS.entitlements
ENABLE_APP_SANDBOXYES
ENABLE_HARDENED_RUNTIMEYES
ENABLE_USER_SELECTED_FILESreadwrite
CODE_SIGN_STYLEAutomatic
MARKETING_VERSION, CURRENT_PROJECT_VERSION직접 배포 타겟과 동일하게

핵심만 짚으면 이렇습니다.

  • INFOPLIST_FILECODE_SIGN_ENTITLEMENTS가 방금 만든 MAS 전용 파일을 가리켜야 합니다. 이 두 줄을 직접 배포용 파일 그대로 두는 실수가 잦습니다.
  • CODE_SIGN_STYLEAutomatic으로 두면, Xcode가 Mac App Store용 프로비저닝 프로파일과 Apple Distribution 인증서를 자동으로 발급·매칭해 줍니다. 직접 서명을 관리할 필요가 없습니다.
  • 네트워크를 쓰지 않는 앱이라면 ENABLE_OUTGOING_NETWORK_CONNECTIONS는 설정하지 않습니다. entitlements에서 network.client를 뺀 것과 같은 맥락입니다.
  • 버전 번호(MARKETING_VERSION 등)는 직접 배포 타겟과 같은 값으로 유지해, 두 채널의 버전이 어긋나지 않게 합니다.

4단계 — Sparkle 코드 분기 (#if canImport(Sparkle))

설정 파일을 갈랐어도 문제가 하나 남습니다. 소스 코드 어딘가에 import Sparkle과 Sparkle을 쓰는 코드가 있을 텐데, MAS 타겟에서는 Sparkle이 링크되지 않으므로 그 import 문 자체가 컴파일 오류를 냅니다.

해법은 Swift의 조건부 컴파일 지시문 **#if canImport(Sparkle)**로 Sparkle 관련 코드를 감싸는 것입니다.

canImport인가 — 별도 플래그를 안 쓰는 이유

#if MAS_BUILD 같은 직접 만든 컴파일 플래그로 분기할 수도 있습니다. 하지만 그러면 “MAS 타겟에는 그 플래그를 켜고, 직접 배포 타겟에는 끈다"는 설정을 사람이 직접 동기화해야 합니다. 타겟이 늘거나 설정이 바뀔 때 어긋나기 쉽습니다.

canImport(Sparkle)은 다릅니다. 이것은 “Sparkle.framework가 이 타겟에 링크되는가"를 컴파일러가 직접 확인합니다. 1편에서 MAS 타겟의 패키지 의존성에서 Sparkle을 제거했으므로, MAS 타겟에서는 canImport(Sparkle)이 **자동으로 거짓(false)**이 됩니다. 별도로 동기화할 플래그가 없습니다. 그래서 macOS 앱들이 MAS 채널을 분리할 때 이 패턴을 표준처럼 씁니다.

분기 ① — Sparkle 전용 파일은 통째로 감싼다

자동 업데이트 로직만 담은 파일(예: UpdaterCoordinator.swift)은 파일 전체를 #if로 감쌉니다.

#if canImport(Sparkle)
import Foundation
import Sparkle
import Combine

@Observable
@MainActor
final class UpdaterCoordinator {
    // Sparkle 업데이터를 감싸는 코드 …
}
#endif

MAS 빌드에서는 이 파일이 빈 파일처럼 컴파일되어 아무 영향도 주지 않습니다.

분기 ② — Sparkle을 쓰는 쪽도 함께 감싼다

UpdaterCoordinator를 만들고 화면에 전달하는 코드도 함께 분기해야 합니다. 그러지 않으면 MAS 빌드에서 “존재하지 않는 타입"을 참조하게 됩니다.

@main
struct FocusTimerApp: App {
    @State private var viewModel = CompositionRoot.makeContentViewModel()

    #if canImport(Sparkle)
    @State private var updater = UpdaterCoordinator()
    #endif

    var body: some Scene {
        Settings {
            #if canImport(Sparkle)
            PreferenceView(viewModel: viewModel, updater: updater)
            #else
            PreferenceView(viewModel: viewModel)
            #endif
        }

        MenuBarExtra {
            #if canImport(Sparkle)
            MenuBarContent(updater: updater)
            #else
            MenuBarContent()
            #endif
        } label: {
            // 메뉴 막대 아이콘 …
        }
    }
}

private struct MenuBarContent: View {
    #if canImport(Sparkle)
    @Bindable var updater: UpdaterCoordinator
    #endif

    var body: some View {
        Button("FocusTimer 표시") { /* … */ }

        #if canImport(Sparkle)
        Button("업데이트 확인…") { updater.checkForUpdates() }
            .disabled(!updater.canCheckForUpdates)
        #endif

        // 그 외 공통 메뉴 …
    }
}

요점은 세 가지입니다.

  • updater 필드 선언 자체를 #if로 감쌉니다.
  • updater넘겨받는 화면(PreferenceView, MenuBarContent)은 Sparkle이 있을 때와 없을 때 두 가지 형태로 분기합니다.
  • “업데이트 확인…” 같은 Sparkle 전용 UI 요소#if로 감쌉니다. MAS 빌드에서는 이 메뉴가 아예 나타나지 않습니다 — App Store판에는 자체 업데이트 메뉴가 없는 것이 맞습니다.

설정 화면(PreferenceView)에 있는 “자동 업데이트 켜기” 토글 같은 요소들도 모두 같은 패턴으로 감싸면 됩니다.

2편 정리

여기까지 따라왔다면 이제 다음을 갖췄습니다.

  • ✅ MAS 전용 권한 파일 FocusTimer-MAS.entitlements (최소 권한)
  • ✅ MAS 전용 FocusTimer-MAS-Info.plist (Sparkle 키 제거, App Store 메타데이터 추가)
  • ✅ MAS 타겟 빌드 설정 정리
  • #if canImport(Sparkle)로 분기된 자동 업데이트 코드

이제 FocusTimer MAS 타겟은 직접 배포 타겟과 진짜로 다른, App Store에 올릴 수 있는 형태가 되었습니다. 남은 일은 이 빌드를 실제로 App Store Connect에 올리는 길을 마련하는 것입니다.

다음 편에서는 업로드용 ExportOptions-MAS.plist를 만들고, App Store Connect에 앱 레코드를 등록하며, 두 채널이 앞으로도 깨지지 않도록 검증하는 방법으로 시리즈를 마무리합니다.