누적시간 — 이탈시간 30초 규칙 적용 + 재접속 경과시간 회귀 수정

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

재접속 시 이탈 시간 누적 규칙(30초 이내 포함 · 30초 초과 제외)을 코드에 반영하고, 긴 네트워크 단절 재접속 시 발생하던 경과시간 회귀(03:49→02:29)를 단조 클램프로 차단했다. 산술 하네스로 전 케이스 검증 통과. 적용 완료
배경/원인: 경과시간 03:49→02:29 감소 원인 분석 · 재현 경로 파악 과정
규칙유저 재접속 시 — 이탈 ≤30초: 이탈 시간까지 누적 포함 / 이탈 >30초: 이탈 시간 제외
변경 파일apps/socket/src/sfu-socket/handlers/connection-handlers.ts(규칙) · apps/web/hooks/use-cumulative-elapsed.ts(회귀 방지)
임계값기존 GRACE_PERIOD_MS = 30_000 재사용 — 새 임계값 도입 없음
검증산술 하네스 5개 시나리오 전체 통과 + tsc --noEmit 클린(변경 파일 무오류)
주의PPI-900(≤30초 idle gap 제외)을 ≤30초 한정으로 부분 revert — 점프(09:08→18:03) 회귀 QA 권장

1. 규칙 ↔ 수정 전 동작

누적 영속값(세션 duration)을 기준으로, 이탈 시간(gap = disconnect→reconnect)이 누적에 들어가는지를 정리:

재접속 시점코드 경로수정 전 gap규칙조치
≤30초 (grace 내) connection-handlers.ts cancelled 분기 — PPI-900이 이전 세션을 disconnectedAt로 종료 제외포함 수정 reconnect 시각으로 종료
>30초 (grace 만료) grace 타이머 onExpireendPendingSessions(..., disconnectedAt) → 재접속 시 LESSON_SESSION_EXPIRED → 클라 새 세션(reconnect 시각) 제외제외 무수정 이미 규칙 일치
핵심: 임계값(30초)·분기(cancel vs consumeExpired)는 이미 존재. 수정 전엔 양쪽 다 gap 제외였고(PPI-900), 규칙 준수를 위해 ≤30초 케이스만 제외→포함으로 전환하면 됐다. >30초는 그대로 둔다.

2. 변경 ① 서버 — ≤30초 이탈 시간 포함

apps/socket/src/sfu-socket/handlers/connection-handlers.ts (grace 내 재접속 = cancelled 분기)

// cancel() 로 grace 타이머를 취소한 직후, 이전 pending 세션을 종료한다.
endPendingSessions(
  guestUserId,
  lessonIndexNum,
  "reconnect-cancel",
- cancelled.disconnectedAt   // PPI-900: idle gap 제외 (disconnect 시각으로 종료)
+ Date.now()                 // 규칙: ≤30초 재접속은 이탈 시간 포함 (reconnect 시각으로 종료)
)

exitedAtOverridedisconnectedAtDate.now()(재접속 시각)로 바꾸면 lesson-session.service.ts의 duration 계산(rawDuration = (now − enteredAt)/1000, :112)이 [enteredAt, reconnect]를 덮어 ≤grace(30초) gap이 누적에 포함된다.

>30초는 왜 무수정? grace가 만료되면 onExpire가 이미 endPendingSessions(..., disconnectedAt)disconnect 시각에 종료하고, 재접속 시 클라가 새 세션을 reconnect 시각에 만든다. 그 사이 gap은 어떤 세션에도 안 들어가 자동 제외 → 규칙 일치.

3. 변경 ② 클라이언트 — 재접속 경과시간 회귀 차단(단조 클램프)

apps/web/hooks/use-cumulative-elapsed.ts — 산술 자체는 그대로 두고, 출력 단에 "회기 내 감소 금지" 가드만 추가.

// (1) 직전 표시값 기억용 ref
const lastElapsedRef = useRef<{ key: string; value: number } | null>(null);

// (2) finalize(): 같은 회기에서 새 값이 직전값보다 작으면 직전값 유지 (래칫)
const finalize = (value: number): number => {
  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;
};

// (3) 3개 return 경로 전부 finalize() 경유 + deps 에 userId, lessonIndex 추가
return finalize(total);

4. 검증 — 산술 하네스 (전체 통과)

업데이트된 getCumulativeElapsedSeconds + finalize 클램프를 그대로 옮기고, 시간축을 주입해 시퀀스로 단조성·gap 포함/제외를 자동 검사했다.

[1] ≤30초 재접속 — 이탈 15초 포함 (서버 Change①)
   끊김 직전 라이브(now=200)        → 03:20 (200s)
   재접속 직후 ended=215(gap포함)    → 03:35 (215s)  ✓ 이탈 15초 포함
   ✓ 단조 비감소

[2] >30초 재접속 — 이탈 60초 제외 + race 회귀 클램프
   끊김 직전 라이브(now=200)        → 03:20 (200s)
   race: {count:0}, anchor=260      → 03:20 (200s)  ✓ 회귀 방지(00:02로 안 떨어짐)
   치유: ended200+live(260)         → ...
   이후 진행 now=320                → 04:20 (260s)  ✓ 이탈 60초 제외(실시간320−gap60)

[2b] 대조 — 클램프 OFF면 03:20 → 00:02 회귀(버그). 클램프 ON에서 [2]가 방지.

[3] 점프(09:08→18:03) 무손상
   재접속 전 → 09:08(548s) / 재접속 후 → 09:10(550s)  ✓ 이중가산 없음(트리밍 유지)

[4] 회기 전환 — 다른 lessonIndex 는 클램프 리셋(새로 누적)  

✅ 전체 통과  ·  tsc --noEmit 클린(변경 파일 무오류)
하네스는 클라이언트 산술(getCumulativeElapsedSeconds)을 1:1 복제해 결정론 검증한다. 서버 duration 계산 결과를 sessions[] 입력으로 모델링해 end-to-end 표시값을 확인.

5. 주의 · 후속

항목내용
PPI-900 부분 revert 변경①은 PPI-900이 막던 "누적 점프"를 ≤30초 한정으로 되돌린다. 점프의 실제 가드는 PR #703의 before-anchor 트리밍(use-cumulative-elapsed.ts)이고 하네스 [3]에서 무손상 확인했으나, 실 환경에서 점프 시나리오 QA 권장. (jacob-handover monitor-dashboard 영역)
클램프 전제 한 회기 내 정상 누적은 단조 증가(감소는 race 버그뿐), 위로 튀는 버그는 #703로 차단됨 — 이 전제에서 안전. 만약 다른 원인으로 값이 잘못 부풀면 클램프가 그 높은 값을 고착시킬 수 있음(현재 알려진 그런 경로 없음).
근본 정공법(별건) 모니터에 서버 현재 sessionId를 내려 currentSessionId ID 매칭 → anchor 추정·race 의존 제거. 시블링(09:08→18:03, PR #703)이 지목한 후속과 동일. 클램프는 증상 차단, 정공법은 구조 제거 — 병행 권장.

관련 문서 · 코드

작성: 2026-06-23 / chulsu · 규칙 적용(≤30s 포함/>30s 제외) + 재접속 회귀 단조 클램프 · 산술 하네스 전체 통과