Code › tail-villain
실시간 음성 인터뷰의 백엔드 릴레이
Gemini Live 연결과 자격 증명, 세션 수명을 백엔드 WebSocket 릴레이에서 관리한 과정
실시간 음성 인터뷰는 일단 소리가 나는 데까지는 갔지만, 그 상태로 제품에 넣기에는 경계가 너무 약했다. 브라우저에서 바로 Gemini Live에 붙이면 구현은 단순해진다. 대신 그 순간 API 키가 프론트에 가까워지고, 긴 WebSocket 연결을 어디서 책임질지도 흐려진다.
tail-villain의 인터뷰는 사용자가 답변하고, 꼬빌이 바로 이어서 물고 늘어지는 흐름이어야 한다. 그 흐름이 real-time voice로 바뀌면 단순히 음성을 재생하는 문제가 아니라, 자격 증명, 세션 수명, transcript 저장, turn 제어가 한 줄로 엮인다. 그래서 실시간 음성 인터뷰는 브라우저와 모델을 직접 연결하는 구조가 아니라, 백엔드 relay를 사이에 두는 구조로 가야 했다.
처음에는 장애가 하나로 섞여 있었다. Start를 누르면 인터뷰가 바로 끝났고, 방금 넣은 interruption 코드가 문제인지, UI가 End 버튼을 잘못 누르는지, 아니면 Google 쪽 Live socket이 닫히는지 분간하기 어려웠다.
가장 먼저 잡힌 건 React hook 순서 문제였다. Start와 End를 막기 위해 넣은 guard가 loading return 아래에 hook을 하나 더 만들면서, 로딩 상태와 로딩 이후 렌더의 hook 순서가 달라졌다. 이건 voice 문제가 아니라 페이지 구조 문제였고, hook을 없애고 일반 비동기 함수로 바꾸는 쪽이 맞았다.
그다음에는 Start 버튼이 같은 위치에서 End나 Finish로 바뀌는 흐름이 위험했다. 빠른 두 번째 클릭이나 빈 세션 종료가 complete 호출로 이어질 수 있었기 때문에, 시작 직후 End 동작을 잠깐 막고 transcript turn이 하나도 없는 Live 세션은 완료 리포트를 만들지 않도록 막았다. 인터뷰를 끝내는 행위는 사용자가 실제로 답변한 뒤에만 의미가 있기 때문이다.
그래도 문제가 남았다. 이번에는 complete 호출이 없는데도 Live가 끝났다. relay 로그를 더 좁게 남기자 upstream이 열렸다가 곧바로 닫히고, 그 뒤에 client socket도 닫히는 순서가 잡혔다. 프론트가 완료를 누른 게 아니라, 모델 쪽 Live 연결이 유지되지 않았다.
그때 region 문제가 드러났다. Seoul region은 기본 handshake에서는 열렸지만, 실제 브라우저 relay 흐름에서는 오래 버티지 못했다. us-central1로 되돌린 뒤에는 최소 probe에서 setupComplete를 받고 몇 초 동안 연결이 유지됐다. 이 차이는 화면에서는 똑같이 바로 끝나는 증상으로 나타나지만, 로그에서는 완전히 다른 실패로 분리된다.
릴레이 구조를 선택한 이유도 그 과정에서 더 분명해졌다. 브라우저가 mic PCM frame을 보내고, 백엔드가 Vertex AI Live session에 전달한 뒤, 모델 audio와 transcription packet을 다시 브라우저로 돌려준다. 사용자는 voice 인터뷰를 하지만, 제품 입장에서는 자격 증명을 숨기고 transcript를 기존 interview message 테이블에 남길 수 있다.
여기서 선택하지 않은 건 MP3 파일을 만들어서 업로드하는 방식이었다. Live session은 사용자의 말을 잘게 흐르는 audio frame으로 받고, assistant audio도 흐르듯 돌아온다. 백엔드 relay는 그 흐름을 끊지 않으면서도, 어떤 turn이 저장 가능한 답변인지와 어떤 출력이 UI에 보여야 하는지를 판단할 수 있는 위치에 있어야 했다.
Vercel serverless 같은 환경에 긴 WebSocket relay를 기대하기 어려웠던 점도 구조 선택에 영향을 줬다. 실서비스에서 실시간 음성 인터뷰를 유지하려면 연결 수명이 긴 런타임이 필요했고, Cloud Run 쪽이 더 자연스러운 대상이 됐다. 프론트가 API 키를 들고 모델에 직접 붙는 방식은 빠른 실험에는 편하지만, 제품 경계로는 오래 가져가기 어렵다.
어려웠던 부분은 실시간 음성 인터뷰에서도 text mode처럼 structured state를 바로 얻고 싶었다는 점이다. 기존 인터뷰 흐름에는 reasoning, next action, evaluation 같은 상태가 있고, text generation에서는 JSON이나 tool call로 어느 정도 모양을 강제할 수 있다. 그래서 Live session 안에서도 상태 업데이트용 native function calling을 붙이면 voice와 state를 동시에 얻을 수 있을 거라고 기대했다.
실제로는 그렇게 깔끔하지 않았다. tool response를 보내면 upstream Live session이 닫히거나, timing에 따라 assistant spoken output이 억제됐다. 반대로 tool response를 생략하면 모델이 같은 tool을 반복해서 호출하고, 말로 돌아오지 못하는 경우가 생겼다. UI에서 중복 출력을 막는 guard를 넣어도 upstream generation 자체가 흔들리면 비용과 대화 품질은 이미 영향을 받는다.
그래서 Live state tool은 기본값에서 껐다. 필요하면 환경 변수로 켤 수 있게 남겨 두되, 기본 voice interview path는 말하기와 듣기가 먼저 안정되는 쪽으로 돌렸다. 실시간 음성 인터뷰에서 metadata 업데이트가 중요하지 않은 건 아니지만, 그 metadata 때문에 사용자가 듣는 질문이 끊기면 우선순위가 뒤집힌다.
이 판단은 약간 아쉬웠다. text mode와 Live mode가 같은 상태 모델을 공유하면 구현은 더 단순해진다. 하지만 Native Audio Live session은 text generation에 schema를 얹는 모델과 다르게 움직였고, function response 하나도 단순한 acknowledgment가 아니었다. generation을 새로 유발하거나, 필요한 답변을 막거나, 연결을 닫게 만들 수 있었다.
이 작업은 실시간 음성 인터뷰를 붙이는 일이 아니라, voice가 지나갈 안전한 통로를 만드는 일이었다. 브라우저는 사용자의 mic와 playback을 맡고, 백엔드는 credential과 session relay, transcript persistence를 맡고, 모델은 real-time audio response를 만든다. 세 역할이 섞이면 시작은 빨라져도 디버깅이 거의 불가능해진다.
region도, socket lifecycle도, tool calling도 처음에는 같은 증상으로 드러났다. 화면에서는 그냥 Live가 바로 끝난다. 하지만 relay 경계에서 upstream open, upstream close, client close, complete 호출 여부를 따로 보면, UI 실수와 Google socket 문제와 tool calling 불안정성이 서로 다른 사건이라는 게 드러난다.
이때 정한 기준은 단순했다. voice path는 먼저 안정적으로 말하고 들어야 한다. 상태 추적과 평가 parity는 transcript를 이용한 별도 비동기 pass로 옮길 수 있지만, 끊긴 음성 인터뷰는 나중에 복구하기 어렵다.
실시간 음성 인터뷰에 백엔드 릴레이가 필요했던 이유는 성능 때문만은 아니었다. 제품이 어디까지 책임지고, 어디서 모델과 연결하며, 어떤 실험을 기본값 밖으로 밀어낼지 정하는 경계가 필요했기 때문이다.