마지막 업데이트 2026-07-22
| 규칙 | 유저 재접속 시 — 이탈 ≤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 권장 |
누적 영속값(세션 duration)을 기준으로, 이탈 시간(gap = disconnect→reconnect)이 누적에 들어가는지를 정리:
| 재접속 시점 | 코드 경로 | 수정 전 gap | 규칙 | 조치 |
|---|---|---|---|---|
| ≤30초 (grace 내) | connection-handlers.ts cancelled 분기 — PPI-900이 이전 세션을 disconnectedAt로 종료 |
제외 | 포함 | 수정 reconnect 시각으로 종료 |
| >30초 (grace 만료) | grace 타이머 onExpire → endPendingSessions(..., disconnectedAt) → 재접속 시 LESSON_SESSION_EXPIRED → 클라 새 세션(reconnect 시각) |
제외 | 제외 | 무수정 이미 규칙 일치 |
cancel vs consumeExpired)는 이미 존재. 수정 전엔 양쪽 다 gap 제외였고(PPI-900),
규칙 준수를 위해 ≤30초 케이스만 제외→포함으로 전환하면 됐다. >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 시각으로 종료) )
exitedAtOverride를 disconnectedAt → Date.now()(재접속 시각)로 바꾸면
lesson-session.service.ts의 duration 계산(rawDuration = (now − enteredAt)/1000, :112)이
[enteredAt, reconnect]를 덮어 ≤grace(30초) gap이 누적에 포함된다.
onExpire가 이미 endPendingSessions(..., disconnectedAt)로
disconnect 시각에 종료하고, 재접속 시 클라가 새 세션을 reconnect 시각에 만든다. 그 사이 gap은 어떤 세션에도 안 들어가 자동 제외 → 규칙 일치.
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);
userId_lessonIndex 키가 바뀌면 클램프가 안 걸려 새로 0부터 누적.업데이트된 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 표시값을 확인.
| 항목 | 내용 |
|---|---|
| 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)이 지목한 후속과 동일. 클램프는 증상 차단, 정공법은 구조 제거 — 병행 권장. |
connection-handlers.ts(cancelled 분기) · use-cumulative-elapsed.ts(finalize) · lesson-session.service.ts:86-146 · pending-disconnect-tracker.ts:15