← 모든 글

애플 플랫폼 다섯 개, 공유 파일 세 개: Return이 크로스 플랫폼 SwiftUI를 실제로 출시한 방식

제가 만든 명상 타이머 Return은 애플 플랫폼 다섯 곳에서 돌아갑니다. iPhone, iPad, Mac, Apple Watch, Apple TV입니다.1 코드베이스에는 테스트를 뺀 Swift 파일이 40개 있습니다. 그중 다섯 플랫폼 전부가 공유하는 파일은 세 개입니다. 나머지는 별도의 Xcode 타깃으로 갈라져 있고, TimerManager, AudioManager, ContentView 같은 개념을 #if os(...) 조건부 컴파일로 공유하는 대신 그냥 중복해서 갖고 있습니다.

공유 비율은 약 7.5%이고, 이건 의도한 설계입니다.

이 글은 2026년에 크로스 플랫폼 SwiftUI 앱을 출시한다는 게 실제로 어떤 모습인지, 왜 공격적인 코드 공유가 과대평가되어 있는지, 그리고 실제로 공유된 세 파일에는 어떤 공통점이 있는지에 대한 이야기입니다.

Apple Developer의 iOS 26 플랫폼 타일 Apple Developer의 iPadOS 26 플랫폼 타일 Apple Developer의 macOS 26 플랫폼 타일 Apple Developer의 watchOS 26 플랫폼 타일 Apple Developer의 tvOS 26 플랫폼 타일

Return이 대상으로 삼는 다섯 플랫폼을 developer.apple.com이 제시하는 방식 그대로 옮겼습니다. 각각은 Xcode 안에서 별개의 플랫폼 타깃이지, 런타임 분기가 아닙니다.

요약

  • Return 구성: 메인 타깃 Swift 파일 18개(iOS + iPadOS + macOS), tvOS 타깃 파일 10개, watchOS 타깃 파일 7개, 위젯 파일 2개(Live Activities), 그리고 Return/Shared/에 있는 진짜 크로스 플랫폼 파일 3개. 총 40개입니다.
  • 공유되는 세 파일은 모두 저장 계층에 맞닿아 있습니다. MeditationSession, SessionStore, SessionHistoryView입니다. iCloud를 타고 이동하는 상태이지, 플랫폼에 맞춰 변형되는 UI가 아닙니다.
  • tvOS와 watchOS는 메인 타깃 안의 #if os(tvOS) 분기가 아니라 별도의 Xcode 타깃입니다. 조작 모델이 너무 달라서 하나의 ContentView에 들어가지 않습니다.
  • iOS/iPadOS/macOS 메인 타깃 안에서도 #if os 블록은 계속 불어납니다. ContentView.swift에 10개, LiveActivityManager.swift에 8개, VideoBackgroundView.swift에 8개, AudioManager.swift에 6개입니다.
  • 솔직한 결론은 이렇습니다. 애플 플랫폼 다섯 곳에 걸친 공격적인 공유는 유지보수 부채입니다. 작은 공유 코어(저장 계층) 하나에 플랫폼별로 분리된 UI를 붙이는 쪽이, #if로 뒤덮인 거대한 파일 하나보다 더 빨리 출시되고 덜 깨집니다.

플랫폼별 세부 사항은 함께 읽으면 좋은 글이 있습니다. 애플 플랫폼 매트릭스, watchOS 런타임 계약, Liquid Glass SwiftUI 패턴입니다.

숫자

테스트와 UI 테스트를 걷어낸 뒤 Swift 파일 수로 본 코드베이스의 형태입니다.

Return/                            18 files   (iPhone + iPad + Mac, single target)
├── Shared/                         3 files     cross-platform truth   ├── MeditationSession.swift   ├── SessionStore.swift   └── SessionHistoryView.swift
├── ContentView.swift              (10 #if os branches)
├── TimerManager.swift             (2 #if os branches)
├── AudioManager.swift             (6 #if os branches)
├── HealthKitManager.swift
├── LiveActivityManager.swift      (8 #if os branches, iOS-only)
├── ThemeManager.swift
├── VideoBackgroundView.swift      (8 #if os branches)
├── GlassTextShape.swift           (Liquid Glass, see prior post)
├── GlassTimerText.swift
└──  (settings, theme, audio assets, etc.)

ReturnTV/                          10 files   (tvOS, separate target)
├── TVContentView.swift
├── TVTimerManager.swift            duplicates main TimerManager
├── TVAudioManager.swift            duplicates main AudioManager
├── TVDurationPicker.swift
├── TVFocusModifier.swift           tvOS button styles for focus
├── TVSettingsView.swift
└── ReturnWatch Watch App/              7 files   (watchOS, separate target)
├── WatchContentView.swift
├── WatchTimerManager.swift         duplicates main TimerManager
├── WatchAudioManager.swift         duplicates main AudioManager
├── WatchHealthKitManager.swift     duplicates main HealthKitManager (mostly)
├── WatchSettingsView.swift
└── ReturnWidgets/                      2 files   (Live Activity + bundle)
├── ReturnLiveActivity.swift
└── ReturnWidgetsBundle.swift

플랫폼 다섯, 공유 파일 셋, 플랫폼별 별도 타깃 둘에 위젯 타깃 하나, 여기에 메인 타깃 내부의 묵직한 조건부 컴파일까지. 공유 비율은 약 7.5%입니다. “멀티 플랫폼 SwiftUI”를 다루는 대부분의 튜토리얼은 정반대를 권합니다. @Environment(\.horizontalSizeClass)#if os(...)로 모든 플랫폼에 적응하는 ContentView 하나를 쓰라는 겁니다.2 그 방식은 플랫폼이 둘일 때(iPhone + iPad) 통합니다. 다섯이 되면 무너집니다.

공유된 세 파일의 공통점

Return/Shared/MeditationSession.swift는 SwiftData에 인접한 값 타입을 정의합니다.3

struct MeditationSession: Codable, Identifiable, Equatable {
    let id: UUID
    let startDate: Date
    let endDate: Date
    let durationSeconds: Int
    let sourceDevice: DeviceType
    var syncedToHealthKit: Bool

    enum DeviceType: String, Codable, CaseIterable {
        case iPhone, iPad, mac, appleTV, appleWatch
    }
}

이 파일 상단의 주석은 장식이 아니라 실제로 하중을 받습니다. // Add this file to: Return, ReturnTV, ReturnWatch Watch App targets. 동일한 소스 파일을 Xcode 타깃 세 개가 함께 참조합니다. 심볼릭 링크도 아니고, Swift 패키지에 감싸 넣은 것도 아닙니다. 애플의 빌드 시스템은 파일 하나를 바이너리 셋으로 별 문제 없이 컴파일해 줍니다.

SessionStore.swift는 저장 계층입니다. NSUbiquitousKeyValueStore(애플의 iCloud 키-값 저장소)를 얇게 감싸서 MeditationSession 배열을 읽고 씁니다. 이 선택은 중요합니다. 키-값 저장소 동기화 덕분에 Return은 CloudKit 컨테이너를 따로 프로비저닝하지 않고도 기기 간 세션 기록을 얻습니다. 대신 저장소 전체가 총 1MB로 제한된다는 대가를 치릅니다.12 각각 수백 바이트 남짓인 명상 기록 목록이라면 그 한도는 차고 넘칩니다. SessionHistoryView.swift는 그 기록을 그리는 SwiftUI 리스트입니다. 두 파일 모두 iPhone, iPad, Mac, Watch, TV 타깃에서 똑같이 쓰입니다.

이 세 파일의 공통점은 이겁니다. 상호작용이 아니라 상태를 서술한다는 것. MeditationSession은 어느 기기에서든 동일한 개념입니다. 지난 기록 목록은 어느 기기에서든 같은 방식으로 읽힙니다. 어느 쪽도 조작 표면, 윈도 관리자, 오디오 라우팅 판단, 포커스 엔진, 디지털 크라운과 엮이지 않습니다. 파일이 자기가 어느 플랫폼 위에서 도는지 알아야 하는 순간, 그 파일은 공유 가능한 물건이 아니게 됩니다.

나머지는 왜 공유하지 않았나

TimerManager를 봅시다. iOS/iPadOS/macOS 버전은 Timer.publish(every: 1, ...)를 쓰고 알림을 UserNotifications로 흘려보냅니다. tvOS 버전(TVTimerManager)은 사용자가 Siri Remote로 일시정지한 뒤 화면 보호기가 켜지는 상황을 처리합니다. watchOS 버전(WatchTimerManager)은 WKExtendedRuntimeSession에 (WatchSessionManager를 통해) 작업을 위임해서, 화면이 어두워져도 OS가 앱을 계속 살아 있게 만들고, 입력은 터치가 아니라 디지털 크라운으로 받습니다. 플랫폼 셋, 그리고 근본부터 다른 타이머 동작 셋입니다.

물론 class TimerManager { #if os(watchOS) ... #elif os(tvOS) ... } 식으로 하나로 묶을 수는 있습니다. 그 결과물은 모드가 세 개인 클래스, 각 모드가 #if로 가려진 40줄짜리 코드, 그리고 iOS 경로를 건드리면 watchOS 경로가 깨질 위험을 안은 물건입니다. 유지보수 지옥이죠.

파일 이름이 셋인 별개 클래스 셋은 디스크 위의 코드는 늘리지만 머릿속의 코드는 줄입니다. 읽을 수 있는 중복이 읽을 수 없는 추상화를 이깁니다.

같은 논리가 다음에도 그대로 적용됩니다.

  • ContentViewTVContentViewWatchContentView: 내비게이션 모델이 다릅니다. iPhone은 푸시 기반, TV는 포커스 기반, Watch는 리스트 기반입니다.
  • AudioManagerTVAudioManagerWatchAudioManager: 오디오 세션 카테고리가 다르고, watchOS는 백그라운드 오디오 규칙이 더 빡빡하며, tvOS는 AirPlay로 다르게 라우팅합니다.
  • VideoBackgroundView는 메인 타깃에서 #if os(iOS) 분기를 8개 갖고 있고(짝을 이루는 #elseif os(macOS)가 하나 붙습니다), 서로 다른 영상 에셋(fire_phone.mp4fire_mac.mp4), 서로 다른 레이어 타입, 서로 다른 화면비를 각각 다룹니다.4

한 가지 짚어두겠습니다. 메인 Return/ 타깃은 iOS, iPadOS, macOS를 한데 묶습니다. 이 셋은 공유하지 않는 코드보다 공유하는 코드가 더 많습니다. SwiftUI의 NavigationStack은 셋 다에서 동작합니다. .glassEffect()도 셋 다에서 동작합니다. 윈도 관리의 차이는 실재하지만 타깃 하나 안에서 감당할 만합니다. 타깃을 갈라 세운 선은 tvOS와 watchOS 앞에서 그었습니다.

tvOS 사례: 포커스 엔진이 별도 타깃을 강제한 이유

Apple TV의 내비게이션은 포커스 엔진을 중심으로 짜여 있습니다.5 사용자가 조작할 수 있는 UI 요소는 저마다 포커스를 받을 수 있다고 선언하고, Siri Remote의 방향 조작이 요소들 사이로 포커스를 옮기며, 선택 버튼을 누르면 포커스된 요소가 실행됩니다. tvOS의 SwiftUI는 이를 .focusable(), .focusEffect, 그리고 애플 자사 앱이 쓰는 패럴랙스 기울임 효과를 위해 @Environment(\.isFocused)에 반응하는 커스텀 ButtonStyle 타입으로 노출합니다. TVFocusModifier.swift의 실제 프로덕션 코드입니다.6

struct TVCapsuleButtonStyle: ButtonStyle {
    var accentColor: Color = .white
    @Environment(\.isFocused) private var isFocused

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .colorMultiply(isFocused ? focusedTextColor : accentColor)
            .background(
                Capsule().fill(isFocused
                    ? AnyShapeStyle(accentColor)
                    : AnyShapeStyle(.ultraThinMaterial))
            )
            .clipShape(Capsule())
            .scaleEffect(isFocused ? 1.1 : 1.0)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .shadow(color: .black.opacity(isFocused ? 0.3 : 0.1),
                    radius: isFocused ? 20 : 5, y: isFocused ? 10 : 2)
            .animation(.easeInOut(duration: 0.2), value: isFocused)
    }
}

같은 파일은 사각형/원형 컨트롤용 TVCircleButtonStyle도 정의합니다. 두 스타일 모두 포커스를 받으면 색과 반투명도를 뒤집습니다. 포커스가 없는 버튼은 .ultraThinMaterial 위에 앉아 있고, 포커스된 버튼은 강조색으로 채워지면서 크기와 그림자가 올라갑니다. 이 앱에서 이 패턴은 구조적으로 tvOS 전용입니다. @Environment(\.isFocused) 자체는 iOS, iPadOS, macOS, watchOS, tvOS 전부에서 쓸 수 있지만,13 포커스 주도 내비게이션이 주된 상호작용 모델인 곳은 tvOS뿐입니다. Siri Remote는 포인터 이벤트도 터치 이벤트도 만들어내지 않으니까요. iPhone이나 iPad에서 같은 역할을 하는 컨트롤은 탭으로 히트 테스트되고, Mac에서는 호버되거나 클릭됩니다. TVFocusModifier.swift의 버튼 스타일들은 포커스가 사용자의 주된 어포던스라고 전제하고, 시각적 반응 전체를 그 전제 위에 설계합니다. iOS의 터치, Mac의 호버, tvOS의 포커스 주도 내비게이션을 한자리에서 처리하는 ContentView를 쓸 만한 방법은 없습니다. 뷰 구조 자체가 진짜로 다릅니다. tvOS의 ContentView는 포커스 가능한 행들의 그래프이고, iOS의 ContentView는 탭해서 실행하는 스택입니다.

시간 선택기도 마찬가지입니다. iPhone에서는 아래에서 올라와 탭을 받습니다. Apple TV에서는 리모컨으로 옮겨 다니는, 포커스 가능한 셀들의 가로 행입니다. TVDurationPicker.swift가 독립된 파일인 이유는 셀 기반 포커스 설계가 iPhone에는 대응물 자체가 없기 때문입니다. 이 둘을 한 파일에 밀어 넣는다면 서로 무관한 UI 두 개를 #if os(tvOS)로 붙여 놓은 꼴이 됩니다.

watchOS 사례: 확장 런타임 세션, HealthKit, 그리고 더 좁은 표면

watchOS는 다른 플랫폼에는 없는 구조적 제약을 둘 더합니다.

  1. WKExtendedRuntimeSession. 워치 화면이 어두워진 동안에도 앱이 반응하도록 유지합니다.8 이게 없으면 watchOS는 1초 틱 사이사이마다 앱을 공격적으로 정지시키고 타이머가 어긋납니다. Return은 watchOS 타깃의 Info.plistWKBackgroundModes: mindfulness를 선언해서 OS가 이 용도를 인식하고 런타임 예산을 내주도록 합니다. 런타임 세션 자체는 기본 WKExtendedRuntimeSession() 이니셜라이저로 생성합니다.
  2. WatchConnectivity가 아닌 NSUbiquitousKeyValueStore 기반 iCloud 동기화.7 Return의 세션 기록 동기화는 iPhone, iPad, Mac 타깃이 쓰는 것과 동일한 키-값 저장소에 얹혀 갑니다. 그래서 워치에서 기록한 명상이 워치-폰 간 직접 메시징 없이도 iPhone의 기록 화면에 나타납니다. 실시간 상태 동기화가 필요해지면 WatchConnectivity가 훗날의 선택지가 될 수 있지만, Return은 더 단순한 모델을 골랐습니다. 각 기기가 같은 iCloud 키-값 저장소에 쓰고, 어느 기기든 다음 읽기에서 합집합을 보게 되는 방식입니다.

WatchTimerManager.swift는 워치 쪽 타이머이고, 확장 런타임 관련 작업은 ReturnWatchApp.swiftfinal class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate로 정의된 WatchSessionManager에 위임합니다. iOS의 TimerManager에는 대응물이 없습니다. iOS 앱은 별도의 런타임 세션 없이도 포그라운드에서 계속 반응하기 때문입니다. 워치 로직을 #if os(watchOS)로 iOS TimerManager에 밀어 넣는다는 건, iOS 코드 경로가 쓰지도 않는 WatchKit 심볼을 임포트하게 되고, 그 위에 watchOS 코드 경로는 iOS 경로에 없는 초기화 절차까지 필요해진다는 뜻입니다.

WatchHealthKitManager.swift는 메인 HealthKitManager의 축소 변종입니다. 마음챙김 시간을 기록하는 방식은 같지만 권한 요청 UX가 다릅니다(워치는 HealthKitPermissionSheet을 띄울 수 없습니다). 워치 쪽 클래스는 메인 쪽의 대략 절반 크기입니다.

iOS/iPadOS/macOS 메인 타깃 안에서는 무슨 일이 벌어지나

메인 타깃 안에서조차 공유는 자동으로 되지 않습니다. ContentView.swift에는 #if os(macOS) 또는 #if !os(macOS) 블록이 열 개 있고, LiveActivityManager.swift에 여덟 개, VideoBackgroundView.swift에 여덟 개, AudioManager.swift에 여섯 개 있습니다. Live Activities는 iPhone 전용 기능이라 LiveActivityManager 전체가 #if os(iOS)로 감싸여 있습니다. iPhone의 시간 선택기는 iPad와 Mac의 시간 선택기와 레이아웃이 달라서, ContentView는 나란히 놓인 레이아웃 분기를 갖고 있습니다.

지금까지 잘 작동한 원칙은 이렇습니다. 작은 플랫폼 차이(키보드 동작 차이, 패딩 차이, 없는 API)에는 #if os(...)를, 큰 구조적 차이(포커스 대 터치, 운동 세션 대 타이머)에는 별도 타깃을. 제가 결국 쓰게 된 기준선은 “분기 코드가 대략 10줄을 넘느냐”입니다. 그 아래라면 조건부 컴파일로 충분합니다. 그 위라면 그 파일은 두 가지 일을 동시에 하고 있는 것이고, 두 번째 일은 다른 타깃으로 가야 합니다.

다섯 플랫폼 전부에 출시하면 안 되는 경우

솔직한 판단을 적습니다.

앱이 정보 밀도가 높다면 Apple Watch는 건너뛰세요. 46mm 화면에는 30개짜리 목록과 시간 선택기, 설정 페이지가 들어갈 자리가 없습니다. Return이 watchOS에서 살아남는 이유는 핵심 상호작용이 버튼 하나(타이머 시작/정지)이기 때문입니다. 생산성 앱, 금융 앱, 미디어가 풍부한 앱은 그렇지 못합니다.

앱이 상호작용 중심이라면 Apple TV는 건너뛰세요. TV는 앰비언트 경험을 위한 물건입니다(방 건너편 화면에서 도는 타이머, 음악 재생 같은 것). 사용자 입력이 잦아야 하는 것은 무엇이든 플랫폼과 싸우게 됩니다. Return이 tvOS에 올라가 있는 이유는 “20분 타이머를 맞춰 두고 화면 속 불을 바라본다”가 딱 맞는 앰비언트 사례이기 때문입니다. 메모 앱이라면 비참했을 겁니다.

앱이 폰 우선 인터페이스라면 Mac은 건너뛰세요. Mac의 SwiftUI는 동작하지만, NavigationStack의 푸시 모델은 진짜 Mac 사이드바에 견주면 장난감처럼 읽힙니다. Mac에서 덜 만든 티가 날 앱이라면, Catalyst로(iPad 앱을 변환해서) 내보내거나, Mac 네이티브 UI를 제대로 만들 수 있을 때까지 Mac은 아예 건너뛰세요.

사이즈 클래스 대응을 하지 않았다면 iPad는 건너뛰세요. iPhone 앱을 iPad 화면에 늘려 채운 것은 싸구려로 읽힙니다. iPad에는 최소한 사이드바가 있는 NavigationSplitView가 필요하고, 이상적으로는 진짜 2단 레이아웃이 필요합니다. Return은 iPad에서 스플릿 뷰를, iPhone에서 스택을 씁니다. 코드는 같은 타깃 안에 있지만 UI는 진짜로 다릅니다.

제가 그은 규칙은 이겁니다. 앱의 핵심 상호작용이 그 플랫폼의 입력 모델에 대응될 때 그 플랫폼에 출시하라. 명상 타이머는 Apple Watch에 내보내세요(탭 한 번으로 시작). 명상 타이머는 Apple TV에 내보내세요(맞춰 놓고 잊어버리는 물건). 칸반 보드는 둘 중 어디에도 내보내지 마세요.

힘들이지 않고 건너가는 것들

Return에서 다섯 플랫폼 전부에 걸쳐 실제로 공유된 세 가지입니다.

  1. 데이터 모델(MeditationSession). 이 구조체는 모든 플랫폼에서 동일하고, NSUbiquitousKeyValueStore를 통해 동기화되며, 어느 플랫폼이 쓴 것이든 다른 어느 플랫폼에서도 읽을 수 있습니다.
  2. 세션 기록 뷰(SessionHistoryView). 지난 기록의 List는 iPhone, iPad, Mac, Apple Watch, Apple TV에서 똑같이 그려집니다. SwiftUI의 List는 다섯 폼 팩터 전부에 깔끔하게 적응하는 몇 안 되는 기본 요소 중 하나입니다.
  3. 저장 래퍼(SessionStore). 읽기와 쓰기는 플랫폼과 무관하고, 그 아래의 저장소(NSUbiquitousKeyValueStore)는 어디서나 동일한 API입니다.

개념 셋. 상태, 목록 렌더링, 그리고 저장입니다. 하드웨어별 입력 모델과 얽히지 않은, 상태를 담고 화면에 보여주기만 하는 것은 무엇이든 공유할 수 있습니다. 입력, 포커스, 오디오 라우팅, 화면 크기, 백그라운드 실행에 손대는 것은 무엇이든 공유할 수 없습니다.

이 패턴은 iOS 에이전트 개발 가이드에도 등장합니다. 거기서 저는 표현만 달리해서 같은 이야기를 했습니다. iOS 앱에서 에이전트가 쓸 수 있는 부분은 사람이 쓰는 부분과 코드 대부분을 공유하고, 사람의 판단이 필요한 부분(서명, 시각적 마감, 성능)이야말로 플랫폼을 가로질러 잘 공유되지 않는 부분이라는 이야기입니다.9 두 경계선은 서로 겹칩니다. 둘 다 도메인 지식이 어디서부터 중요해지는가에 관한 이야기니까요.

멀티 플랫폼의 비용

ROI는 비대칭입니다. iPhone 앱에 iPad를 더하는 비용은 코드 20% 남짓입니다(사이즈 클래스 분기, 일부 지점의 스플릿 뷰). 같은 타깃에 Mac을 더하면 15~20%가 더 붙습니다(#if os(macOS) 분기, 메뉴 바, 윈도 관리). 작은 앱이라면 주요 타깃 하나마다 파일이 10개 정도 늘어납니다.

값비싼 쪽은 Apple Watch와 Apple TV입니다. Return에 watchOS를 더하는 데는 별도 타깃에 새 파일 11개가 필요했고, 전용 오디오·타이머·HealthKit 매니저가 여기 포함됩니다. tvOS를 더하는 데는 또 다른 별도 타깃에 새 파일 10개가 필요했고, 포커스 관리와 커스텀 시간 선택기가 여기 포함됩니다. 둘을 합치면 사용자 기능 수준에서는 동일한 앱을 위해 Swift 코드 표면적이 거의 두 배가 된 셈입니다.

다섯 플랫폼 전부에 출시하기로 한 결정은 “멀티 플랫폼 그 자체가 좋아서”가 아니었습니다. 각각 별개의 판단이 쌓인 결과입니다. Apple Watch는 명상 타이머가 손목에 있는 게 진짜로 맞기 때문에, Apple TV는 앰비언트 화면 포맷이 방 안의 긴 명상에 어울리기 때문에, Mac은 어떤 사용자들은 회의 사이에 책상에서 명상하기 때문에 골랐습니다. 각 플랫폼은 진짜 사용 사례가 있었기에 자기 타깃을 스스로 벌어냈습니다.

어떤 기능이 자기 타깃을 벌어내지 못한다면, 더 저렴한 수는 그 플랫폼을 건너뛰고 앱이 탁월한 플랫폼에 힘을 몰아주는 것입니다.

당신의 앱에는 어떤 의미인가

세 가지로 정리합니다.

  1. 주요 플랫폼 묶음마다 타깃 하나를 기본값으로 삼으세요. iOS + iPadOS + macOS를 한 타깃에 두는 게 통하는 이유는 핵심 상호작용(터치 + 커서)이 비슷하기 때문입니다. tvOS는 별도 타깃으로, watchOS도 별도 타깃으로. 별도 타깃 하나마다 파일이 10개쯤 들지만, 끝없이 불어나는 #if 분기로 뒤덮인 갓 클래스 하나에서는 벗어나게 해 줍니다.
  2. 상호작용이 아니라 상태를 공격적으로 공유하세요. Codable 모델 구조체, 저장 래퍼, List 렌더링은 거의 공짜로 건너갑니다. 타이머 매니저, 오디오 매니저, 콘텐츠 뷰는 그렇지 않습니다.
  3. 플랫폼마다 자격을 벌게 하세요. 할 수 있다는 이유로 watchOS에 내보내지 마세요. 앱의 핵심 상호작용이 그 플랫폼의 입력 모델에 대응될 때 내보내세요. 나머지는 건너뛰세요.

이 패턴은 같은 계열의 앱들을 두고 제가 써 온 다른 세 가지 표면과 나란히 작동합니다. Apple Intelligence를 위한 타입 기반 App Intents, 여러 LLM을 가로지르는 에이전트를 위한 MCP 서버, 그리고 기기 앞에 있는 사람을 위한 Liquid Glass입니다. 같은 스택에서 가장 바깥 층은 플랫폼입니다. 앱이 애초에 어느 화면 위에서 도는가 하는 문제죠. AI 표면을 고르는 만큼이나 신중하게 그걸 고르세요.

자주 묻는 질문

공유 코드를 Swift 패키지로 만들지 않은 이유는 무엇인가요?

고려는 했습니다. 파일 세 개를 위해서라면 Swift 패키지는 얻는 것보다 절차만 늘립니다. 애플의 Xcode 26 빌드 시스템은 Target Membership 체크박스만 켜 주면 소스 파일 하나를 여러 타깃으로 잘 컴파일해 줍니다. 패키지를 두면 별도의 Package.swift, 별도의 테스트 타깃, 그리고 리팩터링할 때마다 거쳐야 하는 한 겹의 간접 단계가 생깁니다. 작은 공유 코어라면 더 단순한 답이 이깁니다.10

SwiftData는 watchOS와 tvOS에서도 동작하나요?

SwiftData는 iOS 17+, macOS 14+, watchOS 10+, tvOS 17+에서 쓸 수 있으니 Return이 겨냥하는 모든 플랫폼을 덮습니다.11 MeditationSession 구조체는 @Model이 아니라 평범한 Codable인데, Return이 세션 기록 동기화에 SwiftData 컨테이너 대신 NSUbiquitousKeyValueStore를 쓰기 때문입니다. @Model 타입에서도 방식은 똑같이 작동합니다. 모델 파일은 공유하고, 저장 컨테이너는 필요하다면 플랫폼별로 다르게 두면 됩니다.

Mac Catalyst를 써야 하나요, 네이티브 Mac 타깃을 써야 하나요?

Catalyst는 iPad 앱이 충분히 좋아서 Catalyst로 다시 빌드한 Mac 버전이 네이티브처럼 읽힐 때 맞는 도구입니다. Return의 메인 타깃은 Catalyst가 아니라 진짜 멀티 플랫폼 타깃이고, iOS와 iPadOS, macOS를 SwiftUI로 하나의 바이너리에 담아 빌드합니다. Mac UI는 #if os(macOS)로 iPad와 다르게 그립니다. 시트 대신 사이드바, 버튼에 키보드 단축키 같은 식입니다. Catalyst 쪽이 더 간단했겠지만, Mac UI가 Mac 위에 올려놓은 iPad 앱처럼 보였을 겁니다. Catalyst가 가장 유명해진 실패 양상이 바로 그것이고요.

작은 앱이 Apple TV에 출시할 가치가 있나요?

아마 없을 겁니다. Apple TV 앱은 용도가 아주 특정합니다(앰비언트, 미디어, 캐주얼 게임). 앱이 그중 하나에 들어맞지 않는다면, 앱당 Swift 파일 10개를 정당화하기에 그 플랫폼의 사용자층은 너무 좁습니다. Return이 굳이 tvOS를 겨냥한 이유는, 방 건너편 화면에서 이어지는 긴 명상이 이 플랫폼에 들어맞는 몇 안 되는 생산성 인접 사례이기 때문입니다.

다섯 플랫폼 전부에 출시하려면 얼마나 걸리나요?

정확한 숫자를 내놓기는 어렵고, 앱에 따라 다릅니다. Return은 플랫폼을 하나씩 덧붙인 게 아니라 첫날부터 멀티 플랫폼으로 출시했는데, 나중에 끼워 넣는 것보다 이쪽이 빠릅니다. 대략의 어림셈은 이렇습니다. iPhone 전용 MVP에 iPad 지원과 Mac 지원을 더하면 iPhone 전용 대비 대략 1.5배입니다. Apple Watch를 더하면 0.5배가 더 붙습니다. Apple TV를 더하면 또 0.5배입니다. 그러니 다섯 플랫폼 첫 출시는 iPhone 전용 대비 대략 2.5배의 품이 듭니다. 다만 이건 에이전트의 도움을 받은 빌드였고, 중복된 코드 대부분은 손으로 타이핑한 게 아니라 Claude Code가 일괄 편집했다는 단서를 붙여 둡니다.

참고 자료


  1. 저자의 Return. 2026년 4월 21일 App Store에 출시한 명상 타이머 앱입니다. 네이티브 타깃: iOS 26+, iPadOS 26+, macOS 26+, watchOS 26+, tvOS 26+. 전부 SwiftUI. 기기 간 세션 기록에는 NSUbiquitousKeyValueStore를 사용합니다. 

  2. Apple Developer, “Configuring a Multi-Platform App”, 그리고 WWDC 2024의 “SwiftUI essentials” 세션. 애플의 기본 권장 방향은 환경 값에 따라 적응하는 단일 타깃 쪽으로 기울어 있습니다. 이 글이 택한 다중 타깃 노선은 의도적인 이탈입니다. 

  3. 프로덕션 코드: Return/Return/Shared/MeditationSession.swift, SessionStore.swift, SessionHistoryView.swift. MeditationSession.swift의 헤더 주석은 이렇게 적혀 있습니다. “Add this file to: Return, ReturnTV, ReturnWatch Watch App targets.” 

  4. 프로덕션 코드: Return/Return/VideoBackgroundView.swift(#if os(iOS) 분기 8개에 #elseif os(macOS) 분기 1개), Return/Return/ContentView.swift(#if os 분기 10개), Return/Return/AudioManager.swift(#if os 분기 6개), Return/Return/LiveActivityManager.swift(#if os 분기 8개, 파일 전체가 iOS 전용). 분기 개수는 grep -Ec '^\s*#if os\\(' <file> 실행 결과에서 가져왔습니다. 

  5. Apple Developer, “Focus interactions” Human Interface Guidelines. tvOS의 포커스 엔진은 iOS의 터치나 Mac의 포인터와는 근본적으로 다른 내비게이션 모델입니다. 

  6. 프로덕션 코드: Return/ReturnTV/TVFocusModifier.swift. @Environment(\.isFocused)를 감싸 포커스 시 색과 반투명도를 뒤집고 크기 확대와 그림자를 적용하는 ButtonStyle 타입 두 개(TVCapsuleButtonStyleTVCircleButtonStyle)를 정의합니다. 

  7. Apple Developer, “WatchConnectivity”. 페어링된 iPhone과 Watch 사이의 통신을 위한 프레임워크입니다. Return은 세션 동기화에 이 프레임워크를 쓰지 않고 iCloud 키-값 저장소에 의존합니다. 

  8. Apple Developer, “WKExtendedRuntimeSession”“WKBackgroundModes” Info.plist 키. mindfulness 값은 “조용한 명상을 위한 확장 런타임 세션을 활성화한다”고 문서화되어 있습니다. 명상 타이머에 딱 맞는 값이죠. Return은 기본 WKExtendedRuntimeSession()을 생성하고 watchOS 타깃의 Info.plistWKBackgroundModes: mindfulness를 선언합니다. 프로덕션 코드: Return/ReturnWatch Watch App/ReturnWatchApp.swiftWatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate를 정의하고, WatchTimerManager.swift가 확장 런타임 관련 작업을 여기에 위임합니다. 

  9. 저자의 분석: AI 에이전트로 iOS 앱 만들기. 프로덕션 앱 8개에 걸친 에이전트 보조 iOS 개발에 관한 실무자 가이드입니다. 

  10. Apple Developer, “Configuring a Multi-Platform App”. 타깃 멤버십을 쓰면 Swift 패키지 없이도 소스 파일 하나를 여러 타깃으로 컴파일할 수 있습니다. 작은 공유 코어에 맞는 도구입니다. 

  11. Apple Developer, “SwiftData” 플랫폼 지원 현황. iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+, tvOS 17+, visionOS 1+에서 사용할 수 있어 애플 플랫폼 다섯 계열을 전부 덮습니다. 

  12. Apple Developer, “NSUbiquitousKeyValueStore”. 사용자의 여러 기기에 걸쳐 소량의 상태를 동기화하기 위한 애플의 iCloud 키-값 저장소입니다. 애플이 공개한 한도에 따르면 저장소 전체 크기는 모든 키를 합쳐 1MB로 제한됩니다. 프로덕션 코드: Return/Return/Shared/SessionStore.swift

  13. Apple Developer, EnvironmentValues.isFocused. iOS 14+, iPadOS 14+, macOS 11+, tvOS 14+, watchOS 7+에서 사용할 수 있습니다. API 자체는 크로스 플랫폼이고, 달라지는 것은 포커스가 사용자의 주된 내비게이션 어포던스인지 여부입니다. 

관련 게시물

Apple 플랫폼 매트릭스: 어떤 타깃이 어떤 앱에 어울리는가

iOS, iPad, Mac, Watch, Vision, TV. 6개 플랫폼, 6개의 책임. Apple 타깃을 선택하는 일은 엔지니어링 결정 이전에 제품 결정입니다.

16 분 소요

iOS 26의 HealthKit + SwiftUI: 권한 요청, 샘플 타입, 그리고 두 개의 앱을 출시하며 얻은 크로스 플랫폼 패턴

Water(물 섭취량 추적, HKQuantitySample)와 Return(마음챙김 세션, HKCategorySample)에서 가져온 실제 프로덕션 패턴. 권한 UX, async 래퍼, watchOS 변형, 그리고 …

15 분 소요

Codex CLI 설치와 업데이트: Mac, Linux, Windows

OpenAI Codex CLI를 설치하고, 업데이트하고, 버전을 고정하고, 제거하는 모든 방법. 설치 스크립트, npm, Homebrew, winget을 macOS, Linux, WSL, Windows에서 다룹니다.

16 분 소요