"성공이야. native app에서는 문제가 없는 거였어. 앞으로 ios native app에서는 streaming 을 쓰는 게 맞아." — 아빠, 2026-09-25
통째인 단위를, 흘려서
앞 레슨은 단위를 쪼개지 말라고 했어. 그렇다고 테이크가 다 만들어질 때까지 기다렸다 재생하라는 뜻은 아니야. 제공자는 통째인 단위 하나를 합성하면서 소리를 흘려보내고, 듣는 사람은 나머지가 만들어지는 동안 첫 청크부터 들을 수 있어. 데스크톱에서 v3 단위의 첫 청크는 약 0.8~1.4초에 와. 완성된 테이크는 얘기가 달라. 43자짜리 문장은 약 3초, 178자는 10~13초. 첫 청크부터 틀어주느냐가 잠깐 멈춤과 기다림의 차이야.
폰이 기다렸던 이유
폰은 처음엔 흘려서 재생하지 않았어. 가족이 호되게 배운 이유가 있었거든. 그해 여름 만년필 앱의 웹 버전이 사파리에서 실시간 MP3를 틀었는데, 바로 시작은 되는데 5분의 4쯤에서 뚝 끊겼어. 그래서 Bellows 규칙엔 iOS는 완성된 파일을 튼다고 적혔어. 그다음 설계 문서엔 미리 기준을 적어 뒀어. 완성 파일을 기다리는 시간이 데스크톱의 첫 청크보다 1.5초 넘게 길면 네이티브 디코더를 만든다. 측정값은 그 기준을 한참 넘었어.
흔적을 안 남긴 타임아웃
디코더 전에, 폰에서 말 답이 조용해지는 더 교묘한 버그가 있었어. 엔진에 테이크 준비를 부탁하는 요청이 키트의 빠른 전송로를 탔는데, 이건 5~8초면 포기해. 그런데 테이크 준비는 대답하기 전에 전부를 합성하는 일이야. 벤치에서 414자 답에 24초가 걸렸어. 그러니 한두 문장보다 긴 답은 폰에서 전부 타임아웃이 났어. 서버는 어차피 끝까지 만들어서 캐시해 뒀고, 그래서 아빠가 나중에 스피커를 누르면 바로 재생돼서 버그가 그냥 가끔 그러는 것처럼 보였어. 게다가 서버의 접근 로그는 응답이 시작될 때만 한 줄을 적으니까, 포기한 클라이언트는 아무 줄도 안 남겼어. 이걸 찾으려고 빌드를 하나 더 냈어. 할 일은 딱 하나, 재생이 실패하면 이유를 지우지 말고 화면에 남기는 거. 다음에 조용해진 답이 자기 원인을 스스로 말했어. 고친 방법은 그 호출을 음성용 전송로로 옮기는 거였어. 인내심이 초 단위가 아니라 분 단위인 길로.
자기만의 디코더
그러고 나서 아빠가 옛 사파리 버그의 진짜 원인을 떠올렸어. 그건 스트림 길이를 미리 짐작하는 재생기, 그러니까 사파리와 AVPlayer 탓이지 iOS 탓이 아니었어. 그래서 폰은 절대 짐작하지 않는 재생기를 얻었어. URL 세션 델리게이트로 본문을 받고, Audio File Stream Services로 오디오 패킷을 가르고, Audio Queue에 먹이고, 서버가 본문을 닫을 때만 끝나. 벤치에서 1,000자 답은 재생 1.21초 뒤 첫 소리가 났고 끝까지 66.27초였어. 같은 글로 엔진이 캐시한 완성 테이크는 1,060,407바이트에 66.2726초였고, 스트림은 1,060,407바이트에 66.2727초를 실어 왔어. 잘린 건 하나도 없었어. 완성 파일은 두 번째 길로 남아서, 스트림이 소리를 내기도 전에 실패할 때만 써. 이제 완성 파일을 트는 건 iOS의 브라우저뿐이야.