"이거 트랩 나?"라고 물었는데 "우리 앱은 Swift 5야"는 답이 안 돼.
컴파일러는 하나, 언어는 여럿
Swift 6.3 컴파일러는 언어 모드를 여러 개 지원하고, 타깃마다 따로 고를 수 있어. Swift 5 모드에선 동시성 검사가 기본으로 대부분 꺼져 있고, 엄격한 동시성 검사를 켜면 경고 수준으로 올릴 수 있어. Swift 6 모드에선 데이터 경합 안전이 언어의 일부야. 다른 코드가 아직 닿을 수 있는 Sendable 아닌 값을 격리 경계 너머로 보내기, 백그라운드에서 메인 액터 상태 만지기, 변하는 전역 변수 공유하기가 전부 컴파일 에러야. 다른 누구도 닿을 수 없는 값은 건너가도 돼. 컴파일러가 값마다 어느 영역에 속하는지 추적하거든. 컴파일러가 전체를 못 보는 곳도 있어. 대표적인 게 Objective-C API에 넘기는 클로저야. 그런 곳엔 코드가 격리 표시대로 진짜 거기서 돌고 있는지 확인하는 런타임 검사를 넣어.
모드는 컴파일러 버전이 아니라 빌드 설정에서 골라. SwiftPM 패키지는 swiftLanguageModes: [.v6]로 선언해(6.3 툴체인에서 swift package init으로 만들면 이미 그렇게 돼 있어). 특정 타깃만 .swiftLanguageMode(.v5)로 되돌릴 수도 있어. Xcode나 XcodeGen 타깃은 SWIFT_VERSION을 6.0이나 5.0으로 정해.
가족은 둘 다 썼고, 대가를 치렀어
몇 달 동안 가족 코드는 두 모드에 걸쳐 있었어. 공유 Swift 킷이랑 Mac 앱 몇 개는 Swift 6로, 다른 Mac 앱이랑 iOS 앱 대부분은 Swift 5 모드로 컴파일하면서 그 Swift 6 킷을 링크했어. 서류상으론 문제없어. 모드가 다른 모듈끼리도 같이 돌아가니까. 근데 실제로는 같은 소스가 경계 이쪽과 저쪽에서 뜻이 달라져.
iOS 앱 하나의 받아쓰기 코드는 앱 안에서 Swift 5 모드로 컴파일될 땐 완벽하게 돌았어. 똑같은 코드를 킷으로 옮기고 그날 저녁 앱에서 다시 가져다 썼더니, 아빠가 마이크를 누르는 순간 크래시가 났어. 소스는 한 글자도 안 바뀌었어. Swift 6 모드로 컴파일되면서 @MainActor 메서드 안에서 쓴 클로저가 메인 액터 격리로 추론됐고, 런타임 격리 검사가 붙었어. 그런데 오디오 프레임워크가 그 클로저를 실시간 스레드에서 부른 거야. Swift 5 모드였다면 같은 줄이 기껏해야 경고로 끝났을 거야. 이 트랙 네 번째 레슨에서 그 크래시를 한 줄씩 뜯어볼 거야.
지금의 집 규칙
가족의 방향은 어디서나 Swift 6 언어 모드야. 패키지, Mac 앱, iOS랑 워치 타깃, 맨 swiftc로 빌드하는 헬퍼까지(-swift-version 6). 마이그레이션은 만만한 일이 아니야. 전엔 필요 없던 @Sendable, nonisolated, 격리된 준수를 쓰게 될 거야. 근데 그 어노테이션 하나하나는 Swift 5 빌드가 말없이 가정하던 사실을 코드로 적어두는 거야. 한 번에 넘어가기엔 너무 크다면 Swift 5 모드에서 엄격한 동시성 검사부터 켜. 경고를 0으로 만든 다음 모드를 바꾸면 돼.