OpenAI Realtime Whisper API - 브라우저 실시간 STT 파이프라인 구축기
브라우저 마이크 입력을 OpenAI gpt-realtime-whisper 모델로 실시간 전사하기 위해, FastAPI WebSocket 프록시와 PCM16 스트리밍 아키텍처를 설계한 과정과 GA 모델 특유의 트러블슈팅 경험을 정리합니다.
음성 인터페이스의 사용자 경험을 결정하는 가장 큰 변수는 결국 '전사가 흐르는 속도'입니다.
발화가 끝난 뒤 결과가 한 번에 떨어지는 batch 방식과, 말하는 동안 글자가 실시간으로 흘러나오는 streaming 방식은 같은 정확도라도 체감 품질이 전혀 다릅니다.
이 글에서는 OpenAI의 gpt-realtime-whisper GA 모델을 활용해, 브라우저 마이크 입력을 별도 설치 없이 실시간 전사하는 WebSocket 파이프라인을 구축한 과정과 트러블슈팅 경험을 공유합니다.
🆕 gpt-realtime-whisper: 갓 발표된 신규 streaming STT 모델
본 프로젝트가 의존하는 gpt-realtime-whisper는 2026년 5월 7일 OpenAI가 공개한 따끈따끈한 신모델입니다. 이번 발표는 단순한 모델 추가가 아니라, Realtime API 자체가 베타를 졸업해 GA(General Availability)로 전환되는 의미 있는 분기점이기도 했습니다.
한 발표, 세 개의 음성 모델
같은 자리에서 세 개의 음성 모델이 동시에 공개되었고, 각자 다른 음성 워크로드를 담당합니다.
| 모델 | 역할 | 핵심 강점 | 적합한 시나리오 |
|---|---|---|---|
| gpt-realtime-2 | 대화형 음성 에이전트 | reasoning / tool use | 자율 고객 응대, 음성 비서 |
| gpt-realtime-translate | 실시간 음성→음성 번역 | 다국어 동시 통역 | 국경을 넘는 화상 회의 |
| gpt-realtime-whisper | streaming STT 전용 | 초저지연, 저비용 | 라이브 자막, 회의록, 받아쓰기 |
본 프로젝트는 "음성을 듣고 즉시 텍스트로 변환"만 필요하므로, 세 번째 모델인 gpt-realtime-whisper가 정확히 들어맞습니다.
기존 Whisper와 결정적으로 다른 점
OpenAI의 기존 whisper-1 모델은 batch 처리 전용이었습니다. 완성된 오디오 파일을 받아 결과를 한 번에 반환하는 구조라, 발화 도중 부분 결과를 흘려보낼 수 없었습니다.
gpt-realtime-whisper는 처음부터 streaming 전용으로 재설계된 모델이다. 완성된 청크가 아닌 흐르는 오디오를 입력으로 받고, 그 자리에서 부분 텍스트를 생성한다.
이러한 본질적 차이로 인해 다음과 같은 시나리오가 비로소 가능해졌습니다.
- 라이브 방송 자막: 발화 종료 후 자막이 따라붙는 것이 아니라, 말하는 동안 자막이 흘러갑니다.
- 실시간 회의록: 대화가 진행되는 속도에 맞춰 텍스트화가 따라붙어, 회의 종료와 동시에 회의록이 완성됩니다.
- 음성 에이전트 컨텍스트 인식: 사용자가 말하는 도중에도 어시스턴트가 부분 텍스트를 받아 의도를 추론할 수 있습니다.
가격과 latency 제어
- 요금: 분당
$0.017. batch Whisper API($0.006/분)보다는 비싸지만, streaming 전용 모델이라는 점을 감안하면 합리적인 수준입니다. - Latency 조절 가능: 지연 설정값이 낮을수록 부분 텍스트(
delta)가 더 빨리 도착하지만 정확도는 다소 낮아지고, 높을수록 그 반대입니다. 자막용은 낮게, 회의록용은 높게 두는 식으로 워크로드별 튜닝이 가능합니다.
이렇게 갓 출시된 GA 모델이라는 점은 양날의 검이었습니다.
한편으로는 streaming STT라는 본질적으로 새로운 기능을 손에 넣었지만, 다른 한편으로는 구 Realtime API의 예제 코드, Beta 헤더, 페이로드 schema가 뒤섞여 있는 과도기 상태의 문서를 헤쳐 나가야 했습니다.
이 글의 뒤에서 다루는 트러블슈팅은 모두 이 과도기에서 비롯된 것들입니다.
🚨 문제 정의: 왜 기존 Whisper API만으로는 부족할까?
전통적인 /v1/audio/transcriptions 엔드포인트는 명확한 한계를 지닙니다.
- Batch 처리 구조: 파일 전체를 업로드한 뒤 결과를 한 번에 받습니다. 발화 도중 부분 전사가 불가능합니다.
- API 키 노출 위험: 브라우저가 OpenAI에 직접 요청하면 키를 클라이언트에 심어야 합니다. 누구나 콘솔에서 훔쳐갈 수 있는 구조입니다.
- 길이/지연 트레이드오프: 짧게 끊어 여러 번 요청하면 호출 비용이 늘고, 길게 보내면 결과 지연이 발화 길이만큼 누적됩니다.
이를 우회하고자 초기에는 pyaudio + sounddevice 기반의 CLI 프로토타입을 작성해 봤습니다.
하지만 이러한 방식은 명확한 한계가 있었습니다.
브라우저가 메인 인터페이스인 제품에서, 사용자가 마이크 사용을 위해 별도 CLI를 실행해야 한다는 것은 사실상 비현실적이었습니다.
또한 마이크 권한 처리, 디바이스 선택, 시각화 같은 UX 요소들이 모두 브라우저 표준으로 이미 잘 정리되어 있는데, 이를 일부러 외면하고 네이티브 환경에서 재구현하는 것은 무의미한 일이었습니다.
Realtime API 도입과 새로운 난관
기존 Whisper API의 한계를 극복하고자 WebSocket 기반의 스트리밍 인터페이스인 OpenAI Realtime API를 도입했습니다.
gpt-realtime-whisper는 GA 단계의 전사 전용 모델로, 마이크 청크를 실시간으로 흘려보내면 부분 텍스트(delta)와 확정 텍스트(completed) 이벤트를 streaming으로 돌려줍니다.
그러나 공식 문서의 예제를 그대로 따라 했을 때, 다음과 같은 치명적인 결함들이 연이어 관찰되었습니다.
문서 예제와 동일하게 작성한 페이로드가 invalid_request_error로 거부되고, GA 모델임에도 일반 Realtime 헤더를 그대로 사용하면 GA API only 오류가 발생했으며, 무엇보다 turn_detection이 동작하지 않아 모델이 segment를 스스로 끊지 못하는 문제가 있었습니다.
브라우저에서 안전하게, 실시간으로, segment 경계까지 깔끔하게 관리하려면 어떤 구조가 필요할까?
🏗️ 아키텍처 설계: 4계층 WebSocket 프록시 파이프라인
문제 해결의 핵심은 브라우저, 서버, 모델의 책임을 명확히 분리하는 것이었습니다.
이를 구현하기 위해 전체 파이프라인을 4개의 독립 계층(Phase)으로 분리하여 설계했습니다.
Phase 1 & 2: Browser Mic Capture & PCM16 Encoding
navigator.mediaDevices.getUserMedia로 24kHz mono 오디오 스트림을 획득합니다.AudioContext+ScriptProcessor로 float32 샘플을 가로채,Int16Array로 직접 변환합니다.- 바이너리는
btoa로 Base64 인코딩해 JSON 메시지의data필드에 담아 WebSocket으로 전송합니다.
Phase 3: FastAPI WebSocket Proxy
- 브라우저 ↔ 서버 ↔ OpenAI 양방향 중계를 담당하는 얇은 프록시 레이어입니다.
- 브라우저는
{type: "audio" | "commit" | "config"}등 자체 정의 메시지만 보냅니다. - 서버는 이를 OpenAI Realtime API 스펙의
input_audio_buffer.append/input_audio_buffer.commit/session.update로 번역해 upstream에 전달합니다. - API 키는 절대 클라이언트에 노출되지 않습니다.
.env에서만 로드합니다.
Phase 4: OpenAI Realtime API (gpt-realtime-whisper)
- WebSocket URL:
wss://api.openai.com/v1/realtime?intent=transcription - 모델 지정은 URL이 아닌
session.update페이로드 내부transcription.model로 합니다. delta/completed이벤트를 받아 그대로 프록시를 통해 브라우저에 중계합니다.
flowchart LR
A(["🎤 브라우저\n마이크"]) --> B["Phase 1\ngetUserMedia\n24kHz mono"]
B --> C["Phase 2\nfloat32 → Int16\nBase64 인코딩"]
C --> D["Phase 3\nFastAPI\nWebSocket Proxy"]
D --> E{{"메시지 변환\nsession.update\ninput_audio_buffer.*"}}
subgraph upstream["Phase 4: OpenAI Realtime API"]
F("gpt-realtime-whisper\nstreaming transcription")
end
E -.->|"audio.append"| F
F -.->|"delta / completed"| D
D --> G(["📝 실시간 자막"])
🛠️ 개발 과정 및 트러블슈팅
파이프라인 설계 이후, 실제 OpenAI 엔드포인트에 붙이는 과정에서 다수의 함정에 직면했습니다.
이러한 난관들은 페이로드 실험 스크립트(probe)와 UX 측면의 로직 재설계를 통해 해결했습니다.
1. invalid_model 오류 — URL 파라미터의 함정
일반 Realtime API의 관례를 따라 URL에 ?model=gpt-realtime-whisper를 붙였더니, 연결 직후 invalid_model 오류가 반환되었습니다.
💡 해결책: 전사 세션은 URL에 모델 파라미터를 받지 않습니다. 다음과 같이 intent 쿼리만 사용하고, 모델 지정은 session.update 페이로드 내부에서 합니다.
URL = "wss://api.openai.com/v1/realtime?intent=transcription"
2. GA API only 오류 — Beta 헤더의 잔재
일반 GA Realtime 예제 코드를 참고해 OpenAI-Beta: realtime=v1 헤더를 함께 보냈더니, **"이 모델은 GA API에서만 사용 가능"**이라는 오류가 발생했습니다.
💡 해결책: GA 모델에 Beta 헤더를 보내면 거부됩니다. Authorization만 남깁니다.
headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
3. session.update 페이로드 schema 변경
문서의 구 예제는 input_audio_format, input_audio_transcription 같은 평면 키를 사용하지만, 현재 GA 스펙은 audio.input 아래에 중첩된 구조를 요구합니다.
💡 해결책: 어느 구조가 맞는지 확인하기 위해 페이로드 후보들을 차례로 던져 보는 probe 스크립트를 작성해 검증했습니다.
<probe_strategy>
<candidate_A>session.type="transcription" + 평면 키 (구 스펙)</candidate_A>
<candidate_B>session.type="transcription" 만 (모델 없이)</candidate_B>
<candidate_C>session.type="transcription" + audio.input 중첩 (현재 정답)</candidate_C>
<verdict>
각 후보 전송 후 session.updated / error.code 를 로그.
Candidate C 만 session.updated 응답을 반환.
</verdict>
</probe_strategy>
검증 결과 확정된 정답 페이로드는 다음과 같습니다.
{
"type": "session.update",
"session": {
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 24000},
"transcription": {
"model": "gpt-realtime-whisper",
"language": "ko",
},
}
},
},
}
4. turn_detection 미지원 — 자동 분할의 부재
일반 GA Realtime 모델은 server_vad로 발화 시작/종료를 자동 감지하지만, gpt-realtime-whisper는 이 기능을 지원하지 않습니다. 페이로드에 turn_detection을 포함하면 거부됩니다.
💡 해결책: input_audio_buffer.commit을 수동으로 보내 segment 경계를 직접 확정해야 합니다. 초기에는 1.5초 / 2초 / 3초 / 10초의 자동 주기 commit을 사용자가 선택하도록 UI를 만들었습니다.
commitTimer = setInterval(() => {
ws.send(JSON.stringify({ type: "commit" }));
}, interval); // 예: 2000ms
5. 자동 주기 commit의 UX 함정
주기를 짧게 하면 짧은 발화를 자주 segment 분리해 자막이 토막토막 끊겼고, 길게 하면 발화 도중 한참 동안 결과가 나오지 않아 답답한 UX가 만들어졌습니다.
또한 사용자가 길게 한 문장을 말하는 중간에 commit이 끼어들면, 한 문장이 두 개의 completed 이벤트로 분리되어 시각적 응집성도 깨졌습니다.
💡 해결책: "사용자가 발화를 멈춘 시점"이 곧 자연스러운 segment 경계라는 점에 착안해, 주기적 commit을 제거하고 "Stop 버튼 클릭 시 1회 commit"으로 단일 패턴으로 통합했습니다. 다만 사용자가 stop을 누르지 않고 무제한 녹음하는 경우를 대비해 10분 안전망 타이머를 두었습니다.
// 안전망: 최대 길이 도달 시 강제 commit
const MAX_SEGMENT_MS = 10 * 60 * 1000;
commitTimer = setTimeout(() => {
ws.send(JSON.stringify({ type: "commit" }));
}, MAX_SEGMENT_MS);
// Stop 시 마지막 commit
async function stopRecording() {
clearTimeout(commitTimer);
if (processor) {
processor.disconnect();
processor = null;
}
await new Promise((r) => setTimeout(r, 150)); // 큐 잔여 chunk가 빠질 시간
ws.send(JSON.stringify({ type: "commit" }));
audioCtx.close();
mediaStream.getTracks().forEach((t) => t.stop());
}
6. 예상이 빗나간 발견 — delta는 commit 없이도 실시간 도착
"Stop 시 commit"으로 바꿀 때, 가장 우려했던 부분은 발화 도중 화면이 비어 있을지도 모른다는 점이었습니다.
turn_detection 미지원이라는 사실로부터 "commit이 있어야 segment 처리가 시작되고, 따라서 delta도 commit 이후에만 발생할 것"이라고 추론했기 때문입니다.
그러나 실제로 동작시켜 본 결과, 예상은 빗나갔습니다.
audio.append 만 흘려보내도 conversation.item.input_audio_transcription.delta 이벤트가 실시간으로 도착했다. commit 은 단지 segment 경계 확정과 .completed 발생의 역할만 담당한다.
💡 시사점: 모델은 streaming transcription을 background로 항상 진행하고 있고, commit은 "지금까지의 audio buffer를 하나의 segment로 확정 + 최종 transcript 발행"의 의미만 갖습니다. 이 발견 덕분에 "발화 중 실시간 자막 + Stop 시 segment 확정"이라는 이상적인 UX가 추가 작업 없이 자연스럽게 달성되었습니다.
📄 실제 동작 화면 및 흐름
완성된 앱은 단일 페이지 다크 테마 인터페이스로, 좌측 사이드바에 연결 상태/설정/세션 통계를, 우측 메인 영역에 마이크 컨트롤과 전사 결과를 배치합니다.
1단계 — 초기 진입

연결 상태는 대기 중. 좌측 사이드바에서 전사 언어와 커밋 모드(마이크 중지 시점에 한 번만 전사, 최대 10분)를 확인하고 하단의 ▶ 서버 연결을 클릭합니다.
2단계 — WebSocket 연결 완료

연결 상태가 연결됨으로 바뀌고, 사이드바 하단에 세션 ID(sess_...)가 표시됩니다. 빨간 ⏹ 버튼이 활성화되며 메인 영역에 발화를 시작하세요 / 말하는 동안 실시간으로 전사됩니다 안내가 노출됩니다.
3단계 — 실시간 전사 진행

이전 segment의 확정 결과(내 보험 잘 가입했나요?, 초록색)와 현재 발화의 부분 텍스트(안녕하세요 이안입니다., 노란 이탤릭)가 동시에 표시됩니다.
화면이 보여주는 결정적 증거
3단계 스크린샷은 단순한 UI 캡처가 아니라, 앞서 "💡 시사점"에서 다룬 "delta는 commit 없이도 실시간 도착한다"는 발견의 시각적 증거이기도 합니다.
- 초록색 확정 텍스트(
내 보험 잘 가입했나요?): 직전 발화 종료 시commit이 발생해completed이벤트로 받은 segment. - 노란색 이탤릭 텍스트(
안녕하세요 이안입니다.): 사용자가 지금 이 순간 말하고 있는 중인 발화의delta이벤트 누적분. 아직commit을 보내지 않았음에도 화면에 실시간으로 흘러가는 중입니다.
만약 가설대로 "delta가 commit 이후에만 발생"한다면 노란색 이탤릭 영역은 발화 종료 전까지 비어 있어야 합니다. 화면이 그렇지 않다는 것이 곧 모델의 실제 동작을 확정 짓습니다.
이벤트 시퀀스 (한 발화 기준)
아래는 사용자가 약 4초간 한 문장을 발화하고 Stop을 눌렀을 때, 브라우저가 수신하는 이벤트 시퀀스입니다.
T+0.0s ▶ 사용자가 🎤 버튼 클릭
T+0.1s → audio chunk (PCM16 100ms) ───→ OpenAI
T+0.2s → audio chunk ───→ OpenAI
...
T+1.4s ← delta: "안녕" (실시간 부분 텍스트)
T+1.9s ← delta: "하세요. 오늘"
T+2.6s ← delta: "은 날씨가"
T+3.5s ← delta: "정말 좋습니다."
T+4.0s ⏹ 사용자가 Stop 클릭 → commit 전송
T+4.3s ← completed: "안녕하세요. 오늘은 날씨가 정말 좋습니다."
(segment 확정 + 저장)
여기서 두 가지 핵심 동작이 동시에 관찰됩니다.
- 실시간성: Stop을 누르기도 전에
delta가 약 1.4초부터 흘러나오기 시작합니다. - 확정성: Stop을 누른 순간에만
completed가 발생해 segment가 한 덩어리로 저장됩니다.
사용자가 자동 commit 주기를 선택해야 했던 이전 UI에서는, 1초 간격 자막 토막이나 5초 지연된 응답 중 하나를 감수해야 했습니다.
파이프라인은 자연스러운 발화 단위와 streaming 자막의 즉시성을 동시에 만족시켰습니다.
🚀 마무리
본 아키텍처의 핵심은 각 계층의 역할을 명확히 분리하고, 모델의 실제 동작을 추론이 아닌 실험으로 확정짓는 구조를 설계하는 것에 있습니다.
- 브라우저 (Phase 1-2): 표준 Web Audio API로 마이크 접근, 권한, 시각화를 모두 위임합니다. 네이티브 의존성이
0입니다. - FastAPI 프록시 (Phase 3): API 키 격리, 메시지 번역, 이벤트 필터링을 담당합니다. 코드는
200줄미만으로 유지됩니다. - OpenAI Realtime (Phase 4): streaming transcription을 background로 항상 진행하며,
commit시점에만 segment를 확정합니다.
세 계층을 깔끔하게 분리함으로써, 어느 한쪽이 변경되어도 다른 쪽에 파급 효과 없이 독립적으로 진화할 수 있는 구조를 확보했습니다.
향후에는 호환성을 우선해 사용한 ScriptProcessor를 AudioWorklet으로 마이그레이션해 메인 스레드 부담을 줄이는 작업에 집중할 예정입니다.