누적 경과시간 추적 — 세션 합산 · 단조 클램프 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
한 회기(userId + lessonIndex)에서 아동이 여러 번 입·퇴장해도 "누적 경과시간"을 정확히 한 번만 센다. 서버는 입장마다 LessonSession 레코드를 만들고 퇴장 시 duration을 Lesson.sessionSummary.totalDuration에 더한다. 클라이언트 훅 useCumulativeElapsed는 종료 세션들의 합 + 현재 라이브 구간을 매초 계산해 타이머로 보여준다.
이 영역은 회귀 버그가 반복됐다 — 재접속 시 시간이 거꾸로 줄거나(감소), 모니터 재접속 직후 값이 점프하거나(이중 가산), 시작 버튼을 누르기 전부터 시간이 흐르는(잔여 라이브 레코드) 문제다. 코드의 단조 클램프(finalize)·이중 가산 방지·라이브 앵커 게이트·3600초 캡이 각각 이 회귀들을 막는 가드다. 입장 1시간 한도(P0 #1 입장 제어의 time_limit_exceeded)도 이 totalDuration이 기준이다.
한눈에 보는 흐름
POST …/sessions → createSessionLessonSession 신규 생성(enteredAt, sessionId=${index}#${now}) → sessionSummary에 sessionCount+1·currentSessionId 기록.endSession / endPendingSessionsduration = floor((exitedAt − enteredAt)/1000) 계산(override 없으면 3600초 캡) → 세션 레코드 확정 → totalDuration += duration, lastExitedAt = now, currentSessionId 제거.useCumulativeElapsed + ElapsedTimerGET …/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? | 마지막 입장 / 퇴장 시각 — lastExitedAt은 P0 #1 재입장 윈도우의 입력 |
currentSessionId? | 현재 활성 세션 ID. 접속 중일 때만 존재 → 중복 접속(already_connected) 판정 근거 |
서버 라이프사이클
createSession — 입장endPendingSessions("auto-closed-on-new-session")로 직전 미종료 세션 정리(누적은 endSession이 담당) → ② lesson 재조회(fresh) → ③ LessonSession 생성 → ④ sessionSummary 갱신: totalDuration 유지 · sessionCount+1 · lastEnteredAt=now · currentSessionId=sessionId (lastExitedAt은 이전 값 보존).endSession — 퇴장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 객체에서 누락 → 제거됨.endPendingSessionsexitedAt 없는(미종료) 세션을 모두 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 != null | anchor 이전 구간만 cap(min(end,anchor) − enteredAt) — (B)와 겹치는 이중 가산 방지 |
| 종료 세션 | 그 외 | min(duration, 3600) |
라이브 세션 (duration == null) | anchor == null | skip — 시작 전 잔여 라이브 레코드가 시간 진행시키는 회귀 차단 |
| 라이브 세션 | anchor != null | cap((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.tsx는 setInterval(tick, 1000)로 매초 getCumulativeElapsedSeconds()를 호출해 MM:SS로 렌더한다(inline/box variant).
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| hooks/use-cumulative-elapsed.ts | 클라 합산 훅 — 종료 세션 합 + 라이브 앵커 + 단조 클램프(finalize) + 폴백 |
| lib/api/services/lesson-session.service.ts | createSession·endSession·endPendingSessions — duration 계산, sessionSummary 누적 |
| app/api/lessons/[userId]/[index]/sessions/route.ts | GET(세션 목록)·POST(입장 생성) → LessonSessionController |
| lib/spare-content-utils.ts | MAX_SINGLE_SESSION_SECONDS=3600, 서버측 단순 합 calculateCumulativeSeconds |
| shared/ui/elapsed-timer.tsx | 1초 틱 타이머 UI |
| types/db/lesson.types.ts · lesson-session.types.ts | SessionSummary · 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()은 표시값 폭주 방지. ② 서버 endSession은 exitedAtOverride가 없을 때(서버 재시작/배포로 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이 기록하는 lastExitedAt은 P0 #1 validateLessonTime의 재입장 윈도우(20분) 입력이다. 단, lastExitedAt < lessonStart이면 stale로 보고 첫 입장으로 폴백하므로, 누적시간·입장 두 시스템을 같이 봐야 한다.