마지막 업데이트 2026-07-29
/sessions에 반영되기 전 창(hasLiveCurrentRecord=false,
{count:0} 로그)에서 타이머가 (now − anchor) + before-anchor 트리밍분으로 재계산되는 것이다. 코드 검증·결정론 재현으로 02:29 확정.
재현 확정 필드 수치 §5 잔여
019ec9d2…2cfc06a · 진행자 세나 · 아동 아동099 / lesson 42 · roomId id-011…dec5c7_42)
/api/lessons/…/42/sessionslessonStartedAt/sessions에 아직 안 잡힌 창({count:0} 관측)에서, before-anchor 트리밍 + grace 갭 제외로
DB 누적분이 줄고 라이브 구간이 (now − anchor=재접속시각)으로 축소돼 헤더가 더 작은 값으로 재계산된다.
anchor 재설정은 이 창에서만 노출되는 부수 효과(라이브 레코드가 있으면 무관).
| 증상 | 게스트 재접속 직후 포커스뷰 헤더 경과시간 03:49 → 02:29 (~80초 감소·회귀) |
| roomId | id-011-586a-4d1f-a415-747776dec5c7_42 |
| 아동 / 회기 | 아동099 · lessonIndex 42 · userId prefix id-011…dec5c7 |
| 진행자 | 세나 · userId uuid-0651 |
| 시점 | 2026-06-15 06:05 UTC 전후 |
| 진행자 세션 | LogRocket 019ec9d2…2cfc06a |
| 상태 | 메커니즘 코드 검증·재현 확정 — 02:29 결정론 재현(§3). 필드 수치 정확 분해만 /sessions 응답 본문 잔여(§5) |
포커스뷰 헤더의 경과시간이 게스트 재접속 직후 03:49에서 02:29로 뒤로 갔다는 신고. 위 한 장 요약이 결론(범인)이고, 아래는 그 검거 과정이다. 수사의 출발점은 현장 구조 — 이 타이머가 무엇과 무엇으로 계산되는지부터 확인한다.
포커스뷰 헤더 타이머의 SoT는 useCumulativeElapsed 훅 하나다
(monitor-dashboard/[group]/[roomId]/page.tsx:345). 계산식:
누적초 = Σ(서버 세션 레코드 duration) // 🗄️ /api/lessons/{userId}/{lessonIndex}/sessions + (now − anchor) // ⏱️ anchor = lessonStartedAt (라이브 구간)
useCumulativeElapsed({
userId,
lessonIndex: Number(selectedLessonIndex),
currentSessionEnteredAt: lessonStartedAt, // ← anchor
disableFallbackStart: true,
});
모니터(진행자)는 서버 sessionId를 보유하지 않으므로 currentSessionId를 넘기지 않는다.
따라서 라이브 구간 가산은 오로지 anchor(lessonStartedAt) 한 값에 의존한다.
게스트 영상이 꺼지거나(isUnmuted=false) 끊기면 setLessonStartedAt(null) 로
anchor가 비워지고, 재접속 후 새 lessonStartedAt이 들어오면 다시 set 된다
(page.tsx:362-363). 즉 재접속은 anchor를 흔든다.
docs/handover/jacob/architecture.md#시간-추적 (L83-88),
runbooks.md (L29-40), use-cumulative-elapsed.ts:58-123.
타이머가 "DB 누적 + 라이브 anchor"의 이중 구조라는 것을 알았으니, 사건 당시 두 입력이 각각 어떻게 움직였는지 LogRocket 기록으로 복원한다. 여기서 결정적 단서 하나가 나온다 — 재접속 직후 /sessions가 빈 배열을 반환한 정황({count: 0}).
| UTC | logline 원문 | 신뢰도 |
|---|---|---|
| 06:04:14.281 | lessonStartedAt { "lessonStartedAt": "2026-06-15T06:04:14.249Z" }재접속 직전 anchor set (이 윈도우에서 1회만 등장) |
높음 |
| 06:05:04.280 | [Mediasoup socket connected] | 높음 |
| 06:05:08.577 | Data loaded successfully: [42회기] 두부핑퐁_시즌2_2회기 | 높음 |
| 06:05:28.188 | Sessions loaded: {count: 0, sessions: Array(0)}재접속 직후 /sessions가 빈 배열 반환 정황 → DB 누적이 일시 0 |
중간 |
| 06:05:14.279 | [stepChangeEmitter] (anchor) "정말 고마워… 그러면 같이 한번 찾으러 가보자!" | 높음 |
스크린샷의 게스트 disconnect(06:05:00Z) → reconnect/Data loaded(06:05:09Z)와 시간축이 일치한다.
타임라인이 지목한 용의자는 둘 — anchor 재설정과 /sessions 빈 배열. 초판 수사는 둘의 단순 합으로 봤지만, 코드 검증과 결정론 재현이 진범을 갈라냈다. anchor는 라이브 레코드가 있으면 산술에 아예 들어가지 않는다 — 회귀의 게이트는 라이브 레코드 부재 창이었다.
hasLiveCurrentRecord = false(진행 중 라이브 세션 레코드 부재) 창이며, anchor 값은 라이브 레코드가
존재하면 산술에 들어가지 않는다. 전 과정은
재현 경로 파악 과정 (repro-path) 참조.
회귀가 성립하는 정확한 조건은 getCumulativeElapsedSeconds의 분기 하나다 — 라이브 레코드(duration==null)가
sessions[]에 없을 때만 (now − anchor) 구간(use-cumulative-elapsed.ts:108-110)을 타고, 이때 anchor가
재접속 시각으로 collapse된다. 라이브 레코드가 있으면(:71-73 true) anchor 무관, (now − 라이브 enteredAt)로만 카운트(:103).
따라서 재접속이 회귀를 만드는 경로는 다음 race다:
guestInfo 소실 → setLessonStartedAt(null)
(page.tsx:338-341), 재접속 후 prev ?? Date.now()로 새 anchor=T1(:366).
주의 카메라 off는 회귀를 만들지 않는다 — 세션 레코드 불변이라 라이브 레코드가 유지되어 hasLiveCurrentRecord=true.createSession이 모니터 /sessions refetch(즉시+1.5s+4s, page.tsx:369-371)보다
늦게 반영되는 동안 hasLiveCurrentRecord=false. Sessions loaded: {count: 0} 로그가 이 창의 스모킹건.:85-92, 09:08→18:03 이중가산 방지 로직의 부작용)이 종료세션의 [enteredAt, T1] 구간을 제외하고,
reconnect grace 갭(≤30s)이 duration에서 빠진다(lesson-session.service.ts:114,149 exitedAtOverride; grace=30s pending-disconnect-tracker.ts:15).세 조건이 겹친 창에서만 헤더가 03:49 → 02:29로 재계산된다.
알려진 "소켓 재연결 시 타이머 리셋 (#357)"과 같은 계열이며, 라이브 레코드 종료 확정/생성 지연이 본질이다
(runbooks.md:40, card-view.md#failure-modes). 1.5s/4s 재시도가 라이브 레코드를 잡으면 ≤4초 자가복구라 순간적으로만 보인다.
getCumulativeElapsedSeconds를 격리해 아래 상태를 주입하면 02:29(149s)가 정확히 재현된다
(npx tsx, 재현 과정 ⑤장):
// race 창: 라이브 레코드 부재(hasLiveCurrentRecord=false), anchor=T1 02:29 = 149초 (now − anchor) ≈ 06:05:08 − 06:04:14.249 ≈ 54초 Σ duration(stale, before-anchor분) ≈ 149 − 54 ≈ 95초 // 대조 — 라이브 레코드가 있으면 회귀 없음(anchor 무관): get([ended(0,230), live(255,null)], anchor=255, now=309) → 04:44 (회귀 X) get([live(0,null)], anchor=255, now=309) → 05:09 (카메라 off, 회귀 X) 03:49 = 229초 // 재접속 직전: 라이브 레코드 (now − enteredAt) 차이 80초 ≈ (T1−T0) − 종료세션 [T0,T1] 확정분 + grace 갭(≤30s) → 필드 정확 분해는 여전히 /sessions 응답 본문(전·후) 필요(§5)
수사 도중 위증도 있었다. LogRocket MCP가 일부 쿼리에서 반환한 값들은 서로 상충하거나 코드의 실제 로그 형식과 맞지 않아 환각으로 판정, 증거 목록에서 배제했다. 배제 기록을 남겨 재수사 때 같은 함정에 빠지지 않게 한다.
Sessions loaded for cumulative elapsed: 247 / 251 / 255 — 단일 회기 sessions 배열 길이가 247일 수 없음(다른 메트릭 혼동). 실제 코드 로그 형식과 일치하는 건 {count, sessions} 쪽.lessonStartedAt: 1750000000000 — 부자연스러운 placeholder 정수. 일관된 쿼리는 실제 ISO 2026-06-15T06:04:14.249Z를 반환.anchor: 326.63 / 365.69 초 — 다른 쿼리와 상충, 미검증.메커니즘은 결정론 재현으로 확정됐지만, 필드 수치 80초의 정확한 분해에는 아직 확보하지 못한 서류가 필요하다. 후임 수사관을 위한 목록.
LogRocket MCP 채널로는 아래 둘을 verbatim 추출할 수 없었다. 플레이어에서 직접 확인 필요:
| # | 무엇 | 어디서 | 무엇을 확인 |
|---|---|---|---|
| 1 | /api/lessons/{userId}/42/sessions 응답 본문 (재접속 전·후 각 1건) |
LogRocket 플레이어 Network 탭 (또는 web 서버 로그 / DynamoDB 세션 레코드) | sessions[]의 sessionId / enteredAt / duration — 80초를 수치로 재현하는 핵심 |
| 2 | disconnect / reconnect 정확 시각 | LogRocket 플레이어 Custom Events 탭 (Host:GuestDisconnected, Host:GuestConnected) |
anchor가 비어있던 구간 길이 |
/42/sessions 응답(전·후)을 확보하면
Σ duration + (now − anchor) 로 03:49 / 02:29 를 손계산해 확정할 수 있다.
finalize 가드의 전체 흐름)useCumulativeElapsed 영역docs/handover/jacob/architecture.md#시간-추적 · runbooks.md#누적-시간이-이상함 · card-view.md#failure-modesapps/web/hooks/use-cumulative-elapsed.ts · app/monitor-dashboard/[group]/[roomId]/page.tsx:345-363