경과시간 03:49 → 02:29 감소 — 게스트 재접속 후 누적시간 회귀 분석

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

포커스뷰(모니터 대시보드) 헤더의 경과시간이 게스트 재접속 직후 03:49 → 02:29 로 ~80초 줄어든 현상. 근본 원인은 재접속 후 진행 중 라이브 세션 레코드가 모니터 /sessions에 반영되기 전 창(hasLiveCurrentRecord=false, {count:0} 로그)에서 타이머가 (now − anchor) + before-anchor 트리밍분으로 재계산되는 것이다. 코드 검증·결정론 재현으로 02:29 확정. 재현 확정 필드 수치 §5 잔여
정정: anchor 재설정 자체는 회귀의 직접 원인이 아님(라이브 레코드 있으면 산술 무관) — §3 / 재현 과정 참조.
(LogRocket 세션 019ec9d2…2cfc06a · 진행자 세나 · 아동 아동099 / lesson 42 · roomId id-011…dec5c7_42)
📌 한 장 요약 — 타이머 = 「DB 누적」 + 「라이브 카운터」
🗄️ DB 누적Σ duration /api/lessons/…/42/sessions
의 종료 세션 합
+
⏱️ 라이브now − anchor anchor = lessonStartedAt
(포커스뷰가 넘기는 값)
=
🖥️ 헤더 표시03:49 → 02:29 재접속 직후
~80초 감소
재접속 후 라이브 세션 레코드가 /sessions에 아직 안 잡힌 창({count:0} 관측)에서, before-anchor 트리밍 + grace 갭 제외로 DB 누적분이 줄고 라이브 구간이 (now − anchor=재접속시각)으로 축소돼 헤더가 더 작은 값으로 재계산된다. anchor 재설정은 이 창에서만 노출되는 부수 효과(라이브 레코드가 있으면 무관).
증상게스트 재접속 직후 포커스뷰 헤더 경과시간 03:49 → 02:29 (~80초 감소·회귀)
roomIdid-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)

1. 타이머는 어떻게 계산되나

포커스뷰 헤더 타이머의 SoT는 useCumulativeElapsed 훅 하나다 (monitor-dashboard/[group]/[roomId]/page.tsx:345). 계산식:

누적초 = Σ(서버 세션 레코드 duration)   // 🗄️ /api/lessons/{userId}/{lessonIndex}/sessions
        + (now − anchor)                // ⏱️ anchor = lessonStartedAt (라이브 구간)

포커스뷰 호출부 — anchor = lessonStartedAt

useCumulativeElapsed({
  userId,
  lessonIndex: Number(selectedLessonIndex),
  currentSessionEnteredAt: lessonStartedAt,  // ← anchor
  disableFallbackStart: true,
});

모니터(진행자)는 서버 sessionId를 보유하지 않으므로 currentSessionId를 넘기지 않는다. 따라서 라이브 구간 가산은 오로지 anchor(lessonStartedAt) 한 값에 의존한다.

anchor는 소켓 이벤트로 set/reset 된다

게스트 영상이 꺼지거나(isUnmuted=false) 끊기면 setLessonStartedAt(null) 로 anchor가 비워지고, 재접속 후 새 lessonStartedAt이 들어오면 다시 set 된다 (page.tsx:362-363). 즉 재접속은 anchor를 흔든다.

이 "DB 누적 + 라이브 anchor" 이중 구조가 누적시간 회귀의 본질이다. 출처: docs/handover/jacob/architecture.md#시간-추적 (L83-88), runbooks.md (L29-40), use-cumulative-elapsed.ts:58-123.

2. LogRocket 타임라인 (06:05 UTC 전후) 확정

UTClogline 원문신뢰도
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.577Data 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)와 시간축이 일치한다.

3. 원인 코드 검증·재현

정정 (2026-06-22): 초판은 회귀 원인을 "DB 누적 하락 + anchor 재설정"의 단순 합으로 봤으나, 코드 검증·결정론 재현 결과 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다:

  1. 네트워크 끊김으로 anchor 재스탬프 — 호스트 측 guestInfo 소실 → setLessonStartedAt(null) (page.tsx:338-341), 재접속 후 prev ?? Date.now()로 새 anchor=T1(:366). 주의 카메라 off는 회귀를 만들지 않는다 — 세션 레코드 불변이라 라이브 레코드가 유지되어 hasLiveCurrentRecord=true.
  2. 라이브 레코드 부재 창 — 재접속 후 게스트 createSession이 모니터 /sessions refetch(즉시+1.5s+4s, page.tsx:369-371)보다 늦게 반영되는 동안 hasLiveCurrentRecord=false. Sessions loaded: {count: 0} 로그가 이 창의 스모킹건.
  3. 산술 주체 — 이 창에서 before-anchor 트리밍(: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)

4. ⚠️ 근거로 쓰면 안 되는 값 (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 초 — 다른 쿼리와 상충, 미검증.

5. 확정에 필요한 로그 미확보

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가 비어있던 구간 길이
다음 행동: 위 세션 URL을 열어 Network 탭의 /42/sessions 응답(전·후)을 확보하면 Σ duration + (now − anchor) 로 03:49 / 02:29 를 손계산해 확정할 수 있다.

6. 관련 문서

분석 작성: 2026-06-22 / chulsu · LogRocket 019ec9d2…2cfc06a · 상태: 원인 추정(미확정), §5 로그 확보 시 확정