TTS Blob src 교체 AbortError — AI 발화 말끊김 세션 로그 원인 분석 원인 확인

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

작성일: 2026-07-21 · 발생일: 2026-07-20 · 대상 로그: 6-019f7ee8…/0/2dfd0dd5… · 앱: tbwewz/ppi-prod

결론 — 범인부터 공개한다

AI TTS 말끝이 잘릴 수 있는 직접 원인은 확인됐다. 이전 TTS 청크의 HTMLAudioElement.play()가 완료되기 전에 새 Blob URL을 같은 audio 엘리먼트의 src에 설정했다. 브라우저는 이전 재생 요청을 취소하고 AbortError를 반환한다.

청크 도착 간격이 일정하지 않은 것은 이 현상을 촉발하는 조건일 뿐, 근본 문제는 새 청크를 즉시 같은 엘리먼트에 덮어쓰는 재생 경합 처리다. 이 로그만으로 TTS 공급 서버가 비정상적으로 빠르게 청크를 보냈다고 단정할 수는 없다.

수사 일지 ① — 사건 발생과 직접 증거

7/20 — 신고 접수

prod 세션(6-019f7ee8…/0/2dfd0dd5…)에서 AI TTS 말끝이 잘린다는 신고가 들어왔다. 세션 로그를 열자 브라우저가 스스로 범행을 진술한 기록이 두 번 남아 있었다 — AbortError, 그것도 62초 간격으로 같은 문구가 반복됐다. 일회성 사고가 아니라 패턴이라는 뜻이다.

시각컴포넌트로그와 판정
19:00:50.822 KST
10:00:50.822 UTC
TTS_PLAYERAbortError: The play() request was interrupted by a new load request.
새 로드가 기존 재생을 중단했다는 브라우저의 직접 진단.
19:01:52.549 KST
10:01:52.549 UTC
TTS_PLAYER동일한 AbortError 재발. 일회성 사용자 권한/네트워크 실패가 아니라 재생 경합 패턴.
[2026-07-20T10:00:50.822Z] [WARN] [TTS_PLAYER]
TTS local audio element playback failed
{
  "name": "AbortError",
  "message": "The play() request was interrupted by a new load request. https://goo.gl/LdLk22"
}

수사 일지 ② — 범행 수법: 코드 경로와 발생 조건

로그에서 코드로

브라우저 진단문("The play() request was interrupted by a new load request")이 가리키는 곳은 명확했다 — 재생 중인 엘리먼트에 새 로드를 덮어쓰는 코드. TTS 재생 경로를 따라가 보니 구현이 로그와 정확히 같은 그림을 그리고 있었다.

apps/web/entities/guest-session/lib/tts-player.tsplayBlobAudioElement()는 재생 중인 엘리먼트에도 즉시 다음 순서로 동작한다.

audio.srcObject = null;
audio.src = objectUrl;       // 새 TTS 청크의 Blob URL
void audio.play().catch(...);
청크 A 도착audio.src = A
play() 시작
청크 A 재생 중아직 ended
청크 B 도착audio.src = B로 즉시 교체
결과A의 재생 요청 취소
AbortError 및 말끝 절단 가능

코드 주석도 이전 src를 새 Blob으로 교체하면 이전 재생이 즉시 중단된다고 명시한다. 따라서 로그와 구현이 동일한 원인을 가리킨다.

수사 일지 ③ — 용의선상의 다른 신호들 (별건 분리)

공범인가, 별건인가

같은 세션 로그에는 다른 이상 신호도 여럿 찍혀 있었다. 이들을 말끊김의 원인으로 엮으면 수사가 흐려진다 — 하나씩 대조해 AbortError와의 관계를 판정하고, 별도 조사 대상과 무관 신호를 분리했다.

신호로그 시각의미 / 관계
ai-audio outbound stalled19:12:08producer는 live·unmuted·transport connected이나 5초 표본의 bytes/packets/energy delta가 모두 0. 원격 진행자에게 전달되는 AI 오디오가 멈출 수 있는 별도 경로이며, 위의 local Blob AbortError와는 별도 조사 대상.
AUDIO_PROBE suspected silent세션 중 5회AudioContext가 running인데 hardware clock이 전진하지 않았다. 기기 출력 파이프라인 이상 후보이나, TTS AbortError의 직접 원인은 아니다.
noise reduction mismatch19:08:49원하는 far_field와 실제 null 불일치. 아동 입력 잡음/인식 품질 후보이며 TTS 출력 말끊김의 원인은 아니다.
External STT duplicate skipped강제 퇴장 직후 77회/6.619초, 재차 9회/0.706초종료 뒤 잔여 STT 콜백 또는 동일·무음 청크 정황. TTS 재생 중단의 근거로 사용하지 않는다.

수사 일지 ④ — 검거 계획: 재생 큐로 단일 소비자 보장

수법이 확정됐으니 수갑을 설계한다

범행 수법이 "재생 중 src 즉시 교체"로 확정된 이상, 대응은 새 청크가 현재 재생을 건드리지 못하게 만드는 것이다. 청크 도착 간격을 조절하는 쪽(공급 측)이 아니라 소비 측에서 순서를 보장한다.

권고안이며 이 문서에서 구현을 확정하지 않는다. 새 청크가 도착해도 현재 재생의 src를 변경하지 말고 FIFO 큐에 넣는다. 현재 청크의 ended 후에만 다음 Blob URL을 설정한다.
  1. 청크 도착: Blob URL을 재생 큐에 추가하고, 재생 중이면 반환한다.
  2. 단일 async 재생 루프: 큐 맨 앞을 audio.src에 설정 → play()ended를 기다린 뒤 다음 항목으로 진행한다.
  3. 정상 완료 뒤에만 해당 Blob URL을 revoke한다.
  4. 세션 종료·사용자 끼어들기처럼 의도적으로 중단할 때만 pause(), src 초기화 및 큐 비우기를 수행한다.
  5. 의도적 취소에는 세션 세대/generation을 남겨 예상된 AbortError로 분류하고, 그 외 AbortError는 재생 경합 오류로 유지한다.

청크 간 무음까지 최소화해야 하는 요구가 확인되면, 후속 대안으로 Web Audio의 예약 재생을 검토할 수 있다. 그러나 현재 직접 증거에 대한 최소 대응은 단일 소비자 FIFO 큐다.

검증 포인트 — 재현·검증 기준

관련 문서