마지막 업데이트 2026-07-29
증인은 셋 — 게스트 클라이언트(LogRocket), 서버(Loki), 진행자 클라이언트(LogRocket). 세 기록을 같은 roomId·시각축에 겹쳐 놓자 이상한 공백이 드러났다. 게스트는 06:04:54에 분명 emit을 호출했는데, 서버의 첫 수신 기록은 06:05:20 — 그 사이 26초가 비어 있었다.
06:04:54.317 [게스트] auto-finish 발동 → goToNextStep (하랑대화1→2 자체 전환)
06:04:54.322 [게스트] socket.emit(guest-step-update act1) · notifyAutoFinish 호출 ✅
└ 클라는 emit 호출했으나… 서버 미도달
06:04:56.7 [게스트] 호스트 "응" 메시지 수신 정상(다운링크 OK)
06:05:00.909 [서버] Host changed step act1 ← 진행자 수동 점프(host→server 실시간 정상)
06:05:06.507 [서버] monitoring peer …c7c32921 disconnect ← 진행자 모니터 새로고침
06:05:07.046 [서버] Host requested room state {hasState:true} → stale(act0/1) 복원
06:05:17/18 [서버] Host changed step act0/1 ← 진행자 "새로고침" 버튼(stale push) → 아동 회귀
06:05:20.580 [서버] Guest auto-finish {matchedCondition} ← 26초 만에 첫 도달
06:05:20.623 [서버] Guest step update act1 ← 26초 만에 첫 도달
26초 공백의 용의자는 여럿이었다 — 클록 오차, 서버/인스턴스 장애, 소켓 끊김, 다운링크 문제. 하나씩 알리바이를 검증해 전부 배제하자, 남는 것은 단 하나 — 게스트의 업링크였다.
대시보드 실시간 로그 패널(ppi-logs)은 행 가상화로 스크롤이 안 돼, Loki query_range API를 직접 호출해 roomId로 필터링했다.
# LogQL — 룸 단위 전 인스턴스, 시각 윈도우 {service="ppi-socket", env=~"prod"} |= "id-011-586a-4d1f-a415-747776dec5c7_42" # 패널이 가상화돼 스크롤 불가 → Grafana 프록시로 직접 호출 GET /api/datasources/proxy/uid/<lokiUid>/loki/api/v1/query_range ?query=...&start=<ns>&end=<ns>&direction=forward&limit=5000
06:04:53.272 Guest step update in room …_42 {"activityIndex":0,"stepIndex":1} ← 마지막
⟨ 06:04:54 게스트가 act1 emit… 서버 수신 0건 ⟩
06:05:20.580 Guest auto-finish from guest-…_42 {"matchedCondition":"그러면같이한번찾으러가보자!"} ← 26s 만에
06:05:20.623 Guest step update in room …_42 {"activityIndex":1,"stepIndex":0} ← 26s 만에
06:05:00.909 Host changed step …_42 {act1} ← 진행자 수동 점프(정상 실시간)
06:05:06.507 Cleaning up peer monitoring:…c7c32921… after disconnect ← 모니터 소켓 끊김
06:05:07.040 Peer …c7c32921…-545e7890 joined room, found 3 existing producers ← 재입장
06:05:07.046 Host requested room state …_42 {"hasState":true} ← stale(act0/1) 복원
# {…} |= "Guest transport state: guest-id-011…_42" 04:52.307 RTT=9ms ← 정상(초당 보고) ⟨ 04:52 → 05:20 보고 0건 (step-update 공백과 동일 구간) ⟩ 05:20.580 RTT=9ms / 05:20.715 RTT=102ms ← 복구 찰나 스파이크
RTT 값이 아니라 보고 자체가 28초 끊김 = 게스트發 모든 데이터(step-update·auto-finish·transport stat)가 그 구간 안 올라감.
# {…} |= "Guest step update in room" — 06:04:50~06:05:22, 룸별 최대 공백 a77c4cf8…_5 6건 maxGap 5.4s ← 정상 연속 id-011…_42 7건 maxGap 27.4s ← 우리 룸만 이상
범인이 잡히지 않은 이유가 남았다. 26초나 막혔는데 왜 어떤 안전장치도 작동하지 않았나. 첫 번째 은폐 장치는 engine.io 하트비트의 허용범위였다.
engine.io 하트비트는 서버 PING(다운링크) → 클라 PONG(업링크). PONG도 같은 WebSocket/TCP 스트림이라 stall 동안 같이 묶인다. 그런데:
disconnect 이벤트가 없으니 socket.io 재연결·앱의 connect 기반 복구가 하나도 안 걸렸고, 보낸 emit은 그냥 26초 대기. (※ 25/20s는 기본값 — 단, "disconnect 미발생" 경험 사실만으로 "한계 미초과"는 확정)
하트비트가 못 잡았다면 모니터의 네트워크 경보라도 울렸어야 했다. 그러나 경보가 감시하는 4개 입력을 하나씩 대조해 보니, 이 stall은 전부 감시망 바깥에 있었다.
useMonitorNetworkLevel은 4개 입력을 보는데, 이 stall은 전부 미충족:
| 입력 | 감시 대상 | stall 중 |
|---|---|---|
| guestTransportRtt ≥400ms ×2 | WebRTC(UDP) 미디어 RTT | 업데이트 끊겨 마지막 ~9ms에 stale → 임계 미달 |
| 트랙 7초 끊김 | track.readyState==="live" | 미디어 안 흘러도 "live" 유지 |
| socketConnected 7초 | 모니터 자신의 소켓 | 게스트 소켓 아님 + connected |
| bitrate/packetLoss | WebRTC stats | 업데이트 끊겨 stale |
stall 자체는 26초로 끝났지만, 피해는 거기서 멈추지 않았다. 경고 없이 멈춘 화면을 본 진행자의 정당한 개입이 — stale 상태를 그대로 push하는 버튼 설계와 만나 — 아동을 이미 지나온 활동으로 되돌렸다.
진행자 모니터의 guestCurrentStep은 게스트發 이벤트로만 갱신된다(use-host-socket.ts). 호스트 자신의 수동 점프는 갱신하지 않음. 26초간 게스트 업데이트 미수신 + 06:05:06 리마운트의 stale 복원으로 그 값은 하랑대화1에 멈춤.
물리 원인(단말 네트워크)은 우리 손 밖이라 당장 체포할 수 없다. 대신 같은 수법의 범행이 다시 일어나면 즉시 식별되도록 감시망을 깔았다 — 연결은 유지된 채 emit만 안 닿는 stall을 ack+타임아웃으로 잡아내는 감지기다.
연결은 유지(disconnect 미발생)되나 emit이 서버에 안 닿는 stall을 클라가 ack+타임아웃으로 감지해 LogRocket 커스텀 이벤트로 기록. 사후 분석 시 "자동전환 미반영 = 업링크 stall"을 즉시 식별.
// apps/web/lib/socket-uplink-stall-detector.ts (신규) export function emitWithUplinkStallDetection(eventName, payload, { timeoutMs = 5000, roomId }) { if (!socket.connected) { socket.emit(eventName, payload); return; } // 미연결은 버퍼링·판정 제외 socket.timeout(timeoutMs).emit(eventName, payload, (err) => { if (err) trackEvent("GuestUplinkStall", {...}); // ack 미수신 = 업링크 정체 (시작 1회) else if (wasStalled) trackEvent("GuestUplinkRecovered", { stalledMs }); // 복구 1회 }); }
| 파일 | 변경 |
|---|---|
| apps/web/lib/socket-uplink-stall-detector.ts | 신규 — ack+타임아웃 헬퍼 + LogRocket 기록 |
| apps/web/lib/guest-step-utils.ts | emitGuestStepUpdate → 헬퍼 경유 |
| apps/web/entities/guest-socket/model/use-guest-socket.ts | notifyAutoFinish → 헬퍼 경유 |
| apps/socket/src/sfu-socket/handlers/session-handlers.ts | GUEST_STEP_UPDATE·GUEST_AUTO_FINISH 핸들러가 ack 응답 |
이번 작업은 감지·로깅만 — stall을 고치거나 재전송하지 않음. 백로그: