누적 경과시간 추적 — 세션 합산 · 단조 클램프 코드레벨 동작 흐름

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

누적 경과시간 useCumulativeElapsed sessionSummary.totalDuration LessonSession 레코드 단조(monotonic) 클램프 이중 가산 방지 라이브 앵커 MAX 3600초 캡 백로그 P0 #3

TL;DR

한 회기(userId + lessonIndex)에서 아동이 여러 번 입·퇴장해도 "누적 경과시간"을 정확히 한 번만 센다. 서버는 입장마다 LessonSession 레코드를 만들고 퇴장 시 durationLesson.sessionSummary.totalDuration에 더한다. 클라이언트useCumulativeElapsed는 종료 세션들의 합 + 현재 라이브 구간을 매초 계산해 타이머로 보여준다.

이 영역은 회귀 버그가 반복됐다 — 재접속 시 시간이 거꾸로 줄거나(감소), 모니터 재접속 직후 값이 점프하거나(이중 가산), 시작 버튼을 누르기 전부터 시간이 흐르는(잔여 라이브 레코드) 문제다. 코드의 단조 클램프(finalize)·이중 가산 방지·라이브 앵커 게이트·3600초 캡이 각각 이 회귀들을 막는 가드다. 입장 1시간 한도(P0 #1 입장 제어time_limit_exceeded)도 이 totalDuration이 기준이다.

한눈에 보는 흐름

1
입장 — POST …/sessionscreateSession
lesson-session.service.ts:26
이전 미종료 세션 auto-close → LessonSession 신규 생성(enteredAt, sessionId=${index}#${now}) → sessionSummarysessionCount+1·currentSessionId 기록.
↓ 진행 중 (라이브 레코드: duration 없음)
2
퇴장 — endSession / endPendingSessions
:86 · :150
duration = floor((exitedAt − enteredAt)/1000) 계산(override 없으면 3600초 캡) → 세션 레코드 확정 → totalDuration += duration, lastExitedAt = now, currentSessionId 제거.
↑ 재입장 시 1번으로 (같은 회기, totalDuration 누적)
3
표시 — useCumulativeElapsed + ElapsedTimer
hooks/use-cumulative-elapsed.ts:63 · shared/ui/elapsed-timer.tsx
GET …/sessions로 레코드 로드 → 종료 세션 합 + 라이브 앵커 구간을 매초 합산 → 단조 클램프(finalize)로 회귀 방지 → MM:SS 렌더.

데이터 모델 — 무엇이 저장되는가

LessonSession — 입장 1회 = 레코드 1개 types/db/lesson-session.types.ts

필드의미
sessionId정렬 키 ${lessonIndex}#${enteredAt}
enteredAt입장 시각(epoch ms)
exitedAt?퇴장 시각. 없으면 = 현재 접속 중(라이브)
duration?세션 길이(초). 없으면 = 라이브 레코드
startActivityIndex / startStepIndex입장 시점 진도(종료 시 end… 기록)

SessionSummary — 회기 단위 집계 (Lesson.sessionSummary) types/db/lesson.types.ts:11

필드의미
totalDuration전체 누적 시간(초) — 종료된 세션 duration의 합
sessionCount총 입장 횟수
lastEnteredAt? / lastExitedAt?마지막 입장 / 퇴장 시각 — lastExitedAtP0 #1 재입장 윈도우의 입력
currentSessionId?현재 활성 세션 ID. 접속 중일 때만 존재 → 중복 접속(already_connected) 판정 근거

서버 라이프사이클

1
createSession — 입장
service.ts:26-84
endPendingSessions("auto-closed-on-new-session")로 직전 미종료 세션 정리(누적은 endSession이 담당) → ② lesson 재조회(fresh) → ③ LessonSession 생성 → ④ sessionSummary 갱신: totalDuration 유지 · sessionCount+1 · lastEnteredAt=now · currentSessionId=sessionId (lastExitedAt은 이전 값 보존).
2
endSession — 퇴장
service.ts:86-146
이미 exitedAt 있으면 중복 방지 return. now = exitedAtOverride ?? Date.now(), rawDuration = floor((now−enteredAt)/1000). override 없으면 min(raw, 3600)(orphan idle gap 방지), 있으면 raw. 세션 update(exitedAt·duration·disconnectReason) → totalDuration += duration · lastExitedAt=now · currentSessionId 객체에서 누락 → 제거됨.
↓ 소켓 disconnect backstop
3
endPendingSessions
service.ts:150-174
exitedAt 없는(미종료) 세션을 모두 endSession으로 닫는다. exitedAtOverride를 넘기면 grace period를 제외한 실제 disconnect 시점으로 duration 계산. (createSession의 auto-close는 override 없이 호출 → 캡 적용.)

★ 클라이언트 합산 — useCumulativeElapsed

핵심 알고리즘. hooks/use-cumulative-elapsed.ts:63-146. 입력: currentSessionEnteredAt(= anchor, 현재 연결에서 레슨 진행 중일 때만 세팅), currentSessionId, disableFallbackStart.

const cap = (sec) => Math.min(Math.max(sec, 0), MAX_SINGLE_SESSION_SECONDS); // [0, 3600]
const anchor = currentSessionEnteredAt;

// 회귀 방지: 같은 회기(userId_lessonIndex)에서 직전 표시값 미만으로 내려가지 않게 클램프
const finalize = (value) => {
  const key = `${userId}_${lessonIndex}`;
  const prev = lastElapsedRef.current;
  const guarded = prev && prev.key === key && value < prev.value ? prev.value : value; // 단조
  lastElapsedRef.current = { key, value: guarded };
  return guarded;
};

// 라이브 레코드 매칭: 게스트는 sessionId 정밀, 모니터(id 없음)는 라이브 존재 자체
const matchesCurrent = (s) => currentSessionId == null || s.sessionId === currentSessionId;
const hasLiveCurrentRecord = anchor != null && sessions.some(s => s.duration == null && matchesCurrent(s));

그다음 sessions[]를 합산한다:

레코드조건가산
종료 세션 (duration != null)!hasLiveCurrentRecord && anchor != nullanchor 이전 구간만 cap(min(end,anchor) − enteredAt) — (B)와 겹치는 이중 가산 방지
종료 세션그 외min(duration, 3600)
라이브 세션 (duration == null)anchor == nullskip — 시작 전 잔여 라이브 레코드가 시간 진행시키는 회귀 차단
라이브 세션anchor != nullcap((now − enteredAt)/1000)
(B) 라이브 앵커 구간anchor != null && !hasLiveCurrentRecord+= cap((now − anchor)/1000)finalize
(폴백) 시작 추정!disableFallbackStart && sessions 비어있음 && anchor == null(now − fallbackStartTimeRef)/1000

(B) 라이브 앵커 구간이 핵심. 진행 중 세션이 아직 라이브 레코드로 안 잡혔거나(레코드 생성 지연) 이미 종료 레코드로 확정됐을 때, [anchor, now]를 한 번 더해 "지금 흐르는 시간"을 메운다. 단, 종료 세션의 anchor 이후 부분은 (B)와 겹치므로 anchor 이전만 세서 이중 가산을 막는다.

소비자 — 게스트 vs 모니터

소비자전달 props특성
아동 게스트 화면
guest-page-session/model/use-guest-page-session.ts:95
currentSessionEnteredAt + currentSessionId (폴백 허용)sessionId 정밀 매칭. 본인 연결의 라이브 레코드만 라이브로 인정.
모니터 포커스뷰
app/monitor-dashboard/[group]/[roomId]/page.tsx:346
currentSessionEnteredAt: lessonStartedAt, disableFallbackStart: true (id 없음)서버 sessionId 미보유 → 라이브 레코드 존재 자체로 매칭. 폴백 비활성.
모니터 세션 카드
features/session/ui/session-card.tsx:231
위와 동일hasStartedSession으로 "시작 전" 구분(anchor 유무).

ElapsedTimer shared/ui/elapsed-timer.tsxsetInterval(tick, 1000)로 매초 getCumulativeElapsedSeconds()를 호출해 MM:SS로 렌더한다(inline/box variant).

코드 맵 — 파일별 역할

파일역할
hooks/use-cumulative-elapsed.ts클라 합산 훅 — 종료 세션 합 + 라이브 앵커 + 단조 클램프(finalize) + 폴백
lib/api/services/lesson-session.service.tscreateSession·endSession·endPendingSessions — duration 계산, sessionSummary 누적
app/api/lessons/[userId]/[index]/sessions/route.tsGET(세션 목록)·POST(입장 생성) → LessonSessionController
lib/spare-content-utils.tsMAX_SINGLE_SESSION_SECONDS=3600, 서버측 단순 합 calculateCumulativeSeconds
shared/ui/elapsed-timer.tsx1초 틱 타이머 UI
types/db/lesson.types.ts · lesson-session.types.tsSessionSummary · LessonSession 스키마

읽을 때 주의할 함정

1. 단조 클램프(finalize)는 같은 회기 안에서만. key = userId_lessonIndex가 바뀌면 lastElapsedRef가 리셋되어 0부터 다시 센다(use-cumulative-elapsed.ts:71). 재접속 직후 /sessions 반영 지연으로 값이 작아져도 직전 표시값 아래로 안 내려간다. → 게스트 재접속 경과시간 감소 회귀 방지.

2. 이중 가산 방지 — anchor 이전 구간만. 진행 중 세션이 종료 레코드로 확정되면 그 duration의 anchor 이후 부분이 (B) 라이브 앵커 구간과 겹친다. 그래서 종료 세션은 [enteredAt, anchor]만 더한다(use-cumulative-elapsed.ts:101). → 모니터 재접속 직후 09:08→18:03 점프 방지.

3. 라이브 레코드는 anchor가 있을 때만 카운트. 게스트가 재접속해 "시작"을 누르기 전에는 이전 연결의 잔여 라이브 레코드가 sessions[]에 남아 있어도 anchor == null이라 skip한다(use-cumulative-elapsed.ts:116). 이 게이트가 없으면 시작 전부터 시간이 흐른다.

4. MAX_SINGLE_SESSION_SECONDS = 3600 캡의 이중 의미. ① 클라 cap()은 표시값 폭주 방지. ② 서버 endSessionexitedAtOverride가 없을 때(서버 재시작/배포로 grace 타이머가 유실된 orphan 세션이 다음 createSession auto-close로 닫힐 때)만 캡을 씌워 idle gap이 무한 누적되지 않게 한다. 정상 disconnect backstop은 override로 실제 시각을 넘기므로 캡이 안 걸린다(service.ts:113).

5. currentSessionId 유무로 매칭 의미가 다르다. 게스트는 sessionId 정밀 매칭, 모니터는 서버 sessionId를 모르므로 currentSessionId == null 분기로 "라이브 레코드 존재" 자체를 현재로 본다(use-cumulative-elapsed.ts:85). 같은 훅이지만 호출 측에 따라 라이브 판정이 달라진다.

6. 두 집계 경로가 공존한다. 클라 useCumulativeElapsed(라이브 포함 + 단조 클램프)와 서버측 calculateCumulativeSeconds(spare-content-utils.ts:12, 단순 합·클램프 없음)는 별개다. 예비 콘텐츠(spare time) 판정 등은 후자를 쓰므로, 표시 타이머와 미세하게 다를 수 있다. → 이탈시간 규칙·재접속 회귀 참고.

7. lastExitedAt은 입장 제어와 공유된다. endSession이 기록하는 lastExitedAtP0 #1 validateLessonTime의 재입장 윈도우(20분) 입력이다. 단, lastExitedAt < lessonStart이면 stale로 보고 첫 입장으로 폴백하므로, 누적시간·입장 두 시스템을 같이 봐야 한다.

관련 문서

수업 입장 제어 — 시간 검증·can-enter (P0 #1) — totalDuration 1시간 한도·lastExitedAt 재입장 윈도우의 소비처 Meet/룸 + 호스트 세션 라이프사이클 — createSession/endSession을 호출하는 상위 흐름 누적시간 회귀 재현경로 파악 과정 — 이 가드들이 도입된 디버깅 기록 누적시간 이중 가산 (모니터 재접속) — 함정 #2의 원인 분석 누적시간 경과시간 감소 (게스트 재접속) — 함정 #1의 원인 분석 누적시간 이탈시간 규칙·재접속 회귀 수정 — 함정 #6·#7 운영·관리·인프라 문서화 백로그 — 이 문서는 P0 #3 항목