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

산 자를 취소한 유령

~12 min · race-condition, generation-token, state-machine, async

Level 0불씨 없음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"'진행 중인 녹음 없음.' 녹음이 진행 중인 동안 앱이 한 말이야."

기능과 크래시

탭 문법은 손이 편하라고 만든 거야. 길게 누르면 눌러서 말하기, 짧게 톡 친 다음 한 번 더 치면 핸즈프리로 전환. 첫 구현은 뻔했고 틀렸어. 빠른 탭이 세션을 취소하고, 두 번째 탭이 새 핸즈프리를 시작하는 방식. 가끔 됐어. 가끔은 '진행 중인 녹음 없음' 으로 죽었고. 그 문장이 찍히는 바로 그 순간에 사실이 아니었던 에러 메시지지.

레이스

첫 탭이 async 취소를 쏴. 두 번째 탭이 새 세션을 시작하고. 취소가 느리면, 그리고 실제로 가끔 느려, 오디오를 무너뜨리는 게 즉각적이지 않으니까, 그 취소가 새 세션이 생긴 다음에 도착해. 자기가 낡았다는 걸 모른 채로 엉뚱한 녹음을 무너뜨리지. 이미 죽은 세션의 유령이 손을 뻗어서 살아 있는 걸 죽이는 거야. 그다음엔 세션이 있을 거라 믿은 코드가 아무것도 못 찾고, 그 어이없음을 그대로 신고하고.

탭 1  -> 세션 A 취소  (async, 느림...)
탭 2  -> 세션 B 시작   (빠름)
           ... A-취소가 드디어 착륙 ...
           -> B 를 무너뜨림  # 유령이 엉뚱한 세션을 죽임
           -> "진행 중인 녹음 없음"

해법 둘, 둘 다 필요해

첫 번째 해법이 레이스를 통째로 없애. 세션을 절대 재시작하지 않는다. 빠른 탭이 더는 취소를 안 해. 짧은 결정 창 동안, 그러니까 0.45초쯤 세션을 녹음 중인 채로 둬. 그 사이에 두 번째 탭이 오면 같은 세션이 그 자리에서 핸즈프리로 바뀌고. 두 번째 탭 없이 창이 만료되면 그때 취소해. 무너뜨리고 다시 시작하는 틈이 없으니 유령이 착륙할 자리도 없는 거야. 두 번째 해법은 심층 방어야. 오디오 세션마다 세대 토큰 을 찍고, 취소가 뭘 하기 전에 자기 세대부터 확인하게 해. 세대 1 의 취소가 세대 2 에 도착하면 아무 일도 안 하고 그냥 돌아가는 거야. no-op 이 되는 거지. 첫 번째 해법이 레이스 자체를 없애고, 두 번째 해법이 레이스가 다시 생겨도 피해를 못 주게 막아.

무너뜨리고 다시 짓지 말고 그 자리에서 바꿔. 상태 변화를, 부수고 대체물을 만드는 대신 기존 걸 고치는 걸로 표현할 수 있으면 언제나 그쪽을 골라. 부수기와 만들기 사이의 틈이 낡은 async 버그가 전부 사는 자리거든. 틈이 없으면 유령도 없어.
async 작업에 세대를 찍고, 도착할 때 확인해. 자기를 낳은 것보다 오래 살 수 있는 취소나 콜백이나 완료 핸들러는, 뭘 하기 전에 자기가 아직 유효한지 증명해야 해. 자원에 계속 올라가기만 하는 세대 토큰을 두고 효과가 나는 순간에 비교하면, '낡은 작업이 현재 상태를 망침' 이 '낡은 작업이 무해하게 아무것도 안 함' 으로 바뀌어. 몇 줄로 버그 한 부류를 통째로 죽이는 거야.

Code

그 자리에서 변환, 낡은 세대는 아무것도 못 함·swift
actor AudioSession {
    private var generation = 0

    func start() -> Int {
        generation += 1
        return generation          // 호출자가 자기 세대 토큰을 쥠
    }

    func cancel(generation g: Int) {
        guard g == generation else { return }   // 낡은 유령 -> no-op
        teardown()
    }
}

// 빠른 탭은 취소 안 함. 같은 세션에 결정 창을 엶.
func onQuickTap() {
    phase = .recording(pendingDecision: true)
    decisionTask = Task {
        try? await Task.sleep(for: .milliseconds(450))
        guard !Task.isCancelled else { return }
        await stopAndCancel()          // 창 만료, 두 번째 탭 없음
    }
}

func onSecondTap() {
    decisionTask?.cancel()
    phase = .recording(handsFree: true)   // 제자리 변환 — 절대 재시작 안 함
}

External links

Exercise

취소나 타임아웃이나 콜백이, 대상이 사라지고 대체물이 생긴 다음에 도착할 수 있는 async 코드를 찾아봐. 유령을 따라가 봐. 늦게 도착한 그놈이 새 객체한테 무슨 짓을 해? 그다음 세대 토큰을 붙이고, 똑같은 순서가 이제 어떻게 무해하게 흘러가는지 적어봐.
Hint
유령 패턴은 늘 똑같아. A 를 무너뜨리는 느린 작업이 B 를 만드는 빠른 작업 뒤에 착륙하는데, 무너뜨리는 쪽은 B 가 A 가 아니라는 걸 알 방법이 없는 거야. 세대 토큰이 있으면 늦게 온 취소가 자기 도장을 지금 것과 비교해 보고, 안 맞는 걸 확인하고, 아무것도 안 건드린 채 돌아가. 그 순서는 여전히 벌어져. 다만 더 이상 중요하지 않을 뿐이지.

Progress

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

댓글 0

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

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