실시간 음성 대화 앱이 심사에서 두 번째로 같은 사유로 거부됐습니다. 심사 노트의 요지는 "통화를 시도했을 때 앱이 응답하지 않았다"였습니다.
첨부된 스크린샷을 열어 봤습니다. 통화 화면이 정상적으로 떠 있었습니다. 종료 버튼도, 경과 타이머도 살아 있었습니다. 자막 영역만 비어 있고 "천천히 하세요" 안내가 떠 있었습니다.
연결은 됐습니다. 그리고 서버에서 오는 이벤트가 0건이었습니다.
당신의 앱에서 "연결됨"이라는 상태 값은, 실제로 데이터가 흐른다는 증거입니까?
끝난 스트림은 되살아나지 않습니다
원인은 이벤트 스트림의 수명이었습니다.
세션 객체가 스트림을 init에서 한 번 만듭니다. 그리고 stop()이 그 continuation을 finish()합니다. 끝난 AsyncStream은 다시 연결해도 영원히 이벤트를 주지 않습니다. 이후의 yield는 조용히 버려지고 for await는 즉시 끝납니다.
그 직전 커밋에서 저는 "실패 경로에 정리가 누락됐다"는 문제를 고쳤습니다. 고친 방식이 이랬습니다 — 진입점 begin() 맨 앞에 정리를 넣는 것.
func begin() async {
session.stop() // ← 실패 경로 정리 누락을 고친다고 넣은 줄
audio.stop()
...
}그런데 모델이 세션 인스턴스 하나를 들고 있었습니다. 그래서 이렇게 됩니다.
첫 통화를 시작하는 순간, 그 통화가 쓸 스트림이 닫힙니다.
증상이 특히 나쁩니다
오디오·자막·발화 감지가 전부 사라지는데 에러가 하나도 없습니다.
상태 값은 여전히 "통화 중"입니다. 하트비트도 계속 나갑니다. 그래서 사용 시간만 녹습니다. 기기와 무관하게 100% 재현됩니다.
그리고 이게 심사자가 본 화면입니다. 타이머가 도는 빈 통화 화면.
부모 앱에는 이 결함이 없었습니다
이 앱은 포크입니다. 부모 앱을 확인해 봤습니다. (같은 앱의 다른 실시간 결함은 앱이 자기 목소리를 사용자 발화로 오인한 이야기에 적었습니다.)
부모는 처음부터 세션을 팩토리로 만듭니다.
let makeVoice: () -> any VoiceSession // 통화마다 새 세션통화마다 새 세션이니 스트림도 새것입니다. 같은 정리 호출을 넣어도 아무 문제가 없습니다.
포크 두 개에 그 형태가 흘러오지 않았습니다. 여기서 규칙 하나가 나옵니다. 수정은 부모에서 포크로 흐르지만, 포크가 부모의 좋은 형태를 물려받는 것은 자동이 아닙니다. 버그 픽스는 전파를 챙기는데, 설계 형태는 아무도 전파하지 않습니다.
테스트가 전부 초록이었던 이유가 더 나쁩니다
이 결함은 테스트로 잡혀야 했습니다. 안 잡혔습니다. 이유가 문제입니다.
테스트 대역이 스트림을 lazy var나 계산 프로퍼티로 만들었습니다. 그래서 begin()이 맨 앞에서 stop()을 부르는 시점에 대역은 아직 스트림을 만들지도 않은 상태였습니다. 아무 일도 일어나지 않았고, 초록이 떴습니다.
프로덕션은 그 시점에 스트림을 영구히 끝냈습니다.
대역의 수명이 프로덕션과 다르면 수명 결함은 원리적으로 잡히지 않습니다. 세 앱 다섯 파일의 대역을 init 생성으로 바꿨습니다.
빈 대역도 같은 함정입니다. events가 즉시 끝난 스트림인 대역은 "소켓이 죽었다"에 해당하는 상태입니다. 그걸 정상 통화 테스트의 전제로 쓰면 안 됩니다.
당신이라면?
연결됐다고 표시되는데 데이터가 안 오는 앱이 있습니다. 어디에 방어선을 두겠습니까?
- 진입점에서 정리를 부른다 — 제가 한 것. 재사용되는 객체가 하나라도 있으면 시작이 곧 절단이 된다.
- 상태 값을 더 촘촘히 본다 — 상태는 "통화 중"이었다. 상태는 살아 있다는 증거가 아니다.
- 흐른 바이트를 센다 — 통화 회차마다 오디오 바이트를 세면 0인 통화가 즉시 드러난다.
방어선 세 겹
- 통화마다 새 세션(팩토리). 그리고 펌프에는 "어느 세션의 스트림인지"를 인자로 넘깁니다. 모델 필드를 다시 읽으면 옛 펌프가 새 스트림을 물 수 있습니다.
- 죽은 통화를 보이게 합니다. 스트림이 에러 없이 끝났는데 상태가 아직 "통화 중"이면, 실패로 바꾸고 정산해 닫습니다. 이게 있었다면 심사 거부가 아니라 에러 화면이었습니다.
- 재사용을 던집니다.
connect()두 번째 호출은 조용히 죽는 대신 예외를 냅니다.
검증은 상태 값이 아니라 회차마다 오디오 바이트로 했습니다. 심사와 같은 기기 계열의 시뮬레이터에서 1회차 19,200바이트 / 2회차 19,200바이트. 수정 전에는 둘 다 0이었습니다.
자가진단 세 줄
- 테스트 대역의 객체 수명이 프로덕션과 같습니까?
lazy로 만든 대역은 "아직 안 만들어서" 통과합니다. 수명 결함은 그 틈으로 전부 빠져나갑니다. - "연결됨" 상태 말고, 흐른 양을 세는 계측이 있습니까? 바이트·이벤트 수 어느 쪽이든 0을 볼 수 있어야 합니다.
- 에러 없이 끝난 스트림을 실패로 승격시킵니까? 조용한 종료는 정상 종료와 같은 모양입니다. 상태와 교차해서 봐야 갈립니다.
솔직한 부분
심사를 두 번 태우고 나서 찾았습니다. 1차 거부도 같은 함수의 다른 결함이었습니다. 즉 저는 같은 함수를 두 번 고치면서 두 번 다 놓쳤습니다.
그리고 2차 스모크 테스트는 상태 값만 봤습니다. 그래서 이 결함을 통과시켰습니다. 제가 만든 검증이 제가 만든 버그와 같은 착각을 공유하고 있었습니다 — 상태가 "통화 중"이면 통화가 되고 있다는 착각입니다.