OpenAI Realtime 세션 매니저 — 코드레벨 동작 흐름 P0코드레벨

마지막 업데이트 2026-07-22

작성일: 2026-06-14 대상: 개발자 — 핑퐁이 AI 세션의 진입점/흐름 파악 핵심 파일: apps/web/hooks/use-session-manager.ts

개요 · 범위

useSessionManager는 브라우저와 OpenAI Realtime API 사이의 WebRTC 세션 1개를 통째로 관리하는 훅이다. 마이크 캡처 → 오디오 가공 → OpenAI 송출, OpenAI 음성 수신 → 스피커 재생, 데이터채널 이벤트(전사·발화 상태) 처리, 끼어들기/침묵/종료까지 담당한다.

사용처(중요): 이 훅은 V1 게스트 릴레이(hooks/use-guest-session-relay.ts:144)와 프롬프트 테스트 페이지(components/pages/prompt-test.tsx:87)에서만 쓰인다.
V2(client-guest/SFU)는 이 훅을 쓰지 않고 별도 구현 entities/guest-session/model/use-ai-session.ts를 사용한다. 두 경로는 OpenAI Realtime 연동 로직이 평행하게 존재하므로, V2 작업 시 이 문서가 아니라 use-ai-session을 봐야 한다.

아키텍처 한눈에

[브라우저] useSessionManager
  ├─ 마이크: getUserMedia → volumeAmplifier(내장 마이크 한정) → audioProcessingChain(Gate/EQ/Comp/Limiter) → pc.addTrack
  ├─ SDP offer → createSession() ──HTTP──▶ /api/realtime/call
  │                                   └─ createCall() ──FormData(sdp+session)──▶ OpenAI /v1/realtime/calls
  │                                                 ◀── answer SDP ──┘
  ├─ pc.setRemoteDescription(answer) → pc.ontrack → audioElement.srcObject (스피커)
  └─ dataChannel "oai-events": 이벤트 송수신 (전사/발화/세션설정)

SDP를 서버로 프록시하는 이유: OPENAI_API_KEY를 브라우저에 노출하지 않기 위해. 클라이언트는 offer SDP만 보내고, Next API 라우트가 키를 붙여 OpenAI에 중계한 뒤 answer SDP를 돌려준다. 세션 instruction/voice/VAD 등 민감·정책 설정도 서버에서 조립된다.

진입점 · 시그니처

훅 옵션 (UseSessionManagerProps, L36–52)

옵션역할
onLogChatLog 생성 콜백 (user/assistant 전사, text/auto/fallback)
rtcConfigurationICE/TURN 서버 설정 (ref로 보관, 런타임 갱신 가능)
onPeerTalkingStatusChanged / onUserTalkingStatusChanged핑퐁이/아동 발화 상태 변화 통지
onNoGuestAudio15초간 입력 무음 시 호출 (침묵 타이머)
onAutoFinish자동 종료 조건 충족 시 (스텝 전환용)
onWebRTCDisconnected / onWebRTCFailed연결 끊김(즉시) / 5초 지속 또는 failed(세션 중단)
onAssistantTurnFinalized핑퐁이 응답 턴의 SessionLog id 확정 시점
enableSocketEmission, roomId게스트 역할일 때 소켓으로 상태 송출(WebRTC 통계·증폭 상태)
initialSilenceDurationMs프로필 저장 VAD 기본값. 없으면 VAD_FALLBACK_MS

StartSessionParams (L54–67) · 반환값

startSession(){ dataChannel, outputAudioStream, inputAudioStream }을 반환한다. outputAudioStream이 곧 핑퐁이 음성 스트림이며, 게스트 릴레이가 이를 호스트에게 그대로 add한다.

startSession() 흐름 (L665–978)

  1. 1동시성 가드 진입. requestingActivityIdRef로 같은/다른 activity 재요청을 구분(로그만). 실제 무효화는 아래 requestId로 처리.
  2. 2세션 파라미터를 ref에 반영. activityRef, turnDetectionRef.interrupt_response, autoFinishConfig, startMent, autoTransition/fallback 활성화.
  3. 3RTCPeerConnection 생성(rtcConfigurationRef). WebRTC 모니터링 상태추적·상태감시 시작.
  4. 4스피커(audioElement) 준비. createAudioElement()<audio autoplay> 생성 + setSinkId로 출력 장치 라우팅. pause/error/playing/ended 리스너로 재생 이상 추적.
  5. 5수신 트랙 바인딩. connection.ontrack = e => audioElement.srcObject = e.streams[0] (핑퐁이 음성 재생).
  6. 6마이크 스트림 구성. createMediaStream() → 내장 마이크면 volumeAmplifier, 항상 audioProcessingChain 적용 → 각 트랙 enabled=enableMicrophoneconnection.addTrack. audioProcessingChain.startProcessing() 시작.
  7. 7데이터채널 생성. createDataChannel("oai-events") + message/open 리스너. (open 시 isActive=true)
  8. 8세션 컨텍스트 조립. avatar/subAvatars/user 조회, voice 결정(avatar.voice || "sage").
  9. 9requestId 발급. requestId=`${Date.now()}-${Math.random()}`tokenRequestIdRef에 저장(최신 요청 식별).
  10. 10SDP 협상. createOffer→setLocalDescriptioncreateSession(offer.sdp,...)로 answer SDP 수신.
  11. 11최신 요청 검증. tokenRequestIdRef !== requestId면 더 새 요청이 시작된 것 → throw START_FAILED(이 결과 폐기).
  12. 12연결 확정. setRemoteDescription(answer)peerConnectionRef 저장 → audioStream 도착까지 100ms 폴링 대기(isActiveRef && audioStream).
  13. 13후속 시작. 볼륨 모니터링·소켓 증폭상태 콜백·STT 폴백·트랙/상태 모니터링 시작. autoTransition이면 AUTO_START_MESSAGE("대화시작") 또는 startMent 송신.
finally 주의: 성공/실패와 무관하게 requestingActivityIdRef=null로 리셋. 실패 시 catch에서 stopSession() 호출 후 SessionError("START_FAILED") 재throw.

세션 생성 (SDP 프록시) createSession → /api/realtime/call → createCall

클라이언트 createSession()(use-session-manager.ts:1036)은 10초 AbortController 타임아웃으로 /api/realtime/call에 POST한다. 라우트(app/api/realtime/call/route.ts)는 createCall()(lib/openai-create-call.ts)을 호출하고, 여기서 실제 OpenAI 요청·세션 config 조립·재시도가 일어난다.

세션 config 구조 (openai-create-call.ts:84–103)

const sessionConfig = {
  type: "realtime",
  model: resolvedModel,            // 모델 버전 매핑(아래)
  instructions: finalInstruction,  // 변수치환된 프롬프트(prompt/preset/user vars)
  audio: {
    input: {
      transcription: { model: "gpt-4o-transcribe", language: "ko" },
      noise_reduction: { type: inputAudioNoiseReduction },  // 기본 far_field
      turn_detection: turnDetection,
    },
    output: { voice: voice },      // avatar.voice || "sage"
  },
};

모델 버전 매핑 (L27–30, packages/shared constants)

realtimeModelVersion실제 모델
v1.0 (DEFAULT_REALTIME_MODEL_VERSION)gpt-realtime-2025-08-28
v1.5gpt-realtime-1.5

turn_detection (VAD) (L43–59)

재시도 · 타임아웃

서버(createCall): 최대 3회 재시도, 각 시도 10초 타임아웃, 실패 간 1초 대기. answer SDP가 "v="로 시작하지 않으면 invalid로 간주.

클라이언트(createSession): 별도 10초 AbortController. 초과 시 SessionError("SESSION_TIMEOUT"). ⚠️ 서버가 3회×10초까지 갈 수 있어 클라 10초가 먼저 끊길 수 있음(타임아웃 계층 불일치는 알아둘 것).

오디오 파이프라인

입력(마이크 → OpenAI) createMediaStream, L996–1034

getUserMedia(autoGainControl:false, echoCancellation:true, noiseSuppression:true)
  └─▶ [내장 마이크면] volumeAmplifier.processMediaStream
       └─▶ audioProcessingChain.processMediaStream (항상)
           └─▶ pc.addTrack

출력(OpenAI → 스피커)

데이터채널 이벤트 처리 handleDataChannelMessage, L332–522

이벤트동작
session.created / session.updated서버가 확정한 turn_detectionturnDetectionRef에 동기화
input_audio_buffer.speech_started침묵타이머 해제, STT폴백 중단, autoFinish 아동발화 시작, isInputAudioBufferPlaying=true, peerTalking=false
input_audio_buffer.speech_stopped(폴백 활성 시 50ms 후 STT폴백 재시작), autoFinish 아동발화 종료, 끼어들기 OFF면 즉시 interruptBlocked=true(잔여음 차단), playing=false
output_audio_buffer.started침묵타이머 해제, STT폴백 중단(에코방지), peerTalking=true, 끼어들기 OFF면 interruptBlocked=true
output_audio_buffer.stopped / .cleared(500ms 후 폴백 재시작), peerTalking=false, interruptBlocked=false, 침묵타이머 시작, 직전 핑퐁이 전사로 autoFinish 판정
...input_audio_transcription.completed아동 음성 전사 → ChatLog(role:user, type:audio) 생성 + createLog(DB) + onLog
conversation.item.added (user/input_text)텍스트 입력 로그. AUTO_START_MESSAGE면 type=auto, 폴백 전사와 동일하면 type=fallback, 아니면 text
response.output_audio_transcript.done핑퐁이 응답 전사 → ChatLog(role:assistant) + onAssistantTurnFinalized(item_id). 자동종료 문구 판정(after-pingpong-speech)

파생 상태 · 끼어들기 제어

발화 상태

마이크 입력 게이팅 (L1233–1237)

// 세 조건이 모두 만족해야 마이크 트랙 enabled
audioInputEnabled = microphoneEnabled && !autoFinishSilence && !interruptBlocked;
끼어들기(interrupt) 동작: interrupt_response=false(기본)면 아동 발화 종료(speech_stopped) 또는 핑퐁이 발화 시작(output started) 즉시 interruptBlocked=true로 마이크를 닫아 핑퐁이 말 도중 아동 음성이 끼어드는 것을 막는다. 핑퐁이 발화 종료 시 해제. 런타임 토글은 toggleInterruptResponse()session.update로 OpenAI에 반영.

응답 취소 cancelResponse, L230

response.cancel + output_audio_buffer.clear 두 메시지를 전송. sendMessage에서 핑퐁이 발화 중 텍스트 입력 시, after-child-speech 자동종료 시 호출된다.

침묵 타이머 · WebRTC 연결 처리

종료 (stopSession) 정리 순서 L592–663

  1. dataChannel 리스너 제거 + close
  2. peerConnection close
  3. mediaStream 트랙 stop
  4. audioElement 정리(srcObject 트랙 stop, 리스너 제거)
  5. 각종 ref/타이머 초기화 + webrtcMonitoring 중지(closed 송출)
  6. sttFallback·volumeAmplifier·audioProcessingChain·autoFinish 정리
  7. isActive=false, requestId/requestingActivityId 리셋(재시도 허용)

통합 서브 훅 (별도 문서 예정)

역할로드맵
useVolumeAmplifier내장 마이크 자동 게인#17
useAudioProcessingChainGate→EQ→Comp→Limiter DSP#7
useSttFallbackWeb Speech API 폴백 전사#18
useSessionAutoFinish자동 종료(문구/침묵 기반)(Activity #12 연계)
useWebRTCMonitoring연결 상태·getStats 통계 송출#19

함정 · 주의

파일 · 라인 레퍼런스

파일역할
apps/web/hooks/use-session-manager.ts훅 본체(세션 라이프사이클·이벤트·상태)
apps/web/app/api/realtime/call/route.tsSDP 프록시 엔드포인트
apps/web/lib/openai-create-call.tsOpenAI 호출·세션 config 조립·재시도
apps/web/lib/constants.tsREALTIME_MODEL, DEFAULT_VOICE("sage"), AUTO_START_MESSAGE("대화시작")
packages/shared/src/utils/constants.tsDEFAULT_REALTIME_MODEL_VERSION("v1.0")
apps/web/hooks/use-guest-session-relay.ts:144소비처(V1 게스트 릴레이)
apps/web/components/pages/prompt-test.tsx:87소비처(프롬프트 테스트)

관련 문서