← 모든 글

Apple 플랫폼 5개, 공유 파일 3개: Return이 실제로 크로스 플랫폼 SwiftUI를 출시하는 방법

제 명상 타이머인 Return은 iPhone, iPad, Mac, Apple Watch, Apple TV의 5개 Apple 플랫폼에서 실행됩니다.1 코드베이스에는 40개의 Swift 파일이 있습니다(테스트 제외). 그중 3개가 5개 플랫폼 모두에서 공유됩니다. 나머지는 별도의 Xcode 타깃으로 분리되어 있으며, TimerManager, AudioManager, ContentView 같은 개념을 #if os(...) 조건부 컴파일을 통해 공유하기보다는 중복으로 구현합니다.

공유 비율은 약 7.5%이며, 이는 의도적입니다.

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

iOS 26 platform tile from Apple Developer iPadOS 26 platform tile from Apple Developer macOS 26 platform tile from Apple Developer watchOS 26 platform tile from Apple Developer tvOS 26 platform tile from Apple Developer

Return이 타깃으로 삼는 5개 플랫폼, developer.apple.com이 제시하는 그대로입니다. 각 플랫폼은 런타임 분기가 아니라 Xcode에서 별개의 플랫폼 타깃입니다.

한눈에 보기

  • Return: 메인 타깃 Swift 파일 18개(iOS + iPadOS + macOS), tvOS 타깃 파일 10개, watchOS 타깃 파일 7개, 위젯 파일 2개(Live Activities), 그리고 Return/Shared/에 진정한 크로스 플랫폼 파일 3개. 총 40개.
  • 공유된 3개 파일은 영속성 인접 파일들입니다: 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개.
  • 솔직한 결론: 5개 Apple 플랫폼에 걸친 공격적인 공유는 유지보수 부채입니다. 작은 공유 코어(영속성 계층)에 플랫폼별로 분리된 UI를 더하는 편이, 거대한 #if로 가득 찬 단일 파일보다 더 빠르게 출시되고 덜 부서집니다.

플랫폼별 동반 글은 Apple 플랫폼 매트릭스, 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

5개 플랫폼, 공유된 3개 파일, 플랫폼별 별도 타깃 2개와 위젯 타깃 1개, 그리고 메인 타깃 내부의 무거운 조건부 컴파일. 공유 비율은 약 7.5%입니다. 대부분의 “멀티 플랫폼 SwiftUI” 튜토리얼은 그 반대를 제안합니다: @Environment(\.horizontalSizeClass)#if os(...)를 통해 모든 플랫폼에 적응하는 하나의 ContentView를 작성하라는 것입니다.2 그것은 두 플랫폼(iPhone + iPad)에서는 작동합니다. 다섯에서는 무너집니다.

공유된 3개 파일의 공통점

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. 동일한 소스 파일이 3개의 Xcode 타깃 모두에서 참조되며, 심볼릭 링크되거나 Swift 패키지에 포함되어 있지 않습니다. Apple의 빌드 시스템은 하나의 파일을 3개의 바이너리로 기꺼이 컴파일합니다.

SessionStore.swift는 영속성 계층입니다: MeditationSession 배열을 읽고 쓰는 NSUbiquitousKeyValueStore(Apple의 iCloud Key-Value Store)를 감싼 얇은 래퍼입니다. 이 선택은 중요합니다: KV-store 동기화는 CloudKit 컨테이너를 프로비저닝하지 않고도 Return에 디바이스 간 세션 기록을 제공하지만, 전체 저장소가 총 1 MB로 제한된다는 트레이드오프가 있습니다.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) ... }로 통합할 수도 있습니다. 그 결과는 세 가지 모드를 가진 클래스가 되고, 각 모드는 40줄의 #if-게이트 코드가 되며, iOS 경로를 건드리면 watchOS 경로가 깨질 위험이 있습니다. 그것은 유지보수 악몽입니다.

세 개의 별도 클래스에 세 개의 파일명을 두는 편이 디스크에는 코드가 더 많지만 머릿속에는 코드가 더 적습니다. 읽을 수 있는 중복이 읽을 수 없는 추상화보다 낫습니다.

같은 논리가 다음에도 적용됩니다:

  • ContentView vs TVContentView vs WatchContentView: 내비게이션 모델이 다릅니다(iPhone에서는 푸시 기반, TV에서는 포커스 기반, Watch에서는 리스트 기반).
  • AudioManager vs TVAudioManager vs WatchAudioManager: 오디오 세션 카테고리가 다르고, watchOS는 백그라운드 오디오 규칙이 더 엄격하며, tvOS는 AirPlay로 다르게 라우팅합니다.
  • VideoBackgroundView는 메인 타깃에 8개의 #if os(iOS) 분기를 가지며(하나의 #elseif os(macOS) 동반과 함께), 다른 비디오 자산(fire_phone.mp4 vs fire_mac.mp4), 다른 레이어 타입, 다른 종횡비를 다룹니다.4

한 가지 짚자면: 메인 Return/ 타깃은 실제로 iOS, iPadOS, macOS를 한데 묶습니다. 그 세 플랫폼은 공유하지 않는 코드보다 공유하는 코드가 더 많습니다. SwiftUI의 NavigationStack은 세 플랫폼 모두에서 작동합니다. .glassEffect()도 세 플랫폼 모두에서 작동합니다. 윈도우 관리의 차이는 실재하지만 한 타깃 내에서 다룰 만합니다. 별도 타깃의 경계선을 그은 곳은 tvOS와 watchOS였습니다.

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

Apple TV 내비게이션은 포커스 엔진을 중심으로 구축됩니다.5 사용자가 상호작용할 수 있는 모든 UI 요소는 자신을 포커스 가능하다고 선언합니다. Siri Remote의 시스템 화살표는 요소들 사이로 포커스를 이동시키고, 선택을 누르면 포커스된 요소가 활성화됩니다. tvOS의 SwiftUI는 이를 .focusable(), .focusEffect, 그리고 Apple의 자체 앱이 사용하는 시차 기울임 효과를 위해 @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에서뿐입니다. tvOS에서는 Siri Remote가 포인터나 터치 이벤트를 만들지 않습니다. iPhone이나 iPad에서는 동등한 컨트롤이 탭으로 히트 테스트되고, Mac에서는 호버 또는 클릭됩니다. TVFocusModifier.swift의 버튼 스타일들은 포커스가 사용자의 주된 어포던스라고 가정하고, 그것을 중심으로 시각적 응답 전체를 설계합니다. iOS의 터치, Mac의 호버, tvOS의 포커스 기반 내비게이션을 한 곳에서 처리하는 ContentView를 작성할 좋은 방법은 없습니다. 뷰 구조 자체가 진정으로 다릅니다: tvOS의 ContentView는 포커스 가능한 행들의 그래프이고, iOS의 ContentView는 탭하면 동작하는 스택입니다.

지속 시간 선택기에 대해서도 마찬가지입니다. iPhone에서는 아래에서 슬라이드해 올라오며 탭을 받습니다. Apple TV에서는 사용자가 리모컨으로 탐색하는 포커스 가능한 셀들의 가로 행입니다. TVDurationPicker.swift가 자체 파일인 이유는 셀 기반 포커스 디자인이 iPhone에는 대응물이 없기 때문입니다. 그것들을 한 파일에 강제로 넣으면 #if os(tvOS)로 붙여놓은 무관한 두 UI를 갖게 됩니다.

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

watchOS는 다른 플랫폼에 없는 두 가지 구조적 제약을 추가합니다:

  1. WKExtendedRuntimeSession - 시계 화면이 어두워진 동안에도 앱을 응답 가능한 상태로 유지하기 위한 것입니다.8 이것이 없으면 watchOS는 매 초 틱 사이에 앱을 공격적으로 일시 중단시키고 타이머가 표류합니다. Return은 watchOS 타깃의 Info.plistWKBackgroundModes: mindfulness를 선언하여 OS가 그 사용 사례를 인식하고 런타임 예산을 부여하도록 합니다. 런타임 세션 자체는 기본 WKExtendedRuntimeSession() 이니셜라이저로 생성됩니다.
  2. WatchConnectivity가 아닌 NSUbiquitousKeyValueStore를 통한 iCloud 동기화. Return의 세션 기록 동기화는 iPhone, iPad, Mac 타깃이 사용하는 동일한 키-값 저장소를 타고 흐릅니다. 그래서 워치에서 기록된 명상이 워치-폰 간 직접 메시징 없이도 iPhone의 기록 뷰에 나타납니다. WatchConnectivity는 향후 실시간 상태 동기화의 옵션이 될 수 있지만, Return은 더 간단한 모델을 선택했습니다: 각 디바이스가 동일한 iCloud KV-store에 쓰고, 어느 디바이스에서든 다음 읽기는 합집합을 봅니다.

WatchTimerManager.swift는 워치 측 타이머입니다. 확장 런타임 작업을 WatchSessionManager에 위임하는데, 이는 ReturnWatchApp.swiftfinal class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate로 정의되어 있습니다. iOS의 TimerManager에는 대응물이 없는데, 이는 iOS 앱이 명시적인 런타임 세션 없이도 포그라운드에서 응답 가능한 상태를 유지하기 때문입니다. 워치 로직을 #if os(watchOS)를 통해 iOS의 TimerManager에 넣는다면 iOS 코드 경로가 사용하지도 않는 WatchKit 심볼을 임포트해야 하고, watchOS 코드 경로는 iOS 경로가 갖지 않는 초기화 경로를 필요로 하게 됩니다.

WatchHealthKitManager.swift는 메인 HealthKitManager의 더 작은 변형입니다. 마음챙김 분을 동일한 방식으로 기록하지만, 권한 프롬프트 UX가 다릅니다(워치는 HealthKitPermissionSheet를 표시할 수 없습니다). Watch 클래스는 메인 클래스의 대략 절반 크기입니다.

메인 iOS/iPadOS/macOS 타깃 내부에서 일어나는 일

메인 타깃 안에서도 공유는 자동이 아닙니다. ContentView.swift는 10개의 #if os(macOS) 또는 #if !os(macOS) 블록을 가지고 있고, LiveActivityManager.swift는 8개, VideoBackgroundView.swift는 8개, AudioManager.swift는 6개를 가지고 있습니다. Live Activities는 iPhone 전용 기능이므로 LiveActivityManager 전체가 #if os(iOS)로 감싸여 있습니다. iPhone의 지속 시간 선택기는 iPad와 Mac의 지속 시간 선택기와 다른 레이아웃을 사용하므로 ContentView는 병렬 레이아웃 분기를 가집니다.

작동한 패턴은 이렇습니다: 작은 플랫폼 차이(다른 키보드 동작, 다른 패딩, 누락된 API)에는 #if os(...), 큰 구조적 차이(포커스 vs. 터치, 운동 세션 vs. 타이머)에는 별도 타깃. 제가 결국 사용한 임계값은 “분기가 약 10줄을 넘어가는지”입니다. 그 미만이면 조건부 컴파일이 괜찮습니다. 그 이상이면 그 파일은 두 가지 일을 동시에 하고 있는 것이고, 두 번째 일은 다른 타깃에 속합니다.

5개 플랫폼 모두에 출시하지 말아야 할 때

솔직한 평가입니다.

앱이 정보 밀도가 높다면 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가 필요하고, 이상적으로는 진짜 두 영역 레이아웃이 필요합니다. Return은 iPad에서는 분할 뷰를, iPhone에서는 스택을 사용합니다. 코드는 같은 타깃에 있지만 UI는 진정으로 다릅니다.

제가 그은 규칙은 이렇습니다: 앱의 핵심 상호작용이 그 플랫폼의 입력 모델과 맞을 때 그 플랫폼에 출시하세요. 명상 타이머는 Apple Watch에 출시하세요(시작 한 번 탭). 명상 타이머는 Apple TV에 출시하세요(설정하고 잊어버림). 칸반 보드는 어느 쪽에도 출시하지 마세요.

노력 없이 이동하는 것

Return에서 5개 플랫폼 모두에 걸쳐 공유된 세 가지:

  1. 데이터 모델 (MeditationSession). 구조체는 모든 플랫폼에서 동일하고, NSUbiquitousKeyValueStore를 통해 동기화되며, 어느 플랫폼이든 다른 플랫폼이 쓴 것을 읽을 수 있습니다.
  2. 세션 기록 뷰 (SessionHistoryView). 과거 세션의 List는 iPhone, iPad, Mac, Apple Watch, Apple TV에서 동일하게 렌더링됩니다. SwiftUI의 List는 다섯 가지 폼 팩터 모두에 깔끔하게 적응하는 몇 안 되는 프리미티브 중 하나입니다.
  3. 영속성 래퍼 (SessionStore). 읽기와 쓰기는 플랫폼 비종속적이며, 기저 저장소(NSUbiquitousKeyValueStore)는 어디서나 동일한 API입니다.

세 가지 개념. 상태, 리스트 렌더링, 영속성. 하드웨어 특화 입력 모델을 수반하지 않는 상태 기반의 표현형은 모두 공유 가능합니다. 입력, 포커스, 오디오 라우팅, 화면 크기, 백그라운드 실행을 건드리는 모든 것은 그렇지 않습니다.

이 패턴은 iOS Agent Development 가이드에서도 다른 말로 같은 주장을 했던 부분에 등장합니다: 에이전트가 작성할 수 있는 iOS 앱의 부분들은 사람이 작성하는 부분들과 대부분의 코드를 공유합니다. 사람의 판단이 필요한 부분(서명, 시각적 마무리, 성능)은 정확히 플랫폼 간에도 잘 공유되지 않는 부분과 같습니다.9 두 경계는 일치합니다. 둘 다 도메인 지식이 중요해지기 시작하는 지점에 관한 것입니다.

멀티 플랫폼의 비용

ROI는 비대칭적입니다. iPhone 앱에 iPad를 추가하는 것은 코드가 약 20% 더 듭니다(사이즈 클래스 분기, 일부 위치의 분할 뷰). 같은 타깃에 Mac을 추가하면 또 15-20%가 듭니다(#if os(macOS) 분기, 메뉴 바, 윈도우 관리). 작은 앱에서 각 메이저 타깃은 약 10개의 파일을 추가합니다.

비싼 쪽은 Apple Watch와 Apple TV입니다. Return에 watchOS를 추가하려면 별도 타깃에 11개의 새 파일이 필요했고, 거기에는 전용 오디오, 타이머, HealthKit 매니저가 포함되었습니다. tvOS를 추가하려면 또 다른 별도 타깃에 10개의 새 파일이 필요했고, 거기에는 포커스 관리와 커스텀 지속 시간 선택기가 포함되었습니다. 이 둘이 합쳐, 사용자 기능 수준에서는 동일한 앱을 위해 Swift 표면적을 거의 두 배로 늘렸습니다.

5개 플랫폼 모두에 출시한다는 선택은 “그 자체로 멀티 플랫폼이 되고 싶다”가 아니었습니다. 별개의 결정들의 연속이었습니다: 명상 타이머는 진짜로 손목에 어울리기 때문에 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 패키지를 사용하지 않나요?

고려했습니다. 파일 3개를 위해서는 Swift 패키지가 절약하는 것보다 더 많은 의례를 추가합니다. Apple의 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 앱은 매우 특정한 사용 사례를 가집니다(앰비언트, 미디어, 캐주얼 게임). 앱이 그중 하나에 맞지 않는다면, 플랫폼의 청중은 앱당 10개의 Swift 파일을 정당화하기에 너무 작습니다. Return이 tvOS를 특별히 타깃으로 삼는 이유는, 방 건너편 화면에서의 긴 명상 세션이 그 플랫폼에 맞는 몇 안 되는 생산성 인접 사용 사례 중 하나이기 때문입니다.

5개 플랫폼 모두에 출시하는 데 얼마나 걸리나요?

정확한 숫자를 주기는 어렵습니다. 앱에 따라 다릅니다. Return은 점진적으로 플랫폼을 추가하지 않고 첫날부터 멀티 플랫폼으로 출시했는데, 이는 사후에 끼워 맞추는 것보다 더 빠릅니다. 대략적인 경험칙으로: iPhone 전용 MVP에 iPad 지원, Mac 지원을 더하는 것은 iPhone 전용보다 대략 1.5배입니다. Apple Watch를 추가하면 또 0.5배. Apple TV를 추가하면 또 0.5배. 그래서 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” 세션. Apple의 기본 가이드는 환경 기반 적응을 사용하는 단일 타깃 쪽으로 기울어 있습니다. 이 글이 취하는 멀티 타깃 경로는 의도적인 이탈입니다. 

  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 (8개의 #if os(iOS) 분기와 1개의 #elseif os(macOS) 분기), Return/Return/ContentView.swift (10개의 #if os 분기), Return/Return/AudioManager.swift (6개의 #if os 분기), Return/Return/LiveActivityManager.swift (8개의 #if os 분기, 파일은 iOS 전용). 분기 수는 grep -Ec '^\s*#if os\\(' <file>을 실행해 측정했습니다. 

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

  6. 프로덕션 코드: Return/ReturnTV/TVFocusModifier.swift. 두 가지 ButtonStyle 타입(TVCapsuleButtonStyleTVCircleButtonStyle)을 정의하며, @Environment(\.isFocused)를 감싸 포커스 시 색상과 반투명도를 반전시키고 스케일과 그림자를 적용합니다. 

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

  8. Apple Developer, “WKExtendedRuntimeSession”“WKBackgroundModes” Info.plist 키. mindfulness 값은 다음과 같이 문서화되어 있습니다: “Enables extended runtime sessions for silent meditation” — 명상 타이머에 알맞은 적합도. Return은 기본 WKExtendedRuntimeSession()을 생성하고 watchOS 타깃의 Info.plistWKBackgroundModes: mindfulness를 선언합니다. 프로덕션 코드: Return/ReturnWatch Watch App/ReturnWatchApp.swiftWatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate를 정의하고, WatchTimerManager.swift는 확장 런타임 작업을 그것에 위임합니다. 

  9. 저자의 분석 Building iOS Apps with AI Agents, 8개의 프로덕션 앱에 걸친 에이전트 보조 iOS 개발 실무자 가이드. 

  10. Apple Developer, “Configuring a Multi-Platform App”. Target membership는 Swift 패키지 없이 하나의 소스 파일을 여러 타깃으로 컴파일하게 해줍니다. 작은 공유 코어에 알맞은 도구. 

  11. Apple Developer, “SwiftData” platform availability. iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+, tvOS 17+, visionOS 1+에서 사용 가능하며, 5개 Apple 플랫폼군 모두를 다룹니다. 

  12. Apple Developer, “NSUbiquitousKeyValueStore”. 사용자의 디바이스 간에 적은 양의 상태를 동기화하기 위한 Apple의 iCloud Key-Value Store. 전체 저장소 크기는 Apple이 게시한 한도에 따라 모든 키에 걸쳐 1 MB로 제한됩니다. 프로덕션 코드: 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 타깃을 선택하는 일은 엔지니어링 결정 이전에 제품 결정입니다.

12 분 소요

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

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

11 분 소요

Loop Engineering: Loops Win Where Verification Is Cheap

Loop engineering, checked against Boris Cherny's full transcripts: every loop he names has cheap verification. That cons…

19 분 소요