PPI-969 헤드셋 연결/해제 시 오디오 출력 자동 재라우팅

📅 2026-05-22 🌿 브랜치: PPI-969 BugFix

🔍 이슈 배경

발생 현상

게스트(아동) 수업 중 USB 헤드셋을 재연결(뽑았다 꽂기)하거나 블루투스 헤드셋이 자동 재연결되면, 이미 setSinkId()로 바인딩된 오디오 엘리먼트가 이전 deviceId를 그대로 가리키게 됩니다. OS가 재연결 시 동일한 기기에 새로운 deviceId를 부여할 수 있기 때문에 핑퐁이 AI 음성과 활동 영상의 오디오가 무음으로 전환됩니다.

재연결 후 헤드셋이 OS 기기 목록에 다시 나타나더라도 기존 오디오 엘리먼트는 사라진 deviceId를 바라봄. 브라우저가 자동 복구하지 않아 수동 새로고침 없이는 소리가 돌아오지 않음.

영향 범위

추가 결함 — mediaDevices 미지원 환경 TypeError

navigator.mediaDevices가 존재하지 않거나 addEventListener를 지원하지 않는 환경(일부 모바일 브라우저, 보안 컨텍스트 외 iframe 등)에서 navigator.mediaDevices.addEventListener("devicechange", …) 호출 시 TypeError가 발생했음.

🐛 기술적 원인

setSinkId()와 deviceId 생명주기

setSinkId(deviceId)는 호출 시점의 deviceId를 오디오 엘리먼트에 고정합니다. USB/블루투스 헤드셋이 재연결되면 OS가 새 deviceId를 생성하거나 기존 ID를 무효화할 수 있습니다. 브라우저는 바인딩된 ID가 사라져도 자동으로 setSinkId("")(기본 출력)로 fallback하지 않습니다.

devicechange 이벤트

Web Audio Output Devices API의 navigator.mediaDevices는 오디오/비디오 기기의 추가·제거·변경 시 devicechange 이벤트를 발화합니다. 이 이벤트를 구독하면 재연결을 감지하고 setSinkId()를 재호출해 복구할 수 있습니다.

연결/해제 시나리오별 처리 방침

시나리오enumerateDevices 결과처리
헤드셋 재연결 (동일 deviceId 복구) 원래 deviceId 목록에 존재 setSinkId(deviceId) 재호출
헤드셋 재연결 (새 deviceId 부여) 원래 deviceId 목록에 없음 setSinkId("") — 기본 출력으로 fallback
헤드셋 분리 원래 deviceId 목록에 없음 setSinkId("") — 기본 출력으로 fallback
fallback을 ""(빈 문자열)로 설정하는 이유: "default"는 Firefox에서 NotFoundError를 유발함. 빈 문자열은 브라우저 기본 출력 기기를 의미하며 모든 주요 브라우저에서 안전함.

🛠 수정 내용

1. use-ai-session.ts — AI 음성 오디오 엘리먼트

apps/web/entities/guest-session/model/use-ai-session.ts

두 가지 변경이 이루어졌습니다.

① startSession 시 사전 생성 오디오 엘리먼트에도 setSinkId 적용

기존에는 new Audio()로 새 엘리먼트를 생성할 때만 setSinkId를 호출했음. 이미 audioElementRef.current에 엘리먼트가 존재하는 경우(세션 재시작)에는 setSinkId가 호출되지 않았음.

// 변경 전: new Audio() 생성 블록 안에서만 setSinkId 호출
if (!audioEl) {
  audioEl = new Audio();
  audioEl.autoplay = true;
  if (audioOutputDeviceId && typeof audioEl.setSinkId === "function") {
    await audioEl.setSinkId(audioOutputDeviceId);
  }
  audioElementRef.current = audioEl;
}

// 변경 후: 사전 생성 여부와 무관하게 최신 출력 기기 적용
if (!audioEl) {
  audioEl = new Audio();
  audioEl.autoplay = true;
  audioElementRef.current = audioEl;
}
if (audioOutputDeviceId && typeof audioEl.setSinkId === "function") {
  await audioEl.setSinkId(audioOutputDeviceId);
}

② devicechange 리스너 추가

useEffect(() => {
  if (!audioOutputDeviceId) return;
  if (!navigator.mediaDevices?.addEventListener) return; // 미지원 환경 가드

  const handleDeviceChange = async () => {
    const audioEl = audioElementRef.current;
    if (!audioEl || typeof audioEl.setSinkId !== "function") return;
    const devices = await navigator.mediaDevices.enumerateDevices().catch(() => []);
    const isAvailable = devices.some(
      (d) => d.kind === "audiooutput" && d.deviceId === audioOutputDeviceId,
    );
    audioEl.setSinkId(isAvailable ? audioOutputDeviceId : "").catch(() => {});
  };

  navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
  return () => navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
}, [audioOutputDeviceId]);

2. meet-video.tsx — 활동 영상 오디오

apps/web/shared/ui/meet-video.tsx

audioOutputDeviceId prop을 새로 추가하고, 두 가지 effect를 연결했습니다.

① videoUrl 변경 시 setSinkId 적용

활동 스텝이 바뀌어 영상 URL이 교체되면 새 video 엘리먼트에 즉시 출력 기기를 바인딩합니다.

useEffect(() => {
  const video = videoRef.current;
  if (!video || !props.audioOutputDeviceId) return;
  if (typeof video.setSinkId !== "function") return;
  video.setSinkId(props.audioOutputDeviceId).catch((e) => {
    logger.warn("video setSinkId failed", { error: e, deviceId: props.audioOutputDeviceId });
  });
}, [props.audioOutputDeviceId, videoUrl]);

② devicechange 리스너 추가

use-ai-session.ts와 동일한 패턴으로 헤드셋 재연결 시 video 엘리먼트에도 재라우팅합니다.

useEffect(() => {
  const deviceId = props.audioOutputDeviceId;
  if (!deviceId) return;
  if (!navigator.mediaDevices?.addEventListener) return;

  const handleDeviceChange = async () => {
    const video = videoRef.current;
    if (!video || typeof video.setSinkId !== "function") return;
    const devices = await navigator.mediaDevices.enumerateDevices().catch(() => []);
    const isAvailable = devices.some(
      (d) => d.kind === "audiooutput" && d.deviceId === deviceId,
    );
    video.setSinkId(isAvailable ? deviceId : "").catch(() => {});
  };

  navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
  return () => navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
}, [props.audioOutputDeviceId]);

📜 개발 히스토리 (커밋 순서)

158a5ac7 — fix: 활동 영상 재생 시 오디오 출력을 선택된 헤드셋으로 라우팅

audioOutputDeviceIduseGuestPageSessionGuestLayoutContentMeetVideo prop chain으로 전달. 영상 URL 변경 시 setSinkId() 호출.

42a98a9e — feat: USB 헤드셋 연결 시 게스트 볼륨 슬라이더 추가

setSinkId()가 시스템 볼륨 슬라이더를 우회하는 문제로 앱 내 볼륨 슬라이더 도입. use-guest-page-sessionaudioVolume 상태 추가, guest-layout.tsx에 슬라이더 UI 구현.

f90181d9 — fix: 헤드셋 연결/해제 시 오디오 출력 기기 자동 재라우팅 (볼륨 슬라이더 롤백 포함)

볼륨 슬라이더 구현 전체 롤백. 대신 devicechange 리스너를 직접 추가해 헤드셋 재연결 시 setSinkId()를 재호출하여 무음 문제 해결. setSinkId fallback을 "default"""로 변경(Firefox 대응).

da5a74a3 — fix: mediaDevices 미지원 환경에서 devicechange 리스너 등록 TypeError 방지

navigator.mediaDevices?.addEventListener optional chaining 가드 추가. 일부 모바일 브라우저·비보안 컨텍스트에서 발생하던 TypeError 방지.

볼륨 슬라이더는 시스템 볼륨 우회 문제의 대안으로 도입됐으나 이번 브랜치에서 롤백됨. setSinkId-시스템볼륨-우회-분석.html 문서 참조.

✅ 최종 상태 요약

항목변경 전변경 후
헤드셋 재연결 후 AI 음성 무음 (setSinkId 재호출 없음) devicechange 감지 후 자동 복구
헤드셋 재연결 후 활동 영상 오디오 무음 devicechange 감지 후 자동 복구
헤드셋 분리 후 오디오 무음 또는 오류 기본 출력으로 자동 fallback
mediaDevices 미지원 환경 TypeError 발생 optional chaining 가드로 무시
setSinkId fallback 값 "default" (Firefox NotFoundError) "" (모든 브라우저 안전)
세션 재시작 시 사전 생성 오디오 엘리먼트 setSinkId 미적용 조건 외부로 이동하여 항상 적용
볼륨 슬라이더는 제공되지 않음. 헤드셋 하드웨어 볼륨 버튼 또는 OS 믹서(장치 레벨)를 사용해야 함. 시스템 볼륨 슬라이더가 setSinkId 사용 시 무효화되는 배경은 setSinkId-시스템볼륨-우회-분석.html 참조.

📋 변경 파일 요약

파일변경 내용
entities/guest-session/model/use-ai-session.ts startSession 내 setSinkId 위치 조정 + devicechange 리스너 useEffect 추가
shared/ui/meet-video.tsx audioOutputDeviceId prop 추가 + videoUrl 변경 시 setSinkId effect + devicechange 리스너 추가
widgets/guest/guest-layout/ui/guest-layout.tsx 볼륨 슬라이더 UI 추가 후 롤백 (순 변경 없음)
shared/ui/guest-layout-content.tsx audioOutputDeviceId prop 전달 추가 후 볼륨 관련 롤백 (순 변경: prop 전달만 유지)
features/guest-page-session/model/use-guest-page-session.ts 볼륨 슬라이더용 audioVolume 상태 추가 후 롤백 (순 변경 없음)