본문 바로가기
C.W.K.
Lesson 01 of 05 · published

만들어지는 프로젝트, 그리고 Swift 6

~15 min · xcodegen, swift-6, concurrency, ios, project-structure

Level 0놀던 폰
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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트랙). 그래서 영영 안 오는 콜백이 태스크를 끝없이 붙잡아 둘 일이 없어.

Code

cwkCordelia, clients/apple/CordeliaMobile/project.yml: 만들어지는 프로젝트의 원본, 설정, 경로 패키지 둘 (발췌, 10-40행)·yaml
# ONE universal target (cwkPippa IOS-NATIVE-DELIVERY §4.1): iPhone and iPad.
# Delivery is TestFlight, internal group only; a cable install is a build check.
#
# The wire lives in the local SwiftPM package CordeliaKit (swift test on a Mac);
# shared family Swift comes from cwkSiblingKit's CwkKit as a path dependency.
#
# Regenerate the project with `xcodegen` in this directory; the .xcodeproj is
# generated output, never hand-edited and never committed.
name: CordeliaMobile
options:
  bundleIdPrefix: com.cwk
  developmentLanguage: en
  deploymentTarget:
    iOS: "17.0"
  createIntermediateGroups: true

settings:
  base:
    # The pipeline bumps this one line (project-yml build numbers).
    CURRENT_PROJECT_VERSION: 27
    # Follows cwk-product.json's numeric core; the TestFlight pipeline rewrites it (todo #759).
    MARKETING_VERSION: 2.0.0
# …
    SWIFT_VERSION: "6.0"
    CODE_SIGN_STYLE: Automatic

packages:
  CwkKit:
    path: ../../../../cwkSiblingKit/CwkKit
  CordeliaKit:
    path: ../../../CordeliaKit
cwkCordelia, clients/apple/CordeliaMobile/project.yml: 유니버설 타깃 하나와 거기 링크된 킷 모듈 일곱 개 (원문 그대로, 42-81행)·yaml
targets:
  CordeliaMobile:
    type: application
    platform: iOS
    sources:
      - path: Sources
      - path: Assets.xcassets
    dependencies:
      - package: CordeliaKit
        product: CordeliaKit
      # ProductBuild: the version line's source.
      - package: CwkKit
        product: CwkKitCore
      # The version line, v<version> (<build>) (todo #759).
      - package: CwkKit
        product: CwkKitAbout
      # The family fix shape and the When-In-Use sampler.
      - package: CwkKit
        product: CwkKitLocation
      # The ears: the one door to the record permission, and voice mode's
      # root (the mic owner, the turn loop, the listening socket): Cordelia is
      # its second child after the Pippa app (todo #820).
      - package: CwkKit
        product: CwkKitMicrophone
      - package: CwkKit
        product: CwkKitVoice
      # Voice mode's face and controls: the root this face is the second child of.
      - package: CwkKit
        product: CwkKitVoiceUI
      - package: CwkKit
        product: CwkKitWake
    settings:
      base:
        PRODUCT_NAME: Cordelia
        PRODUCT_BUNDLE_IDENTIFIER: com.cwk.cordelia.mobile
        TARGETED_DEVICE_FAMILY: "1,2"
        GENERATE_INFOPLIST_FILE: NO
        INFOPLIST_FILE: Resources/Info.plist
        ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
        ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/Speaker.swift: 메인 액터 클래스, 델리게이트 메서드는 nonisolated로 받고 도착한 자리를 확인해 (발췌, 5-74행)·swift
/// Pippa's mouth: plays the audio Oriel sends on the device's speaker, a line
/// Bellows just voiced or one cwkPippa already synthesized (never twice).
///
/// While the ears are closed for her line (the default), the session is
/// playback only, so her own voice can never reach a listener. With Cut-in on
/// the ears keep their play-and-record session open and the line plays through
/// it, so the wake phrase or a tap can stop her (`stop()`). Playback ignores
/// the ring/silent switch, as a voice someone asked for should. AVAudioPlayer
/// lives on the main actor; its delegate is called there.
@MainActor
final class Speaker: NSObject, AVAudioPlayerDelegate {
  private var player: AVAudioPlayer?
  private var waiting: Once<Double>?
  // …
  nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
    let length = player.duration
    MainActor.assumeIsolated {
      waiting?.finish(flag ? .success(length) : .failure(Refusal(500, "the speaker stopped before the end")))
    }
  }

  nonisolated func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
    let reason = error?.localizedDescription ?? "unknown"
    MainActor.assumeIsolated {
      waiting?.finish(.failure(Refusal(400, "the audio could not be decoded: \(reason)")))
    }
  }

External links

Exercise

네 iOS 프로젝트 하나를 만들어지는 프로젝트로 옮겨 봐. project.yml을 쓰고, xcodegen으로 프로젝트를 만들고, git에서 .xcodeproj를 무시하고, 빌드하고 테스트하는 스크립트가 전부 돌기 전에 다시 생성하게 해. 그다음 앱이 받는 프레임워크 콜백(카메라, 오디오, 네트워크, 위치, 알림)을 전부 적어. 콜백마다 어느 스레드가 부르는지, 그리고 세 모양 중 뭘 쓸지 적어. 자기 큐, 액터 격리 없는 평범한 델리게이트, 아니면 도착한 자리를 확인하는 nonisolated 메서드를 가진 메인 액터 타입.
Hint
다시 생성 규칙부터 직접 증명해 봐. 빈 테스트 파일을 하나 더하고, 다시 생성하지 않은 채 스위트를 돌려서 테스트 수를 세. 그다음 다시 생성하고 또 세. 콜백이 어느 큐에서 불리는지는 보통 애플 문서의 델리게이트 설명에 적혀 있어. 적혀 있지 않으면 남의 스레드라고 가정해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.