석우주 8회기 아동 화면 미표시·AI 세션 실패
— iPad WebKit ICE 후보 미수집 원인 분석 분석 완료 · 수정 미적용
마지막 업데이트 2026-09-12
결론
removing participant without connection의 publisherCandidates에 iPad에서 온 [remote] 후보가 0개라는 것이다(정상 접속 룸은 18개). 페이지 새로고침 2회·재진입 3회·진행자 강제퇴장 3회는 모두 무효였고, 탭을 벗어났다가 back_forward로 돌아온 6번째 진입에서 200ms 만에 정상화됐다.현장 대응은 새로고침이 아니라 크롬 앱 완전 종료 후 재실행이다. 새로고침은 WebContent(JS) 층만 다시 띄우고, ICE를 담당하는 WebKit Networking 프로세스는 그대로 살아남기 때문에 이번 사례에서 5회 모두 15초씩만 소모했다. TURN·TCP 폴백을 추가해도 이 유형은 막지 못한다(기기 내부 연결조차 실패).
시간순 인과 다이어그램
20:00:02 iPad reload 진입 (navigationType=reload, reentryCount 1)
→ 20:00:05 HTTP /api/health 3초 타임아웃 → NETWORK_QUALITY "서버 도달 실패" WARN ← 단일 단말 측정, 20:00:43 회복 (함정, 아래 참조)
→ 20:00:21 mediasoup send transport "connecting" … 이후 "connected" 없음 ← 진행자 모니터에 아동 카메라 안 뜸
→ 20:00:58 기기 내부 loopback PC 5초 타임아웃 ← 라우터도 서버도 안 거치는 연결이 실패 = 단말 안 고장
→ 20:01:09 LiveKit connect → 20:01:24 "could not establish pc connection" (15초) ← AI 세션 생성 실패
서버: signal만 붙고 ICE 미도달 → "removing participant without connection", remote 후보 0개
→ 진행자 kick ×3 + 아동 reload/navigate ×4 (20:01:43 / 02:35 / 03:08 / 04:11) — 매번 같은 실패
→ 20:04:24 카메라 트랙 muted (탭 이탈) → 20:04:39 back_forward 진입
→ 20:04:47 transport connected (33ms) · 20:04:50 LiveKit connected (200ms) ← 정상화, 이후 20:21 종료까지 안정
1. 카테고리 분류 — 어느 층의 문제인가
| 후보 | 판정 | 근거 |
|---|---|---|
| ppi-livekit 서버 | 배제 | 20:00~20:10 사이 다른 참가자 170명 정상 접속. removing participant without connection은 2시간(19:00~21:00) 중 6건이고 그중 5건이 이 아동 |
| ppi-socket(SFU) 서버 | 배제 | 20:00~20:05 게스트 transport 상태가 기록된 41개 룸 중 이 룸만 장기 connecting, 나머지 40개는 connected. 서버측 ICE/DTLS 에러 로그 0건 |
| 네트워크 환경 (공유기·ISP·UDP 차단) | 배제 | ICE 서버 없이 같은 페이지 안 PC 두 개를 잇는 기기 내부 loopback(apps/web/shared/lib/webrtc-audio-loopback.ts:80, new RTCPeerConnection())까지 타임아웃. 패킷이 기기 밖으로 나가지 않는 연결이 실패하면 회선으로 설명 불가. 비WebRTC 통신(소켓·API·S3 25파일 다운로드)은 전부 정상 |
| 우리 JS 코드 | 배제 | PC 생성·offer·localDescription·시그널링·transport 생성이 6회 전부 성공. 새로고침마다 코드는 새로 실행됐는데 결과가 같음 |
| 게스트 기기 — iOS WebKit WebRTC 층 | 확정 | 위 넷을 소거하면 남는 유일한 층. 서버가 받은 remote ICE 후보 0개가 직접 증거 |
iPad에서는 "크롬"이라도 WebRTC 구현은 크롬 코드가 아니다. Apple 정책상 iOS 브라우저는 모두 WKWebView(WebKit)를 쓰므로 RTCPeerConnection·ICE·DTLS는 iOS에 내장된 WebKit(libwebrtc 포팅)이 별도 프로세스에서 처리한다. 우리 코드도 크롬 앱도 이 프로세스를 재시작할 수 없다.
2. 딥다이브 ① — 6회 진입 전부 같은 자리에서 멈췄다
| # | 진입 시각 | navigationType | mediasoup transport | LiveKit | 종료 계기 |
|---|---|---|---|---|---|
| 1 | 20:00:02 | reload | connecting (connected 없음) | 20:01:24 pc connection 실패 | 진행자 kick 20:01:37 |
| 2 | 20:01:43 | navigate | connecting | 20:02:03 실패 | 진행자 kick 20:02:29 |
| 3 | 20:02:35 | navigate | connecting | 20:02:55 실패 | 아동 이탈 20:03:02 (kick은 NoGuest) |
| 4 | 20:03:08 | navigate | connecting | 20:03:29 실패 | 아동 이탈 20:04:01 |
| 5 | 20:04:11 | reload | connecting | connect 시작 후 이탈 | 탭 이탈 20:04:24 (카메라 muted) |
| 6 | 20:04:39 | back_forward | connected 33ms | connected 200ms | 정상 진행 → 20:21 수업 종료 |
FACT LiveKit 실패 5건은 모두 livekit_connect_begin에서 정확히 약 15초 뒤 실패했다(iOS ICE failed 타임아웃과 일치). 아동은 2·4번째 인스턴스에서 "화면/친구가 멈췄어요" 이슈 리포트를 5회 보냈다.
추정 접속 직후 20초간 우리 서버 HTTP 응답만 느렸던 흔적(아래 함정 절)은 그 시점에 네트워크 인터페이스 전환 같은 흔들림이 있었을 가능성을 시사한다. 이것이 WebKit ICE 층을 멈추게 한 방아쇠였을 수는 있지만 로그로 확정할 수 없고, 20:00:43 이후 회선은 정상인데 WebRTC는 20:04:47까지 계속 실패했으므로 회선 자체는 원인이 아니다.
3. 딥다이브 ② — WebRTC 4단계 중 어디서 끊겼나
첫 실패 인스턴스의 AI 세션 시도(20:01:09)를 기준으로 단계별 로그를 대조했다.
| 단계 | 하는 일 | 로그 | 결과 |
|---|---|---|---|
| ① PC 생성·SDP | RTCPeerConnection 생성, offer, localDescription | peer_connection_created 09.313 → offer_create_end 09.362 → local_description_set_end 09.414 | 성공 |
| ② 시그널링 | SDP 서버 전달, 방·transport 생성 | create_session_request_end 09.589 · LiveKit 서버 starting RTC session 20:01:11.7 · mediasoup Send transport created 10.210 → 서버 send transport created 20:01:12.4 · producer 2개 생성 | 성공 |
| ③ ICE/DTLS | 후보 수집 → 연결성 검사(STUN) → DTLS | 클라이언트 connecting 이후 connected 없음 · 서버 RTT: nullms 반복 · LiveKit 유저 참가자 participant active 없음 | 여기서 멈춤 |
| ④ 실패 확정 | 15초 ICE 타임아웃 | disconnected 24.751 → could not establish pc connection 24.762 → session_start_failed | 실패 |
③ 단계 원본 로그 (세 출처)
[아동] mediasoup transport — connecting 이후 connected 없음, 송신 0바이트
20:00:21.130 [MEDIASOUP_PRODUCER] Send transport connection state {"state":"connecting"}
20:00:27.142 [MEDIASOUP_PRODUCER] Audio producer outbound stalled {"trigger":"producer-created","bytesSentDelta":0,"packetsSentDelta":0}
20:00:43.149 [MEDIASOUP_PRODUCER] Audio producer outbound stalled {"trigger":"stall-recheck","bytesSentDelta":0,"packetsSentDelta":0}
20:01:10.333 [MEDIASOUP_PRODUCER] Send transport connection state {"state":"connecting"} ← AI 스텝 재생성, 역시 connected 없음
[아동] loopback — 기기 내부 PC 쌍, ICE 서버 없음
20:00:53.316 [VIDEO_AUDIO_LOOPBACK] Video audio routed into loopback graph {"loopbackState":"idle"}
20:00:58.372 [VIDEO_AUDIO_LOOPBACK] Video audio loopback setup failed {"message":"WebRTC loopback connection timeout","attempts":1}
[아동] LiveKit — connecting 09.600 → 15초 뒤 ICE 실패
20:01:24.751 [LIVEKIT_CLIENT_SESSION] LiveKit room connection state changed {"state":"disconnected"}
20:01:24.762 [LIVEKIT_CLIENT_SESSION] Failed to connect LiveKit browser session {"error":{"message":"could not establish pc connection"}}
20:01:26.772 [UNHANDLED_REJECTION] ConnectionError: could not establish pc connection
[ppi-socket] ICE 미성립 → RTT 측정 불가 1분 이상 반복
20:00:10.515 Guest transport state: guest-7f7291c4… is new, RTT: nullms
20:00:23.298 Guest transport state: … is connecting, RTT: undefinedms
20:00:24.051 ~ 20:01:39.230 … is connecting, RTT: nullms (76회 연속)
[ppi-livekit] 결정 증거 — 서버 후보만 있고 iPad 후보(remote) 0개
20:01:26.761 "removing participant without connection" reason=SIGNAL_SOURCE_CLOSE connectionType=unknown
publisherCandidates = [ [local] udp4 host 54.180.32.32:7882, [local] udp4 host 172.17.0.1:7882,
[local] tcp4 host 54.180.32.32:7881, [local] tcp4 host 172.17.0.1:7881 ]
→ [remote] 후보 0개, subscriberCandidates = null
비교) 성공 룸 20:04:52 "participant active" connectionType=udp
[remote] 후보 18개 (host 192.168.200.114 ×17 + prflx 203.152.182.x), selected = udp prflx …:63564
FACT 시그널링 웹소켓은 살아 있었다(SIGNAL_SOURCE_CLOSE는 시그널 채널이 존재했다는 뜻). WebKit이 후보를 하나라도 만들었다면 trickle로 서버에 도달했어야 한다. 후보 0개는 ICE 후보 수집 단계 자체가 아무것도 내놓지 않았다는 뜻이며, 성공 룸은 TURN relay 없이 host+prflx로 직결됐으므로 이 환경은 원래 TURN이 필요하지 않은 환경이다.
4. 왜 새로고침이 무효였나 — 층이 다르다
iPad 크롬 앱 (WKWebView) ├─ WebContent 프로세스 ← 탭의 JS/DOM. 우리 코드(livekit-client, mediasoup-client)가 여기서 실행 ⟵ 새로고침이 초기화하는 범위 ├─ Networking 프로세스 ← 소켓·UDP·ICE 후보 수집·STUN/DTLS (앱 전체 공용) ⟵ 고장이 있던 층(추정) └─ GPU 프로세스 ← 카메라/마이크 캡처, 미디어 코덱 (앱 전체 공용)
- FACT 새로고침 뒤에도 JS 층은 정상이었다(offer·transport·signal 성공). 실패는 항상 그 아래 ICE 단계였고, 서버는 후보를 받지 못했다.
- FACT 회복은
back_forward진입, 즉 탭을 벗어났다가 돌아온 뒤였다. 아동이 그 15초 동안 정확히 무엇을 했는지(탭 전환·앱 전환·Wi-Fi 토글)는 로그에 없다. - 추정 WebKit은 Networking·GPU 프로세스를 앱 수명 동안 재사용한다. 고장이 이 층에 있으면 페이지 재로드로는 닿지 않고, 앱을 스와이프로 완전 종료해야 세 프로세스가 모두 새로 뜬다. 어느 프로세스가 어떤 상태였는지는 로그로 확정할 수 없다.
5. 함정 — "서버 도달 실패" WARN은 서버 장애 증거가 아니다
20:00:05.473 [NETWORK_QUALITY] 서버 도달 실패 (외부 인터넷은 정상) — 우리 서버 문제로 진단 {"externalLatency":47,"serverLatency":null}
20:00:24.636 [NETWORK_QUALITY] 네트워크 정상 복구 {"externalLatency":46,"serverLatency":2175}
20:00:43.058 [GUEST_NETWORK_ALERT] Server-side network issue cleared {"serverLatency":18,"externalLatency":53}
- 판정 규칙이 이분법이다: gstatic
generate_204성공 +HEAD /api/health3초 타임아웃 →issue="server"(apps/web/shared/lib/use-network-quality.ts:72-78, 타임아웃:28). 단일 단말의 측정이지 서버 상태가 아니다. - 같은 시각 이 단말의 다른 우리-서버 요청도 느렸다(
/api/ice-servers응답이 마운트 후 약 5초). reload 직후 Next.js 청크 로딩과 S3 리소스 25개 다운로드가 겹친 구간이라 회선 포화로 3초를 넘긴 것으로 추정되며, 가벼운 204 프로브는 이 상황에서도 빨리 돌아와 "서버 문제"로 오분류될 수 있다. - 같은 시간대 다른 게스트 40개 룸·LiveKit 참가자 170명이 정상이었으므로 서버 전역 장애는 아니다. 확정에는 ppi-web-prod의 20:00:02~25
/api/health지연 데이터가 필요한데 이번 자료에는 없다. - 이 WARN은 20:00:43에 해소됐고 WebRTC 실패는 20:04:47까지 이어졌으므로 원인 판정에는 영향이 없다.
6. 아동측 복구 절차 (재사용)
- 크롬 앱 완전 종료 후 다시 열기 (1순위) — 화면 아래에서 위로 쓸어올려 앱 스위처 → 크롬을 위로 밀어 닫기 → 다시 열고 수업 링크 재진입. 고장난 WebKit 프로세스를 죽이는 유일하게 확실한 방법.
- 다른 앱으로 나갔다가 뒤로가기로 복귀 — 이번 사례에서 실제로 복구된 동작이나 1번보다 확실성이 낮다.
- 1~2가 안 되면 Wi-Fi 끄고 켜기 후 1번 반복 (방아쇠 추정에 대한 조치).
진행자 판별 신호: 아동이 입장은 됐는데(스텝 동기화·채팅 정상) 카메라 화면이 계속 안 뜨고 AI 세션 시작 실패가 반복되면 이 유형이다. 그때 아동에게 1번을 안내한다.
7. 웹 코드 대응 방안 (제안 · 미적용)
고치는 건 불가능하고, 빨리 알아채서 올바른 복구를 시키는 것까지가 웹 코드의 한계다.
| # | 제안 | 효과 |
|---|---|---|
| 1 | 사전 프로브로 즉시 판별 — 입장 직후 loopback 유틸을 진단용으로 1회 돌리거나 RTCPeerConnection을 만들어 2초 안에 host 후보가 0개면 "단말 WebRTC 불가" 확정 | 현재는 transport 대기 + LiveKit 15초 타임아웃을 매번 겪어야 알 수 있음 |
| 2 | 아동 화면 전용 안내 — 현재 토스트 "인터넷이 끊겼어요! 나갔다 다시 들어와 주세요!"(widgets/guest/network-toast/ui/network-toast.tsx:28)는 새로고침만 반복시킴. 이 상태에서는 "크롬을 완전히 닫고 다시 켜 주세요"를 그림 순서로 | 잘못된 복구 동작 차단 |
| 3 | 진행자 모니터에 별도 상태 — 소켓은 살아 있으니 guest_webrtc_unavailable 류 이벤트로 카드 표시 | kick 대신 바로 안내 |
| 4 | 빠른 실패 — 프로브 실패 시 AI 세션 시작·transport 재시도를 건너뜀 | 15초 대기 제거 |
| 5 | 확정용 로깅 — iceGatheringState, 수집된 후보 수/타입 | 다음 재발 때 "후보 미수집" vs "패킷 미송신" 확정 |
- 무효한 대응: TURN relay·TCP 폴백 추가. 기기 내부 연결도 실패하는 상태라 경로를 바꿔도 패킷이 나가지 않는다.
- 검토만: WebRTC 없이도 되는 것만 계속하는 축소 모드(스텝 동기화·채팅·영상 자료 스텝은 소켓만으로 동작). AI 대화를 WebSocket 오디오로 우회하는 건 LiveKit 구조를 벗어나는 별도 설계.
우선순위는 1+2+3을 한 묶음으로 보는 것이 맞다. 원인 분석 세션이라 코드는 건드리지 않았다.
8. 판별법 (재사용) — 다음 재발은 L0에서 끝낸다
| 확인 순서 | 보는 것 | 이 유형이면 |
|---|---|---|
| 1. 아동 로그 | MEDIASOUP_PRODUCER Send transport connection state | connecting만 있고 connected 없음, Audio producer outbound stalled bytesSentDelta 0 |
| 2. 아동 로그 | VIDEO_AUDIO_LOOPBACK | WebRTC loopback connection timeout — 이 한 줄이 회선·서버를 한 번에 배제 |
| 3. 아동 로그 | LIVEKIT_CLIENT_SESSION | could not establish pc connection, connect 시작 ~15초 뒤 |
| 4. 아동 로그 | 소켓·API·RESOURCE_CACHE | 정상 (인터넷 끊김 아님) |
| 5. ppi-socket | Guest transport state | connecting, RTT: nullms 장기 반복, 같은 시간 다른 룸은 connected |
| 6. ppi-livekit | removing participant without connection의 publisherCandidates | [remote] 후보 0개 — 결정 증거 |
iceGatheringState 로그가 없어 gathering이 new에 머문 것인지 complete인데 후보 0개인지는 구분 못 한다. 방아쇠(네트워크 전환 여부)도 로그에 없다. 이 판별법은 Claude 메모리 ipad-webkit-webrtc-stack-stall-discriminator에도 등록했다.관련 문서
- 영상 오디오 WebRTC loopback + EC 스텝 토글 제거 — iOS 덕킹 회피 (PPI-1121) — 이번 판별의 결정 도구가 된 기기 내부 loopback 연결의 구조와 도입 배경
- iPad 백그라운드 복귀 시 영상 loopback 오디오 무음 (PPI-1158) — 같은 iPad CriOS 단말에서 탭 이탈·복귀가 미디어 층에 미치는 영향의 다른 사례
- 송아인 24회기 AI발화·영상오디오 지지직 — 아동 단말 출력 경로 오염 — 리로드로 회복되지 않고 브라우저 앱 완전 종료로만 사라진 같은 성격의 단말 층 이상
- 원준호 47회기 지지직 — 지터·오디오 클럭 언더런 계측 판별 — "네트워크가 아니라 단말"을 로그로 가르는 판별법의 자매 문서