수업 입장 제어 — 시간 검증 · can-enter 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
아동이 수업에 들어오려 할 때 "지금 들어갈 수업이 있는가?"를 결정하는 시스템. 입장 성공/실패의 근원이며, bugs/에 증상 분석은 많지만 설계 흐름 문서가 없던 영역이다. 핵심은 세 개의 독립 검증 경로다 — A. /api/validate-lesson(아동: 오늘 입장 가능한 회차를 시간 윈도우로 탐색) · B. canEnterLesson(회차 단위 게이트: 상태·중복접속·누적시간) · C. validateLessonGap(관리자: 예외 시간 저장 시 8시간 간격 강제).
모든 시간 판정의 기준 시각은 plannedStartTime이며, 이는 enrichUserLessonsWithPpiApiData가 ppi-api 정규 수업과 DynamoDB scheduledTime(예외 개방)을 병합해 계산한다. 입장 모드는 NORMAL(정규)·TEMP(1:1 임시 개방)·FORCED(레거시) 3가지로 갈린다.
한눈에 보는 입장 판정
POST /api/validate-lessonfindAvailableLesson → 시간 윈도우(validateLessonTime) 통과한 첫 회차를 반환. 통과 시 {userId}_{index} roomId로 V2 세션 진입.GET /api/lessons/[userId]/[index]/can-entercanEnterLesson()currentSessionId)·누적 1시간 한도(totalDuration)를 확인.validateLessonGapscheduledTime(예외 개방 시각)을 저장할 때 기존 모든 회차와 8시간 이상 간격을 강제. 위반 시 409. 이 scheduledTime이 나중에 A의 plannedStartTime 기준이 된다.등장 인물 (모듈 맵)
시간 윈도우
validateLessonTime— 상태머신 lib/lesson-time-utils.ts:21getAvailableLessonForUser— 클라 래퍼 :103resolvePlannedStartDate— 기준 시각 :177
경로 A 라우트
findAvailableLessonvalidate-lesson/route.ts:88getActivePlannedLessons:40ERROR_MESSAGES:70
데이터 보강
enrichUserLessonsWithPpiApiDatalib/lesson-ppi-api-utils.ts:248resolvePlannedClasslib/planned-class-resolver.ts:33MissingTodayClass:221
회차 게이트 / 정책
canEnterLessondb-queries.ts:2450resolveBypassModeclass-mgmt-bypass-policy.ts:10validateLessonGaplesson-gap-validation.ts:19
진입점 — 어디서 호출되나
| 호출자 | 경로 | 목적 |
|---|---|---|
아동 게스트 입장 (V1 페이지의 tryV2Flow) | guest-entry.tsx:81 → POST /api/validate-lesson | valid면 V2(/client-guest/session)로 redirect, isLegacyMode면 V1 소켓 흐름에 위임(return false) |
| V2 client-guest 페이지 | client-guest/page.tsx:250 → getAvailableLessonForUser | 로그인 직후 입장 가능 회차 확인 |
| Meet/룸 세션 라이프사이클 | GET …/can-enter → canEnterLesson | 특정 회차 입장 직전 게이트 (중복 접속·완료·시간 한도) |
| 관리자 회차 생성/수정 | POST /api/lessons :78 · PUT …/[index] :115 | 예외 시간 저장 전 8시간 간격 검증 |
경로 A는 "내가 들어갈 수업을 골라준다"(여러 회차 중 탐색), 경로 B는 "이 회차에 들어가도 되나"(단일 회차 판정)로 역할이 다르다. 두 경로는 서로를 호출하지 않는 독립 검증이다.
경로 A — /api/validate-lesson 시간 윈도우 판정
app/api/validate-lesson/route.ts의 POST → findAvailableLesson 순서:
getLessons(userId)로 DynamoDB 회차 전체. getCurrentChildFromSession()으로 child 세션이면 child-auth, 아니면 member-auth.plannedStartTime 계산enrichUserLessonsWithPpiApiData → enrichedLessons(+ plannedStartTime·completionStatus·groupId) 와 missingTodayClasses 반환.isLegacyMode이고 pending(완료 안 됨)인 회차가 있으면 시간 윈도우를 무시하고 즉시 valid. 호스트가 <수업>탭에서 명시적으로 시작한 케이스.plannedStartTime 있음 · terminal-status(완료/중단/취소) 아님 · 오늘(KST) · index 내림차순 정렬.sessionSummary.lastExitedAt 있으면 재입장으로 보고 validateLessonTime(…, {isReentry, lastExitedAt}). 첫 valid 발견 시 즉시 {isValid:true, lesson, isReentry} 반환.errorCode → 해당 메시지 · ③ 미래 수업만 → "다음 수업은 …" 안내 · ④ 아무것도 없음 → "예정된 수업이 없습니다".LESSON_NOT_PROVISIONED 구분missingTodayClasses > 0 → ppi-api엔 오늘 수업 있는데 Lesson 레코드가 없음. 사용자 탓이 아닌 데이터 문제로 "고객센터 연락" 안내 + warn 로깅.★ validateLessonTime — 입장 허용 상태머신
경로 A의 심장. lib/lesson-time-utils.ts:21-98. 입력 scheduledTime(= 회차의 plannedStartTime), now, 옵션 {isReentry, lastExitedAt}. 판정 순서:
| 순서 | 조건 | 결과 | 비고 |
|---|---|---|---|
| 1. 하드컷 | now > scheduled + 60분 | HARD_CUT_EXCEEDED | 가장 먼저 차단 |
| 2. 재입장 | isReentry && lastExitedAt ≥ scheduled && now ≤ lastExitedAt + 20분 | valid | 윈도우 초과 시 REENTRY_EXPIRED |
| 2-가드 | lastExitedAt < scheduled | → 첫 입장으로 처리 | 과거 테스트 잔여 데이터(stale) 무시 |
| 3. 너무 이름 | now < scheduled − 15분 | TOO_EARLY | "15분 이내 예정된 수업이 없어요" |
| 4. 약간 이름 | scheduled − 15분 ≤ now < scheduled | SLIGHTLY_EARLY | 클라가 미니게임 대기실로 전환(아래 참고) |
| 5. 입장 가능 | scheduled ≤ now ≤ scheduled + 30분 | valid | ⚠ 변수명 tenMinutesAfter지만 실제 +30분 |
| 6. 너무 늦음 | now > scheduled + 30분 (하드컷 전) | TOO_LATE |
SLIGHTLY_EARLY 분기: 경로 A가 이 코드와 plannedStartTime을 같이 돌려주면 게스트 화면은 입장 대신 미니게임 대기실로 전환하고 남은 시간(plannedStartTime − now)을 카운트다운한다. guest-entry.tsx:137
plannedStartTime 계산 + 입장 모드
모든 시간 판정의 기준 시각. resolvePlannedClass planned-class-resolver.ts:33가 ppi-api 정규 수업(date+time)과 회차의 scheduledTime(예외 개방)을 합쳐 결정한다.
// planned-class-resolver.ts — KST(+09:00) 기준 ISO 변환
const classDateTime = parseClassDateTimeToIso(cls.date, cls.time); // 정규 시각
// 그룹 수업이면 scheduledTime 무시(정규 시각 사용), 1:1이면 예외 개방 시각 사용
const scheduledTime = cls.groupId ? undefined : lesson?.scheduledTime;
return {
plannedStartTime: scheduledTime ?? classDateTime, // ← 입장 기준 시각
isTempOpen: !!scheduledTime, // 1:1 임시 개방 여부
};
이 값으로 입장 모드(bypass mode)가 갈린다 — class-mgmt-bypass-policy.ts:10:
| 모드 | 조건 | 의미 |
|---|---|---|
FORCED | isLegacyMode | 호스트가 <수업>탭에서 강제 시작 — 시간 윈도우 무시 |
TEMP | !isLegacyMode && !!scheduledTime | 1:1 임시 개방(isTempOpenLesson) — scheduledTime 기준 |
NORMAL | 그 외 | 정규 수업 — ppi-api classDateTime 기준 |
보강 본체 — enrichUserLessonsWithPpiApiData
lib/lesson-ppi-api-utils.ts:248-422. ① accessToken 없으면 그대로 반환(보강 skip). ② child면 getChildScheduledClasses, member면 getScheduledClasses 후 childId 필터. ③ statusMap/groupIdMap 구성 → 모든 회차에 completionStatus·classDate/Time·groupId 부착. ④ plannedStartTime은 enrichedLessons에서 먼저 찾고, 없으면 getLesson DynamoDB 폴백. ⑤ 폴백도 실패하면 missingTodayClasses에 push + warn 로깅(7단계 LESSON_NOT_PROVISIONED의 근거).
경로 B — canEnterLesson 회차 게이트
lib/db-queries.ts:2450-2490. getLesson(userId, index) 후 순서대로 차단:
| 순서 | 조건 | reason |
|---|---|---|
| 1 | 회차 없음 | lesson_not_found |
| 2 | completionStatus !== undefined && !== 0 (= PENDING 아님) | lesson_already_completed |
| 3 | sessionSummary.currentSessionId 존재 | already_connected (중복 접속 차단) |
| 4 | totalDuration ≥ 3600초 (누적 1시간) | time_limit_exceeded |
| 5 | 위 전부 통과 | allowed: true |
두 하드컷의 기준이 다르다. 경로 A의 하드컷은 scheduled + 60분(시계 시각) 기준이고, 경로 B의 time_limit_exceeded는 totalDuration ≥ 3600초(누적 접속 시간) 기준이다. 같은 "1시간"이지만 측정 대상이 다르므로, 시계상 1시간이 지나도 누적이 1시간 미만이면 B는 통과한다(그 반대도 성립).
경로 C — validateLessonGap 예외 시간 8시간 간격
관리자가 회차에 scheduledTime(예외 개방 시각)을 저장할 때, 같은 아동의 다른 모든 회차와 8시간 이상 떨어져 있어야 한다. lib/lesson-gap-validation.ts:19. 위반 시 POST /api/lessons:78 / PUT …/[index]:115가 409 LESSON_TIME_CONFLICT를 돌려준다. (수정 시 currentLessonIndex를 넘겨 자기 자신은 비교 제외.)
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| lib/lesson-time-utils.ts | validateLessonTime 상태머신, getAvailableLessonForUser 클라 래퍼, resolvePlannedStartDate 기준 시각, 시간 포맷터 |
| app/api/validate-lesson/route.ts | 경로 A. findAvailableLesson·getActivePlannedLessons·ERROR_MESSAGES·LESSON_NOT_PROVISIONED 구분 |
| lib/lesson-ppi-api-utils.ts | enrichUserLessonsWithPpiApiData — ppi-api 병합, plannedStartTime 계산, missingTodayClasses |
| lib/planned-class-resolver.ts | resolvePlannedClass — 그룹/1:1 분기, isTempOpen 판정, KST ISO 변환 |
| lib/db-queries.ts:2450 | canEnterLesson — 회차 단위 게이트(상태·중복·누적시간) |
| lib/class-mgmt-bypass-policy.ts | resolveBypassMode·isTempOpenLesson — NORMAL/TEMP/FORCED |
| lib/lesson-gap-validation.ts | validateLessonGap·getScheduledDateMap — 8시간 간격, 캘린더 disabled 맵 |
| components/pages/guest-entry.tsx | 경로 A 호출(tryV2Flow), V2 redirect, SLIGHTLY_EARLY 미니게임 전환 |
읽을 때 주의할 함정
1. tenMinutesAfter는 실제로 +30분. lesson-time-utils.ts:71의 변수명과 JSDoc("10 minutes after")이 코드(scheduled + 30*60*1000)와 불일치한다. 실제 입장 마감은 수업 시작 +30분이다. 함수명만 믿고 동작을 추론하지 말 것.
2. 안내 메시지의 "5분 전"과 실제 컷 "15분"이 다름. TOO_EARLY 컷은 scheduled − 15분인데, upcoming 안내 메시지는 "수업 시작 5분 전부터 입장 가능합니다"라고 말한다(route.ts:67). 사용자 안내 문구와 실제 윈도우가 어긋나 있다.
3. 재입장 stale 가드를 빼면 오판정. lastExitedAt < lessonStart면 이전 테스트의 잔여 이탈 기록으로 보고 첫 입장으로 폴백한다(lesson-time-utils.ts:50). 이 가드가 없으면 과거 이탈 시각으로 재입장 윈도우를 잘못 계산한다. 누적시간/재입장 회귀 버그를 만질 때 핵심.
4. 레거시(FORCED)는 시간 윈도우를 통째로 우회. isLegacyMode pending 회차가 있으면 findAvailableLesson이 validateLessonTime을 호출하기 전에 즉시 valid를 반환한다(route.ts:93). "시간 검증이 안 먹는다"는 신고는 먼저 레거시 모드 여부를 확인할 것.
5. LESSON_NOT_PROVISIONED는 사용자 탓이 아니다. ppi-api는 오늘 수업을 반환했는데 DynamoDB에 Lesson 레코드가 없으면(missingTodayClasses) 입장이 "예정된 수업 없음"처럼 보이지만 실제로는 데이터 정합성 문제다. 이 경우만 "고객센터 연락" 메시지 + warn 로깅으로 구분한다(route.ts:201).
6. child auth vs member auth로 데이터 소스가 갈린다. 게스트 세션이면 child-scoped endpoint(getChildScheduledClasses), 관리자/모니터링이면 member-scoped 후 childId 필터(lesson-ppi-api-utils.ts:268). 같은 아동이라도 호출 주체에 따라 보강 결과가 달라질 수 있다.
7. 그룹과 1:1의 입장 시각 원천이 다르다. groupId가 있으면 scheduledTime을 무시하고 정규 classDateTime을 쓴다(planned-class-resolver.ts:38). 그룹 수업에 1:1 임시 개방 시각이 잘못 끼어드는 것을 막는 분기이므로 임의로 통합하지 말 것.