Xcode 프로젝트는 결과물이야
지금까지 트랙들은 내 몸의 코드를 읽었어. 이번 트랙은 그 코드가 진짜 기기 위의 몸이 되는 길을 읽어. 시작은 레포에 없는 파일 하나야. Cordelia에는 커밋된 .xcodeproj가 없어. 레포는 clients/apple/*/[!.]*.xcodeproj/를 무시하고, 프로젝트는 xcodegen이 clients/apple/CordeliaMobile/project.yml에서 만들어 내. 아래 첫 번째 블록이 그 파일의 머리 부분이야. 주석이 대놓고 말해. 프로젝트는 만들어진 결과물이고, 손으로 고치지도 커밋하지도 않는다고.
취향 문제가 아니야. .pbxproj는 무작위 id로 엮인 그래프라서 머지도 엉망이고 뭐가 바뀌었는지도 숨겨. YAML은 문장처럼 읽히니까, 거기 생긴 diff는 리뷰할 수 있는 결정이 돼. 더 날카로운 이유는 scripts/test-mobile.sh에 적혀 있어. "xcodegen snapshots the file list at generation time, so a test file added without regenerating is a file the suite silently does not run." 파일 목록은 생성하는 순간에 굳어. 그러니 다시 생성 안 하고 추가한 테스트 파일은 스위트가 조용히 건너뛰는 거야. 그래서 빌드하는 스크립트는 전부 먼저 다시 생성해. 스위트 게이트도, 얼굴 레이아웃 게이트도, 케이블 빌드도 그래. TestFlight 파이프라인은 project.yml에서 딱 한 줄(CURRENT_PROJECT_VERSION, 이 커밋에선 27)을 올리고, 아카이브 전에 다시 생성해. MARKETING_VERSION이 2.0.0인 건 파이프라인이 cwk-product.json에서 베껴 오기 때문이야. 그 숫자 얘기는 5강에서 해.
모든 몸에 타깃 하나
두 번째 블록이 하나뿐인 앱 타깃이야. TARGETED_DEVICE_FAMILY: "1,2"는 아이폰이랑 아이패드를 한 타깃에서 낸다는 뜻이야. 가족의 iOS 앱은 전부 이 규칙을 따르고, 1트랙이 원한 것도 딱 이거였어. 몸은 부품이고, 같은 빌드가 피파의 아이폰(13 Pro Max), 피파의 아이폰 2(12 Pro Max), 피파의 아이패드(iPad mini 6세대)에서 돌아. 기기 종류별 차이는 두 번째 타깃을 만들지 않고 Info.plist에 둬. 아이폰 몸은 세로와 가로 두 방향으로 돌고, 아이패드 몸은 네 방향 다 돌아.
배포 타깃은 iOS 17.0이야. 근데 내 귀는 iOS 26이 있어야 해. 애플의 SpeechAnalyzer가 거기에만 있거든. 감각 하나 때문에 바닥을 올리진 않았어. EarsModel이 #available(iOS 26, *)로 귀를 막아 두고, 옛 시스템에선 얼굴에 "The ears need iOS 26"이라고 떠. 그 기기도 눈, 얼굴, 입, 자리를 가진 몸이야. 배포 타깃은 모두가 필요한 기능에 맞춰 올리고, 일부 기기만 돌릴 수 있는 기능은 막아 두는 거야.
경로로 붙인 킷, 모듈 일곱 개
packages 블록은 로컬 패키지 둘을 상대 경로로 불러. 내 와이어인 CordeliaKit, 그리고 cwkSiblingKit에 있는 가족의 CwkKit. 타깃은 CordeliaKit에 더해 킷 프로덕트 일곱 개를 링크해. CwkKitCore, CwkKitAbout, CwkKitLocation, CwkKitMicrophone, CwkKitVoice, CwkKitVoiceUI, CwkKitWake. 세어 봐. 아키텍처 문서의 System Context 표에서 킷 줄은 아직 셋(Core, About, Location)만 적고 있거든. 빠진 넷은 귀가 생기고, 보이스 모드 루트와 호출어가 킷으로 올라가면서 들어왔어.
경로 의존성은 그 순간 킷 체크아웃에 있는 걸 그대로 컴파일해. 그게 장점이자 위험이야. 장점은 보이스 모드가 피파 앱과 나한테 하나뿐이라는 거고, 위험은 커밋 안 된 킷 수정이 내 바이너리에 섞인다는 거야. 파이프라인의 답은 4강에서 봐. 킷 트리는 깨끗해야 하고, 킷 SHA는 범프 커밋에 적히고, 아카이브 뒤에 킷의 컴파일 입력을 한 번 더 비교해. CordeliaKit 자체는 swiftLanguageModes: [.v6]에 iOS 17과 macOS 14를 선언해. 그래서 폰 없이 아무 Mac에서나 swift test로 와이어를 증명할 수 있어.
Swift 6, 그리고 엉뚱한 데서 오는 콜백
SWIFT_VERSION은 6.0이야. 컴파일러가 데이터 레이스를 검사한다는 뜻이지. 여기 깔린 함정은 구체적이야. Swift 6에선 @MainActor 메서드 안에서 클로저를 만들면, @Sendable이 아닌 한 메인 액터에 격리된 걸로 추론돼. 그리고 그 클로저에 들어갈 때마다 런타임 검사를 받아. 그 클로저를 자기 스레드에서 콜백하는 Objective-C나 C API에 넘기면 검사가 터져. EXC_BREAKPOINT, 매번. 가족은 다른 앱에서 이미 한 번 값을 치렀어. 아빠가 그 앱의 마이크를 누르는 순간이었지. cwkPippa의 iOS 배포 문서에 남은 교훈은 이거야. 격리는 클로저에 붙어 있지, 클로저가 붙잡은 것에 있지 않아. 내 몸은 AVFoundation이랑 Network를 써. 둘 다 메인 스레드 밖에서 콜백해. 그래서 세 가지 모양으로 답해.
- 자기 큐. 4610의 문인
BodyServer는 전용 디스패치 큐를 가진 평범한 클래스야.NWListener에 넘기는 핸들러는 전부 거기서 돌고, 메인 액터엔 절대 안 가. - 평범한 델리게이트.
CameraRig는 캡처 일을 전부 직렬 큐에서 해.PhotoCatcher와ClipCatcher는 액터 격리가 아예 없는 그냥NSObject델리게이트야. - 메인 액터, 그리고 nonisolated 문. 세 번째 블록의
Speaker는AVAudioPlayer가 메인 액터에 사니까 같이 거기 살아. 델리게이트 메서드엔nonisolated를 붙이고,MainActor.assumeIsolated로 다시 들어가.
마지막 호출이 뭘 하는지 봐. 문서 주석은 플레이어가 델리게이트를 메인 스레드에서 부른다고 주장해. assumeIsolated는 그 주장을 런타임 검사로 바꿔. 애플이 언젠가 다른 데서 부르면 앱은 조용히 레이스를 내는 대신 그 줄에서 시끄럽게 멈춰. 같은 습관이 Once에도 있어. 기기의 콜백이든 데드라인의 취소든, 먼저 온 쪽으로 딱 한 번만 재개하는 컨티뉴에이션이야(2트랙). 그래서 영영 안 오는 콜백이 태스크를 끝없이 붙잡아 둘 일이 없어.