세상은 멈춰 있지 않습니다.RSS
BIG CHANGE.

Markdown 버전

AI-translated from English; not yet reviewed by a fluent editor.

# OpenAI Realtime으로 로컬 브라우저 음성 대화 만들기

> OpenAI Realtime과 WebRTC로 로컬 브라우저 마이크 대화를 만듭니다. 전체 서버·페이지 코드는 API 키를 서버에 보관하며 재생 확인, 마이크 중지, 오류 처리를 보여 줍니다.

By BIG CHANGE Editorial

Published: 2026-10-19T19:20:34.863Z
Updated: 2026-10-19T19:20:34.863Z
Canonical: https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide

![Conceptual charcoal illustration of a desktop microphone beside a blank browser on a monitor and a small speaker.](https://bigchange.ai/api/media/file/realtime-browser-voice-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, browser test or heard reply is depicted.

OpenAI Realtime API를 사용하면 브라우저가 WebRTC로 마이크 음성을 보내고 음성 답변을 재생할 수 있습니다. 이 가이드는 `gpt-realtime-2.1` 으로 로컬 대화 한 개를 만듭니다. API 키는 컴퓨터의 Node 서버에 둡니다. 모델 접근 권한과 결제 설정이 있는 OpenAI API 프로젝트가 필요하며 ChatGPT 구독으로 API 사용료를 낼 수 없습니다. BIG CHANGE는 현재 문서와 예제 코드를 확인했지만 유료 세션을 열거나 답변을 듣지 않았습니다.

## 핵심 변화

- **달라진 점:** OpenAI는 브라우저 WebRTC를 통한 Realtime 대화 경로를 문서화했습니다. 로컬 서버가 프로젝트 키를 보관하면서 브라우저의 연결 제안을 응답으로 교환합니다.
- **중요한 이유:** 개발자는 더 큰 음성 제품을 설계하기 전에 로컬 페이지 하나에서 마이크부터 음성 답변까지 확인할 수 있습니다. 프로젝트 소유자가 키를 관리하고 API 사용료를 지불합니다.
- **주의할 점:** 로컬 통신 성공이 운영 환경 준비를 입증하지는 않습니다. 공유 버전에는 호출자 인증과 지출 통제가 필요하며 취소되거나 시간 초과된 요청도 이미 OpenAI에 도달했을 수 있습니다.

## 시작하기 전에

Node.js 22.6 이상을 설치하고 정상 마이크·스피커가 있는 최신 브라우저를 사용합니다. 브라우저 마이크 접근에는 보안 컨텍스트가 필요합니다. `http://localhost` 는 이 로컬 예제에서 조건을 충족합니다. `gpt-realtime-2.1` 접근 권한과 결제가 활성화된 OpenAI API 프로젝트 키를 준비합니다. 먼저 프로젝트 속도·지출 한도를 확인하세요. 모델 페이지는 Free 등급을 지원하지 않는다고 명시합니다. 평소 비밀 관리 도구나 로컬 shell 설정으로 키를 서버 프로세스 환경에 보관합니다. 키를 `index.html` 에 넣거나 `.env` 파일로 커밋하거나 대화 기록에 붙여넣지 마세요. [OpenAI WebRTC 가이드](https://developers.openai.com/api/docs/guides/voice-webrtc)는 신뢰 서버 교환을 설명하며 [모델 페이지](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)는 현재 접근 조건과 가격을 제공합니다.

여기서는 Realtime API의 통합 `/v1/realtime/calls` 경로를 사용합니다. WebRTC 가이드는 `/v1/live/sessions` 를 통한 GPT-Live도 설명하지만 세션 프로토콜이 다릅니다. 아래 경로와 페이로드를 함께 사용하세요. 예제는 본인 컴퓨터에 한정되며 로컬 Host·Origin 검사는 공유 서비스 사용자를 인증하지 못합니다.

1. 데모용 빈 디렉터리를 만들고 그 안에서 Express를 설치합니다:

```bash
npm init -y
npm install express
```

설치는 `package.json` 를 만들고 웹 서버 의존성을 추가합니다. 다음 두 파일을 같은 디렉터리에 저장합니다. 코드는 Node 내장 `fetch`, `FormData`, `AbortSignal.timeout` 을 사용합니다. 검토한 구성에는 Node.js 22.6 이상이 필요합니다.

2. 다음을 `server.mjs` 로 저장합니다. 브라우저의 SDP 제안(예정된 WebRTC 연결 설명)을 받아 Realtime 세션 설정과 함께 OpenAI에 보냅니다. 일반 API 키는 이 서버에서만 읽습니다.

```javascript
import express from "express";

const app = express();
// This demo accepts only the local page, including protection against DNS rebinding.
app.use((req, res, next) => {
  const hosts = ["localhost:3000", "127.0.0.1:3000"];
  if (!hosts.includes(req.get("host"))) return res.status(403).send("Local host required");
  if (req.method === "POST" && req.get("origin") !== `http://${req.get("host")}`) {
    return res.status(403).send("Same-origin local page required");
  }
  next();
});
app.use(express.text({ type: "application/sdp", limit: "64kb" }));

app.get("/", (_req, res) => res.sendFile("index.html", { root: process.cwd() }));

app.post("/session", async (req, res) => {
  if (!process.env.OPENAI_API_KEY) return res.status(500).send("Server API key missing");
  if (typeof req.body !== "string" || !req.body.startsWith("v=")) {
    return res.status(400).send("Expected an SDP offer");
  }

  const form = new FormData();
  form.set("sdp", req.body);
  form.set("session", JSON.stringify({
    type: "realtime",
    model: "gpt-realtime-2.1",
    output_modalities: ["audio"],
    audio: { output: { voice: "marin" } },
    instructions: "Be a concise, helpful voice assistant. Do not claim access to external tools."
  }));

  try {
    const upstream = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
      body: form,
      signal: AbortSignal.timeout(25000)
    });
    const answer = await upstream.text();
    if (!upstream.ok) {
      console.error("Realtime session rejected", upstream.status);
      return res.status(502).send(`OpenAI session request failed (${upstream.status})`);
    }
    res.type("application/sdp").send(answer);
  } catch (error) {
    console.error("Realtime session network failure", error.name);
    res.status(502).send("Could not reach the Realtime API");
  }
});

app.listen(3000, "127.0.0.1", () => console.log("Open http://localhost:3000"));
```

서버는 `127.0.0.1:3000` 에 바인딩하고 로컬 페이지의 host와 POST origin만 허용하며 SDP 응답을 브라우저로 돌려줍니다. 상위 요청의 25초 제한은 예제에서 선택한 값입니다. 시간 초과나 브라우저 연결 중단만으로 OpenAI가 요청을 받지 않았다고 판단할 수 없으므로 결과 불명인 시작을 반복하기 전에 사용량을 확인하세요.

3. 다음을 `index.html` 로 저장합니다. 페이지는 **Start voice** 후 마이크 권한을 요청하고 로컬 서버로 제안을 보내며 수신 음성을 재생합니다. **Stop** 은 설정 중에도 연결을 닫고 마이크 트랙을 중지합니다. 30초 설정 타이머도 예제의 선택이며 API 보장이 아닙니다.

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
  const start = document.querySelector("#start");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  const reply = document.querySelector("#reply");
  let current;

  function closeSession(session = current, message = "Stopped") {
    if (!session) return;
    session.closed = true;
    clearTimeout(session.timer);
    session.abort.abort();
    session.mic?.getTracks().forEach(track => track.stop());
    if (session.pc) session.pc.onconnectionstatechange = null;
    session.dc?.close();
    session.pc?.close();
    if (current === session) {
      current = null;
      reply.srcObject = null;
      start.disabled = false;
      stop.disabled = true;
      status.textContent = message;
    }
  }

  start.onclick = async () => {
    const session = { abort: new AbortController(), closed: false };
    current = session;
    start.disabled = true;
    stop.disabled = false;
    status.textContent = "Requesting microphone";
    session.timer = setTimeout(() => closeSession(session, "Setup timed out; microphone stopped"), 30000);
    try {
      const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
      if (session.closed) { mic.getTracks().forEach(track => track.stop()); return; }
      session.mic = mic;
      const pc = session.pc = new RTCPeerConnection();
      pc.ontrack = event => { if (!session.closed) reply.srcObject = event.streams[0]; };
      pc.onconnectionstatechange = () => {
        if (session.closed) return;
        status.textContent = `Connection: ${pc.connectionState}`;
        if (pc.connectionState === "connected") clearTimeout(session.timer);
        if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
          closeSession(session, "Connection ended; microphone stopped. Start a new session.");
        }
      };
      pc.addTrack(mic.getAudioTracks()[0], mic);
      const dc = session.dc = pc.createDataChannel("oai-events");
      dc.onmessage = message => {
        if (session.closed) return;
        try {
          const event = JSON.parse(message.data);
          if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
          if (event.type === "response.done") {
            status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
          }
          if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
        } catch { closeSession(session, "Invalid event; stopped"); }
      };
      dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
      dc.onerror = () => closeSession(session, "Event channel failed; stopped");
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      if (session.closed) return;
      // The official WebRTC example submits the generated offer directly.
      const response = await fetch("/session", {
        method: "POST",
        headers: { "Content-Type": "application/sdp" },
        body: offer.sdp,
        signal: session.abort.signal
      });
      if (!response.ok) throw new Error(await response.text());
      const answer = await response.text();
      if (session.closed) return;
      await pc.setRemoteDescription({ type: "answer", sdp: answer });
      if (!session.closed && pc.connectionState !== "connected") status.textContent = "Signaling complete; waiting for connection";
    } catch (error) {
      if (!session.closed) closeSession(session, `Start failed: ${error.message}`);
    }
  };
  stop.onclick = () => closeSession();
  window.addEventListener("pagehide", () => closeSession());
</script>
</html>
```

피어·이벤트 채널 오류와 페이지 이탈 때도 브라우저가 세션을 닫습니다. 중지나 시간 초과 뒤에 마이크 권한이 도착할 수 있으며 늦게 생긴 트랙도 중지합니다. 상태 줄은 `response.done` 과 보고된 상태를 표시합니다. 이 이벤트만으로 답변이 들렸거나 취소·실패한 응답이 성공했다고 볼 수 없습니다.

4. 두 파일이 있는 디렉터리에서 서버를 시작하며 서버 프로세스 환경에 `OPENAI_API_KEY` 를 미리 설정합니다:

```bash
node server.mjs
```

여세요: `http://localhost:3000`. **Start voice**, **Stop**, **Idle** 상태와 오디오 플레이어가 보여야 합니다. **Start voice** 를 눌러 마이크를 허용하고 민감한 내용 없는 짧은 질문을 말합니다. OpenAI [Realtime 대화 가이드](https://developers.openai.com/api/docs/guides/realtime-conversations)는 기본 음성 활동 감지가 발화 뒤의 멈춤을 응답으로 이어 줄 수 있다고 설명합니다. 실행 성공을 확인하려면 브라우저의 마이크 사용 표시, `connected` 에 도달한 연결, `session.created` 이벤트, 실제로 들리는 말소리, 완료 응답을 보고하는 `response.done` 를 확인합니다. **Stop** 을 누르고 마이크 표시가 꺼졌는지 봅니다. 독자가 해야 할 확인이며 BIG CHANGE는 이 유료 브라우저 세션을 실행하지 않았습니다.

## 확인에 실패할 때

마이크 권한이 거부되거나 입력 장치가 없으면 브라우저 권한과 장치를 점검하고 로컬 `localhost` URL 또는 HTTPS를 사용합니다. 페이지에 `/session` 오류가 나오면 서버 HTTP 상태, 키 존재, 모델 권한, 결제, 속도 제한, 엔드포인트를 확인합니다. 서버는 키와 OpenAI 응답 본문을 출력하지 않습니다. 502는 네트워크 장애나 예제의 25초 제한일 수도 있으며 불확실한 상위 요청 수락 여부를 알려 주지 않습니다.

신호 교환이 성공했는데 연결이 실패하면 페이지 연결 상태, 네트워크 제한, 반환된 SDP 응답을 확인합니다. 연결은 정상인데 조용하면 마이크 입력과 출력 장치를 확인합니다. 브라우저 자동 재생이 차단될 수 있으므로 오디오 제어의 **Play** 를 누르고 필요하면 중지 후 새 세션을 시작합니다. 오류 때마다 **Stop** 을 사용하고 마이크 표시가 꺼졌는지 확인합니다. 요청 결과가 불확실하면 반복 시작 전에 프로젝트 사용량을 확인하세요.

## 비용, 개인정보와 제한

OpenAI가 명시한 `gpt-realtime-2.1` 비캐시 텍스트 입력/출력 가격은 **100만 tokens당 $4/$24**, 오디오 입력/출력은 **100만 tokens당 $32/$64** 입니다. 2026년 10월 19일 확인했습니다. 이전 대화 문맥을 포함할 수 있는 후속 턴까지 사용한 tokens에 따라 비용이 달라집니다. 사용 전에 [모델 가격과 제한](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 및 프로젝트 사용량을 확인하세요. 경과 시간만으로 요금을 추산할 수 없습니다. [Realtime 대화 가이드](https://developers.openai.com/api/docs/guides/realtime-conversations)는 세션 최대 시간을 60분으로 명시합니다.

세션 중 마이크 음성은 OpenAI로 전송됩니다. 전송할 권한이 있는 말만 쓰고 이 점검에서는 사적·제삼자 음성을 피하세요. 로컬 페이지에는 로그인이나 사용자별 예산 통제가 없습니다. 공유 서비스에는 인증·속도 제한을 적용한 `/session` 접근과 서버에서 생성한 안전 식별자가 필요합니다. 로컬 Host·Origin 검사는 충분하지 않습니다. 예제는 외부 도구나 전화를 호출하지 않습니다. 지연, 신뢰성, 음성 품질, 절약, 운영 배포 결과를 측정하지 않았습니다.

## 출처와 추가 자료

- [OpenAI WebRTC 가이드](https://developers.openai.com/api/docs/guides/voice-webrtc): 통합 Realtime 서버 교환, 브라우저 음성 경로, 별도 GPT-Live 프로토콜.
- [OpenAI Realtime 대화 가이드](https://developers.openai.com/api/docs/guides/realtime-conversations): 세션 이벤트, 턴 동작, 응답 상태, 시간 제한.
- [OpenAI `gpt-realtime-2.1` 모델 페이지](https://developers.openai.com/api/docs/models/gpt-realtime-2.1): 모델 접근, token 가격, 속도 제한. 예제 실행 때 가격과 접근 조건을 다시 확인하세요.

## Sources

- [OpenAI WebRTC 가이드](https://developers.openai.com/api/docs/guides/voice-webrtc) — Realtime 통합 calls 인터페이스, 신뢰 키, SDP, 브라우저 음성, 별도 GPT-Live 프로토콜.
- [OpenAI Realtime 대화](https://developers.openai.com/api/docs/guides/realtime-conversations) — 세션 이벤트, VAD, 응답 상태, 60분 제한.
- [OpenAI gpt-realtime-2.1 모델](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — 모델별 token 가격과 접근 조건.
- [OpenAI 음성 비용 최적화](https://developers.openai.com/api/docs/guides/voice-latency-cost) — 문맥과 응답별 사용량.
- [OpenAI API 및 ChatGPT 결제](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — 별도 결제 체계.