수업 중단 후 아동 재입장 — terminal status 서버 게이트 전환 설계 P1설계

마지막 업데이트 2026-08-31

작성일: 2026-08-31 대상: 개발자 — 회기 종결 상태가 실행 경계에서 강제되지 않는 문제 근거 사건: 2026-08-28 20:35 이리안 12회기

한 줄 판정

핵심 해법은 PUT → forceKick 순서 조정이 아니다. DDB의 회기별 접근 게이트를 종료의 기준점으로 삼고, 모든 API·소켓·LiveKit 경로가 같은 accessEpoch를 검사하도록 만드는 것이다. Redis는 권위가 아니라 빠른 종료 통지와 캐시에만 쓴다.

실측된 결함 — 2026-08-28 20:35, 진행자가 수업을 '중단' 처리한 뒤에도 아동은 진행자 없이 20:50:14 엔딩영상까지 수업 전체를 완주했다. 중단 확정 이후 세션 생성 1건, 진행률 갱신 9건, 세션로그 95건, LiveKit call 5건이 전부 서버에서 통과했다. 재검증(validate-lesson)은 0건이었다.

본 문서는 서버 로그(Loki {service_name="ppi-web"}) 실측 대조로 확정한 사건 메커니즘과, 그에 대한 구조 전환 설계안을 담는다. 구현 전 설계 문서이며 아직 코드에 반영되지 않았다.

사건 타임라인 — 서버 로그 실측

아동 이리안(89ad88d1…) / 진행자 안나(99eb7085…, manager) / roomId 89ad88d1…_12

20:35:23.347  진행자 강제퇴장(reason="kick") → 아동 소켓 끊김
              모니터: guest_disconnected → peer_left → setGuestInfo(null)
    ↓ 8.2s   아동이 "수업으로 돌아가기" 클릭 → /guest 재진입
20:35:31.953  GET  /api/users/check-session
20:35:32.434  진행자 '중단' 클릭 (클라)
20:35:32.488  POST /api/validate-lesson  ← 아동
20:35:32.517  validate-lesson 완료 200 (30.9ms) — 통과   상태는 아직 PENDING
20:35:32.571  아동 /client-guest/session 마운트
20:35:32.620  PUT  /api/lessons/{uid}/12 시작  ← 진행자, 클릭 대비 186ms
20:35:32.626  PATCH ppiapi /class-mgmt/classes/{uid}/20260828/status
20:35:32.697  PUT 완료 200 (81.5ms) — STOPPED 확정
20:35:33.562  Ended pending lesson sessions  closedCount=1 (12#1787916869321)
20:35:34.445  LESSON_LOG_FINALIZE 아카이브 생성  totalEntries=938
20:35:34.600  아동 소켓 입장 요청
20:35:35.128  아동 소켓 룸 실제 참여  ← 서버가 인지하는 "입장". 이미 STOPPED
20:35:35.144  POST /api/lesson-progress → 35.176 갱신 성공
20:35:39.256  아동 준비 확인 클릭(confirmReady)
20:35:39.298  POST /api/lessons/{uid}/12/sessions → 39.376 200, 새 세션 생성
    ↓
20:50:14      엔딩영상까지 완주 → 20:50:37 강제퇴장으로 종료
레이스가 아니라 완전한 선행이다. validate-lesson은 32.517에 이미 끝났고, 중단 PUT은 32.620에 시작했다. 두 구간이 겹치지 않는다. 아동이 검증을 통과하던 시점에 STOPPED는 존재하지도 않았으므로, validate-lesson 입장에서는 오판이 아니라 정상 동작이다.

500ms sleep이 실행되지 않았다

진행자 클릭 32.434 → PUT 시작 32.620 = 186ms. completeLesson의 고정 500ms sleep이 돌았다면 32.93 이후여야 한다. 즉 if (guestInfo) 가 false여서 forceKickGuest("complete")와 sleep이 통째로 스킵됐다. 클라이언트 세션로그 전체에 reason "complete" 강퇴가 0건이다.

apps/web/features/monitor/lesson-completion/model/use-lesson-completion.ts:363~415

왜 아동 연결이 끊기지 않았나

퇴장 로직 자체는 존재한다. completeLessonforceKickGuest("complete") → 게스트 lesson-ended(재입장 버튼 없음). 동작하려면 세 조건이 동시에 성립해야 한다.

  1. 모니터가 그 순간 아동을 인지 — monitorSession.guestInfo != null
  2. 소켓 서버 로컬 peers Map에 guest 존재 (아니면 NoGuest)
  3. 게스트 소켓 생존

이번 건은 1번에서 탈락했다. 원인은 "입장" 시점이 두 갈래로 갈리는 것이다.

아동이 말하는 "입장"        검증 통과 32.517 / 페이지 마운트 32.571
서버가 인지하는 "입장"      소켓 룸 참여 35.128 / GUEST_CONNECTED 39.256
진행자가 '중단'을 누른 시각  32.434  ← 이 순간 방에 게스트가 없었다
                            (직전 아동은 23.347에 강퇴돼 나갔고,
                             새 연결은 2.7초 뒤에 들어온다)
STOPPED 전환은 일회성 이벤트다. 그 순간 방에 있는 게스트만 끊고, 이후 들어오는 연결에는 아무 효과가 없다. 35.128에 들어온 새 연결은 누구도 다시 끊지 않았다. 순서를 바꾸는 것만으로는 이 경로가 막히지 않는다.

guestInfo 세팅 502~518(peerStreams 자동 탐지)·536(get-peers) / 해제 840(guest-disconnected)·905(peer-left) — apps/web/entities/monitor-session/model/use-monitor-session.ts

설계 전제 — completionStatus는 DynamoDB 값이 아니다

이 사실이 설계를 가른다. PUT /api/lessons/[userId]/[index]completionStatus·classDate를 payload에서 떼어낸 뒤 ppi-api에 PATCH한다. 코드 주석도 "PostgreSQL이 수업 상태의 원천"이라고 명시한다.

// Extract ppi-api-only fields before updating lesson (they are not DynamoDB fields)
const completionStatus: ClassStatusType | undefined = updates.completionStatus;
delete updates.completionStatus;
delete updates.classDate;
// PostgreSQL이 수업 상태의 원천이므로 ppi-api 실패를 DynamoDB 업데이트 성공으로 숨기지 않습니다.

apps/web/app/api/lessons/[userId]/[index]/route.ts:158, :176

따라서 매 API마다 terminal 상태를 보려면 ppi-api를 왕복해야 한다. 소켓 서버에는 그 경로가 아예 없다. 그렇다고 같은 completionStatus를 Redis·DDB에 복제해 또 다른 업무 상태 원천을 만들면 안 된다. 책임을 분리해야 한다.

설계안 — 업무 상태와 실행 접근의 분리

업무 상태 권위
ppi-api / PostgreSQL
PENDING · STOPPED · COMPLETED · CANCELLED
          │
          │ 서버 transition command
          ▼
실행 접근 권위
DDB Lesson.accessState + accessEpoch
          │
          ├─ Web API 공통 gate (assertLessonAccess)
          ├─ Socket 내부 access API
          └─ DDB outbox
                  │
                  ├─ Redis 종료 이벤트·캐시   (권위 아님)
                  ├─ Socket room 강제 종료
                  ├─ LiveKit room 종료
                  └─ ppi-api 상태 동기화 재시도

DDB Lesson 항목에 추가할 필드

accessState:       "ACTIVE" | "CLOSED";
accessEpoch:       number;
terminalStatus?:   100 | 200 | 300;
accessChangedAt:   number;
transitionId:      string;
transitionActor:   { type: "member" | "admin"; id: string };
businessSyncState: "PENDING" | "SYNCED" | "FAILED";

별도 테이블이 아니라 기존 Lesson 항목에 두는 이유:

Redis lesson-access:{userId}:{index}는 DDB 값의 projection만 갖는다. 값이 없거나 버전이 의심스러우면 DDB를 다시 읽어야 하며, Redis 장애 시 ACTIVE로 간주하면 안 된다.

종료의 기준점

터미널 상태 변경은 일반 Lesson PUT이 아니라 전용 transition command로 분리한다.

  1. DDB 트랜잭션으로 accessState=CLOSED, accessEpoch+1, outbox를 기록
  2. 이 DDB 커밋이 "더 이상 입장·진행할 수 없는 시점"
  3. outbox 처리기가 Redis 종료 이벤트를 발행하고 ppi-api 상태를 동기화
  4. ppi-api 실패 시 접근 게이트는 닫힌 채 재시도. 자동으로 다시 열지 않는다
  5. 클라이언트에 accessClosed:true, businessSyncState 반환

현재 completeLessonguestInfo 검사·클라이언트 강퇴·500ms sleep은 종료의 필수 조건에서 제거한다.

공통 접근 판정

assertLessonAccess({ principal, userId, lessonIndex, expectedAccessEpoch, capability });

// 판정 순서
1. 아동 세션 인증
2. 세션의 child.id === userId 확인
3. DDB consistent read
4. accessState === ACTIVE 확인
5. 요청의 accessEpoch === 현재 epoch 확인
6. 쓰기라면 DDB 조건식/트랜잭션에서도 다시 확인
상황응답
인증 없음401 UNAUTHORIZED
다른 아동 ID 사용403 FORBIDDEN
종료됨409 LESSON_TERMINATEDterminalStatus·accessEpoch·terminalAt 포함
상태 저장소 확인 불가503 LESSON_ACCESS_UNAVAILABLE

409에 상태를 담는 이유는, 아동이 소켓 종료 이벤트를 놓쳐도 API 응답만으로 종료 화면으로 전환되게 하기 위함이다.

차단 지점과 우선순위

아래 경로는 전부 2026-08-28 사건에서 중단 확정 이후 통과한 것들이다.

우선경로차단 설계난이도 · 부작용
P0POST /api/validate-lessonbody의 ID를 아동 세션에 바인딩하고 DDB gate 검사. 성공 시 room·lesson·epoch가 든 단기 서명 토큰 발급중. 현재는 ID가 일치할 때만 ppi-api 인증을 선택적으로 사용. 상태 장애 시 입장 실패가 늘어 503 UX 필요
route.ts:170
P0Socket JOIN_ROOM서명 토큰과 DDB gate를 중복 게스트 정리 및 socket.join 이전에 확인중~상. 실제 룸 참여는 GUEST_REQUEST_ENTRY보다 앞선 JOIN 단계에서 일어난다
connection-handlers.ts:25
P0 방어층GUEST_REQUEST_ENTRYJOIN에서 검사했더라도 epoch 재확인. terminal이면 룸 초기화·프로필 조회 없이 종료 응답중. 현재는 먼저 socket.join하고 standalone으로도 남긴다
room-handlers.ts:69
P0POST …/12/sessions아동 인증·ID 바인딩 후, Lesson의 ACTIVE/epoch ConditionCheck와 Session Put을 하나의 DDB 트랜잭션으로상. 현재 Session Put과 Lesson summary 갱신이 분리돼 있다
sessions/route.ts:24, lesson-session.service.ts:55
P0POST /api/lesson-progresswithAuthChild 도입, body userId 무신뢰, accessState=ACTIVE AND accessEpoch=:epoch 조건부 Update중. 현재 인증·상태 검사가 전혀 없다
lesson-progress/route.ts:6
P0POST /api/livekit/call기존 아동 ID 인증 뒤 gate 검사. 발급 토큰·room metadata에 epoch 포함. 종료 시 해당 epoch의 room/participant 제거중~상. 신규 발급 차단만으로는 이미 연결된 AI 세션이 끝나지 않는다
livekit/call/route.ts:129, :212
P1GET /api/guest-data아동 인증·ID 바인딩·gate 검사 후 조회하~중. 현재 query parameter만으로 사용자·회기·활동을 반환
guest-data.service.ts:23
P1POST /api/session-logs일반 활동 로그는 gate 적용. 아동/멤버 role별 인증 추가중. 현재 POST에 인증 wrapper가 없고 GET만 멤버 인증
session-logs/route.ts:128, :242
P2lesson-logs upload-url·append신규 주기 업로드는 차단하되, 서버 발급 30~60초짜리 terminal flush token으로 마지막 버퍼 1회만 허용상. 즉시 전면 차단하면 종료 직전 진단 로그를 잃는다
append/route.ts:53, upload-url/route.ts:61
전역 middleware로 일괄 차단하면 안 된다. /api/resources/urls는 종료·강퇴 화면 자체가 캐릭터 이미지 조회에 사용한다 (components/pages/force-kicked.tsx:16). 막으면 종료 화면이 깨진다.

이미 접속한 아동의 강제 종료 — 3층 구조

종료 정확성에서 클라이언트 guestInfo, 로컬 peers 조회, 클라이언트 ACK를 모두 제거해야 한다.

  1. DDB gate가 CLOSED로 커밋된다
  2. outbox가 {roomId, status, accessEpoch, transitionId}를 Redis 채널로 발행
  3. 모든 Socket 인스턴스가 이벤트 수신
  4. 게스트는 일반 room 외에 guest:${roomId} 전용 Socket.IO room에도 참여
  5. 서버가 해당 guest room에 LESSON_TERMINATED 발송
  6. 최대 1초 동안 화면 전환 ACK 대기
  7. ACK 성공 여부와 무관하게 disconnectSockets(true)
  8. 이벤트를 놓친 인스턴스는 최대 10초 주기의 DDB backstop 검사에서 CLOSED를 발견하고 종료
  9. 그 뒤 재접속은 JOIN gate에서 거절
ACK는 사용자에게 종료 화면을 먼저 보여주기 위한 품질 신호일 뿐, 종료의 성공 기준이 아니다. Redis Pub/Sub은 유실될 수 있으므로 그것만으로 확실한 종료를 보장할 수 없다 — 빠른 이벤트 + 주기적 DDB 확인 + 재입장 gate의 세 층이 모두 필요하다.

로컬 peers 조회와 1초 stale 타이머

현재 강퇴는 로컬 Map에서 게스트를 찾고, 고정 peerId(guest-${roomId})를 1초 뒤 다시 지운다. 같은 peerId로 1초 내 재입장하면 새 연결의 transport·producer가 닫힌다.

apps/socket/src/sfu-socket/handlers/room-handlers.ts:332, :379~404 / signalingHandler.ts:151

오조작과 재개

grace period를 두면 안 된다. gate가 닫힌 뒤 몇 초간 강퇴를 미루는 설계는 이번 결함과 정확히 같은 창을 다시 만든다. "잠깐 멈춤"이 필요하면 STOPPED를 재해석하지 말고 별도 PAUSED 업무 상태를 설계한다.

STOPPED·COMPLETED·CANCELLED가 커밋된 뒤에는 모두 기존 아동 연결을 끊는다. 재개는 종료의 취소가 아니라 새 실행 세대를 여는 작업으로 취급한다.

오조작 방지는 커밋 확인 모달에서 처리한다.

추정 알려지지 않은 외부 시스템이 ppi-api 상태를 직접 변경하는지는 미확정이다. 저장소 내 알려진 상태 쓰기 경로는 수업 PUT과 관리자 class-status PATCH 두 개다 (lessons/[userId]/[index]/route.ts:245, class-mgmt/classes/[childId]/[date]/status/route.ts:16). 외부 writer가 있다면 ppi-api transactional outbox/webhook으로 DDB gate를 닫아야 한다 — 폴링만 쓰면 폴링 간격만큼 종료 허용 창이 남는다.

아동 UX

현재 구조를 재사용할 수 있다. 수업 종료는 lesson-ended, 수동 강퇴는 force-kicked이고, lesson-ended에는 재입장 버튼이 없으며 종료 상태는 terminatedRef로 sticky하게 유지된다.

상태화면재입장
COMPLETED기존 lesson-ended없음
STOPPEDlesson-stopped 또는 상태별 문구의 lesson-ended즉시 없음
CANCELLEDlesson-cancelled없음
수동 kick (상태는 PENDING)기존 force-kicked현재 "수업으로 돌아가기" 유지
재개 확인종료 화면에서 저주기 상태 확인 후 "수업이 다시 열렸어요" 버튼전체 페이지 재진입만
소켓 이벤트뿐 아니라 progress·session·LiveKit API에서 409 LESSON_TERMINATED가 와도 같은 전역 종료 reducer를 호출해야 한다. 먼저 AI·미디어·autosave를 중단한 뒤 종료 화면을 고정한다.

use-guest-page-session.ts:1882, :288 / widgets/guest/guest-layout/ui/guest-layout.tsx:286

함께 드러난 결함 3건과 통합 방식

결함내용통합
P1 · 권한HOST_FORCE_KICK에 호스트 권한 검증이 없다. 주석에 no host verification needed - any socket can kick. roomId만 알면 임의 /sfu 소켓이 아동을 강퇴할 수 있다
room-handlers.ts:334
/sfu namespace에 서명된 socket credential 도입, socket.data.principal에서 role·room 권한 획득. 클라이언트가 보낸 role 값을 권한으로 쓰지 않는다. 현재 CORS *, namespace 인증 middleware 없음
server.ts:70, connection-handlers.ts:26
P1 · stale timer강퇴 1초 정리 타이머가 peers.has(guestPeerId)만 확인해, 같은 고정 peerId로 재입장한 새 게스트를 지울 수 있다manual kick과 terminal close를 공통 terminateGuestConnection으로 통합하고, disconnect handler를 유일한 resource cleanup 소유자로. 별도 PR
P1 · terminal guard종결 상태가 실행 경계에서 강제되지 않는다하나의 PR로 끝나지 않는다. DDB gate / API enforcement / socket enforcement / 로그 drain으로 분할

단계별 PR 순서

PR내용핵심 검증
PR1DDB accessState/accessEpoch, 공통 decision service, 상태 불일치 메트릭. 아직 차단하지 않는 shadow mode상태 행렬 단위 테스트, 기존 Lesson 무필드 초기화, ppi-api/DDB 불일치 탐지
PR2전용 transition command, DDB outbox, ppi-api 동기화·재시도, 명시적 reopen. 기존 두 상태 쓰기 경로를 이 서비스로 통합ACTIVE→CLOSED 원자성, 중복 transition 멱등성, ppi-api 실패 시 CLOSED 유지, reopen 순서
PR3withAuthChild, userId 바인딩, validate·guest-data·progress·session·LiveKit gate각 route의 401·403·409·503·ACTIVE 성공 테스트. progress 조건부 업데이트와 session DDB 트랜잭션 경쟁 테스트
PR4서명된 socket credential, JOIN·GUEST_REQUEST_ENTRY gate, HOST_FORCE_KICK 권한 검사위조 role·다른 room·다른 child 거절, terminal join 거절, access service 장애 시 join fail-closed
PR5Redis 종료 이벤트, guest 전용 room, cross-instance disconnect, ACK 계측, 주기적 DDB backstop, stale timer 제거Socket 인스턴스 2개+Redis 통합 테스트 — A에서 종료하고 B의 게스트 제거, 무ACK 강제 종료, 이벤트 유실 후 backstop, 1초 내 동일 peerId 재접속 보호
PR6terminal UX, API 409 전역 처리, LiveKit 실행 room 종료COMPLETED/STOPPED/CANCELLED 화면, 수동 kick만 복귀 버튼, 실행 중 AI 세션 종료
PR7로그 terminal flush token과 지연 finalize종료 전 버퍼 1회 수용, 신규 로그 거절, 중복 flush 멱등성, 최종 archive에 tail 포함

회귀 테스트 — 이번 사건 타임라인 자동화

validate ACTIVE 성공
  → 진행자 terminal commit
  → 2초 뒤 기존 access token으로 socket JOIN
  → JOIN 거절
  → session / progress / livekit 모두 409
  → 종료 전 연결이 있었다면 cross-instance 강제 disconnect
  → reopen 후 새 epoch 토큰으로만 재입장 성공

기존 lesson-session.service.test.tslivekit-call-authorization.test.ts는 재사용 가능하지만, room·connection handler를 직접 검증하는 socket harness는 새로 필요하다.

확정과 추정의 경계

구분항목
확정validate-lesson(32.488~32.517)과 중단 PUT(32.620~32.697)이 겹치지 않는 완전 선행 — 서버 로그 실측
확정500ms sleep 미실행 (클릭 32.434 → PUT 186ms) 및 reason "complete" 강퇴 0건
확정중단 이후 세션 생성 1건, progress 갱신 9건, session-logs 95건, lesson-logs upload-url 43건, livekit/call 5건 통과. 재검증 0건
확정completionStatus가 DynamoDB 필드가 아니며 PostgreSQL이 원천
추정사건 당시 아동 단말의 WebRTC 실패 원인. 확정 범위는 "브라우저/WebRTC 런타임 계층 이상"까지이고 "단말 자체 결함"은 근거 부족. 로컬 loopback timeout은 Safari 상태 전이 지연·동시 PC 자원 고갈·페이지 런타임 정체로도 발생한다 (shared/lib/webrtc-audio-loopback.ts:64)
추정ppi-api 상태를 변경하는 외부 writer 존재 여부
철회된 초기 판정 — 초안에서 "/guest2/[roomId]가 같은 URL로 복귀해 validate-lesson을 우회한다"고 적었으나 오판이다. apps/web/app/guest2/[roomId]/page.tsx는 서버에서 무조건 /guest로 redirect한다. 재입장은 항상 validate-lesson을 탄다.

파일 · 라인 레퍼런스

역할경로
수업 종료 흐름 (guestInfo 가드 · 500ms sleep)apps/web/features/monitor/lesson-completion/model/use-lesson-completion.ts:363~415
강퇴 emit (ACK 미대기)apps/web/entities/host-socket/model/use-host-socket.ts:327
강퇴 서버 핸들러 (권한 미검증 · 1초 타이머)apps/socket/src/sfu-socket/handlers/room-handlers.ts:332, :379~404
입장 요청 host 조회 (로컬 peers 전용)apps/socket/src/sfu-socket/handlers/room-handlers.ts:246
모니터 presence 판정 (Redis 인지)apps/socket/src/sfu-socket/monitor-presence.ts:27, :68
terminal status 제외 (입장 시 1회)apps/web/app/api/validate-lesson/route.ts:44~49
상태 쓰기 (ppi-api PATCH)apps/web/app/api/lessons/[userId]/[index]/route.ts:158, :176, :245
진행률 저장 (인증 없음)apps/web/app/api/lesson-progress/route.ts:6
guestInfo 세팅/해제apps/web/entities/monitor-session/model/use-monitor-session.ts:502~518, :536, :840, :905
게스트 종료 화면 분기apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1885 / widgets/guest/guest-layout/ui/guest-layout.tsx:286~297
강퇴 화면 재입장 버튼apps/web/components/pages/force-kicked.tsx:41 / apps/web/lib/guest-utils.ts:75

관련 문서