마지막 업데이트 2026-07-22
repro-path 스킬(인테이크 → 코드검증 → 반증루프 → 결정론적 재현)을 따라, 원본 분석 문서의 1차 가설을
코드 실행으로 검증·수정한 메타 문서다. 원인 분석 본편은
경과시간 03:49 → 02:29 감소 — 게스트 재접속 후 누적시간 회귀 분석 참조.
| 대상 이슈 | 포커스뷰 헤더 경과시간이 게스트 재접속 직후 03:49 → 02:29 (~80초) 감소 |
| roomId | id-011-586a-4d1f-a415-747776dec5c7_42 · 아동099 lesson42 · 진행자 세나 · 2026-06-15 06:05 UTC |
| 방법 | 코드 분석 + 반증 서브에이전트(refuter) + 격리 산술 하네스로 결정론적 재현 |
| 결과 | 02:29(149s) 정확 재현 성공 — 단, 원본 1차 가설(anchor 재설정이 원인)은 부분 정정됨 |
| 핵심 정정 | 회귀의 직접 원인은 anchor 재스탬프가 아니라 hasLiveCurrentRecord = false 창(라이브 세션 레코드 부재). anchor는 라이브 레코드가 있으면 산술에 들어가지 않는다. |
| E2E 재현 | Chrome DevTools 레시피 도출. 실제 구동은 dev 서버 down + 라이브 레슨 스택(OpenAI Realtime·mediasoup·호스트/게스트 2 브라우저) 필요로 보류 → 산술 재현으로 대체(더 엄밀) |
입력은 이미 작성된 원인 분석 문서 + "이 이슈 발생 재현 경로 찾아줘" 요청. 증상 키워드 "경과시간 감소/회귀"를 라우팅표에 매핑 → 도메인 = 누적시간 타이머(useCumulativeElapsed), 영역 = monitor-dashboard 포커스뷰(jacob-handover).
이 케이스는 LogRocket/Grafana 1차 조사(Stage 0~2)가 원본 문서로 이미 끝나 있었다. 게다가 원인이 미디어/타이밍 버그가 아니라 결정론적 상태 로직(클라이언트 산술)이라, 이번 라운드의 가치는 LogRocket 재조사가 아니라 코드 검증 + 반증 + 재현에 있다고 판단했다.
원본 §3의 설명을 1차 가설로 둔다:
재접속이 타이머의 두 입력을 동시에 흔들었다 — ①/sessions가 일시적으로 빈 배열 반환(DB 누적 하락), ② anchor(lessonStartedAt)가 새 시각으로 재설정되어now − anchor가 축소.
now − anchor 항을
실제로 쓰는 경로에 있어야 한다. 코드를 읽어 이 전제를 검증하기로 함.
apps/web/hooks/use-cumulative-elapsed.ts:58-123의 getCumulativeElapsedSeconds와
app/monitor-dashboard/[group]/[roomId]/page.tsx:338-371의 lessonStartedAt 라이프사이클을 읽었다.
sessionId 미보유 → currentSessionId를 안 넘김(page.tsx:349). 라이브 구간 가산은 anchor 한 값에만 의존.lessonStartedAt은 재접속마다 서버 복원이 아니라 prev ?? Date.now()로 새로 찍힘. disconnect 시 null(page.tsx:339,363), 재unmute 시 새 값(:366).hasLiveCurrentRecord(:71-73): sessions[]에 duration==null 레코드가 하나라도 있으면 true. 이때 (now − anchor) B구간(:108-110)을 타지 않고, 라이브 레코드의 (now − enteredAt)로만 카운트(:103).anchor 값(T0 vs T1)은 산술에 들어가지 않는다.
즉 "anchor 재설정이 회귀를 만든다"는 인과는, 라이브 레코드가 없는 창에서만 성립한다.
안티패턴 "반증 없이 첫 가설 확정"을 피하기 위해, 가설을 깨는 것이 목적인 refuter 서브에이전트를 띄워 코드 기반으로 독립 반증시켰다(관점: 인과 일관성 · 반례 이벤트 · 대안 설명).
| 관점 | 발견 | 판정 |
|---|---|---|
| 인과 일관성 | 재접속 정상 경로는 게스트 createSession이 새 라이브 레코드(duration==null) 생성 + 이전 세션 endPendingSessions auto-close. → hasLiveCurrentRecord=true → anchor 무관. |
1차 인과 기각 |
| 반례 이벤트 | 카메라 off(isUnmuted:false)는 세션 레코드 불변 — disconnect도, auto-close도 없음. 라이브 레코드 유지 → 회귀 없음. 네트워크 끊김만 30s grace 후 guestInfo 소실 경로. |
두 케이스 분리 |
| 대안 설명 | 실제 산술 주체는 ① before-anchor 트리밍(:85-92, 09:08→18:03 이중가산 방지용 로직의 부작용)이 종료세션의 [enteredAt, T1] 구간 제외 + ② grace 갭(≤30s)이 duration에서 빠짐(exitedAtOverride). |
기여원 추가 |
| 주장 | 근거 (직접 grep/read) | |
|---|---|---|
| 네트워크 끊김 30초 grace | apps/socket/src/utils/pending-disconnect-tracker.ts:15 GRACE_PERIOD_MS = 30_000 | 확인 |
| createSession이 이전 세션 auto-close | lesson-session.service.ts:36-39 endPendingSessions(..., "auto-closed-on-new-session") | 확인 |
| grace 갭을 duration에서 제외 | lesson-session.service.ts:93,107,114,149-150 exitedAtOverride | 확인 |
/sessions refetch(즉시 + 1.5s + 4s)보다 늦게 반영되는 race 창
(= hasLiveCurrentRecord=false)에서만 발생. 이 창에서 before-anchor 트리밍 + grace 갭으로 헤더가 더 작은 값으로 재계산된다.
{count:0} 로그(원본 §2)가 바로 그 창의 스모킹건.
flaky한 E2E race를 띄우는 대신, 버그의 본질인 getCumulativeElapsedSeconds 산술을 그대로 옮겨
문서에 기록된 상태 전이를 주입했다. Date.now()를 now 인자로 치환해 결정론화.
모니터 호출 조건 고정(currentSessionId=null, disableFallbackStart=true).
// ① BEFORE 끊김: 라이브 레코드 존재, anchor=T0 get([{enteredAt:0, duration:null}], anchor=0, now=229) // hasLive=true // ② AFTER race [{count:0}]: 라이브 레코드 부재, anchor=T1 get([], anchor=255, now=255+54) // hasLive=false // ③ AFTER race [stale duration=95]: 부분 확정, anchor=T1 get([{enteredAt:0, duration:95}], anchor=255, now=255+54) // hasLive=false // ④ 반례 R1: 새 라이브 레코드 반영됨(정상 경로) get([{enteredAt:0,duration:230},{enteredAt:255,duration:null}], anchor=255, now=255+54) // ⑤ 반례 R3: 카메라 off/on — 레코드 불변 get([{enteredAt:0, duration:null}], anchor=255, now=255+54) // hasLive=true
① BEFORE (끊김 직전, now=229s) hasLiveCurrentRecord=true → now - enteredAt => 03:49 (229s) ✓ 03:49 재현 ② AFTER race [{count:0}] (now=T1+54s) ← 회귀 hasLiveCurrentRecord=false → now - anchor = 54s => 00:54 (54s) ✓ 회귀 발생 (229s→54s) ③ AFTER race [stale duration=95s] (now=T1+54s) ← 문서 02:29 재현 hasLiveCurrentRecord=false → beforeAnchor(95s) + (now-anchor)(54s) => 02:29 (149s) ✓ 02:29 정확 재현 ④ 반례 R1 [라이브 레코드 반영됨] (now=T1+54s) ← 회귀 없음(정상) hasLiveCurrentRecord=true → 230(full) + (now-255)=54 => 04:44 (284s) ✓ 연속 카운트(회귀 없음) ⑤ 반례 R3 [카메라 off/on] (now=T1+54s) ← 회귀 없음 hasLiveCurrentRecord=true → now - enteredAt(0) = 309s => 05:09 (309s) ✓ 원복(회귀 없음)
hasLiveCurrentRecord=false이며 anchor 재스탬프는 ④⑤에서 산술에 무관함이 코드 실행으로 증명됨.
guestInfo 소실 → setLessonStartedAt(null)(page.tsx:338-341). 카메라 off는 ❌ (반례 R3)createSession POST가 모니터 /sessions refetch(즉시+1.5s+4s, page.tsx:369-371)보다 늦게 반영 → hasLiveCurrentRecord=false 창.disconnectedAt override로 종료 확정(grace 갭 ≤30s 제외) 또는 {count:0}.now − enteredAt).guest-video-unmuted → anchor=T1(재접속 시각). ← T* 인과 지점hasLiveCurrentRecord=false → before-anchor 트리밍 + (now−T1)로 재계산.(T1−T0) − 종료세션의 [T0,T1] 확정분 + grace 갭.hasLiveCurrentRecord=true 복귀 → ≤4초 자가복구(사람이 캡처하려면 그 창에서 봐야 함).이 버그는 포커스뷰 타이머 버그 중 드물게 네트워크 조작으로 결정론화 가능한 케이스라 chrome-devtools MCP로 자동화할 수 있다. 레시피:
emulate/throttle)로 끊어 guestInfo 소실 유도.POST /api/lessons/.../sessions(createSession)를 4초+ 지연/블록 → race 창을 refetch 밖으로 유지.take_snapshot/evaluate_script로 캡처.오프라인→온라인은 올바른 트리거 계열이지만, 단순히 끊었다 붙이면 거의 재현되지 않는다. 코드 레벨 함정이 둘 있다.
guest-disconnected가 아예 안 나간다서버는 게스트 소켓 disconnect 정리 시, 같은 peerId를 새 소켓이 이미 점유했으면 정리·브로드캐스트를 통째로 skip한다
(apps/socket/src/sfu-socket/signalingHandler.ts:149-159):
const replaced = !!currentPeer && currentPeer.socketId !== socket.id;
if (replaced) { // 게스트가 같은 peerId로 빠르게 재진입
...skipping cleanup and broadcast; continue; // guest-disconnected emit 안 함
}
// 정상 정리 경로에서만:
socket.to(roomId).emit("guest-disconnected", { roomId }); // :162, 끊김 즉시 emit
guest-disconnected 미발송 → 호스트 guestInfo 안 비워짐 → anchor 재스탬프 없음 → 회귀 없음.guest-disconnected가 나가 guestInfo가 비워진다.회귀가 보이려면 hasLiveCurrentRecord=false(새 라이브 레코드 미반영)여야 하는데, 모니터는 재접속 직후
/sessions를 즉시 + 1.5s + 4s 재시도로 다시 읽는다(monitor-dashboard/[group]/[roomId]/page.tsx:369-371).
createSession이 그 안에 반영되면 hasLiveCurrentRecord=true로 복귀 → 회귀가 ≤4초 만에 자가복구된다.POST /api/lessons/{userId}/{lessonIndex}/sessions(createSession)를
4초 넘게 block/throttle → 모니터의 즉시/1.5s/4s 재시도가 전부 새 라이브 레코드를 못 잡아 hasLiveCurrentRecord=false 유지(함정② 회피).take_snapshot/evaluate_script로 캡처.| 원본 §3 서술 | 정정 |
|---|---|
| "anchor가 새 lessonStartedAt로 재설정되어 now−anchor가 작아짐" → 회귀 원인 | anchor 재스탬프는 라이브 레코드가 있으면 산술에 무관(hasLiveCurrentRecord 게이트). 회귀의 게이트는 "라이브 레코드 부재 창"이다. |
| "DB 누적 일시 하락 + anchor 재설정"의 단순 합 | 실제 산술 주체는 before-anchor 트리밍(:85-92)의 [enteredAt,T1] 제외 + grace 갭(≤30s) 제외 + race 창. |
| disconnect와 카메라 off를 사실상 동일 취급 | 둘은 다름 — 카메라 off는 회귀 안 남(레코드 불변). 네트워크 끊김(guestInfo 소실)만 회귀 경로. |
sessionId를 내려(guest-session-state 등) currentSessionId를
넘기면, 라이브 레코드 부재 창에서도 anchor 기반 추정 대신 ID 매칭으로 정합. 시블링 문서(09:08→18:03, PR #703)가 제안한
"후속 정공법"과 동일 선상.
apps/web/hooks/use-cumulative-elapsed.ts:58-123 · app/monitor-dashboard/[group]/[roomId]/page.tsx:338-371 · lib/api/services/lesson-session.service.ts:34-39,93-150 · apps/socket/src/utils/pending-disconnect-tracker.ts:15docs/handover/jacob/{architecture,focus-view,card-view,runbooks}.md