Code › tail-villain

라이브 릴레이 뒤의 OpenAI Realtime

Gemini 구조를 버리지 않고 OpenAI 텍스트, 음성, Realtime 경로를 기존 라이브 릴레이 뒤에 붙인 과정

OpenAI 지원을 붙일 때 가장 피하고 싶었던 건 모델 이름만 바꾼 별도 길을 하나 더 만드는 일이었다.

기존 tail-villain에는 이미 Gemini 기반 텍스트 생성, TTS와 STT, Gemini Live를 통과하는 실시간 음성 인터뷰, 그리고 그 앞뒤에 붙은 transcript 저장과 세션 상태가 있었다. OpenAI를 추가한다고 해서 이 구조를 밀어내면, 음성은 새로 동작할 수 있어도 제품이 지금까지 쌓아 둔 경계가 무너진다.

그래서 이번 작업의 중심은 OpenAI로 갈아타는 일이 아니라, OpenAI 텍스트, TTS, STT, Realtime을 기존 릴레이와 provider-neutral 계약 뒤에 넣는 일이었다.


먼저 텍스트 생성 경로부터 provider를 분리했다.

인터뷰와 Study 응답은 단순히 문자열을 받아오는 기능이 아니다. 구조화된 생성, 스트리밍 응답, timeout, retry, usage logging, fallback metadata가 같이 움직인다. 예전 코드가 Gemini 호출 모양에 직접 기대고 있으면 OpenAI를 붙이는 순간 분기문이 사방으로 번지고, 어느 provider에서 실패했는지도 추적하기 어려워진다.

그래서 호출자는 텍스트를 생성한다는 계약만 보게 하고, 내부 routing policy가 OpenAI와 Gemini를 고르게 했다. OpenAI structured generation과 streaming provider를 추가하면서도 Gemini는 fallback provider로 남겼고, OpenRouter를 사이에 넣는 대신 직접 provider를 붙이는 쪽을 택했다. 실패했을 때 어떤 모델이 처음 호출됐고 어디로 fallback됐는지 남겨야 했고, 음성까지 포함한 제품 경계를 우리가 직접 잡아야 했기 때문이다.

이 분리는 겉으로는 크게 드러나지 않는다. 사용자는 같은 Study 화면에서 답을 받고 같은 인터뷰 화면에서 질문을 받지만, 내부의 모델 호출은 특정 회사 SDK의 모양이 아니라 tail-villain이 기대하는 응답 계약을 중심으로 움직이게 됐다.


음성에서는 AI Voice와 Live Mode를 끝까지 나눠야 했다.

AI Voice는 text mode에 붙은 음성 기능이고, 모델 응답은 먼저 텍스트로 스트리밍된다. 답변이 끝나면 완성된 문장을 하나의 TTS payload로 합성해서 재생하므로, 사용자는 텍스트 대화를 유지하면서 음성으로 읽어 주는 도움을 받는다. 비용과 실패 처리도 비교적 단순한데, 텍스트가 이미 저장된 뒤라서 오디오 준비가 실패해도 대화 자체는 남아 있기 때문이다.

Live Mode에서는 사용자의 마이크 입력이 작은 오디오 frame으로 릴레이를 지나고, 모델의 답변도 speech-to-speech로 돌아온다. 낮은 latency, push-to-talk, interruption, transcript persistence, session lifecycle이 한꺼번에 엮이기 때문에 AI Voice의 빠른 버전으로 취급하면 안 된다. 둘 다 목소리를 쓰지만 제품 계약은 다르다.

OpenAI TTS와 STT를 추가할 때도 이 구분을 유지했다. AI Voice에서는 텍스트 생성이 먼저 끝나고, 그 뒤에 TTS가 붙는다. Live에서는 OpenAI Realtime session driver가 기존 live relay 뒤에 들어가고, 릴레이는 여전히 opening, transcript 저장, 취소, fallback 책임을 가진다.

브라우저를 모델에 직접 붙이지 않는 기준도 그대로 남겼다. 이전에 Gemini Live를 붙일 때 API key와 긴 WebSocket session을 프론트에 넘기지 않으려고 백엔드 relay를 만들었고, OpenAI Realtime이 들어와도 그 경계는 바꾸지 않았다. provider만 달라졌지, 제품이 책임져야 하는 자리는 같다.


릴레이 뒤에 provider를 숨겼다고 해서 UI 문제가 자동으로 사라지지는 않았다.

AI Voice opening에서 먼저 드러난 증상은 소리가 나지 않는 문제였다. 일반 답변은 TTS autoplay가 됐지만, opening은 텍스트 stream이 저장된 뒤에도 페이지가 소유한 TTS 경로를 호출하지 않았다. 브라우저의 autoplay 제한도 같이 걸려 있었다. 사용자가 Start를 누른 행위에서 오디오 권한을 풀어 두지 않으면, 나중에 만들어진 opening audio는 재생될 준비가 되어 있어도 조용히 막힐 수 있다.

수정은 두 갈래였다. interview entry route에서 tts=1 의도를 세션까지 유지했고, 사용자의 시작 gesture에서 audio를 먼저 unlock했다. 그다음 opening stream이 끝나 persisted message가 생기면, 그 메시지를 기준으로 TTS를 합성해 바로 재생했다.

이때 중요한 건 Live Mode와 섞지 않는 일이었다. AI Voice opening은 text stream 뒤의 TTS autoplay이고, Live opening은 Realtime connection에서 흘러오는 음성이다. 둘을 같은 loading이나 speaker 상태로 뭉개면 한쪽을 고칠 때 다른 쪽이 깨진다.


소리가 나기 시작하자 다음에는 화면 상태가 틀어졌다.

opening bubble이 두 개 보이는 순간이 있었다. 하나는 streaming 중인 임시 bubble이고, 다른 하나는 저장된 persisted message였다. 텍스트 생성은 이미 끝났는데 TTS 준비를 기다리느라 clearInterviewStream()이 늦게 실행됐고, 그 사이에 같은 opening이 두 경로로 화면에 나타났다.

또 다른 증상은 completed opening 아래에 Thinking... bubble이 남는 것이었다. 모델은 이미 답변을 끝냈고 메시지도 저장됐는데, TTS를 준비하는 동안 pending assistant 상태가 계속 켜져 있었다. 사용자 입장에서는 꼬빌이 이미 말해 놓고 다시 생각하는 것처럼 보인다.

이건 단순히 loading 문구를 바꾸는 문제가 아니었다. 하나의 상태가 너무 많은 일을 대표하고 있었다. 텍스트 생성, 메시지 persistence, 오디오 준비는 서로 이어지지만 같은 lifecycle은 아니다. 텍스트가 끝나고 저장이 끝나는 순간, 생성 중이라는 UI는 내려가야 한다. 그 뒤 TTS가 준비 중이면 speaker나 persona 옆의 audio loading으로 보여 주면 된다.

그래서 final metadata와 persisted message가 도착하면 transient stream을 먼저 지우고, opening message가 final이 되는 순간 pending assistant bubble도 끄도록 나눴다. TTS loading은 그 뒤에 독립적으로 진행되며, 실패해도 텍스트 메시지를 다시 생성하지 않고 성공하면 저장된 메시지에 음성을 붙여 재생한다.

작은 차이처럼 보이지만 voice UI에서는 이 순서가 신뢰를 만든다. 텍스트를 만드는 중인지, 서버에 메시지를 남기는 중인지, 소리를 준비하는 중인지가 섞이면 화면은 금방 거짓말을 한다.


Live 화면에서도 비슷한 기준이 필요했다.

일부 화면 분기는 URL의 voiceMode=1만 보고 Live UI를 결정하고 있었다. 하지만 URL은 사용자가 들어올 때 선택한 모드를 전달할 뿐이고, 세션이 만들어진 뒤에는 저장된 deliveryMode가 기준이 되어야 한다. URL과 세션 상태가 다르면 같은 세션이 한 화면에서는 Live로 처리되고, 다른 화면 분기에서는 일반 Voice로 처리된다.

그래서 rendering 기준을 isLiveDeliveryMode로 모았고, 이 값은 URL뿐 아니라 저장된 session state까지 반영한다. 사용하지 않던 transcription flag도 제거했다. Live는 실시간 음성 인터뷰이고 AI Voice는 텍스트 기반 인터뷰에 붙은 TTS 기능이므로, 이 경계가 코드에서도 UI에서도 같아야 fallback이 안전해진다.

Provider fallback도 같은 원칙을 따른다. OpenAI 경로가 실패하면 Gemini가 남아 있어야 하지만, fallback이 사용자에게 다른 제품처럼 보이면 안 된다. relay는 provider를 바꿀 수 있어도 transcript 저장, cancellation, opening 처리, 완료 흐름은 같은 제품 계약으로 유지해야 한다.


새 provider를 붙이는 과정에서는 기존 구조가 실제 추상화인지, Gemini에 맞춘 코드를 이름만 일반화한 것인지 드러났다. 개별 테스트에서는 새 provider가 통과했지만, 전체 테스트에서는 오래된 fixture가 Gemini 직접 호출에 기대고 있던 부분이 발견됐다. 이 의존을 걷어낸 뒤에야 provider-neutral 경계가 실제로 동작했다.

가장 기억에 남은 건 AI Voice opening의 세 가지 증상이었다. 소리가 안 나고, bubble이 두 개 보이고, 완료된 뒤에도 Thinking이 남았다. 세 증상은 모두 다른 lifecycle에서 나온 문제였다. 권한과 mode propagation, transient stream cleanup, pending assistant state가 각각 따로 고쳐져야 했다.

Gemini를 유지한 채 OpenAI를 기존 릴레이 뒤에 넣고, 텍스트 생성과 저장, 오디오 준비를 서로 다른 상태로 분리했다. 덕분에 provider를 바꾸더라도 transcript 저장과 세션 진행, 사용자가 보는 인터뷰 흐름은 그대로 유지할 수 있었다.