TTS 재생 스톨 — onended 미발생으로 인한 재생 큐 영구 블로킹 원인 분석 원인 분석 수정 필요

마지막 업데이트 2026-07-22

작성일: 2026-07-06 발생일: 2026-07-05 (prod) 대상: iPad 게스트 2세션 (4회기 / 16회기) 핵심 파일: apps/web/entities/guest-session/lib/tts-player.ts

1. 요약

"핑퐁이가 대답을 안 한다"의 실체는 TTS 요청·응답(네트워크) 지연이 아니라, TTS 재생 단계의 정지였다. 재생 중인 청크의 WebAudio onended 이벤트가 발생하지 않으면서 playing=true로 고착됐고, 이후 도착한 모든 AI 발화가 재생 큐에 갇혀 27~41초간 완전 침묵 → 진행자 강제 재접속(kick)으로 이어졌다.

2. 조사 대상 세션

세션회기기기스톨 발생침묵 시간종료 방식
세션 A (f78bbe0c…_4)4회기iPad (내장 마이크/전면 카메라) 00:31:19 / 00:32:49 (UTC, 2회)27초 / 41초강제 재접속(kick) 2회
세션 B (4d728a81…_16)16회기iPad (내장 마이크/전면 카메라) 01:03:55 (UTC, 1회)35초강제 재접속(kick) 후 재입장 실패 반복

두 세션 모두 스톨 전후로 기기·네트워크 불안정 정황 동반: 킥 직후 NETWORK_QUALITY "외부·서버 모두 도달 실패(인터넷 전체 다운)" 진단, critical NetworkAlert(cameraVideo·cameraAudio·socket), 재입장 시 PermissionDenied 반복.

3. TTS 파이프라인 구조 — 공급과 소비의 분리

AI 응답 텍스트는 스트리밍 델타로 들어와 문장 경계에서 청크로 잘려 큐에 쌓인다 (appendDeltasentence_boundary / max_buffer / finalize). 각 청크는 병렬로 합성(fetch)·디코딩되지만, 재생은 큐 맨 앞부터 한 번에 하나다.

청크 분할·큐잉
enqueue
(턴 경계 넘어 공유)
합성 fetch
Typecast API
0.5~1.1초 정상
디코딩
AudioBuffer
~10ms 정상
재생
source.start()
여기서 정지
onended
다음 청크 진행의
유일한 트리거
다음 청크로 넘어가는 경로는 source.onended → finishCurrentPlayback → playing=false → tryPlayNext() 하나뿐이다. 디코딩 완료 시에도 tryPlayNext()가 호출되지만 첫 줄 if (this.playing) return에서 전부 튕긴다. 타이머·재생 위치 폴링·buffer.duration 데드라인 같은 보조 경로는 없다. onended가 유실되는 순간 큐 전체가 stop()(킥에 의한 세션 정리)까지 영구 정지한다.

4. 스톨 타임라인 — 세션 A 킥#1 (00:31:07 ~ 00:31:46)

진행자 메시지에 대한 AI 응답(턴 d3173c00, 10글자+7글자 청크 2개)에서 발생. 막대 길이는 실제 로그 타임스탬프 비율.

00:31:08:18:28:38:46.8
턴1 청크1 (10글자)정상이면 ~2.5초
턴1 청크2 (7글자)~2초 분량 → onended 미발생
킥 00:31:46.8
턴2 (00:31:20, 13+12글자)fetch·디코딩 성공, 재생 0회
턴3 (00:31:32, 13+12글자)fetch·디코딩 성공, 재생 0회
턴4 (00:31:42, 18+15글자)fetch·디코딩 성공, 재생 0회
fetch+디코딩 (전부 정상) 재생 지연 (전조) 재생 정지 (onended 미발생) 큐 대기 (블로킹)

이벤트 로그 요약

시각 (UTC)이벤트판정
00:31:07.646Sent response.create → 00:31:08.366 Response completedOpenAI 정상 (0.7초)
00:31:08.835청크1 fetch 응답 (534ms) → 디코딩(10ms) → 재생 시작공급 정상
00:31:09.552 ~ 19.390전 로거 10초 완전 공백 후 청크1 onended (재생 10.5초)런타임 정지 전조
00:31:19.391청크2 재생 시작 → onended 영영 미발생스톨 시작
00:31:20 / 32 / 42진행자 메시지 3건 → AI 응답 3턴 생성(0.7~0.9초), 청크 6개 fetch·디코딩 성공 — 재생 0회큐 블로킹 (27초 침묵)
00:31:46.846Guest force kickedStopping session으로 큐 폐기. 직후 "인터넷 전체 다운" 진단스톨 종료

5. 재발 패턴 — 세션 A 킥#2, 세션 B

세션 A 킥#2 (00:32:38 ~ 00:33:30) — 메인스레드 정지의 결정적 증거

세션 B (01:03:55 ~ 01:04:30)

공통 서명: ① 스톨 직전 청크가 "짧은 오디오인데 ~10초 재생"으로 늘어지거나(전조), 시작 직후부터 무음 ② 마지막 청크의 onended 미발생 ③ 그 사이 fetch·디코딩·OpenAI 응답은 계속 정상 ④ 진행자 듣기실패 프리셋 → 강제 재접속.

6. onended "지연"과 "미발생"의 구분

청크재생 시작onended소요기대치
A#1 청크1 (10글자)00:31:08.84600:31:19.39010.5초 (지연)~2.5초
A#1 청크2 (7글자)00:31:19.391미발생킥까지 27초~2초
A#2 청크1 (14글자)00:32:39.01400:32:49.15310.1초 (지연)~3.5초
A#2 청크2 (14글자)00:32:49.504미발생킥까지 41초~3.5초
B 청크1 (12글자)01:03:55.632미발생킥까지 35초~3초

확인 방법: LogRocket 내보내기 JSON의 TTS chunk playback started / ended를 diagnostic의 traceId + chunkIndex로 짝 맞추고 타임스탬프 차를 계산. "미발생"은 세션 정리(Stopping session) 시점까지 짝이 없음 + 그 사이 다른 로거는 정상 간격으로 계속 찍힘(로그 수집 유실 배제) + 콜백을 무효화하는 generation 증가는 킥 시점에만 발생함으로 교차 검증. 기대치 기준은 같은 세션 정상 청크들의 글자당 0.2~0.4초 비율.

7. tts-player 코드 취약점

스톨의 트리거는 기기(iPad) 런타임/오디오 정지지만(전 로거 동시 공백, 콜백 동시 해빙, 네트워크 진단이 근거), 몇 초짜리 환경 장애가 영구 블로킹이 된 것은 코드 구조 때문이다.

#취약점위치
1단일 실패 지점 — 청크 진행 경로가 source.onended 하나뿐. 유실 시 playing=true 고착tts-player.ts:413-417
2watchdog 부재 — 비디오에는 있는 백스톱이 TTS엔 없음. buffer.duration으로 예상 길이를 아는데도 데드라인 강제 진행 없음전체
3복구 시도가 도달 불가능 — 유일한 ctx.resume()tryPlayNext() 안에 있는데 재생 중엔 첫 줄에서 리턴 → 정작 멈췄을 때 resume 0회 실행tts-player.ts:398,405
4iOS interrupted 미처리 — 체크는 "suspended"만, statechange 리스너 없음. resume도 await 없이 fire-and-forget 후 즉시 source.start()tts-player.ts:405
// tts-player.ts:397 — 재생 중이면 어떤 호출도 무시 (resume 라인까지 못 감)
private tryPlayNext(): void {
  if (this.disposed || this.playing) return;
  ...
  if (ctx.state === "suspended") ctx.resume().catch(() => {});  // fire-and-forget
  ...
  source.onended = () => { ... this.finishCurrentPlayback(gen, metadata); };  // 유일한 진행 경로
  source.start();  // resume 완료를 기다리지 않음
}
"suspended 상태로 재생 시작 → 무음 + 영구 고착" 버그(1.92.0/1.93.0 보고, 별건 사례의 유력 원인)와 같은 취약점 패밀리다. 그 변종은 시작 시점에 이미 컨텍스트가 죽어 있는 경우고, 본 문서의 세션 A는 재생 도중 정지가 덮친 경우다. 세션 B(시작 직후 무음)는 로그상 두 변종을 구분할 수 없다 — 현재 AudioContext.state가 로그에 찍히지 않기 때문. 픽스는 하나로 수렴한다.

8. 재발 시 로그 판별법

  1. [TTS_PLAYER] TTS chunk playback started 이후 같은 traceId·chunkIndex의 playback ended가 수십 초간 없음 → 스톨 확정
  2. 그 사이 TTS chunk queued / response received / audio decoded가 계속 쌓임 → 공급 정상 + 재생 블로킹 (TTS API 문제 아님)
  3. started→ended 간격이 clientTextLength × 0.3초 대비 3배 이상 → 전조 (런타임 정지)
  4. 전 로거 ~10초 완전 공백 후 여러 콜백 동시 해빙 → 메인스레드 정지 서명
  5. 주변 정황: Received message from host: {{듣기실패1차}}(현장 무음 방증), Guest force kicked(스톨 종료점), NETWORK_QUALITY/NetworkAlert(기기 환경)

9. 수정 방향

#수정효과
1재생 watchdog — 재생 시작 시 buffer.duration + 여유(예: 3초) 타이머를 걸고, onended 미도착 시 강제 finishCurrentPlayback + 경고 로그onended 유실 시 몇 초 내 자동 복구. "지연" 변종과 "미발생" 변종 모두 커버
2AudioContext statechange 리스너suspended/interrupted 진입·이탈을 로그로 남기고, 이탈 시 resume + 진행 점검재발 시 로그 한 줄로 판별 가능 + 인터럽션 자동 복귀
3start 전 상태 보장resume()을 await하거나 state === "running" 확인 후 source.start()"suspended 상태로 시작 → 즉사" 변종 차단

관련 문서: iOS 덕킹 TTS/Realtime 모드 감쇠폭 차이 원인분석, 아동070 1회기 아동 소리 안 들림 원인분석, 영상 종료 자동전환 미발생 Watchdog 분석 (비디오 쪽 watchdog 선례)