콘텐츠로 이동

브라우저·클라이언트 통합 가이드

이 문서는 브라우저에서 마이크를 WebRTC Gateway에 연결하고, oai-events 데이터채널로 세션과 대화 상태를 제어하는 실전 절차를 설명합니다. Gateway는 OpenAI Realtime과 비슷한 클라이언트 계약을 제공하지만, OpenAI에 직접 연결하지 않습니다. 모든 세션은 Gateway에서 세션 전용 /sohri WebSocket으로 이어지며, openai_realtime 엔진을 선택해도 실제 provider 연결과 자격증명은 socket-backend가 소유합니다.

Gateway에서 client-secret을 발급받지 마세요

POST /v1/realtime/client_secrets는 제거되어 항상 404입니다. 브라우저는 로그인 세션으로 App Backend의 POST /v1/workspaces/client-secrets를 호출해 단기 EdDSA(Ed25519) JWT를 발급받아야 합니다. 워크스페이스 장기 API key를 브라우저 번들, localStorage, 로그에 넣지 마세요.

전체 연결 순서

  1. App Backend에서 짧은 수명의 Ed25519 client-secret을 발급받습니다.
  2. RTCPeerConnection을 만들고 로컬 마이크 트랙을 추가합니다.
  3. offer를 만들기 전에 oai-events DataChannel을 생성합니다.
  4. createOffer()와 setLocalDescription() 후 ICE gathering이 complete가 될 때까지 기다립니다.
  5. 완성된 offer SDP 문자열을 Gateway의 POST /v1/realtime/calls에 raw body로 보냅니다.
  6. 응답의 raw answer SDP를 setRemoteDescription()에 적용합니다.
  7. 원격 오디오 트랙은 <audio autoplay> 또는 네이티브 오디오 출력에 연결하고, 데이터채널 이벤트로 세션·STT·응답·TTS·분석 상태를 갱신합니다.
  8. 종료 시 마이크, DataChannel, PeerConnection, 오디오 출력 참조를 모두 정리합니다.

Gateway와 브라우저 모두 후보를 SDP 안에 모아 한 번에 교환합니다. candidate를 나중에 보내는 PATCH/POST API는 없으므로 ICE 수집이 끝나기 전에 offer를 POST하면 안 됩니다.

1. App Backend에서 client-secret 발급

로그인 사용자가 대상 워크스페이스의 ACTIVE 멤버인 상태에서 App Backend를 호출합니다.

POST /v1/workspaces/client-secrets HTTP/1.1
Authorization: Bearer <로그인 access token>
X-Workspace-Id: <workspace UUID>
Content-Type: application/json

{"expiresInSeconds": 1800}

성공 응답은 201 Created이며 실제 JWT는 다음 위치에 있습니다.

{
  "data": {
    "clientSecret": {
      "value": "eyJ...",
      "expiresInSeconds": 1800,
      "expiresAt": "2026-09-23T03:00:00Z"
    }
  }
}

expiresInSeconds는 서버 정책 범위로 조정될 수 있습니다. 앱은 expiresAt을 보관해 만료가 임박한 토큰을 새 연결에 재사용하지 말고, 메모리에서만 다루는 것이 좋습니다. 이 JWT는 alg=EdDSA인 Ed25519 서명 토큰이며 socket-backend가 공개키로 검증합니다. Gateway는 JWT 서명이나 만료를 검증하지 않고 형태만 확인한 뒤 같은 Bearer 값을 socket-backend에 전달합니다.

async function issueClientSecret({ appBackendUrl, loginAccessToken, workspaceId }) {
  const response = await fetch(
    `${appBackendUrl.replace(/\/+$/, "")}/v1/workspaces/client-secrets`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${loginAccessToken}`,
        "X-Workspace-Id": workspaceId,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ expiresInSeconds: 1800 }),
    },
  );

  if (!response.ok) {
    throw new Error(`client-secret 발급 실패: HTTP ${response.status}`);
  }

  const body = await response.json();
  const issued = body?.data?.clientSecret;
  if (!issued?.value || !issued?.expiresAt) {
    throw new Error("client-secret 응답 형식이 올바르지 않습니다.");
  }
  return issued;
}

App Backend 주소와 Gateway 주소는 서로 다를 수 있습니다. Gateway의 제거된 /v1/realtime/client_secrets로 이 요청을 보내지 마세요.

2. 대표 브라우저 JavaScript 골격

아래 코드는 연결 구조와 프로토콜 순서를 보여 주는 골격입니다. APP-SPECIFIC 주석이 붙은 인증 저장소, UI 상태, 자막, 분석 결과 처리, 재연결 정책은 제품 요구사항에 맞게 구현해야 합니다. TTS 오디오는 데이터채널의 base64가 아니라 원격 WebRTC 오디오 트랙으로 도착합니다.

class SohriRealtimeClient {
  constructor({
    appBackendUrl,
    gatewayUrl,
    loginAccessToken,
    workspaceId,
    audioElement,
    conversationEngine = "sohri",
    language = "ko",
    instructions = "",
    externalUser,
    iceServers = [{ urls: "stun:stun.l.google.com:19302" }],
    onEvent = () => {},
    onState = () => {},
    onReconnectNeeded = () => {},
  }) {
    Object.assign(this, {
      appBackendUrl,
      gatewayUrl,
      loginAccessToken,
      workspaceId,
      audioElement,
      conversationEngine,
      language,
      instructions,
      externalUser,
      iceServers,
      onEvent,
      onState,
      onReconnectNeeded,
    });
    this.pc = null;
    this.dc = null;
    this.localStream = null;
    this.closedByUser = false;
  }

  async connect() {
    // 이전 transport의 close 콜백이 새 재연결을 다시 예약하지 않도록 먼저 정리합니다.
    this.closedByUser = true;
    this.teardownTransport();
    this.closedByUser = false;

    // APP-SPECIFIC: loginAccessToken은 앱의 안전한 인증 상태에서 가져옵니다.
    let issued = await issueClientSecret({
      appBackendUrl: this.appBackendUrl,
      loginAccessToken: this.loginAccessToken,
      workspaceId: this.workspaceId,
    });

    const pc = new RTCPeerConnection({ iceServers: this.iceServers });
    this.pc = pc;
    this.wirePeerConnection(pc);

    // APP-SPECIFIC: 원하는 echoCancellation/noiseSuppression 정책을 명시하세요.
    this.localStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
      },
    });
    for (const track of this.localStream.getAudioTracks()) {
      pc.addTrack(track, this.localStream);
    }

    // offer 생성 전에 만들며 label은 정확히 "oai-events"여야 합니다.
    this.dc = pc.createDataChannel("oai-events");
    this.wireDataChannel(this.dc);

    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    await waitForIceGatheringComplete(pc, 10_000);

    // 엔진은 미디어가 흐르기 전에 확정되도록 SDP query에도 싣습니다.
    const callsUrl = new URL(
      `${this.gatewayUrl.replace(/\/+$/, "")}/v1/realtime/calls`,
    );
    callsUrl.searchParams.set("conversationEngine", this.conversationEngine);

    let response = await this.postOffer(callsUrl, issued.value);
    if (response.status === 401) {
      // 만료·폐기 가능성을 고려해 새 토큰으로 한 번만 재시도합니다.
      issued = await issueClientSecret({
        appBackendUrl: this.appBackendUrl,
        loginAccessToken: this.loginAccessToken,
        workspaceId: this.workspaceId,
      });
      response = await this.postOffer(callsUrl, issued.value);
    }
    if (!response.ok) {
      const detail = await response.text().catch(() => "");
      throw new Error(`SDP 교환 실패: HTTP ${response.status} ${detail}`);
    }

    const answerSdp = await response.text();
    await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
    this.onState({ phase: "answer-applied" }); // APP-SPECIFIC
  }

  postOffer(url, clientSecret) {
    return fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${clientSecret}`,
        "Content-Type": "application/sdp",
      },
      body: this.pc.localDescription.sdp,
    });
  }

  wirePeerConnection(pc) {
    pc.ontrack = async ({ track, streams }) => {
      if (track.kind !== "audio") return;
      const stream = streams[0] ?? new MediaStream([track]);
      this.audioElement.srcObject = stream;
      try {
        // APP-SPECIFIC: iOS/Safari 등에서는 사용자 제스처로 먼저 오디오를 unlock해야 합니다.
        await this.audioElement.play();
      } catch (error) {
        this.onState({ phase: "audio-play-blocked", error });
      }
    };

    pc.onconnectionstatechange = () => {
      const state = pc.connectionState;
      this.onState({ phase: "peer", state }); // APP-SPECIFIC
      if (!this.closedByUser && state === "failed") {
        this.onReconnectNeeded({ reason: "peer-failed" });
      }
      // disconnected는 일시적일 수 있습니다. 즉시 새 연결을 중첩 생성하지 말고 유예를 둡니다.
    };

    pc.onicecandidateerror = (event) => {
      this.onState({ phase: "ice-error", event }); // APP-SPECIFIC
    };
  }

  wireDataChannel(dc) {
    dc.onopen = () => {
      this.onState({ phase: "datachannel-open" }); // APP-SPECIFIC

      const sohri = {
        conversationEngine: this.conversationEngine,
        ...(this.externalUser ? { externalUser: this.externalUser } : {}),
      };
      if (this.conversationEngine === "openai_realtime" && this.instructions) {
        sohri.instructions = this.instructions;
      }

      this.send({
        type: "session.update",
        event_id: crypto.randomUUID(),
        session: {
          language: this.language,
          input_audio_transcription: { language: this.language },
          sohri,
        },
      });
    };

    dc.onmessage = ({ data }) => {
      let event;
      try {
        event = JSON.parse(data);
      } catch {
        this.onState({ phase: "invalid-event", data }); // APP-SPECIFIC
        return;
      }
      this.handleServerEvent(event);
    };

    dc.onerror = (error) => {
      this.onState({ phase: "datachannel-error", error }); // APP-SPECIFIC
    };
    dc.onclose = () => {
      if (!this.closedByUser) {
        this.onReconnectNeeded({ reason: "datachannel-closed" });
      }
    };
  }

  handleServerEvent(event) {
    switch (event.type) {
      case "session.created":
        // backend 세션 ID를 관측/상관키로 저장할 수 있습니다.
        this.onState({ phase: "session-created", session: event.session });
        break;

      case "session.updated":
        // 특히 openai_realtime은 backend ACK 뒤 이 이벤트이므로 그 전 response.create를 피합니다.
        this.onState({ phase: "session-ready", session: event.session });
        break;

      case "input_audio_buffer.speech_started":
      case "input_audio_buffer.speech_stopped":
        this.onState({ phase: event.type }); // APP-SPECIFIC: 녹음 UI
        break;

      case "conversation.item.input_audio_transcription.completed":
        this.onState({ phase: "stt", text: event.transcript }); // APP-SPECIFIC
        break;

      case "response.created":
        this.onState({ phase: "response-started", response: event.response });
        break;

      case "response.output_audio_transcript.delta":
        this.onState({ phase: "assistant-delta", text: event.delta }); // APP-SPECIFIC
        break;

      case "response.output_audio_transcript.done":
        this.onState({ phase: "assistant-text", text: event.transcript }); // APP-SPECIFIC
        break;

      case "sohri.dialog_text":
        // SOHRI 스트리밍 TTS의 확정 텍스트. response_id로 TTS 이벤트와 연결합니다.
        this.onState({ phase: "dialog-text", event }); // APP-SPECIFIC
        break;

      case "sohri.tts_stream_start":
        // 첫 가청 프레임이 Gateway RTP 트랙에 기록된 시점입니다.
        this.onState({ phase: "tts-start", event }); // APP-SPECIFIC: 자막 시계 시작
        break;

      case "sohri.tts_stream_end":
        // duration_ms는 총 길이 확정값이지 브라우저 스피커 재생 완료 통지가 아닙니다.
        this.onState({ phase: "tts-duration-known", event });
        break;

      case "sohri.tts_stream_aborted":
        this.onState({ phase: "tts-aborted", playedMs: event.played_ms });
        break;

      case "response.done":
        this.onState({
          phase: "response-done",
          status: event.response?.status,
          response: event.response,
        });
        break;

      case "sohri.dialog_complete":
        this.onState({ phase: "dialog-complete", event });
        break;

      case "error":
        this.handleGatewayError(event.error ?? {});
        break;

      default:
        if (event.type?.startsWith("sohri.analysis.")) {
          this.onState({ phase: "analysis", kind: event.type, data: event.data });
        }
    }
    this.onEvent(event); // APP-SPECIFIC: 원본 이벤트 로깅/상태 저장
  }

  handleGatewayError(error) {
    // APP-SPECIFIC: 사용자 메시지에는 correlation_id를 함께 표시하면 운영 추적에 유용합니다.
    this.onState({ phase: "server-error", error });
    if (error.code === "server_shutting_down") {
      this.onReconnectNeeded({ reason: error.code });
    }
  }

  send(event) {
    if (!this.dc || this.dc.readyState !== "open") {
      throw new Error("oai-events DataChannel이 열리지 않았습니다.");
    }
    this.dc.send(JSON.stringify(event));
  }

  sendText(text) {
    this.send({
      type: "conversation.item.create",
      event_id: crypto.randomUUID(),
      item: {
        type: "message",
        role: "user",
        content: [{ type: "input_text", text }],
      },
    });
  }

  createResponse() {
    this.send({ type: "response.create", event_id: crypto.randomUUID() });
  }

  cancelResponse() {
    this.send({ type: "response.cancel", event_id: crypto.randomUUID() });
  }

  clearPendingAudio() {
    this.send({ type: "output_audio_buffer.clear", event_id: crypto.randomUUID() });
  }

  disconnect() {
    this.closedByUser = true;
    this.teardownTransport();
    this.onState({ phase: "closed" });
  }

  teardownTransport() {
    for (const track of this.localStream?.getTracks() ?? []) track.stop();
    this.localStream = null;
    if (this.dc) {
      this.dc.onopen = null;
      this.dc.onmessage = null;
      this.dc.onerror = null;
      this.dc.onclose = null;
    }
    if (this.pc) {
      this.pc.ontrack = null;
      this.pc.onconnectionstatechange = null;
      this.pc.onicecandidateerror = null;
    }
    try { this.dc?.close(); } catch {}
    try { this.pc?.close(); } catch {}
    this.dc = null;
    this.pc = null;
    if (this.audioElement) this.audioElement.srcObject = null;
  }
}

function waitForIceGatheringComplete(pc, timeoutMs) {
  if (pc.iceGatheringState === "complete") return Promise.resolve();
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      cleanup();
      reject(new Error("ICE 후보 수집 시간 초과"));
    }, timeoutMs);
    const onChange = () => {
      if (pc.iceGatheringState === "complete") {
        cleanup();
        resolve();
      }
    };
    const cleanup = () => {
      clearTimeout(timeout);
      pc.removeEventListener("icegatheringstatechange", onChange);
    };
    pc.addEventListener("icegatheringstatechange", onChange);
    onChange(); // 초기 확인과 listener 등록 사이에 complete가 된 경우를 처리합니다.
  });
}

connect() 실패 시에도 disconnect() 또는 동등한 정리 루틴을 호출하도록 앱의 상위 try/catch/finally를 구성하세요. 예제의 onReconnectNeeded 안에서 즉시 connect()를 중복 호출하지 말고, 현재 인스턴스를 정리한 후 단일 재연결 작업만 실행해야 합니다.

3. SDP와 인증 요청의 주의점

  • 요청 body는 JSON 객체가 아니라 pc.localDescription.sdp 문자열 자체입니다.
  • Content-Type은 application/sdp, 응답 성공 상태는 201 Created입니다.
  • Authorization에는 App Backend가 발급한 client-secret JWT를 그대로 넣습니다.
  • 브라우저 요청은 Authorization과 application/sdp 때문에 CORS preflight를 발생시킵니다. Gateway의 허용 Origin은 scheme, host, port가 정확히 일치해야 합니다.
  • 401은 헤더/credential 형태 오류 또는 socket-backend 인증 거부입니다. 새 client-secret으로 한 번 재시도한 뒤 계속 실패하면 로그인·워크스페이스 소속·키 배포를 점검합니다.
  • 502는 backend WebSocket, PeerConnection, SDP/ICE setup 실패 범주입니다. 무한 토큰 재발급으로 해결하지 말고 제한된 backoff로 재시도하며 서버 상태를 확인합니다.

엔진, 사용자 ID, DM/TTS, 첫 EPD 설정과 같은 작은 스칼라는 calls URL query로 줄 수 있습니다. instructions와 큰 scenarioGraph는 URL에 넣지 말고 DataChannel의 session.update로만 보냅니다.

4. session.update와 conversation engine

권장 확장 위치는 session.sohri입니다.

{
  "type": "session.update",
  "event_id": "evt-setup-1",
  "session": {
    "language": "ko",
    "sohri": {
      "conversationEngine": "openai_realtime",
      "instructions": "짧고 명확한 한국어로 답하세요.",
      "externalUser": {
        "id": "user-42",
        "latitude": 37.5665,
        "longitude": 126.978
      }
    }
  }
}
  • 엔진 미지정 기본값은 sohri입니다.
  • 가능한 값은 sohri, openai_realtime뿐이며 세션 시작 후 다른 엔진으로 바꿀 수 없습니다.
  • 가장 이른 확정이 필요하면 calls query의 conversationEngine에도 같은 값을 지정합니다.
  • openai_realtime은 backend의 conversation_session_updated ACK 후에 활성화되고, 그 결과가 DataChannel의 session.updated로 전달됩니다. 초기 response.create는 이 ACK 뒤에 보내는 것이 명확합니다.
  • instructions는 session.sohri.instructions 또는 top-level sohri.instructions에서만 인정되며 openai_realtime에서만 사용할 수 있습니다.
  • openai_realtime과 SOHRI DM scenario, botFirst, SOHRI TTS 선택 파라미터를 함께 보내면 거부됩니다.
  • SOHRI 엔진에서는 scenarioSlug, scenarioGraph, botFirst, voicePreset 등을 session.sohri에 둘 수 있습니다. 적용 결과 또는 오류를 받은 뒤 대화를 진행하세요.

언어·EPD·엔진 설정을 한 이벤트에 함께 보냈다가 한 필드가 유효하지 않으면 전체 update가 거부될 수 있습니다. 요청의 event_id와 오류의 error.event_id, correlation_id를 로그 상관키로 보관하면 문제를 찾기 쉽습니다.

5. 서버 이벤트 처리

클라이언트는 알려진 이벤트만 switch한 뒤 버리는 방식보다, 알 수 없는 이벤트도 원본 상태 로그에 남기는 방식이 안전합니다. 특히 openai_realtime은 provider 이벤트를 realtime_event에서 꺼내 원형에 가깝게 DataChannel로 전달합니다.

핵심 처리 기준은 다음과 같습니다.

  • session.created: backend 세션 생성. session.id를 관측 상관키로 저장합니다.
  • session.updated: 언어·DM·엔진 설정 결과. OpenAI 엔진 초기화에서는 ready 경계로 사용합니다.
  • input_audio_buffer.speech_started, .speech_stopped: EPD 기반 발화 UI를 갱신합니다.
  • conversation.item.input_audio_transcription.completed: transcript를 사용자 발화로 확정합니다.
  • response.created: assistant 응답 상태를 in_progress로 엽니다.
  • response.output_audio_transcript.delta, .done: 봇 자막을 누적·확정합니다.
  • sohri.dialog_text: SOHRI 스트리밍 TTS 텍스트를 response_id 기준으로 연결합니다.
  • sohri.tts_stream_start: 첫 가청 RTP frame 기록 시점이므로 자막 진행 시계를 시작할 수 있습니다.
  • sohri.tts_stream_end: duration_ms라는 총 길이의 확정입니다. 실제 스피커 재생 완료 이벤트로 해석하지 마세요.
  • sohri.tts_stream_aborted: played_ms에서 자막 진행을 멈추고 잘린 응답임을 표시합니다.
  • response.done: response.status가 completed, cancelled, failed인지 확인하고 응답 상태를 반드시 terminal로 닫습니다.
  • sohri.analysis.*: summary, emotion, dementia, depression, biomarker, processing/complete 결과를 종류별 저장소에 전달합니다.
  • sohri.dialog_complete: 개별 response.done과 다른 전체 대화 완료 신호입니다.
  • error: error.type, code, status, message, correlation_id, details를 보존합니다. server_shutting_down은 재연결 가능한 오류로 취급합니다.

TTS 음성 자체는 pc.ontrack의 remote audio입니다. DataChannel의 TTS 이벤트는 자막·상태 동기화를 위한 메타데이터이며 오디오 대체물이 아닙니다.

6. 바지인과 playout ACK 책임

자연스러운 바지인은 브라우저가 별도 ACK를 만들어 보내는 구조가 아닙니다. 마이크 트랙을 계속 전송하면 Gateway와 socket-backend가 발화 후보, 확정 발화, 현재 TTS 재생을 조정합니다.

Gateway가 담당하는 항목은 다음과 같습니다.

  • 후보 발화에서 TTS duck 또는 선택적 pause를 적용합니다.
  • 실제 outbound frame에 gain이 적용된 뒤 backend에 tts_duck_applied를 보냅니다.
  • 첫 가청 frame과 스트림 종료에서 tts_playback_state를 전환하고, 정상 소진 시 tts_playback_completed를 보냅니다.
  • 확정 바지인·취소에서 playout generation을 올려 오래된 TTS frame을 폐기합니다.
  • OpenAI provider interrupt에서는 실제 기록된 재생 위치를 계산해 backend에 truncate 정보를 돌려줍니다.

브라우저가 담당하는 항목은 다음과 같습니다.

  • remote audio element의 autoplay가 실제로 허용되도록 사용자 제스처 기반 unlock을 처리합니다.
  • sohri.tts_stream_*, response.done을 이용해 UI·자막을 맞춥니다.
  • 사용자가 명시적으로 “응답 중지”를 누르면 response.cancel을 보냅니다.
  • 로컬 UI에서 대기 음성을 즉시 비우려는 동작은 output_audio_buffer.clear를 보내고 output_audio_buffer.cleared를 기다립니다.
  • 브라우저 자체 DSP/AEC 정책 때문에 마이크 트랙을 임의로 중단하지 않습니다. 트랙을 끊으면 자연 바지인을 감지할 입력도 사라집니다.

브라우저는 tts_duck_applied, tts_playback_state, tts_playback_completed, realtime_playback_truncated를 직접 합성해 보내면 안 됩니다. 이는 Gateway와 backend 사이의 제어 계약입니다. 또한 Gateway의 “첫 가청/재생 위치”는 RTP 트랙에 기록한 기준이므로, 브라우저의 autoplay 차단이나 로컬 출력 지연까지 증명하지는 않습니다. 앱은 audio.play() 실패를 별도로 표시해야 합니다.

7. coturn은 선택적 외부 책임

기본 경로는 STUN으로 후보를 수집한 뒤 Gateway UDP endpoint로 직접 연결하는 방식입니다. 대칭 NAT, 기업망 UDP 차단 등에서 직접 연결이 실패하면 외부 coturn의 relay 후보가 필요할 수 있습니다.

  • coturn은 Gateway 프로세스에 내장되어 있지 않습니다.
  • 브라우저 RTCPeerConnection({iceServers})에 TURN URL, username, credential을 넣는 것은 클라이언트 구성 책임입니다.
  • 운영 credential은 App Backend 등 신뢰할 수 있는 서버에서 단기 발급하고, 정적 비밀번호를 브라우저 번들에 넣지 않는 것이 좋습니다.
  • 현재 Gateway 자체 ICE 설정은 URL 하나만 지원하고 TURN username/credential 필드는 없습니다. 인증형 coturn을 Gateway 측 후보 수집에도 쓰려면 배포/코드 확장이 필요합니다.
  • coturn을 배포했다는 사실만으로 relay 후보가 생기지 않습니다. 양쪽 ICE 설정과 방화벽, Gateway UDP 포트 범위를 함께 검증해야 합니다.

8. 종료와 재연결

명시적 종료에서는 다음 순서를 권장합니다.

  1. 자동 재연결 플래그를 끕니다.
  2. 로컬 MediaStreamTrack.stop()으로 마이크 표시와 장치를 해제합니다.
  3. DataChannel을 닫습니다.
  4. RTCPeerConnection.close()를 호출합니다.
  5. <audio>.srcObject, 자막 timer, 분석/응답 임시 상태를 정리합니다.

DataChannel이 닫히면 Gateway도 해당 세션을 종료합니다. 현재 signaling API는 한 번의 비-트리클 offer/answer만 지원하며 ICE restart나 re-offer endpoint가 없습니다. 복구가 필요하면 새 client-secret, 새 PeerConnection, 새 DataChannel로 전체 연결을 다시 만드세요.

재연결 정책은 다음을 구분해야 합니다.

  • disconnected: 모바일 망 전환처럼 회복할 수 있으므로 즉시 중복 연결하지 말고 짧은 유예를 둡니다. Gateway 기본 복구 유예는 10초입니다.
  • failed, DataChannel close: 기존 자원을 정리하고 새 세션을 만듭니다.
  • SDP 401: client-secret을 새로 발급해 한 번만 재시도합니다.
  • 네트워크 오류, 502, server_shutting_down: 지수 backoff와 jitter를 적용합니다.
  • 사용자 명시 종료, 권한 거부, 반복 인증 실패: 자동 재연결하지 않습니다.

재연결 루프에는 “현재 연결 세대”나 단일 in-flight promise를 두어 오래된 비동기 fetch/getUserMedia 완료가 새 연결 상태를 덮어쓰지 못하게 하세요.

상세 문서