"사용자가 권한 창이 떠 있는 동안 정지를 눌렀어. 그러고 나서 권한 창에 대답했더니 마이크가 열렸어."
일시 중단은 세상을 멈추는 버튼이 아니야
await는 일시 중단 지점이야. 네 함수가 기다리는 동안 다른 코드가 돌아. 같은 actor로 들어오는 다른 호출도 예외가 아니야. 다시 이어갈 땐 await 전에 확인했던 상태가 더는 참이 아닐 수 있어. 이게 actor 재진입이고, 일부러 이렇게 만든 거야. 한 호출이 네트워크를 기다리는 동안 모든 일을 거부하는 actor는 스스로 교착 상태에 빠질 테니까.
실전 규칙은 이거야. await 뒤에는 다음 줄이 전제하는 걸 매번 다시 확인해. 기다리는 대상에 사람이 대답할 때(권한 대화상자, 파일 선택기, 공유 시트, 알림) 제일 중요해. 사람은 대답하는 데 몇 분씩 걸릴 수도 있고, 그사이 기다림을 시작한 객체가 멈추거나 해체되거나 새로 만들어질 수 있거든.
멈추지 않은 정지
가족의 받아쓰기 턴은 start() 안에서 시스템 권한 창 두 개를 기다렸어. 그 단계에서도 정지 버튼은 이미 살아 있었고, stop()은 착실하게 상태를 idle로 바꾸고 전부 정리했어. 이어서 사용자가 대화상자에 대답하자 continuation이 재개됐고, start()는 아무 일 없었다는 듯 하던 일을 이어갔어. 오디오 세션, 탭, 엔진까지 전부. 사용자가 멈춘 턴이 마이크를 열었고, 상태엔 "듣는 중"이라고 떴어. 새로 깐 앱에서 첫 받아쓰기를 하자마자 만날 수 있는 버그였어. 사람이 딱 머뭇거리는 순간이니까.
고치는 데 새 상태는 필요 없었어. 늦게 오는 콜백을 거르려고 세대 카운터가 이미 있었거든. start()는 첫 await 전에 자기 세대를 잡아두고 await마다 확인해. stop()은 세대를 올려서 그 전의 start를 전부 낡은 걸로 만들고. 취소 핸들러만으론 부족했을 거야. 사용자가 결국 대답하면 continuation은 정상적으로 재개되니까.
첫 회귀 테스트는 아무것도 증명 못 했어. 정지 뒤에 "듣는 중이 아님"을 확인했는데, 고장 난 코드에서도 통과했어. 시뮬레이터엔 한국어 음성 모델이 없어서 start()가 몇 줄 뒤에 어차피 빠져나갔거든. 테스트가 시뮬레이터를 재고 있었던 거야. 단언을 가드만 만들어낼 수 있는 state == .idle로 바꾸자, 옛 코드에선 실패하고 고친 코드에선 통과했어.
continuation, 데드라인, 단언문
- checked continuation은 딱 한 번 재개돼야 해. 두 번 재개하면 트랩이 나고, 한 번도 안 하면 멈춘 태스크가 영원히 새.
- 어떤 콜백은 끝내 안 와. 메일에서 온 PDF를 기다리던 공유 확장이 한 시간 동안 살아 있었어. 기기에서 로드 완료 콜백이 끝까지 안 불렸거든. 이런 기다림엔 전부 데드라인이 필요해. 태스크 그룹은 안 돼. 그룹은 모든 자식을 기다리는데, 문제는 바로 안 돌아오는 자식이잖아. 먼저 끝나는 쪽이 이기는 경주로 해. 태스크 둘, 락 걸린 문 하나, continuation 하나.
XCTAssertEqual(await store.state(id), .queued)는 컴파일이 안 돼. XCTest 단언문 인자는 await를 못 쓰는 autoclosure야. 값을 먼저let으로 꺼내고 그걸로 단언해.