마지막 업데이트 2026-09-21
전화가 안 걸릴 때 "어디서 끊겼는지"를 기록해 주는 블랙박스를 모든 연결에 달았다. 연결 자체는 하나도 안 바꿨다.
그림 7장으로 본다. 그림 1~5가 "무엇을 왜 만들었나", 그림 6~7이 "리뷰에서 볼 것". 코드 상세와 관전 포인트 전문은 맨 아래 접힌 칸에 있다.
9/1 석우주 8회기. 연결 세 개가 동시에 실패했는데 로그로는 이유를 못 갈랐다.
전부 WebRTC 연결(RTCPeerConnection)이고, 이제 전부에 🔍 관찰자가 붙는다.
SDK가 만든 연결(mediasoup·LiveKit)은 SDK 내부의 비공개 필드로 꺼내 붙인다. 그 접근은 ice-diagnostics-adapters.ts 한 파일에만 있다.
붙어서 메모하다가, 실패하면 딱 한 번 로그를 남긴다. 성공하면 아무것도 안 남긴다.
왜 즉시 방출? 실패 직후 SDK가 연결을 닫고 앱이 S3 버퍼를 비운다. getStats를 기다리면 그 경계를 놓쳐 로그가 유실된다.
전화 걸기에 비유: 암호화 → 내 번호 받기 → 서로 번호 교환 → 내 전화기 고장 → 상대가 안 받음.
confidence는 세 단계. observed=통계에 직접 적힘, suspected=정황, unknown=증거 부족. 오래된(6초 초과) 스냅샷은 판정에 안 쓴다.
LogRocket이나 S3 세션 로그에서 ICE_DIAGNOSTIC_SUMMARY로 검색하면 이런 객체가 나온다.
browser_local, 나머지 network세션 매니저·AI 세션·LiveKit 세 경로가 PC 생성 이후의 모든 예외를 ICE 실패로 보고한다. PR에 아직 미수정.
고치는 방향: 트리거를 createOffer~setRemoteDescription 블록 안으로 좁히거나, localDescription이 있을 때만 보고. LiveKit은 ConnectionErrorReason 필터 또는 transport가 생긴 뒤(monitors.length > 0)에만 failPending().
협상도 안 한 연결이 1초마다, 연결된 뒤에도 5초마다. 게스트 iPad에서 최대 7개가 동시에.
| 레이어 | 파일 | 핵심 변경 |
|---|---|---|
| 공유 lib (신규) | shared/lib/ice-diagnostic-evidence.ts | getStats 화이트리스트 축약 readIceStats, 층별 분류 classifyIceFailure (그림 4). 순수 함수. |
| 공유 lib (신규) | shared/lib/ice-diagnostics.ts | observeIceConnection(리스너·getStats 샘플·close 가로채기·attempt 관리, 그림 3), reportIceFailure, unavailableIceMonitor. |
| 공유 lib (신규) | shared/lib/ice-diagnostics-adapters.ts | mediasoup _handler._pc, LiveKit TransportsCreated의 publisher/subscriber _pc 사설 접근 격리. full reconnect 시 pending 이전 PC는 실패 기록 후 교체. |
| 공유 lib | shared/lib/webrtc-audio-loopback.ts | sender/receiver source: loopback. connection-failed·timeout·loopback_failed 분기. |
| voice-agent | lib/voice-agent/livekit-client-session.ts | Room 생성 직후 observeLiveKitIce. connect 거부·재연결 중 Disconnected → failPending(). 앱 주도 종료·abort 제외. |
| hook (V2 SFU) | hooks/mediasoup/use-mediasoup-{consumer,device,producer}.ts | transport 생성 직후 observeMediasoupIce, connect 콜백 실패 시 fail("negotiation_failed"). |
| hook (Realtime) | hooks/use-session-manager.ts, entities/guest-session/model/use-ai-session.ts | PC 생성 직후 관찰, start 실패 catch에서 AbortError만 제외하고 보고 (그림 6). |
| hook (V1 P2P) | hooks/use-web-rtc.ts | PC 생성 2곳 모두 관찰, 실패 시 보고. |
| 테스트 | shared/lib/ice-diagnostic*.test.ts (신규 4), livekit-client-session-diagnostics.test.ts | stale 카운터 배제·1회 방출·Safari 누락 카운터·transient disconnect 무시·교체 transport·PC 접근 불가·업로드 계약(실 logger→S3 PUT)·SDK connect 거부/abort. |
| 문서 | docs/ice-diagnostics.md | 필드 해석표, 검증 명령, 기존 baseline 실패(LiveKit 스위트 3건·ESLint 로딩 오류) 무관 명시. |
세션 매니저·AI 세션 catch가 넓다. PC 생성 이후 getUserMedia 거부·토큰 fetch 실패·avatar/user 조회 실패·"신규 요청에 의해 무시됨" SessionError까지 negotiation_failed. PC가 new라 layer=signaling, cause=description_incomplete로 떨어진다 (그림 6).
LiveKit room.connect 거부 사유 미구분. 만료 토큰·ws 연결 불가·ServerUnreachable도 failPending() → sdk_connect_failed. 새 테스트가 generic Error로 이 동작을 고정.
V1 use-web-rtc의 타임아웃과 시그널링 실패가 같은 트리거. 5초 race reject도 negotiation_failed. connection_timeout은 loopback 외 미사용.
미협상 PC도 1Hz getStats. connected 후 5초 샘플은 6초 stale 규칙·disconnected 즉시 샘플 때문에 거의 버려짐 (그림 7). 리뷰 뒤 로컬에 fail 시 타이머 정리·emitted 후 샘플 중단이 추가됐으나 PR 커밋 미반영.
close 가로채기가 버려지는 getStats 1회 발행. close() → onState() → sample() 직후 dispose(). close 경로는 rememberState()만이면 충분.
_handler._pc 사설 접근이 세 곳(consumer·producer 기존 RTT 수집기 + 어댑터). 헬퍼 하나로 모을 것. unavailableIceMonitor 요약 리터럴도 fail() 스키마 수동 중복.
신규 파일 주석 전부 영어, 한 줄 주석에 /** */ (CLAUDE.md: 한글, 한 줄은 //).
관찰 전용. 진단 예외는 전부 try/catch로 삼켜지고(테스트 고정), 실패 없는 세션은 로그 0건. 검증: vitest 4파일 + LiveKit 진단 2건, tsc. 미검증: iPad/WebKit 실기기, 실 LogRocket·S3 전달, 폴링 CPU 실측.