"빌드 8은 폰에서 멀쩡했어. 빌드 9는 마이크를 누르자마자 죽었어. 소스는 똑같았고."
그날 저녁 일어난 일
가족의 iOS 건강 앱에 눌러서 말하는 받아쓰기 기능이 있었어. 앱 안 코드였고 Swift 5 모드로 컴파일됐지. 아빠 아이폰에서 잘 돌았어. 그날 저녁 받아쓰기 코드를 공유 Swift 킷으로 옮겼는데, 그 킷은 Swift 6 언어 모드를 선언한 패키지였어. 앱은 곧바로 킷 버전을 가져다 쓰도록 바꿔서 TestFlight 빌드 9로 올렸어. 아빠가 마이크를 누르자 앱이 죽었어. 누를 때마다 매번. 시뮬레이터에서 테스트 74개가 통과했고, 케이블 연결한 아이패드에서도 또 통과했는데.
크래시 리포트를 아래에서 위로 읽으면 이야기가 다 나와. 오디오 프레임워크의 실시간 메신저 스레드가 앱 클로저를 불렀고, 클로저는 제일 먼저 "나 지금 맞는 실행자 위에 있어?"라고 물었어. 아니었고, 그래서 트랩이 났어.
EXC_BREAKPOINT (SIGTRAP) thread: RealtimeMessenger.mServiceQueue
_dispatch_assert_queue_fail
dispatch_assert_queue
_swift_task_checkIsolatedSwift
swift_task_isCurrentExecutorWithFlagsImpl
Forge 0x9e998
AVFAudio AVAudioNodeTap::TapMessage::RealtimeMessenger_Perform()
어떻게 된 거냐면
- 탭은
@MainActor클래스의 메서드에서 설치됐어. - Swift 6 모드에선 메인 액터 격리 문맥 안에서 쓴
@Sendable아닌 클로저가 메인 액터 격리로 추론돼. - 컴파일러가 끝까지 검사 못 하는 API에 격리된 클로저를 넘기면, Swift 6가 클로저 입구에 런타임 격리 검사를 넣어. 소스엔 그런 걸 요청한 흔적이 하나도 없어.
AVAudioNodeTapBlock은 가져온 Objective-C 블록 타입이고@Sendable이 아니라서, 컴파일 단계에선 아무것도 막지 않았어.AVAudioEngine은 탭을 실시간 오디오 스레드에서 불러. 탭이란 게 원래 그러라고 있는 거니까. 검사는 실패하고, SIGTRAP이 떨어져.
Swift 5 모드에선 같은 줄이 기껏해야 경고고, 런타임 검사도 없어. 빌드 8이 살아남은 이유가 그거야.
고친 것과 드러난 것
클로저가 물려받을 격리를 없애. 타입에 @Sendable을 적거나, nonisolated 팩토리에서 만들어서 넘겨. 붙잡은 sink엔 이미 @unchecked Sendable이 붙어 있었지만 소용없었어. 격리는 클로저에 붙어 있지, 클로저가 붙잡은 것에 붙어 있는 게 아니야. 같은 파일에 같은 모양 클로저가 세 개 더 있었는데, 탭이 먼저 불려서 거기까지 안 갔을 뿐이야. 음성 인식 결과 핸들러 하나와 권한 콜백 두 개였어(@MainActor 타입의 static 멤버도 격리되니까 거기선 nonisolated static이 답이야). 그리고 인식 콜백을 정직하게 @Sendable로 만들자, 컴파일러가 인식 결과 객체를 메인 액터로 넘기는 걸 거부했어. 처음부터 있던 진짜 데이터 경합이었지. 지금 콜백은 Sendable한 사실(받아쓴 String, isFinal Bool)만 읽어서 그것만 넘겨.
어떤 테스트도 못 잡은 이유, 여기서만 터지는 이유
오디오 엔진을 켠 테스트가 없었어. 그래서 시뮬레이터에서도 케이블로 연결한 아이패드에서도 탭이 불리지 않았고, 검사까지 가지도 않았어. 이런 버그가 런타임 트랩까지 가는 흔한 길은 가져온 Objective-C나 C 블록 타입이야. 검사받는 Swift 안에선 컴파일러가 Sendable 아닌 클로저를 경계 너머로 보내는 걸 거부하거든. 같은 런타임 검사는 @preconcurrency 준수를 거쳐 불리는 메서드처럼, 컴파일러가 검사하지 못한 코드에서 들어오는 호출도 지켜. 가족이 가져온 규칙은 이거야. 패키지가 C나 Objective-C API에 넘기는 클로저는 전부 리뷰 대상이야. 그리고 회귀 테스트는 detached 태스크에서 블록을 불러서 격리가 없다는 걸 확인해.