ICE 연결 실패 진단 로그 공통 계측

마지막 업데이트 2026-09-21

86ad8b2f charles-na · 2026-09-21 Feature (로깅 전용) 17 files +1,413 −3 PR #1124 · 브랜치 PPI-1327

전화가 안 걸릴 때 "어디서 끊겼는지"를 기록해 주는 블랙박스를 모든 연결에 달았다. 연결 자체는 하나도 안 바꿨다.

그림 7장으로 본다. 그림 1~5가 "무엇을 왜 만들었나", 그림 6~7이 "리뷰에서 볼 것". 코드 상세와 관전 포인트 전문은 맨 아래 접힌 칸에 있다.

그림 1전에는 "왜?"만 남았다

9/1 석우주 8회기. 연결 세 개가 동시에 실패했는데 로그로는 이유를 못 갈랐다.

전과 후 비교 왼쪽은 세 연결이 모두 실패했을 때 물음표만 남는 이전 상태, 오른쪽은 각 연결에 블랙박스가 붙어 실패 원인 한 줄이 남는 새 상태. loopback ✕ SFU transport ✕ LiveKit ✕ ? iPad 탓? 와이파이 탓? 후보 못 모음 / 후보는 있는데 무응답 / 암호화 실패 — 구분 불가 후 (이 PR) loopback ✕ SFU transport ✕ LiveKit ✕ 🔍🔍🔍 실패마다 로그 1줄 layer = ice_connectivity cause = udp_path_unresponsive confidence = suspected stateHistory, stats … "후보는 모았는데 서버가 응답을 안 했다"까지 로그로 확정

그림 2게스트 한 화면이 여는 연결은 최대 7개

전부 WebRTC 연결(RTCPeerConnection)이고, 이제 전부에 🔍 관찰자가 붙는다.

게스트가 여는 연결 7개 게스트 iPad를 가운데 두고 loopback 2개, SFU 2개, LiveKit 2개, Realtime 1개 연결이 각각 목적지로 뻗어 있고 모두 관찰자가 붙어 있다. 📱 게스트 (아동) iPad · client-guest loopbacksender 🔍 · receiver 🔍 (2개) → 같은 기기 안. AI 목소리를 내 스피커로 (iPad 덕킹 회피) mediasoup (SFU)send 🔍 · recv 🔍 (2개) → ppi-socket 서버. 진행자 모니터로 영상·소리 전달 LiveKitpublisher 🔍 · subscriber 🔍 (2개) → LiveKit 서버. 핑퐁이(AI agent)와 대화 Realtimeai-session / session-manager 🔍 (1개) → OpenAI. Realtime 모드일 때만 + V1 P2P (use-web-rtc, 레거시 meet/guest) 🔍 — 운영 미사용이지만 같이 계측

SDK가 만든 연결(mediasoup·LiveKit)은 SDK 내부의 비공개 필드로 꺼내 붙인다. 그 접근은 ice-diagnostics-adapters.ts 한 파일에만 있다.

그림 3관찰자가 하는 일 — 시간순

붙어서 메모하다가, 실패하면 딱 한 번 로그를 남긴다. 성공하면 아무것도 안 남긴다.

관찰자 타임라인 연결 시작에 관찰자가 붙고, 상태가 바뀔 때마다 메모하고, 1초마다 통계를 찍다가, 실패하면 최근 6초 안 통계만 골라 원인을 분류해 로그 한 줄을 LogRocket과 S3에 보낸다. 1 연결 생성🔍 바로 부착 2 상태 변할 때마다 메모new → connecting → checking 후보 개수·종류, STUN 에러 코드도 3 📷 1초마다 통계 스냅샷getStats → 숫자·라벨만 보관 IP·SDP·URL은 버림 실패failed 이벤트 / SDK 거부 / 협상 예외 최근 6초 안 스냅샷만 사용 5 원인 분류그림 4의 사다리 6 📝 로그 1줄await 없이 즉시 LogRocket 콘솔 + S3 수업 로그 같은 객체, error 레벨 ✅ 성공 → 로그 0건 ✅ 잠깐 끊김(disconnected) → 로그 0건 ✅ 앱이 직접 닫음 → 로그 0건 🔁 실패 후 다시 연결됐다가 또 실패 → attempt 2로 새 로그 (같은 attempt에선 딱 1번)

왜 즉시 방출? 실패 직후 SDK가 연결을 닫고 앱이 S3 버퍼를 비운다. getStats를 기다리면 그 경계를 놓쳐 로그가 유실된다.

그림 4원인 찾는 사다리 — 위에서부터 하나씩

전화 걸기에 비유: 암호화 → 내 번호 받기 → 서로 번호 교환 → 내 전화기 고장 → 상대가 안 받음.

원인 분류 사다리 DTLS 실패, 로컬 후보 0개, SDP 미완성, 소켓 송신 에러, UDP 무응답, 모름 순서로 위에서부터 검사한다. ① 암호화(DTLS) 실패라고 통계에 적혀 있나?dtlsState === "failed" layer=dtls cause=dtls_failed연결은 됐는데 암호 악수에서 죽음 · observed ② 내 번호(후보)를 하나도 못 받았나?gathering 끝 + 로컬 후보 0개 layer=candidate_gathering cause=no_local_candidates석우주 케이스가 재발하면 여기서 잡힘 · observed ③ 서로 번호 교환(SDP)이 안 끝났나?local 또는 remote description 없음 layer=signaling cause=description_incompleteICE 전 단계 문제. 앱의 협상 에러를 같이 봐야 함 · ⚠ 그림 6의 오탐이 여기로 떨어짐 ④ 내 전화기가 보내다 버렸나?packetsDiscardedOnSend > 0, 성공 pair 없음 layer=local_network cause=socket_send_errors브라우저/OS/인터페이스 쪽 의심 · suspected ⑤ 보냈는데 상대가 한 번도 안 받았나?UDP 요청 > 0, 응답 0, 성공 pair 없음 layer=ice_connectivity cause=udp_path_unresponsive와이파이·방화벽·NAT·서버 중 하나. 라우터 탓 단정 금지 · suspected ⑥ 그 외Safari가 카운터를 안 줘도 여기 (0으로 안 봄) layer=ice_connectivity | unknown cause=unknown증거 부족. 모른다고 적는다 · unknown

confidence는 세 단계. observed=통계에 직접 적힘, suspected=정황, unknown=증거 부족. 오래된(6초 초과) 스냅샷은 판정에 안 쓴다.

그림 5실제로 남는 로그 한 줄

LogRocket이나 S3 세션 로그에서 ICE_DIAGNOSTIC_SUMMARY로 검색하면 이런 객체가 나온다.

ICE_DIAGNOSTIC_SUMMARY { source: "mediasoup", role: "recv", roomId: "…", transportId: "…", pcId: "pc-m1x…-3", attempt: 1, scope: "network", trigger: "connection_failed", layer: "ice_connectivity", cause: "udp_path_unresponsive", confidence: "suspected", elapsedMs: 14210, states: { connection: "failed", ice: "failed", gathering: "complete", signaling: "stable", localDescription: true, remoteDescription: true }, stateHistory: [ "0:new/new/new/stable", "412:connecting/checking/gathering/stable", "14203:failed/failed/complete/stable" ], localCandidateCount: 4, localCandidateTypes: ["host","srflx","relay"], iceErrorCodes: [701], statsStatus: "available", statsAgeMs: 980, stats: { localTypes, remoteTypes, protocols, pairStates: ["in-progress","failed"], requestsSent: 57, responsesReceived: 0, unansweredUdpPairs: 3, dtlsState: "new", selectedPair: undefined }, online: true, visibility: "visible" }
source / role — 그림 2의 어느 연결인지
pcId + attempt — 실패 1건의 고유 키
trigger — 누가 실패를 알렸나 (네이티브 / SDK / 앱)
layer / cause / confidence — 그림 4의 결과
stateHistory — "ms:connection/ice/gathering/signaling" 최대 12줄
stats — 숫자·라벨만. IP·SDP·URL·자격증명 없음
statsAgeMs — 이 통계가 몇 ms 전 것인지 (6000 초과면 판정 제외)
scope — loopback이면 browser_local, 나머지 network

그림 6⚠ 리뷰 포인트 1 — 연결 안 해본 실패도 "ICE 실패"로 찍힌다

세션 매니저·AI 세션·LiveKit 세 경로가 PC 생성 이후의 모든 예외를 ICE 실패로 보고한다. PR에 아직 미수정.

오탐 경로 마이크 권한 거부, 토큰 발급 실패, LiveKit 인증 만료 같은 ICE와 무관한 실패가 모두 ICE 실패 로그 바구니로 흘러 들어간다. 🎤 마이크 권한 거부getUserMedia NotAllowedError 🔑 세션 토큰 발급 실패createSession fetch 에러 🔄 새 요청이 이전 요청을 덮음SessionError "START_FAILED" (정상 교체) 🚫 LiveKit 토큰 만료 401room.connect 거부, transport 생성 전 🪣 ICE 실패 로그 trigger=negotiation_failed layer=signaling cause=description_incomplete (LiveKit: statsStatus=sdk_pc_unavailable) PC는 아직 new 상태인데 ICE 실패로 집계됨 → "ICE_DIAGNOSTIC_SUMMARY 검색" 트리아지가 권한·인증 이슈로 오염

고치는 방향: 트리거를 createOffer~setRemoteDescription 블록 안으로 좁히거나, localDescription이 있을 때만 보고. LiveKit은 ConnectionErrorReason 필터 또는 transport가 생긴 뒤(monitors.length > 0)에만 failPending().

그림 7⚠ 리뷰 포인트 2 — 비용: 1초마다 통계를 묻는다

협상도 안 한 연결이 1초마다, 연결된 뒤에도 5초마다. 게스트 iPad에서 최대 7개가 동시에.

폴링 비용 연결 전에는 1초마다, 연결 후에는 5초마다 getStats를 호출하며 그 결과 대부분은 버려진다. 연결 전 (new / connecting) 📷 × 1 / 초 호스트가 produce 안 해서 recv transport가 수업 내내 new면 → 수업 내내 매초 연결 후 (connected) 📷 × 1 / 5초 6초 넘으면 판정 제외 + 끊길 때 즉시 재촬영 → 거의 항상 버려지는 사진 닫을 때 (close) 📷 × 1 버림 close 가로채기가 사진 찍자마자 dispose → 재시도·재접속·종료마다 1회 낭비 싼 대안: SDP 적용 뒤에만 시작, connected 중엔 중단, close 경로는 상태 메모만. CPU 실측은 미완.

리뷰어용 상세 (접힘)

레이어별 변경 요약 — 파일 10줄
레이어파일핵심 변경
공유 lib (신규)shared/lib/ice-diagnostic-evidence.tsgetStats 화이트리스트 축약 readIceStats, 층별 분류 classifyIceFailure (그림 4). 순수 함수.
공유 lib (신규)shared/lib/ice-diagnostics.tsobserveIceConnection(리스너·getStats 샘플·close 가로채기·attempt 관리, 그림 3), reportIceFailure, unavailableIceMonitor.
공유 lib (신규)shared/lib/ice-diagnostics-adapters.tsmediasoup _handler._pc, LiveKit TransportsCreated의 publisher/subscriber _pc 사설 접근 격리. full reconnect 시 pending 이전 PC는 실패 기록 후 교체.
공유 libshared/lib/webrtc-audio-loopback.tssender/receiver source: loopback. connection-failed·timeout·loopback_failed 분기.
voice-agentlib/voice-agent/livekit-client-session.tsRoom 생성 직후 observeLiveKitIce. connect 거부·재연결 중 Disconnected → failPending(). 앱 주도 종료·abort 제외.
hook (V2 SFU)hooks/mediasoup/use-mediasoup-{consumer,device,producer}.tstransport 생성 직후 observeMediasoupIce, connect 콜백 실패 시 fail("negotiation_failed").
hook (Realtime)hooks/use-session-manager.ts, entities/guest-session/model/use-ai-session.tsPC 생성 직후 관찰, start 실패 catch에서 AbortError만 제외하고 보고 (그림 6).
hook (V1 P2P)hooks/use-web-rtc.tsPC 생성 2곳 모두 관찰, 실패 시 보고.
테스트shared/lib/ice-diagnostic*.test.ts (신규 4), livekit-client-session-diagnostics.test.tsstale 카운터 배제·1회 방출·Safari 누락 카운터·transient disconnect 무시·교체 transport·PC 접근 불가·업로드 계약(실 logger→S3 PUT)·SDK connect 거부/abort.
문서docs/ice-diagnostics.md필드 해석표, 검증 명령, 기존 baseline 실패(LiveKit 스위트 3건·ESLint 로딩 오류) 무관 명시.
관전 포인트 전문 — 8건
오탐

세션 매니저·AI 세션 catch가 넓다. PC 생성 이후 getUserMedia 거부·토큰 fetch 실패·avatar/user 조회 실패·"신규 요청에 의해 무시됨" SessionError까지 negotiation_failed. PC가 newlayer=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 실측.

관련 문서