목차
30시간 해커톤에서 Live STT를 선택하고 구현하기까지
부메랑은 부동산 계약 현장의 대화를 실시간으로 분석하는 AI 계약 보조 서비스다. 이 프로젝트에서 내가 가장 오래 고민하고 가장 많은 시간을 사용한 부분은 STT였다. 처음부터 Live STT를 선택했던 것은 아니다. 오히려 기획 단계에서는 구현 난이도 때문에 가장 피하고 싶었던 방식에 가까웠다.
세 가지 STT 방식
기획 단계에서는 크게 세 가지 방법을 검토했다.
전체 녹음 후 STT
계약 전체를 녹음한 뒤 하나의 파일을 STT API에 전달하는 방식이다. 전체 문맥을 활용할 수 있어 정확도가 가장 높고 화자 분리에도 가장 유리했다. 하지만 실제 부동산 계약은 30분 이상 진행될 수 있다. 모바일 브라우저에서 이 시간 동안 전체 음성을 보관해야 하고, 페이지 이탈이나 브라우저 종료 시 녹음 데이터 자체가 사라질 위험이 있었다. 계약이 끝난 뒤에도 전체 파일의 STT 결과가 나오기까지 기다려야 했다. 무엇보다 계약이 진행되는 동안 사용자에게 아무런 피드백을 제공할 수 없다는 점이 서비스의 목표와 맞지 않았다.
10~15초 배치 기반 STT
두 번째 방법은 음성을 10~15초 단위로 나눠 서버로 지속적으로 전송하는 방식이었다. 전체 녹음 방식보다 결과를 빠르게 보여줄 수 있고, 개별 STT 호출 자체도 어렵지 않았다. 문제는 그 다음이었다. 클라이언트가 음성을 지속적으로 잘라서 보내야 했고, 서버에서는 각 배치의 순서를 관리하고 실패한 요청을 복구하며 문장 경계에서 잘린 결과를 다시 병합해야 했다. 화자 분리를 유지하려면 각 배치에서 인식된 사람이 이전 배치의 누구인지를 다시 연결하는 별도의 처리도 필요했다. 구현이 불가능한 것은 아니었지만 30시간 안에 만들고 검증해야 하는 해커톤에서는 직접 관리해야 할 상태가 지나치게 많았다.
Live STT
세 번째가 Live STT였다. WebRTC와 WebSocket을 함께 사용하고 백엔드에도 실시간 미디어 연결 계층을 구현해야 했다. 발화 중에는 확정된 문장이 아니라 지속적으로 수정되는 Interim Result가 전달되기 때문에 클라이언트에서도 Interim과 Final 결과를 별도로 관리해야 했다. 최종 데모에서는 안정적인 화자 분리도 제공할 수 없었다. 하지만 다른 두 방식에는 없는 장점이 하나 있었다.
사용자가 말을 하는 순간 화면에 실제 텍스트가 나타났다.
부메랑에서 가장 중요한 것은 가장 정확한 녹취록을 만드는 것이 아니었다. 부동산 계약에 익숙하지 않은 사용자가 계약 현장에서 느끼는 불안을 줄이는 것이었다. 아무것도 보여주지 않은 채 나중에 정확한 분석 결과를 제공하는 것보다, 현재 서비스가 계약 내용을 듣고 있다는 사실을 즉시 보여주는 것이 더 중요하다고 판단했다.
표면적인 기술 난이도와 실제 개발 비용
처음에는 Live STT가 세 방법 중 가장 개발 비용이 클 것이라고 예상했다. 하지만 각 구조를 실제로 구현해야 할 수준까지 나눠보면서 판단이 바뀌었다. 배치 방식은 구조 자체는 단순했지만 음성 분할, 순서 관리, 결과 병합, 실패 복구 등 많은 기능을 서비스 내부에서 직접 구현해야 했다. 반면 Live STT는 WebRTC 연결이라는 초기 난이도는 높았지만 스트림 연결 이후의 연속적인 음성 처리는 Streaming API가 담당할 수 있었다.
결국 어려워 보이는 기술과 실제로 직접 구현해야 할 코드의 양은 반드시 같지 않았다. 사용자 경험과 제한된 구현 시간을 기준으로 팀에 Live STT 방식을 제안했고 최종적으로 이 구조를 사용했다.
Google Speech-to-Text v1을 선택한 이유
당시 STT 서비스들을 정리한 표
| 서비스 | 한국어 | 스트리밍 지원 | 중간 결과 → 최종 결과 | 평균 CER ↓ | 검토 결과 |
|---|---|---|---|---|---|
| Google Speech-to-Text v1 | O | O | O | 11.50%* | 최종 선택 |
| NAVER CLOVA Speech | O | O | O | 7.52% | 한국어 정확도 우수 |
| AWS Transcribe Streaming | O | O | O | 11.11% | 유력 후보 |
| Azure AI Speech | O | O | O | 10.88%* | 유력 후보 |
OpenAI Whisper (whisper-1) | O | X | X | 11.39% | Streaming 요구로 제외 |
CER(Character Error Rate)는 낮을수록 인식 정확도가 높음을 의미한다. -
RTZR Awesome Korean Speech RecognitionBenchmark
Live STT를 사용하기로 결정한 뒤에는 Google, NAVER CLOVA, AWS, Azure 등 실시간 스트리밍을 지원하는 STT API를 비교했다. 공개된 한국어 음성 인식 벤치마크와 연구에서는 CLOVA와 국내 STT가 Google보다 높은 정확도를 보인 사례가 있었기 때문에, 한국어 인식 정확도만 놓고 보면 Google이 가장 좋은 선택은 아니었다.
하지만 부메랑에서 더 중요한 요구사항은 최종 녹취록의 정확도만이 아니라 대화가 진행되는 동안 결과를 계속 갱신하는 것이었다. Google Speech-to-Text v1은 gRPC Bidirectional Streaming과 interim_results, is_final, stability를 제공해 아직 바뀔 수 있는 결과와 확정된 결과를 명확히 구분할 수 있었다.
특히 추가 음성이 들어올 때 하나의 발화에 대한 중간 결과를 계속 수정하는 구조가 API 계약과 예제로 잘 드러나 있었다. 덕분에 프론트엔드에서는 Interim Result를 현재 자막으로 갱신하고, Final Result가 도착하면 확정된 대화로 누적하는 흐름을 설계하기 쉬웠다.
CLOVA 역시 Streaming을 지원하므로 기능이 없어서 제외한 것은 아니었다. 다만 약 30시간 안에 WebRTC 오디오 파이프라인을 연결하고 디버깅해야 했던 조건에서는 문서와 샘플이 충분하고 중간 결과의 상태를 직접 제어하기 쉬운 Google의 구조가 구현 위험을 줄이는 데 더 유리했다. 결국 한국어 정확도 하나를 최대화하기보다 실시간 결과 제어, WebRTC 백엔드와의 결합, 개발 시간, 구현 리스크를 함께 고려해 Google Speech-to-Text v1을 선택했다.
실제 구현 구조
최종 구조는 다음과 같았다.

여기서 WebRTC와 WebSocket은 서로 다른 역할을 담당했다.
실제 음성 데이터는 WebRTC Audio Track을 통해 브라우저에서 FastAPI 백엔드로 전달했다. WebSocket은 SDP Offer / Answer, ICE Candidate 같은 WebRTC Signaling과 STT의 Interim / Final 이벤트 전달에 사용했다.
백엔드에서는 aiortc로 Audio Track을 수신하고 STT가 처리할 수 있는 PCM 데이터로 변환한 뒤 Google Cloud Streaming STT에 전달했다.
프론트엔드에서는 Interim Result를 현재 발화로 즉시 갱신하고 Final Result가 도착하면 확정된 대화 세그먼트로 누적했다.
트러블 슈팅 - 서버가 아무 말 없이 멈췄다
Live STT를 연결하는 과정에서 가장 오래 원인을 찾지 못했던 문제는 WebRTC도, WebSocket도, Google STT도 아니었다. FFmpeg였다. 입력 오디오를 STT가 처리할 수 있는 형식으로 맞추고 노이즈 필터를 적용하기 위해 FFmpeg를 Streaming Process로 사용했다.
초기 구현은 단순했다. 오디오 Chunk를 FFmpeg의 stdin에 쓰고 같은 크기의 데이터를 stdout에서 읽었다. 문제는 FFmpeg가 입력 Chunk 하나를 받았다고 해서 동일한 크기의 출력 Chunk를 즉시 반환한다는 보장이 없다는 점이었다.
출력 데이터가 충분하지 않은 상태에서 stdout.read()가 실행되면 해당 호출은 필요한 데이터가 들어올 때까지 기다린다. 예외도 발생하지 않았고 의미 있는 오류 로그도 나오지 않았다.
결과적으로 서버가 그냥 멈췄다.
해커톤 당일 약 두 시간 동안 WebRTC 연결, ICE Candidate, WebSocket Session, STT API를 하나씩 의심하며 확인했다. 서버가 죽은 것도 아니고 예외가 발생한 것도 아니어서 처음에는 FFmpeg가 원인이라고 생각하기 어려웠다.
결국 프로세스 I/O를 따라가면서 stdout.read()가 Blocking된 상태라는 것을 확인했다.
Blocking I/O를 실시간 경로에서 분리하기
문제를 확인한 뒤 FFmpeg 출력 구조를 변경했다.

stdout과 stderr를 별도의 Thread에서 계속 소비하도록 만들고, FFmpeg가 생성한 데이터는 Shared Buffer에 저장하도록 했다.
메인 오디오 처리 경로에서는 Buffer에 결과가 있는지를 확인한다. 일정 시간 안에 처리된 결과가 준비되지 않으면 기존 음성 Chunk를 그대로 다음 단계로 통과시키도록 했다. 노이즈 처리 하나 때문에 전체 STT 파이프라인이 정지하는 것보다 일시적으로 원본 오디오를 사용하는 편이 실시간 서비스에서는 더 나은 실패 방식이라고 판단했다.
이 문제에서 배운 것
이 문제를 겪기 전에는 실시간 연결에서 장애가 발생하면 WebSocket 연결 상태나 WebRTC Signaling 같은 네트워크 계층부터 확인했다. 하지만 실제 장애는 프로세스 내부의 동기식 I/O 한 줄에서 발생했다. 실시간 시스템에서 중요한 것은 각 기능이 동작하는지뿐만 아니라 어떤 연산이 전체 처리 경로를 기다리게 만들 수 있는지를 확인하는 것이라는 점을 배웠다.
특히 예외도 로그도 없이 애플리케이션이 멈춰 있는 상황에서는 네트워크만 확인할 것이 아니라 Thread 상태, Queue, Buffer, Process stdin/stdout 같은 Blocking 지점까지 추적해야 했다. 30시간의 해커톤에서 Live STT를 선택한 것은 구현 난이도만 보면 위험한 결정이었다. 하지만 사용자에게 즉각적인 피드백을 제공한다는 제품의 핵심 가치를 기준으로 보면 가장 적합한 선택이었다. 그리고 그 구현 과정에서 단순히 WebRTC나 Streaming STT API를 사용해 본 것보다 실시간 데이터 파이프라인이 실제로 어디에서 멈출 수 있는지를 직접 경험한 것이 더 큰 기술적 경험으로 남았다.