Joyuno's Dev Blog

250ms 안에 “말 끊고-대답하는” 2026 실시간 Voice Agent 아키텍처: Realtime API vs Streaming STT→LLM→TTS, 무엇을 언제 쓰나

AI Multimodal

들어가며

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) 의존성/환경

# Node 20+
npm init -y
npm i ws express
export OPENAI_API_KEY="..."

1) 서버: 클라이언트 WebSocket ↔ OpenAI Realtime WebSocket 브리지

현실적인 요구(실무)를 코드에 반영합니다.

  • full-duplex: 입력 받는 동안 출력도 동시에 전달
  • barge-in: 사용자가 말 시작하면(클라 VAD/서버 신호) 현재 출력 스트림을 중단 요청
  • tool 호출: “고객 주문 조회” 같은 실제 업무 흐름을 위해 함수 호출(서버에서 실행) 훅
// 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)