AI 오디오 변경 하나로 production이 터져서 2,724줄을 롤백한 이야기
팟캐스트 플랫폼의 음성 시스템을 업그레이드하려는 변경을 배포했습니다. 6일과 여러 커밋 끝에 2,724줄의 코드를 삭제하고 안정 버전으로 롤백했습니다. 실제로 일어난 일과 production AI 변경에 테스트를 어떻게 다뤄야 하는지 배운 점을 공유합니다.
이 글은 더 빨리 쓸 생각이었습니다. 하지만 전체 상황을 정리하는 데 예상보다 오래 걸렸고, 공유하기 전에 이야기를 정확히 파악하고 싶었습니다.
2026년 4월 28일, DIALØGUE의 text-to-speech 시스템을 개선한다고 생각한 변경을 배포했습니다. 다른 voice model이 문서상으로는 괜찮아 보였습니다. 현재 model의 음성이 긴 팟캐스트에서 drift 되는 것을 듣고 있었고, 직접 체감하고도 있었습니다. 그래서 전환했습니다.
4월 30일까지, 2,724줄의 코드를 삭제하고 4월 22일의 stable path로 롤백했습니다.
실제 사용자의 팟캐스트가 멈춰버렸습니다. 오디오를 생성할 수 없었습니다. 시스템은 AUDIO_FAILED를 표시했고 재시도 시 409 Conflict를 던졌습니다. 처음에는 이것이 실제 버그인 줄 알았습니다. 알고 보니 이는 증상이었습니다 — 재시도가 일어날 때 팟캐스트는 이미 failed 상태였습니다. 진짜 문제는 더 단순했고 더 심각했습니다. 새 설정에서 세그먼트가 timeout되었습니다. fallback도 timeout되었습니다. 그리고 시스템은 그냥 포기했습니다.
더 나쁜 것은, 이것이 65분에 가까운 음성 분량의 스크립트였다는 점입니다. 플랫폼 팟캐스트의 중앙값은 26분입니다. 그 두 배가 넘는 분량을, 그런 분량을 성공적으로 생성해본 적 없는 시스템에 통과시켰습니다. 그게 진짜 리스크였습니다 — 그리고 model 변경이 그것을 드러낸 것입니다.
고백하자면, 빌더로서 제 모습이 좋지는 않았습니다. production 시스템이 받아야 하는 stress testing 없이 설정 변경을 push했습니다. 무슨 일이 일어났는지, 무엇이 고장 났는지, 무엇을 배웠는지. 여기에 남깁니다.
DIALØGUE가 하는 일
본 적이 없다면, DIALØGUE는 AI 팟캐스트 생성기입니다. 주제나 PDF, 또는 쇼 에피소드를 입력하면 두 진행자가 대화하는 팟캐스트를 만들어줍니다. 모든 것은 Google의 Gemini text-to-speech synthesis로 돌아갑니다.
오디오 파이프라인은 이렇게 동작합니다:
- 소스 자료에서 outline 생성
- outline을 전체 스크립트로 확장
- 스크립트를 세그먼트로 분할
- Gemini TTS로 각 세그먼트의 오디오 합성
- 세그먼트를 연결하고, loudness를 normalize하고, 최종 MP3를 업로드
production default는 대부분의 케이스에서 잘 작동하는 Gemini TTS model이었습니다. 하지만 스크립트가 길수록 세그먼트 간 voice drift가 더 잘 들렸습니다. 세그먼트 1의 호스트 A와 세그먼트 6의 호스트 A가 완전히 같게 들리지 않았습니다. 25분 팟캐스트라면 눈에 띄지만 익숙해집니다. 40분에 가까워지면 꽤 심각해집니다.
다른 model 설정을 시도하기로 했습니다. 더 긴 팟캐스트 전반에 걸쳐 voice 일관성을 개선하려는 것이었습니다.
제가 만든 것
4월 말 약 일주일에 걸쳐, 다른 model 설정을 시도하는 일련의 커밋을 push했습니다. 단순한 한 줄 변경이 아니었습니다. 아키텍처 전체를 바꿨습니다:
- default TTS model을 새 설정으로 전환
- fallback chain 추가 — primary model이 timeout되면 stable production model로 fallback
- chunk 레벨 QA 시스템 구축: 트랜스크립트를 더 작은 단위로 분할, 각각 합성, ffmpeg analysis로 오디오 품질 검증
- UI가 세그먼트별 합성 상태를 표시할 수 있도록 workflow progress tracking 추가
- retry 로직 강화 — chunk당 3회 시도, exponential backoff
- 최종 조립된 MP3가 audio QA를 통과하지 않으면
COMPLETE를 차단하는 long-form audio quality gate 추가
아이디어는 "세그먼트별로 오디오를 생성하고 일관되길 바라는 것"에서 "트랜스크립트를 chunk하고, 각 chunk를 QA하고, 실패를 retry하고, 최종 파일을 검증하는 것"으로 전환하는 것이었습니다.
4월 28일까지 코드는 production에 배포되었습니다. 단위 테스트는 통과했습니다. 통합 테스트도 통과했습니다. 제가 갖추지 않은 것 — 그리고 갖췄어야 했던 것 — 은 새 설정에 대해 50분 이상의 전체 export를 실행하는 load test였습니다. 실제로 중요했던 테스트를 건너뛴 것입니다.
무엇이 고장 났는가
거의 즉시, production 팟캐스트 하나가 실패했습니다.
긴 에피소드였습니다 — Big Tech capex 분석으로 65분에 가까운 오디오 분량이었습니다. 시스템이 오디오 단계에 도달하고 AUDIO_FAILED를 반환했습니다.
처음에는 409 Conflict를 보고 orchestrator에 버그가 있는 줄 알았습니다. 알고 보니 409는 부수적인 증상이었습니다 — 팟캐스트가 이미 failed로 표시된 후의 재시도였습니다. 첫 번째 실패는 훨씬 단순했습니다.
세그먼트 0이 새 model 설정에서 timeout되었습니다. timeout은 60초로 설정되어 있었습니다. fallback model — stable path가 사용하는 바로 그 model — 도 timeout되었습니다. 시스템은 팟캐스트를 failed로 표시하고 넘어갔습니다.
fallback은 작동했어야 합니다. 하지만 fallback은 failed된 primary call에서 이미 시간을 소모한 동일한 request context 내에서, 동일한 60초 timeout budget으로, 동일한 큰 세그먼트에 대해 호출되었습니다. 반면 stable path는 동일한 model을 full timeout available 상태로 fresh하게 호출하고 세그먼트를 개별적으로 처리합니다. 같은 model, 다른 조건 — 그래서 stable 버전은 잘 처리하지만 fallback은 그렇지 못했던 것입니다.
production에서 chunk QA 시스템을 한 줄 변경으로 비활성화했습니다. timeout은 고쳐지지 않았습니다. 새 설정은 stable model이 문제없이 처리하는 세그먼트에서도 여전히 timeout되고 있었습니다.
글쎄요, 이런 순간에는 선택해야 합니다. 새로운 path의 디버깅을 계속할 것인지, 아니면 작동하는 것으로 돌아갈 것인지.
롤백
4월 30일, 결정을 내렸습니다.
롤백 커밋(4a5bfc8)은 2,724줄의 코드를 삭제했습니다:
- TTS chunker 모듈 전체
- 오디오 품질 분석 gate
- workflow progress tracking
- chunk QA retry 테스트 스위트
- fallback model chain 설정
그리고 중요한 부분을 보존하기 위해 321줄을 추가했습니다 — localized voice prompt 표현, frontend 호환성, stable path를 위한 regression coverage.
삭제하기에는 꽤 많은 줄입니다. 솔직히 말하면 패배를 인정하는 기분이었습니다. 하지만 그렇지 않았습니다. 진행처럼 보였지만 준비되지 않은 것보다 장기적으로 올바른 path를 선택한 것이었습니다.
롤백 후 다음을 수행했습니다:
- shared base Docker 이미지 재빌드
generate-speech서비스 재배포- failed된 팟캐스트를 스크립트 승인 상태로 리셋
- 일반 UI에서 "Generate Audio" 클릭
결과:
- Model: Gemini TTS (stable production 설정)
- 세그먼트: 6개 중 6개 완료
- 재생시간: 1,527초 — 약 25분
- 최종 MP3: 30.5 MB
- 상태:
COMPLETE
이틀간 멈춰있던 팟캐스트가 롤백 후 약 11분 만에 완료되었습니다. 사용자의 에피소드가 출시되었습니다 — 계획보다 이틀 늦었지만, 완성되었습니다.
65분이라는 숫자에 대해 하나: 이것은 원시 스크립트 길이를 기반으로 한 추정치입니다. 최종 output이 아닙니다. 리셋 후 스크립트는 정상적인 단축 pipeline을 통과했고, 최종 조립된 오디오는 약 25분이었습니다. 원래 스크립트는 더 길었을 것입니다 — 그게 실패한 이유 중 하나이기도 합니다.
데이터
다음에 무엇을 할지 결정하기 전에 실제 숫자가 필요했습니다. 느낌이 아니라 데이터입니다. production database를 쿼리하여 팟캐스트 재생시간 정보를 가져왔습니다.
| 코호트 | 건수 | 중앙값 | p90 | 최대 |
|---|---|---|---|---|
| 최근 30일 | 8 | 26분 | 31분 | 34분 |
| 최근 90일 | 72 | 27분 | 36분 | 45분 |
| 전체 | 120 | 26분 | 30분 | 45분 |
팟캐스트의 중앙값은 약 26분입니다. p90은 34~36분 정도입니다. 가장 긴 완료 팟캐스트는 45분이었습니다.
그리고 더 엄중한 데이터 — 오디오 단계에 도달한 팟캐스트를 추정 재생시간별로 분류한 것입니다:
| 추정 재생시간 | 오디오 도달 | 완료 | 실패 | 진행 중 |
|---|---|---|---|---|
| 15분 미만 | 18 | 17 | 0 | 1 |
| 15~30분 | 36 | 31 | 2 | 3 |
| 30~40분 | 24 | 23 | 0 | 1 |
| 40~50분 | 3 | 1 | 2 | 0 |
| 50분 이상 | 2 | 0 | 2 | 0 |
"진행 중"은 시작되었지만 완료되지 않은 팟캐스트를 포함합니다 — 사용자가 중단, 생성 중 취소, pending 상태로 방치. 50분 이상으로 시도된 팟캐스트 2건. 둘 다 오디오 단계에서 실패. 완성은 제로.
50분에 도달한 완료된 production 팟캐스트는 단 하나도 없습니다. 시스템은 15~40분 범위에서 잘 작동합니다. 40분을 넘으면 리스크가 급격히 증가합니다. 그리고 50분 이상은 사실상 미검증 영역입니다.
배운 것
1. 설정 변경에는 production 수준의 stress testing이 필요합니다
이 부분에 대해서는 정직해야 합니다. 새 model 설정은 짧은 테스트 프롬프트에서는 잘 작동했을지도 모릅니다. 하지만 production에서 실제 스크립트와 실제 timeout 제약 하에서는 stable 설정이 문제없이 처리하는 세그먼트에서 실패했습니다.
배포 전에 제대로 benchmark하지 않았습니다. 짧은 테스트 프롬프트는 성공합니다. 60초 timeout 하에서 여러 세그먼트를 가진 65분 팟캐스트는 완전히 다른 컨텍스트입니다. 두 시나리오의 간격 — 거기에 production 인시던트가 존재합니다.
설정 자체에 대해 제가 틀렸을 수도 있습니다 — 더 많은 tuning이나 더 긴 timeout이 필요했을지도 모릅니다. 하지만 핵심은 변하지 않습니다. 사람들이 실제로 의존하는 시스템이 받아야 하는 stress testing 없이 core production dependency를 변경했다는 것입니다.
2. voice 불일치가 진짜 문제입니다. timeout이 아니라
timeout은 눈에 보이는 실패였습니다. 하지만 처음에 다른 설정을 시도하려 한 이유는 voice 불일치였습니다. stable model에서도 음성이 synthesis 호출 간에 shift됩니다. 세그먼트 1의 호스트 A는 세그먼트 6의 호스트 A와 완전히 같게 들리지 않습니다.
짧은 팟캐스트에서는 거의 눈에 띄지 않습니다. 긴ものでは 누적됩니다. 그리고 50분 이상 팟캐스트 — 다시 말하지만, production에서 완료한 사람은 없습니다 — 에서는 아마 매우 뚜렷했을 것입니다.
chunked 접근 방식은 각 chunk를 더 작고 통제되게 만들어 이를 해결하려 했습니다. 그게 올바른 방향이라고 생각합니다. implementation이 아직 production 준비가 되지 않았을 뿐입니다.
3. 인시던트 이후가 아니라 이전에 telemetry가 필요했습니다
실패가 발생했을 때 TTS cost나 performance를 특정 팟캐스트 ID에 매핑할 수 없었습니다. 로그에 유용한 entry가 없었습니다. workflow event에 TTS 생성 기록이 없었습니다.
팟캐스트의 status 필드, 혼란스러운 409 오류, 그리고 로컬에서의 timeout 동작 재현으로 실패를 진단해야 했습니다. 결국 작동했지만, 다시 경험하고 싶은 종류의 디버깅 경험이 아니었습니다.
이후 TTS cost telemetry를 추가했습니다 — 사용한 model, fallback model, retry count, 시도별 상태, 트랜스크립트 문자 수, output audio 바이트, 오디오 재생시간. 이것은 인시던트 이전에 존재했어야 합니다. 경험상, 항상 그렇습니다. 관찰 가능성은 화재 이후에 구축합니다. 이전이 아니라.
4. 롤백은 실패가 아닙니다
2,724줄의 코드를 삭제하는 것은 기분이 나빴습니다. 아니라고 가장하지는 않겠습니다. 일주일을 들여 무언가를 만들고, 아키텍처에 자부심을 느끼고, 그리고 준비되지 않았다는 이유로 모두 무너뜨리는 것.
하지만 올바른 판단이었습니다. chunk QA 시스템은 좋은 디자인이었습니다. 돌아올 것입니다 — 더 작고 격리된 변경으로, 적절한 canary validation과 함께. 다만 model 설정 변경의 일부로는 아닙니다. 그리고 새 설정이 stable path가 문제없이 처리하는 세그먼트에서 여전히 timeout되는 중에는 더욱 아닙니다.
5. 50분 에피소드는 다른 제품입니다
이건 저에게 놀라웠습니다. 시스템이 어떤 길이의 스크립트든 처리할 줄 알았습니다. 데이터는 그렇지 않다고 말합니다.
누군가 진정으로 50분 팟캐스트를 원한다면, 그것은 다른 generation profile입니다. 아마 오디오 생성 전에 45분 이하로 tighten되는 스크립트가 필요할 것입니다. TTS 전 manual review gate. 세그먼트별 더 강력한 timeout budget. 아마 완전히 다른 synthesis 전략일 수도 있습니다.
15~40분 범위에 default path를 최적화하는 것이 올바른 판단입니다. 50분 이상 에피소드는 norm이 아닌 exceptional path로 처리해야 합니다. engineering이 아니라 product 판단이라고 생각합니다.
현재 상황
production은 stable TTS 설정으로 돌아왔습니다. 완벽하지는 않습니다. 긴 팟캐스트의 voice 불일치는 여전히 있고, 들립니다. 하지만 플랫폼이 대다수 케이스에서 작동할 만큼 충분히 안정적입니다.
DIALOGUE가 무엇을 만드는지 들어보고 싶다면 https://podcast.chandlernguyen.com에서 직접 시도해볼 수 있습니다.
인시던트 보고서는 리포지토리에 커밋되었습니다. 롤백은 failed 팟캐스트를 리셋하기 위한 정확한 SQL shape와 함께 문서화되었습니다. telemetry는 이제 다음 시도를 위해 준비되어 있습니다.
그리고 교훈은 제가 원했던 것보다 명확합니다:
production AI에서 적절한 stress testing 없이 core dependencies를 변경하는 비용은 실패한 실험이 아닙니다. 실패한 사용자 경험입니다.
다른 model 설정으로 다시 시도할 것입니다. 하지만 다음에는 더 나은 telemetry, canary deployment path, 그리고 무엇이든 production에 닿기 전에 전체 50분 이상 export를 실행하는 stress test와 함께.
production AI dependencies를 변경하기 전에 따랐어야 할 체크리스트:
- 최악의 job을 stress test하세요. 예상하는 가장 길고 무거운 워크로드를 실행하세요 — 짧은 테스트 프롬프트가 아닙니다. 시스템이 40분 팟캐스트를 처리한다면 50분으로 테스트하세요.
- 첫날부터 시도별 telemetry를 기록하세요. model 이름, fallback model, retry count, timeout, 트랜스크립트 문자 수, output audio 바이트, 오디오 재생시간. 인시던트 이전에 존재했어야 합니다. 항상 그렇습니다.
- 실제 job 하나를 canary로 테스트하세요. 모두를 위해 스위치를 켜기 전에 실제 production job 하나를 새 설정으로 end to end 실행하고 output을 검증하세요.
- 동일한 timeout budget 하에서 fallback을 검증하세요. primary와 동일한 timeout을 공유하는 fallback은 fallback이 아닙니다 — 동일한 조건에서 두 번째로 실패할 기회일 뿐입니다.
- 배포 전에 rollback SQL과 runbook을 정의하세요. 멈춘 job을 리셋하는 방법을 정확히 알아두세요. 명령어를 문서화하세요. 이것이 없으면 인시던트 중에 즉흥 대응하게 됩니다.
production AI 시스템을 빌드하시는 분들은 model 설정 변경을 테스트하면서 사용자를 깨뜨리지 않는 패턴이 있나요? 진심으로 알고 싶습니다. "하나를 변경했는데 모든 것이 고장 난" 비슷한 경험을 해보셨나요?
Chandler





