최근 OpenAI에서 공개한 Realtime Voice API를 활용해, WebRTC 기반 음성 통신 애플리케이션을 구현해봤습니다. 이 글에서는 Next.js (클라이언트 & 서버 라우트), Flask (백엔드), 그리고 OpenAI Realtime API를 연동하여 실시간 음성 통신을 구축한 과정을 설명합니다.
이 모든 과정을 브라우저 기반으로 구현합니다.
Next.js 클라이언트
↓ [POST /api/realtime/ephemeral]
Next.js 서버 (NextResponse)
↓ [POST /realtime/ephemeral]
Flask 서버 (requests.post)
↓
OpenAI Realtime API (/v1/realtime/sessions)
↓
Ephemeral token 발급 후 응답 전달
클라이언트는 OpenAI와 직접 연결하지 않고, 백엔드를 거쳐 ephemeral token만 발급받은 뒤 WebRTC로 연결합니다.
이유 | 설명 |
---|---|
🔐 OpenAI API Key 보호 | 클라이언트에 노출되면 안 되므로 서버에서 처리 |
⏱ Ephemeral 토큰 | 짧은 TTL(수분) → 연결 직전에 발급해야 안전 |
🧰 연결 설정 커스터마이징 | voice, 포맷, VAD 설정 등을 서버에서 통제 가능 |
🧾 로깅 & 디버깅 | Flask에서 로그를 남겨 문제 추적 용이 |
/api/realtime/ephemeral
구현@app.route("/api/realtime/ephemeral", methods=["POST"])
def create_realtime_ephemeral():
try:
body = {
"model": OPENAI_REALTIME_MODEL,
"voice": OPENAI_REALTIME_VOICE,
"modalities": ["text", "audio"],
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"turn_detection": {"type": "server_vad", "silence_duration_ms": 600},
"instructions": "항상 한국어로 1~2문장으로 간결히 말해 주세요.",
}
resp = requests.post(
"https://api.openai.com/v1/realtime/sessions",
headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json",
"OpenAI-Beta": "realtime=v1",
},
json=body,
timeout=10,
)
resp.raise_for_status()
data = resp.json()
client_secret = data.get("client_secret", {})
return jsonify({
"client_secret": client_secret.get("value"),
"expires_at": client_secret.get("expires_at"),
"model": data.get("model", OPENAI_REALTIME_MODEL),
"voice": OPENAI_REALTIME_VOICE,
})
except requests.HTTPError as e:
logging.error("Realtime ephemeral HTTP error: %s", e.response.text)
return jsonify({"error": e.response.text}), e.response.status_code
except Exception as e:
logging.exception("Realtime ephemeral error")
return jsonify({"error": str(e)}), 500
✅ Flask는 OpenAI 세션 생성 API를 호출하여 ephemeral token을 생성하고, 클라이언트가 WebRTC로 연결할 수 있도록 전달합니다.
OpenAI Realtime API의 보안을 위해서입니다. API Key를 클라이언트에 직접 노출하지 않고, 서버에서 짧은 TTL을 가진 임시 토큰(Ephemeral Token)을 발급해서 브라우저가 WebRTC 연결 직전에만 사용할 수 있게 합니다.
이렇게 하면 Key 유출 위험을 줄이고, 토큰이 만료되면 재사용이 불가능하므로 보안성이 높아집니다.
/api/realtime/ephemeral
import { NextResponse } from 'next/server';
export async function POST() {
try {
const origin = process.env.NEXT_PUBLIC_API_HOST;
const res = await fetch(`${origin}/realtime/ephemeral`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const text = await res.text();
try {
const json = JSON.parse(text);
return NextResponse.json(json, { status: res.status });
} catch {
return new NextResponse(text, { status: res.status });
}
} catch (err: any) {
return NextResponse.json({ error: String(err?.message ?? err) }, { status: 500 });
}
}
✅ Next.js는 클라이언트에서 요청을 받아 Flask 서버에 proxy 요청을 보내고, 응답을 전달합니다.
이 코드는 다음 기능을 수행합니다:
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [muted, setMuted] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const pcRef = useRef<RTCPeerConnection | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number | null>(null);
function log(s: string) {
setLogs((prev) => [s, ...prev].slice(0, 200));
}
async function getEphemeral(): Promise<{ client_secret: string }> {
const res = await fetch('/api/realtime/ephemeral', { method: 'POST' });
const data = await res.json();
return { client_secret: data.client_secret };
}
function startAudioMeter(stream: MediaStream) {
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
const source = ctx.createMediaStreamSource(stream);
source.connect(analyser);
// 캔버스에 레벨 그리기 로직 생략
}
const local = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection();
local.getTracks().forEach((t) => pc.addTrack(t, local));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const { client_secret } = await getEphemeral();
const resp = await fetch('https://api.openai.com/v1/realtime?...', { headers, body: offer.sdp });
const answerSdp = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
pc.getSenders().forEach((s) => s.track?.stop());
pc.close();
local.getAudioTracks().forEach((t) => (t.enabled = !muted));
enabled
속성으로 음소거 토글useEffect(() => () => disconnect(), []);
<button>
: 연결, 음소거, 해제<canvas>
: 오디오 레벨 미터<audio>
: 모델의 음성 응답 재생<pre>
: 로그 출력 (useState, useRef 포함)기능 | 설명 |
---|---|
상태 관리 | connecting , connected , muted , logs 등으로 UI 상태 제어 |
마이크 접근 | navigator.mediaDevices.getUserMedia() 사용 |
WebRTC 연결 | RTCPeerConnection 객체로 offer/answer 및 ICE 설정 |
서버 인증 요청 | /api/realtime/ephemeral 호출로 ephemeral 발급 |
마이크 음성 시각화 | AudioContext + AnalyserNode + Canvas 조합 |
마이크 음소거 | track.enabled = false 방식으로 제어 |
연결 해제 | 트랙 및 커넥션 정리, 상태 초기화 |
[1] 마이크 열기
↓
[2] WebRTC 연결 객체 생성
↓
[3] Offer 생성 → OpenAI로 전송
↓
[4] Answer 수신 → 연결 완료
↓
[5] 내 마이크 음성 → 실시간 스트리밍
↓
[6] OpenAI → 음성 응답 → audio 재생
이 구조는 최신 브라우저 기술(WebRTC)과 OpenAI의 실시간 음성 모델을 활용한 고성능 음성 인터페이스를 구현하는 데 적합합니다. 마이크만 있으면, 어떤 플랫폼에서도 작동 가능한 구조이며, 차후 모바일 또는 데스크탑 앱으로의 확장도 용이합니다.