본문 바로가기
C.W.K.
Stream
Lesson 07 of 07 · published

덫이 된 탭

~12 min · swift-6, concurrency, actor-isolation, audio-thread, race-condition

Level 0불씨 없음
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"컴파일러가 버그를 놓친 게 아니야. 검사를 하나 끼워 넣었고, 그 검사가 코드가 한 번도 피하겠다고 약속한 적 없는 스레드에서 터졌어."

첫 단어에서 난 크래시

2026-09-15 에 Firekeeper 가 켜자마자 첫 녹음에서 죽기 시작했어. 크래시 리포트 둘 다 RealtimeMessenger.mServiceQueue 위의 EXC_BREAKPOINT 였어. AVFAudio 의 실시간 큐, 마이크 버퍼를 건네주는 스레드지. 터진 스택은 swift_task_isCurrentExecutor 를 지나고 있었어. Swift 가 직접 프로그램을 멈춘 거야.

업그레이드가 바꾼 것

그 전날 가족 전체 일괄 작업이 Firekeeper 를 Swift 6 와 Observation 프레임워크로 옮기면서 AudioCaptureService 에 @MainActor 를 붙였어. 별일 없어 보였지. 캡처는 UI 에서 시작하니까. 그런데 마이크 탭이 메인 액터 메서드 안에 인라인 클로저로 적혀 있었어. Swift 6 에선 @Sendable 이 안 붙은 클로저가 자기가 적힌 자리의 격리를 물려받아. 그래서 컴파일러는 그 탭을 메인 액터 코드로 보고, 메인 액터에서 도는지 확인하는 런타임 검사를 끼워 넣었어. AVFAudio 의 탭 블록 타입엔 sendable 표시가 없으니 그 추론을 막을 것도 없었고. 첫 버퍼가 실시간 큐로 들어오고, 검사가 실패하고, 앱이 멈췄어. 하루 늦게 드러난 건 그 사이 설치된 앱을 다시 빌드하지 않았기 때문이야.

@MainActor final class AudioCaptureService {
    func start() {
        input.installTap(...) { buffer, _ in   // MainActor 메서드 안에 적힌 인라인 클로저
            ...                                // -> MainActor 로 추론 -> 런타임 실행자 검사
        }                                      // AVFAudio 가 실시간 큐에서 부름 -> TRAP
    }
}

해법은 이미 있었어

가족 헬스 앱이 닷새 전에 정확히 이 크래시를 겪었고, 공용 키트엔 이미 답이 있었어. CwkKitSpeech.AudioTap.block(into:) 은 탭 블록을 액터 바깥에서 만들어. 그러니 어떤 격리도 거기 추론될 수가 없지. 일괄 작업이 그걸 안 썼을 뿐이야. 이제 Firekeeper 는 탭을 키트에서 받고, 절대 인라인으로 안 적어. 탭이 그다음에 부르는 레벨 콜백 타입에도 @Sendable 을 붙였어. 거기가 다음 덫이었을 테니까. 남은 Swift 6 경고 둘은 나중에 '조용히 시키지 말고 검사되는 구조로' 닫았어. 눌러 둔 경고는 미뤄 둔 덫이니까.

세대는 취소만이 아니라 쓰기도 지켜야 해

더블 탭 레슨에서 오디오 세션에 세대 토큰을 줬지. 낡은 취소가 아무것도 못 하게. 2026-09-27 교차 브레인 리뷰가 한 층 위에서 같은 유령을 찾았어. 전사가 나가 있는 동안 받아쓰기를 취소하고, 새로 녹음을 시작하고, 그다음 옛 전사가 돌아오게 해 봐. 옛 파이프라인의 취소 처리기가 새 녹음 위에다 공유 상태를 idle 로 바꿔 버렸어. 캡처 쪽 세대 가드는 새 마이크를 멈추는 걸 제대로 거부했지. 그래서 마이크는 켜진 채로 남고, 상태는 idle 이라고 말하고, Stop 은 무시됐어. 그래서 세대 확인을 취소에서 모든 쓰기로 넓혔어. DictationSessions 장부가 시작이나 취소마다 새 세션을 열고, 파이프라인은 녹음이 멈출 때 현재였던 세션을 들고 다니고, await 뒤의 쓰기는 전부 state(owning: session) 을 거쳐. 밀려난 파이프라인이면 그 함수는 아무것도 안 돌려줘. 늦게 온 쓰기는 아무 데도 안 떨어지는 거지.

네 클로저를 어느 스레드가 부르는지 알고, 그걸 타입에 적어. Swift 6 에선 클로저가 어디 적혔는지가 그 클로저의 격리를 정해. 프레임워크가 자기 큐에서 부르는 콜백, 그러니까 오디오 탭, 파일 감시, 네트워크 처리기는 전부 액터 바깥에서 만들거나 @Sendable 로 표시해. 안 그러면 언어가 네가 한 적도 없는 가정을 검사해.
낡은 작업은 쓸 수 없어야 해. 취소만 지키는 건 반쪽이야. await 하고 나서 공유 상태를 쓰는 코드는 먼저 자기가 아직 그 상태의 주인인지 증명해야 해. 안 그러면 느린 옛 작업이 새 작업의 세상을 다시 써 버려.

Code

두 가지 해법 (AudioCaptureService 와 FirekeeperKit DictationSessions)·swift
// 1. 탭 블록은 키트에서, 어떤 액터도 추론될 수 없는 자리에서 만들어.
// "Never an inline closure here. This method is MainActor, so Swift 6 would
//  infer the block MainActor-isolated and trap on AVFAudio's realtime queue."
input.installTap(onBus: 0, bufferSize: 4096, format: tapFormat,
                 block: AudioTap.block(into: session))

// 2. await 뒤의 쓰기는 전부 세션이 주인이야.
public struct DictationSessions: Sendable {
  public private(set) var current: UInt64 = 0
  @discardableResult
  public mutating func begin() -> UInt64 { current &+= 1; return current }
  public func owns(_ session: UInt64) -> Bool { session == current }
}

private func state(owning session: UInt64) -> AppState? {
  sessions.owns(session) ? state : nil   // 밀려난 파이프라인은 아무 데도 못 씀
}

External links

Exercise

프레임워크가 자기 큐에서 부르는 클로저를 네 코드에서 찾아봐(오디오, 파일 이벤트, 소켓, 타이머). 각각 어느 스레드가 부를지, 그 클로저가 어떤 격리 안에 적혀 있는지 적어. 둘이 다르면, 타입이 사실을 말하게 하려면 클로저를 어떻게 만들지 정해 봐.
Hint
안전한 모양은 이래. nonisolated 함수에서 콜백을 만들거나, @Sendable 로 표시하고 캡처도 sendable 로 두거나, 명시적으로 액터로 넘어가거나. 다만 넘어가는 건 그 자체가 nonisolated 거나 @Sendable 인 콜백 안에서만 돼. 이미 액터 격리를 물려받은 클로저 안에서 넘어가면 늦어. 런타임 검사는 들어오는 순간, 넘어가기 전에 돌거든. 위험한 모양은 조용히 컴파일되는 그거야. 액터 격리 메서드 안의 인라인 클로저를 딴 데서 부르는 것.

Progress

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

댓글 0

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

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