수업 입장 제어 — 시간 검증 · can-enter 코드레벨 동작 흐름

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

수업 입장 제어 validateLessonTime /api/validate-lesson canEnterLesson plannedStartTime 임시 개방 / 레거시 / 재입장 LESSON_NOT_PROVISIONED 백로그 P0 #1

TL;DR

아동이 수업에 들어오려 할 때 "지금 들어갈 수업이 있는가?"를 결정하는 시스템. 입장 성공/실패의 근원이며, bugs/에 증상 분석은 많지만 설계 흐름 문서가 없던 영역이다. 핵심은 세 개의 독립 검증 경로다 — A. /api/validate-lesson(아동: 오늘 입장 가능한 회차를 시간 윈도우로 탐색) · B. canEnterLesson(회차 단위 게이트: 상태·중복접속·누적시간) · C. validateLessonGap(관리자: 예외 시간 저장 시 8시간 간격 강제).

모든 시간 판정의 기준 시각은 plannedStartTime이며, 이는 enrichUserLessonsWithPpiApiData가 ppi-api 정규 수업과 DynamoDB scheduledTime(예외 개방)을 병합해 계산한다. 입장 모드는 NORMAL(정규)·TEMP(1:1 임시 개방)·FORCED(레거시) 3가지로 갈린다.

한눈에 보는 입장 판정

A
아동 입장 — POST /api/validate-lesson
components/pages/guest-entry.tsx:83 (V1→V2) · app/client-guest/page.tsx:250 (V2)
"오늘 지금 들어갈 수 있는 회차"를 찾는다. DynamoDB 회차 + ppi-api 보강 → findAvailableLesson → 시간 윈도우(validateLessonTime) 통과한 첫 회차를 반환. 통과 시 {userId}_{index} roomId로 V2 세션 진입.
↓ 입장 후 회차 단위 게이트
B
회차 입장 게이트 — GET /api/lessons/[userId]/[index]/can-enter
lib/db-queries.ts:2450 canEnterLesson()
특정 회차에 입장 가능한지 판정. 완료 상태·중복 접속(currentSessionId)·누적 1시간 한도(totalDuration)를 확인.
↑ 입장 시각의 원천 데이터를 만드는 사전 단계
C
관리자 예외 시간 저장 — validateLessonGap
POST /api/lessons · PUT /api/lessons/[userId]/[index]
scheduledTime(예외 개방 시각)을 저장할 때 기존 모든 회차와 8시간 이상 간격을 강제. 위반 시 409. 이 scheduledTime이 나중에 A의 plannedStartTime 기준이 된다.

등장 인물 (모듈 맵)

시간 윈도우

  • validateLessonTime — 상태머신 lib/lesson-time-utils.ts:21
  • getAvailableLessonForUser — 클라 래퍼 :103
  • resolvePlannedStartDate — 기준 시각 :177

경로 A 라우트

  • findAvailableLesson validate-lesson/route.ts:88
  • getActivePlannedLessons :40
  • ERROR_MESSAGES :70

데이터 보강

  • enrichUserLessonsWithPpiApiData lib/lesson-ppi-api-utils.ts:248
  • resolvePlannedClass lib/planned-class-resolver.ts:33
  • MissingTodayClass :221

회차 게이트 / 정책

  • canEnterLesson db-queries.ts:2450
  • resolveBypassMode class-mgmt-bypass-policy.ts:10
  • validateLessonGap lesson-gap-validation.ts:19

진입점 — 어디서 호출되나

호출자경로목적
아동 게스트 입장 (V1 페이지의 tryV2Flow)guest-entry.tsx:81POST /api/validate-lessonvalid면 V2(/client-guest/session)로 redirect, isLegacyMode면 V1 소켓 흐름에 위임(return false)
V2 client-guest 페이지client-guest/page.tsx:250getAvailableLessonForUser로그인 직후 입장 가능 회차 확인
Meet/룸 세션 라이프사이클GET …/can-entercanEnterLesson특정 회차 입장 직전 게이트 (중복 접속·완료·시간 한도)
관리자 회차 생성/수정POST /api/lessons :78 · PUT …/[index] :115예외 시간 저장 전 8시간 간격 검증

경로 A는 "내가 들어갈 수업을 골라준다"(여러 회차 중 탐색), 경로 B는 "이 회차에 들어가도 되나"(단일 회차 판정)로 역할이 다르다. 두 경로는 서로를 호출하지 않는 독립 검증이다.

경로 A — /api/validate-lesson 시간 윈도우 판정

app/api/validate-lesson/route.tsPOSTfindAvailableLesson 순서:

1
회차 로드 + 세션 인증 판별
route.ts:181-189
getLessons(userId)로 DynamoDB 회차 전체. getCurrentChildFromSession()으로 child 세션이면 child-auth, 아니면 member-auth.
2
ppi-api 보강 → plannedStartTime 계산
route.ts:192 · lesson-ppi-api-utils.ts:248
enrichUserLessonsWithPpiApiDataenrichedLessons(+ plannedStartTime·completionStatus·groupId) 와 missingTodayClasses 반환.
3
레거시 우선 판정
route.ts:93-101
isLegacyMode이고 pending(완료 안 됨)인 회차가 있으면 시간 윈도우를 무시하고 즉시 valid. 호스트가 <수업>탭에서 명시적으로 시작한 케이스.
↓ 레거시 없으면
4
오늘의 활성 회차 필터링
getActivePlannedLessons route.ts:40
plannedStartTime 있음 · terminal-status(완료/중단/취소) 아님 · 오늘(KST) · index 내림차순 정렬.
5
각 회차에 시간 윈도우 적용
route.ts:109-134 · validateLessonTime
sessionSummary.lastExitedAt 있으면 재입장으로 보고 validateLessonTime(…, {isReentry, lastExitedAt}). 첫 valid 발견 시 즉시 {isValid:true, lesson, isReentry} 반환.
↓ valid 없으면 우선순위대로 메시지 선택
6
실패 메시지 결정
route.ts:136-167
① 오늘 회차가 하드컷 초과 → HARD_CUT · ② 가장 가까운 errorCode → 해당 메시지 · ③ 미래 수업만 → "다음 수업은 …" 안내 · ④ 아무것도 없음 → "예정된 수업이 없습니다".
↓ 데이터 정합성 보정
7
LESSON_NOT_PROVISIONED 구분
route.ts:201-220
result invalid + errorCode 없음 + 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 < scheduledSLIGHTLY_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:

모드조건의미
FORCEDisLegacyMode호스트가 <수업>탭에서 강제 시작 — 시간 윈도우 무시
TEMP!isLegacyMode && !!scheduledTime1:1 임시 개방(isTempOpenLesson) — scheduledTime 기준
NORMAL그 외정규 수업 — ppi-api classDateTime 기준

보강 본체 — enrichUserLessonsWithPpiApiData

lib/lesson-ppi-api-utils.ts:248-422. ① accessToken 없으면 그대로 반환(보강 skip). ② child면 getChildScheduledClasses, member면 getScheduledClasseschildId 필터. ③ statusMap/groupIdMap 구성 → 모든 회차에 completionStatus·classDate/Time·groupId 부착. ④ plannedStartTimeenrichedLessons에서 먼저 찾고, 없으면 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
2completionStatus !== undefined && !== 0 (= PENDING 아님)lesson_already_completed
3sessionSummary.currentSessionId 존재already_connected (중복 접속 차단)
4totalDuration ≥ 3600초 (누적 1시간)time_limit_exceeded
5위 전부 통과allowed: true

두 하드컷의 기준이 다르다. 경로 A의 하드컷은 scheduled + 60분(시계 시각) 기준이고, 경로 B의 time_limit_exceededtotalDuration ≥ 3600초(누적 접속 시간) 기준이다. 같은 "1시간"이지만 측정 대상이 다르므로, 시계상 1시간이 지나도 누적이 1시간 미만이면 B는 통과한다(그 반대도 성립).

경로 C — validateLessonGap 예외 시간 8시간 간격

관리자가 회차에 scheduledTime(예외 개방 시각)을 저장할 때, 같은 아동의 다른 모든 회차와 8시간 이상 떨어져 있어야 한다. lib/lesson-gap-validation.ts:19. 위반 시 POST /api/lessons:78 / PUT …/[index]:115409 LESSON_TIME_CONFLICT를 돌려준다. (수정 시 currentLessonIndex를 넘겨 자기 자신은 비교 제외.)

코드 맵 — 파일별 역할

파일역할
lib/lesson-time-utils.tsvalidateLessonTime 상태머신, getAvailableLessonForUser 클라 래퍼, resolvePlannedStartDate 기준 시각, 시간 포맷터
app/api/validate-lesson/route.ts경로 A. findAvailableLesson·getActivePlannedLessons·ERROR_MESSAGES·LESSON_NOT_PROVISIONED 구분
lib/lesson-ppi-api-utils.tsenrichUserLessonsWithPpiApiData — ppi-api 병합, plannedStartTime 계산, missingTodayClasses
lib/planned-class-resolver.tsresolvePlannedClass — 그룹/1:1 분기, isTempOpen 판정, KST ISO 변환
lib/db-queries.ts:2450canEnterLesson — 회차 단위 게이트(상태·중복·누적시간)
lib/class-mgmt-bypass-policy.tsresolveBypassMode·isTempOpenLesson — NORMAL/TEMP/FORCED
lib/lesson-gap-validation.tsvalidateLessonGap·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 회차가 있으면 findAvailableLessonvalidateLessonTime을 호출하기 전에 즉시 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 임시 개방 시각이 잘못 끼어드는 것을 막는 분기이므로 임의로 통합하지 말 것.

관련 문서

게스트 입장 플로우 — 디바이스·네트워크 품질·can-enter (이 검증 이후의 입장 단계) Meet/룸 + 호스트 세션 라이프사이클 + 입장 승인 — can-enter를 호출하는 상위 흐름 PPI-API 데이터 보강 (P0 #2) — 이 검증의 입력인 plannedStartTime·completionStatus를 만드는 데이터 레이어 클래스 관리/스케줄링 — 예외 시간(scheduledTime)을 만드는 관리자 흐름 운영·관리·인프라 문서화 백로그 — 이 문서는 P0 #1 항목