누적시간 회귀 03:49 → 02:29 — 재현 경로 파악 과정

마지막 업데이트 2026-07-22

이 문서는 원인 분석 결과가 아니라, "재현 경로를 어떻게 파악하고 실제로 재현했는가"의 전 과정 기록이다. repro-path 스킬(인테이크 → 코드검증 → 반증루프 → 결정론적 재현)을 따라, 원본 분석 문서의 1차 가설을 코드 실행으로 검증·수정한 메타 문서다. 원인 분석 본편은 경과시간 03:49 → 02:29 감소 — 게스트 재접속 후 누적시간 회귀 분석 참조.
대상 이슈포커스뷰 헤더 경과시간이 게스트 재접속 직후 03:49 → 02:29 (~80초) 감소
roomIdid-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 브라우저) 필요로 보류 → 산술 재현으로 대체(더 엄밀)
REPRO-PATH 파이프라인
단발 신고 → 결정론적 재현 경로
① 인테이크/라우팅증상=타이머 회귀 → 도메인=useCumulativeElapsed (monitor-dashboard)
② 1차 가설원본 문서 채택: "anchor 재설정 + /sessions 빈 배열"
③ 코드 검증hasLiveCurrentRecord 게이트 발견 → anchor가 무관해지는 분기 존재
④ 반증 루프refuter: 1차 가설 인과 기각, 두 disconnect 서브케이스 분리
⑤ 결정론 재현산술 격리 하네스로 02:29 정확 재현 + 반례 대조

① 인테이크 & 증상 라우팅

입력은 이미 작성된 원인 분석 문서 + "이 이슈 발생 재현 경로 찾아줘" 요청. 증상 키워드 "경과시간 감소/회귀"를 라우팅표에 매핑 → 도메인 = 누적시간 타이머(useCumulativeElapsed), 영역 = monitor-dashboard 포커스뷰(jacob-handover).

이 케이스는 LogRocket/Grafana 1차 조사(Stage 0~2)가 원본 문서로 이미 끝나 있었다. 게다가 원인이 미디어/타이밍 버그가 아니라 결정론적 상태 로직(클라이언트 산술)이라, 이번 라운드의 가치는 LogRocket 재조사가 아니라 코드 검증 + 반증 + 재현에 있다고 판단했다.

② 1차 가설 (원본 문서 채택)

원본 §3의 설명을 1차 가설로 둔다:

재접속이 타이머의 두 입력을 동시에 흔들었다 — ① /sessions가 일시적으로 빈 배열 반환(DB 누적 하락), ② anchor(lessonStartedAt)가 새 시각으로 재설정되어 now − anchor가 축소.
의심 지점: "anchor 재설정이 now−anchor를 줄여 회귀"라는 인과가 성립하려면, 타이머가 now − anchor 항을 실제로 쓰는 경로에 있어야 한다. 코드를 읽어 이 전제를 검증하기로 함.

③ 코드 검증 — anchor가 무관해지는 분기 발견

apps/web/hooks/use-cumulative-elapsed.ts:58-123getCumulativeElapsedSecondsapp/monitor-dashboard/[group]/[roomId]/page.tsx:338-371lessonStartedAt 라이프사이클을 읽었다.

1차 가설 위협: 라이브 레코드가 존재하면 anchor 값(T0 vs T1)은 산술에 들어가지 않는다. 즉 "anchor 재설정이 회귀를 만든다"는 인과는, 라이브 레코드가 없는 창에서만 성립한다.

④ 반증 루프 — refuter 서브에이전트

안티패턴 "반증 없이 첫 가설 확정"을 피하기 위해, 가설을 깨는 것이 목적인 refuter 서브에이전트를 띄워 코드 기반으로 독립 반증시켰다(관점: 인과 일관성 · 반례 이벤트 · 대안 설명).

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). 기여원 추가

refuter 주장의 코드 재검증 (load-bearing이라 직접 확인)

주장근거 (직접 grep/read)
네트워크 끊김 30초 graceapps/socket/src/utils/pending-disconnect-tracker.ts:15 GRACE_PERIOD_MS = 30_000확인
createSession이 이전 세션 auto-closelesson-session.service.ts:36-39 endPendingSessions(..., "auto-closed-on-new-session")확인
grace 갭을 duration에서 제외lesson-session.service.ts:93,107,114,149-150 exitedAtOverride확인
수렴된 수정 가설: 회귀는 네트워크 끊김으로 guestInfo 소실 → anchor 재스탬프(T1) 상태에서, 재접속 후 새 라이브 레코드가 모니터 /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).

주입한 5개 상태 (T0=레슨시작=0, 끊김=230s, 재접속 T1=255s)

// ① 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

실행 결과 (npx tsx)

① 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)   ✓ 원복(회귀 없음)
재현 확정: ③에서 원본 §3 손계산(Σduration≈95s + (now−anchor)≈54s)을 그대로 주입하니 02:29(149s)가 정확히 재현. ②③(라이브 레코드 부재)에선 회귀, ④⑤(라이브 레코드 존재)에선 회귀 없음 → 회귀의 게이트는 hasLiveCurrentRecord=false이며 anchor 재스탬프는 ④⑤에서 산술에 무관함이 코드 실행으로 증명됨.

최종 재현 경로 (필요충분 조건)

전제 (셋 다 동시 성립해야 회귀)

  1. 네트워크 끊김으로 호스트 측 guestInfo 소실 → setLessonStartedAt(null)(page.tsx:338-341). 카메라 off는 ❌ (반례 R3)
  2. 재접속 후 게스트 createSession POST가 모니터 /sessions refetch(즉시+1.5s+4s, page.tsx:369-371)보다 늦게 반영 → hasLiveCurrentRecord=false 창.
  3. 직전 세션이 disconnectedAt override로 종료 확정(grace 갭 ≤30s 제외) 또는 {count:0}.

단계

  1. 호스트 포커스뷰 시청 중, 게스트 진행 → anchor=T0, 헤더 03:49 (라이브 레코드 now − enteredAt).
  2. 게스트 네트워크 끊김 → 30s grace 시작, guestInfo 소실 → anchor=null.
  3. 게스트 재접속 → createSession(이전 세션 auto-close) + guest-video-unmuted → anchor=T1(재접속 시각). ← T* 인과 지점
  4. 모니터 즉시 refetch가 race 창에 걸림 → hasLiveCurrentRecord=false → before-anchor 트리밍 + (now−T1)로 재계산.
  5. → 헤더 02:29로 회귀. 회귀폭 ≈ (T1−T0) − 종료세션의 [T0,T1] 확정분 + grace 갭.
  6. 1.5s/4s 재시도가 라이브 레코드를 잡으면 hasLiveCurrentRecord=true 복귀 → ≤4초 자가복구(사람이 캡처하려면 그 창에서 봐야 함).
재현 신뢰성: 결정론 아님(좁은 race, ≤4초 자가복구). 산술 레벨은 결정론적으로 재현(위 ⑤장). E2E를 결정론화하려면 게스트 createSession POST를 refetch 4초 창 밖까지 인위 지연(네트워크 throttle)해야 한다.

E2E Chrome DevTools 재현 레시피 (도출 · 미실행)

이 버그는 포커스뷰 타이머 버그 중 드물게 네트워크 조작으로 결정론화 가능한 케이스라 chrome-devtools MCP로 자동화할 수 있다. 레시피:

  1. 게스트 페이지를 chrome-devtools로 띄워 레슨 진행, 호스트 모니터 헤더 시간 확보.
  2. 게스트 네트워크를 offline(emulate/throttle)로 끊어 guestInfo 소실 유도.
  3. 재접속 직후 게스트 POST /api/lessons/.../sessions(createSession)를 4초+ 지연/블록 → race 창을 refetch 밖으로 유지.
  4. 그 창에서 호스트 헤더 회귀를 take_snapshot/evaluate_script로 캡처.
실제 구동은 보류: 로컬 dev 서버 down(3000/3001) + 라이브 레슨 스택(OpenAI Realtime·mediasoup·예약 레슨·호스트/게스트 2 브라우저)을 자격증명 없이 띄울 수 없음. 대신 산술 격리 재현(⑤장)으로 대체 — 동일 버그를 결정론적으로, 더 엄밀하게 증명함.

⚠️ 재현 함정 — "그냥 오프라인→온라인"이 안 먹는 이유

오프라인→온라인은 올바른 트리거 계열이지만, 단순히 끊었다 붙이면 거의 재현되지 않는다. 코드 레벨 함정이 둘 있다.

함정 ① — 빠른 재접속이면 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

함정 ② — guestInfo가 비워져도 회귀는 ≤4초 race 창에서만 보인다

회귀가 보이려면 hasLiveCurrentRecord=false(새 라이브 레코드 미반영)여야 하는데, 모니터는 재접속 직후 /sessions즉시 + 1.5s + 4s 재시도로 다시 읽는다(monitor-dashboard/[group]/[roomId]/page.tsx:369-371).

결정론적 재현 레시피 (함정 회피)
  1. 레슨 진행 중, 호스트 포커스뷰 열어 헤더 타이머가 의미 있는 값(예: 3분+) 표시.
  2. 게스트 DevTools offline호스트 화면에서 게스트가 사라질 때까지 유지(함정① 회피).
  3. 게스트 online 재접속.
  4. ★ 핵심: 재접속 직후 게스트 POST /api/lessons/{userId}/{lessonIndex}/sessions(createSession)를 4초 넘게 block/throttle → 모니터의 즉시/1.5s/4s 재시도가 전부 새 라이브 레코드를 못 잡아 hasLiveCurrentRecord=false 유지(함정② 회피).
  5. 그 창에서 호스트 헤더 회귀를 take_snapshot/evaluate_script로 캡처.
4번 없이는 떴다 사라지는 ≤4초 race(함정②) 또는 아예 안 뜸(함정①). 4번이 있어야 결정론적으로 재현된다.

원본 분석 문서에 반영할 정정

원본 §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)가 제안한 "후속 정공법"과 동일 선상.

관련 문서 · 코드

분석 작성: 2026-06-22 / chulsu · repro-path 스킬(코드검증+반증루프+결정론 재현) · LogRocket 019ec9d2…2cfc06a · 상태: 산술 재현 확정 / E2E 레시피 도출(미실행)