마지막 업데이트 2026-08-31
핵심 해법은 PUT → forceKick 순서 조정이 아니다. DDB의 회기별 접근 게이트를 종료의 기준점으로 삼고, 모든 API·소켓·LiveKit 경로가 같은 accessEpoch를 검사하도록 만드는 것이다. Redis는 권위가 아니라 빠른 종료 통지와 캐시에만 쓴다.
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.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
퇴장 로직 자체는 존재한다. completeLesson → forceKickGuest("complete") → 게스트 lesson-ended(재입장 버튼 없음). 동작하려면 세 조건이 동시에 성립해야 한다.
monitorSession.guestInfo != nullpeers Map에 guest 존재 (아니면 NoGuest)이번 건은 1번에서 탈락했다. 원인은 "입장" 시점이 두 갈래로 갈리는 것이다.
아동이 말하는 "입장" 검증 통과 32.517 / 페이지 마운트 32.571 서버가 인지하는 "입장" 소켓 룸 참여 35.128 / GUEST_CONNECTED 39.256 진행자가 '중단'을 누른 시각 32.434 ← 이 순간 방에 게스트가 없었다 (직전 아동은 23.347에 강퇴돼 나갔고, 새 연결은 2.7초 뒤에 들어온다)
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
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 상태 동기화 재시도
accessState: "ACTIVE" | "CLOSED"; accessEpoch: number; terminalStatus?: 100 | 200 | 300; accessChangedAt: number; transitionId: string; transitionActor: { type: "member" | "admin"; id: string }; businessSyncState: "PENDING" | "SYNCED" | "FAILED";
별도 테이블이 아니라 기존 Lesson 항목에 두는 이유:
(userId, index) Lesson 레코드가 필요하다 — 없으면 검증이 LESSON_NOT_PROVISIONED로 실패한다completionStatus와 의미가 분리돼 기존 PostgreSQL 업무 규칙이 보존된다lesson-access:{userId}:{index}는 DDB 값의 projection만 갖는다. 값이 없거나 버전이 의심스러우면 DDB를 다시 읽어야 하며, Redis 장애 시 ACTIVE로 간주하면 안 된다.
터미널 상태 변경은 일반 Lesson PUT이 아니라 전용 transition command로 분리한다.
accessState=CLOSED, accessEpoch+1, outbox를 기록accessClosed:true, businessSyncState 반환현재 completeLesson의 guestInfo 검사·클라이언트 강퇴·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_TERMINATED — terminalStatus·accessEpoch·terminalAt 포함 |
| 상태 저장소 확인 불가 | 503 LESSON_ACCESS_UNAVAILABLE |
409에 상태를 담는 이유는, 아동이 소켓 종료 이벤트를 놓쳐도 API 응답만으로 종료 화면으로 전환되게 하기 위함이다.
아래 경로는 전부 2026-08-28 사건에서 중단 확정 이후 통과한 것들이다.
| 우선 | 경로 | 차단 설계 | 난이도 · 부작용 |
|---|---|---|---|
| P0 | POST /api/validate-lesson | body의 ID를 아동 세션에 바인딩하고 DDB gate 검사. 성공 시 room·lesson·epoch가 든 단기 서명 토큰 발급 | 중. 현재는 ID가 일치할 때만 ppi-api 인증을 선택적으로 사용. 상태 장애 시 입장 실패가 늘어 503 UX 필요 route.ts:170 |
| P0 | Socket JOIN_ROOM | 서명 토큰과 DDB gate를 중복 게스트 정리 및 socket.join 이전에 확인 | 중~상. 실제 룸 참여는 GUEST_REQUEST_ENTRY보다 앞선 JOIN 단계에서 일어난다connection-handlers.ts:25 |
| P0 방어층 | GUEST_REQUEST_ENTRY | JOIN에서 검사했더라도 epoch 재확인. terminal이면 룸 초기화·프로필 조회 없이 종료 응답 | 중. 현재는 먼저 socket.join하고 standalone으로도 남긴다room-handlers.ts:69 |
| P0 | POST …/12/sessions | 아동 인증·ID 바인딩 후, Lesson의 ACTIVE/epoch ConditionCheck와 Session Put을 하나의 DDB 트랜잭션으로 | 상. 현재 Session Put과 Lesson summary 갱신이 분리돼 있다 sessions/route.ts:24, lesson-session.service.ts:55 |
| P0 | POST /api/lesson-progress | withAuthChild 도입, body userId 무신뢰, accessState=ACTIVE AND accessEpoch=:epoch 조건부 Update | 중. 현재 인증·상태 검사가 전혀 없다 lesson-progress/route.ts:6 |
| P0 | POST /api/livekit/call | 기존 아동 ID 인증 뒤 gate 검사. 발급 토큰·room metadata에 epoch 포함. 종료 시 해당 epoch의 room/participant 제거 | 중~상. 신규 발급 차단만으로는 이미 연결된 AI 세션이 끝나지 않는다 livekit/call/route.ts:129, :212 |
| P1 | GET /api/guest-data | 아동 인증·ID 바인딩·gate 검사 후 조회 | 하~중. 현재 query parameter만으로 사용자·회기·활동을 반환 guest-data.service.ts:23 |
| P1 | POST /api/session-logs | 일반 활동 로그는 gate 적용. 아동/멤버 role별 인증 추가 | 중. 현재 POST에 인증 wrapper가 없고 GET만 멤버 인증 session-logs/route.ts:128, :242 |
| P2 | lesson-logs upload-url·append | 신규 주기 업로드는 차단하되, 서버 발급 30~60초짜리 terminal flush token으로 마지막 버퍼 1회만 허용 | 상. 즉시 전면 차단하면 종료 직전 진단 로그를 잃는다 append/route.ts:53, upload-url/route.ts:61 |
/api/resources/urls는 종료·강퇴 화면 자체가 캐릭터 이미지 조회에 사용한다 (components/pages/force-kicked.tsx:16). 막으면 종료 화면이 깨진다.
종료 정확성에서 클라이언트 guestInfo, 로컬 peers 조회, 클라이언트 ACK를 모두 제거해야 한다.
CLOSED로 커밋된다{roomId, status, accessEpoch, transitionId}를 Redis 채널로 발행guest:${roomId} 전용 Socket.IO room에도 참여LESSON_TERMINATED 발송disconnectSockets(true)현재 강퇴는 로컬 Map에서 게스트를 찾고, 고정 peerId(guest-${roomId})를 1초 뒤 다시 지운다. 같은 peerId로 1초 내 재입장하면 새 연결의 transport·producer가 닫힌다.
{peerId, socketId, connectionGeneration}이 모두 일치할 때만 실행apps/socket/src/sfu-socket/handlers/room-handlers.ts:332, :379~404 / signalingHandler.ts:151
PAUSED 업무 상태를 설계한다.
STOPPED·COMPLETED·CANCELLED가 커밋된 뒤에는 모두 기존 아동 연결을 끊는다. 재개는 종료의 취소가 아니라 새 실행 세대를 여는 작업으로 취급한다.
accessEpoch+1/guest → validate-lesson → 새 세션으로 재입장. 자동 재접속 없음오조작 방지는 커밋 전 확인 모달에서 처리한다.
현재 구조를 재사용할 수 있다. 수업 종료는 lesson-ended, 수동 강퇴는 force-kicked이고, lesson-ended에는 재입장 버튼이 없으며 종료 상태는 terminatedRef로 sticky하게 유지된다.
| 상태 | 화면 | 재입장 |
|---|---|---|
| COMPLETED | 기존 lesson-ended | 없음 |
| STOPPED | 새 lesson-stopped 또는 상태별 문구의 lesson-ended | 즉시 없음 |
| CANCELLED | lesson-cancelled | 없음 |
| 수동 kick (상태는 PENDING) | 기존 force-kicked | 현재 "수업으로 돌아가기" 유지 |
| 재개 확인 | 종료 화면에서 저주기 상태 확인 후 "수업이 다시 열렸어요" 버튼 | 전체 페이지 재진입만 |
409 LESSON_TERMINATED가 와도 같은 전역 종료 reducer를 호출해야 한다. 먼저 AI·미디어·autosave를 중단한 뒤 종료 화면을 고정한다.
use-guest-page-session.ts:1882, :288 / widgets/guest/guest-layout/ui/guest-layout.tsx:286
| 결함 | 내용 | 통합 |
|---|---|---|
| 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 | 내용 | 핵심 검증 |
|---|---|---|
| PR1 | DDB 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 순서 |
| PR3 | withAuthChild, 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 |
| PR5 | Redis 종료 이벤트, guest 전용 room, cross-instance disconnect, ACK 계측, 주기적 DDB backstop, stale timer 제거 | Socket 인스턴스 2개+Redis 통합 테스트 — A에서 종료하고 B의 게스트 제거, 무ACK 강제 종료, 이벤트 유실 후 backstop, 1초 내 동일 peerId 재접속 보호 |
| PR6 | terminal 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.ts와 livekit-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 |
validate-lesson 게이트의 현재 동작hasActiveMonitor가 focus 진행자를 뜻하지 않는 이유completionStatus 업무 상태 규칙