250ms 안에 “말 끊고-대답하는” 2026 실시간 Voice Agent 아키텍처: Realtime API vs Streaming STT→LLM→TTS, 무엇을 언제 쓰나
들어가며
2026년 7월 기준, “실시간 음성 대화”의 병목은 더 이상 모델 지능만이 아닙니다. 사용자가 말을 멈추기 전에도 시스템이 생각을 시작하고, 필요하면 사용자의 barge-in(끼어들기) 에 즉시 반응하며, 첫 오디오가 수백 ms 단위로 나가야 “사람과 대화”처럼 느껴집니다. 이 UX를 만들려면 스트리밍, 풀듀플렉스, 턴테이킹이 핵심이고, 구현 방식은 크게 두 갈래로 정리됩니다.
- (A) Speech-to-Speech Realtime(단일 세션, 오디오 in/out): WebSocket(또는 WebRTC)로 오디오를 넣으면 모델이 오디오로 바로 답합니다. OpenAI Realtime 계열이 대표적입니다. (openai.com)
- (B) Streaming 파이프라인(STT → LLM → TTS): 각각의 컴포넌트를 스트리밍으로 연결해 겉보기 지연을 최소화합니다(현업 표준). (arxiv.org)
언제 (A)가 좋나?
- “대화의 리듬”이 제품 경쟁력일 때(콜센터, 리셉셔니스트, 인터뷰/코칭, 실시간 안내)
- 구현 단순화가 중요할 때(한 세션에서 오디오 입출력/이벤트를 통합)
- 자연스러운 끼어들기/백채널(“음…”, “네”)이 UX에 필요할 때
언제 (A)를 피해야 하나?
- 규제/컴플라이언스(예: 의료 PHI): 2026년 5월 기준으로 Realtime 오디오 모달리티가 BAA 커버 범위와 관련해 주의가 필요하다는 현업 가이드가 있습니다. 이런 경우 HIPAA-eligible STT/TTS + 텍스트 LLM 조합이 현실적입니다. (forasoft.com)
- 오디오 품질/보이스 아이덴티티를 긴 시간 일관되게 유지해야 하는 내레이션 중심 제품(대화형이 아닌 경우)
- 비용/관측가능성(Observability)을 STT/TTS 단위로 쪼개서 최적화해야 하는 경우
🔧 핵심 개념
1) “실시간”은 모델이 아니라 세션 프로토콜 문제다
실시간 Voice Agent의 체감 지연은 보통 아래 합입니다.
- Capture/Chunking: 마이크 PCM을 몇 ms 단위 프레임으로 자를지
- Uplink: 네트워크 RTT/지터
- Endpointing(VAD): 사용자가 “말 끝”냈는지 판단
- Reasoning: 답변 생성(툴 호출 포함)
- Synthesis/Playback: TTS 시작 시점(Time-to-first-audio), 스트리밍 안정성
2026년 트렌드는 “말 끝을 기다리지 않고” 계산을 당겨오는 것입니다. 학계도 endpoint anticipation/ speculative execution(선제 실행)으로 평균 지연을 줄이는 연구들이 나왔습니다. (arxiv.org)
즉, “빠른 모델”보다 부분 컨텍스트에서 미리 시작하는 설계가 중요합니다.
2) Realtime(S2S) vs Streaming 파이프라인의 구조 차이
(A) Realtime(Speech-to-Speech) 세션(단일 WebSocket)
- 클라이언트가 오디오 프레임을 지속 전송
- 서버는 이벤트 기반으로:
- 입력 버퍼 상태
- 부분 인식(있다면)
- 모델의 오디오 출력 버퍼 시작/증분/완료
- 끼어들기 시 출력 중단 등
이런 “서버 이벤트” 중심 설계가 Realtime API 레퍼런스에 드러납니다. (platform.openai.com)
장점: 턴테이킹/바지인 구현이 쉬워지고, STT/LLM/TTS 경계에서 발생하는 지연/불일치가 줄어듭니다. OpenAI도 Realtime을 “저지연 양방향” 케이스로 분리 설명합니다. (openai.com)
(B) Streaming STT → Streaming LLM → Streaming TTS
- STT가 partial transcript를 뱉고
- LLM은 streaming token을 뱉고
- TTS는 token(또는 문장)을 받아 바로 오디오를 스트리밍
현업 튜토리얼/벤치마크 성격의 글에서도 “핵심은 pipelining”이라고 결론냅니다. (arxiv.org)
장점: 컴포넌트별 교체/규제 대응/비용 통제가 쉽습니다.
단점: “끊김 없는 대화”를 위해 endpointing, 중간 취소, TTS 재시작 등 상태머신이 급격히 복잡해집니다.
3) 2026년 선택 기준(현실적)
- 최저 지연 + 자연스러운 대화: Realtime(S2S) 우선 검토. (open.cx)
- 규제/보안/컴플라이언스: 파이프라인 + 벤더별 계약/리전/로그 정책 확인. (forasoft.com)
- TTS 품질의 핵심 지표는 “스트리밍 안정성 + time-to-first-audio”: 단일 수치가 아니라, 끊김/드롭/재연결 시 복원까지 포함해 평가해야 합니다. (codeables.dev)
💻 실전 코드
아래는 “실시간 전화/상담 스타일”을 가정한 구조입니다.
- 브라우저/앱(클라이언트): 마이크 → WebSocket으로 PCM 프레임 전송, 서버에서 오는 오디오 프레임을 재생
- 서버(Node.js): OpenAI Realtime 세션에 프록시로 연결
- 이유: API Key 보호, 세션 정책(툴 허용/금칙어/로그 정책), 멀티테넌트 라우팅, 관측가능성 삽입
주의: 오디오 포맷/이벤트 스키마는 모델/SDK 버전에 따라 바뀔 수 있어 “패턴” 중심으로 보세요. Realtime 모델/이벤트 문서는 공식 문서를 기준으로 맞추는 게 안전합니다. (developers.openai.com)
0) 의존성/환경
1
2
3
4
# Node 20+
npm init -y
npm i ws express
export OPENAI_API_KEY="..."
1) 서버: 클라이언트 WebSocket ↔ OpenAI Realtime WebSocket 브리지
현실적인 요구(실무)를 코드에 반영합니다.
- full-duplex: 입력 받는 동안 출력도 동시에 전달
- barge-in: 사용자가 말 시작하면(클라 VAD/서버 신호) 현재 출력 스트림을 중단 요청
- tool 호출: “고객 주문 조회” 같은 실제 업무 흐름을 위해 함수 호출(서버에서 실행) 훅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// server.ts
import express from "express";
import http from "http";
import WebSocket, { WebSocketServer } from "ws";
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const OPENAI_KEY = process.env.OPENAI_API_KEY!;
if (!OPENAI_KEY) throw new Error("OPENAI_API_KEY missing");
// (예시) 사내 시스템/DB를 대신하는 tool
async function lookupOrder(orderId: string) {
// TODO: 실제로는 DB/API 호출
return { orderId, status: "SHIPPED", eta: "2026-07-09" };
}
// 클라이언트 → 우리 서버로 보낼 메시지(예시)
// { type: "audio", pcm16_base64: "..." }
// { type: "barge_in" }
// { type: "start", locale: "ko-KR" }
type ClientMsg =
| { type: "start"; locale?: string }
| { type: "audio"; pcm16_base64: string }
| { type: "barge_in" };
wss.on("connection", async (clientWs) => {
// OpenAI Realtime WS에 연결(정확한 URL/헤더는 공식 문서에 맞춰 조정)
const oaiWs = new WebSocket("wss://api.openai.com/v1/realtime?model=gpt-realtime", {
headers: {
Authorization: `Bearer ${OPENAI_KEY}`,
"OpenAI-Beta": "realtime=v1",
},
});
let ready = false;
// OpenAI → Client: 오디오/이벤트 전달
oaiWs.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
// 1) 오디오 델타를 클라로 forward
// (이벤트명은 문서 기준으로 맞추세요)
if (msg.type === "output_audio_buffer.delta") {
clientWs.send(JSON.stringify({ type: "audio_out", pcm16_base64: msg.delta }));
}
// 2) tool call 처리(서버에서 실행 후 결과를 다시 Realtime에 제출)
if (msg.type === "tool_call") {
(async () => {
const { name, arguments: args, call_id } = msg;
if (name === "lookupOrder") {
const result = await lookupOrder(args.orderId);
oaiWs.send(
JSON.stringify({
type: "tool_result",
call_id,
result,
})
);
}
})().catch((e) => {
console.error("tool error", e);
});
}
// 3) 디버그/관측(필수)
if (msg.type?.includes("error")) console.error("OAI error:", msg);
});
oaiWs.on("open", () => {
ready = true;
// 세션 설정: 음성, 언어, tool, 턴테이킹 정책 등
// (정확한 필드는 모델/문서에 맞춰 조정)
oaiWs.send(
JSON.stringify({
type: "session.update",
session: {
instructions:
"당신은 한국어로 대화하는 고객센터 에이전트입니다. 짧고 명확하게 말하고, 사용자가 끼어들면 즉시 멈추고 경청하세요.",
tools: [
{
name: "lookupOrder",
description: "주문 상태를 조회한다",
parameters: {
type: "object",
properties: { orderId: { type: "string" } },
required: ["orderId"],
},
},
],
// 입력/출력 오디오 포맷도 여기서 합의(예: pcm16, 16kHz)
},
})
);
clientWs.send(JSON.stringify({ type: "ready" }));
});
// Client → OpenAI: 오디오/제어 전달
clientWs.on("message", (raw) => {
if (!ready) return;
const msg = JSON.parse(raw.toString()) as ClientMsg;
if (msg.type === "audio") {
oaiWs.send(
JSON.stringify({
type: "input_audio_buffer.append",
audio: msg.pcm16_base64,
})
);
return;
}
if (msg.type === "barge_in") {
// 끼어들기: 현재 출력 생성/버퍼를 중단(이벤트명은 문서에 맞춰 조정)
oaiWs.send(JSON.stringify({ type: "output_audio_buffer.clear" }));
return;
}
});
const closeAll = () => {
try { clientWs.close(); } catch {}
try { oaiWs.close(); } catch {}
};
clientWs.on("close", closeAll);
oaiWs.on("close", closeAll);
});
server.listen(3000, () => {
console.log("ws bridge listening on :3000");
});
2) 클라이언트(개념): 마이크 캡처/프레이밍 + barge-in 신호
브라우저에서라면 AudioWorklet로 20ms(320 samples @16kHz) 단위 PCM 프레임을 만들고 base64로 WS 송신합니다.
실무에서는 클라이언트 VAD로 “사용자 발화 시작”을 빨리 감지해서 barge_in을 보내는 편이 UX가 더 안정적입니다(서버/모델 VAD만 믿으면 지연이 늘어남).
예상 동작(출력)
- 사용자가 “주문 1234 상태 알려줘” → 에이전트가 즉시 “확인해볼게요” 같은 backchannel을 내보내며 tool 호출 → 조회 결과를 반영해 답변
- 사용자가 중간에 말 시작 → 현재 음성 출력이 즉시 멈추고 경청으로 전환
⚡ 실전 팁 & 함정
Best Practice (현업에서 체감 큰 것 3가지)
1) 오디오 프레임 크기(20ms 내외) + 고정 샘플레이트
프레임이 커지면 전송/버퍼링 때문에 barge-in이 둔해집니다. 반대로 너무 작으면 WS 오버헤드가 커집니다. “자연스러운 대화”는 대체로 150~250ms 급의 TTS 첫 오디오와 안정적 스트리밍에 좌우된다는 경험칙이 공유됩니다. (codeables.dev)
2) 취소(cancellation)를 1급 기능으로 설계
- 사용자 발화 감지 → 즉시
output_audio_buffer.clear(또는 유사 이벤트) - 진행 중 tool/API 호출도 abort controller로 끊기
실시간 시스템에서 “취소 불가능한 작업”은 곧 “사용자에게 말 끊김 당하는 에이전트”가 됩니다.
3) 관측가능성: turn 단위 트레이싱(필수) P50만 보지 말고,
- end-of-speech 감지 시간
- first audio out 시간
- 끊김/재전송/재연결
- tool latency, 실패율
을 turn_id로 묶어 로그/메트릭화하세요. “어느 구간이 흔들리는지”를 못 보면 최적화가 불가능합니다.
흔한 함정/안티패턴
- 텍스트 기반 에이전트 설계를 그대로 음성에 이식: 음성은 “말하는 중”이라는 상태가 존재합니다. 출력 중 재생/중단/재시작 상태머신을 설계하지 않으면 UX가 바로 무너집니다.
- VAD/Endpointing을 한 군데만 둠: 클라이언트(초기 감지) + 서버(정책) + 모델(최종)로 역할 분리하는 편이 안정적입니다.
- 규제 데이터(상담 녹취/PHI)를 아무 생각 없이 오디오로 올림: 특히 의료/금융은 계약/보관/로그 정책을 먼저 확정해야 합니다. Realtime 오디오의 BAA 관련 주의가 언급된 가이드도 있습니다. (forasoft.com)
비용/성능/안정성 트레이드오프
- Realtime(S2S): 구현은 단순해지지만, 디버깅이 “이벤트 스트림” 중심이라 초기 관측 체계가 없으면 문제 원인 파악이 어렵습니다.
- 파이프라인(STT/LLM/TTS): 비용/벤더 분리가 쉬운 대신, 낮은 지연을 만들려면 speculative/partial 기반으로 점점 복잡해집니다(연구들도 그 방향). (arxiv.org)
🚀 마무리
2026년 7월 실시간 음성 에이전트 구현은 “좋은 STT/TTS를 고르는 문제”를 넘어, 풀듀플렉스 스트리밍 + 취소 + 턴테이킹을 제품 핵심으로 다루는 문제입니다.
도입 판단 기준을 간단히 정리하면:
- 대화 UX(리듬/끼어들기)가 핵심 → Realtime(Speech-to-Speech) 우선 검토 (OpenAI Realtime 모델/서버 이벤트 문서부터). (openai.com)
- 규제/감사/벤더 분리 최적화가 핵심 → Streaming STT→LLM→TTS 파이프라인 + 강한 관측가능성/취소 설계. (forasoft.com)
다음 학습 추천(실무 우선순위) 1) Realtime API 모델/이벤트 레퍼런스 정독(오디오 버퍼/세션 업데이트/취소) (developers.openai.com)
2) “endpoint anticipation / speculative” 계열 논문을 읽고, 내 제품에서 어디까지 선제 실행할지 결정 (arxiv.org)
3) 내 시스템의 SLO를 “P50 첫 오디오”가 아니라 barge-in 반응 시간, 재연결 복원, turn 단위 실패율로 정의하고 측정부터 시작
원하시면, (1) 브라우저 AudioWorklet 기반 16kHz PCM 인코딩/재생까지 포함한 엔드투엔드 예제, (2) LiveKit/mediasoup 같은 미디어 서버(SFU)에 에이전트를 “참가자”로 붙이는 아키텍처(회의/콜) 버전으로도 확장해드릴게요. (forasoft.com)