PPI-1267 좀비 녹음세션 반복
— 수정 설계·구현과 추론 과정 구현 완료 · PR #1060
마지막 업데이트 2026-09-12
결론
JOIN_ROOM)이 부르는 소유권 검증 API에 수업 예정시각+1시간 hard-cut 체크를 한 줄 추가하고, 검증 실패 시(수업시간 만료 포함) 게스트 소켓에 GUEST_FORCE_KICKED를 emit해 재접속 자체를 끊었다. 파일 2개, +63/-5로 끝난다. 처음에 설계·구현했던 "진행자 없는 방 녹음 억제"와 "게스트 유휴 3시간 타임아웃"은 전부 롤백했다 — 아래 왜 버렸는지 참고.이 문서는 이전 버전(모니터 게이트 + idle 타임아웃 버전)을 전면 교체한 것이다. 코드가 여러 번 방향을 바꿨고, 그 판단 과정 자체가 재사용 가치가 있어 아래에 순서대로 남긴다.
최종 인과 다이어그램
방치 탭 재접속(socket.io 자동 재연결 또는 새로고침, ~16분 주기 관측) → 새 소켓 "connect" 이벤트 → joinRoom() → 서버 JOIN_ROOM → validateLessonSessionOwner → /api/internal/.../validate-owner 기존: 소유권(userId·roomId·currentSessionId 일치)만 확인 — 시간 무관 신규: 소유권 통과 후 plannedStartTime+1h(hard cut) 재검증 → 예정시각+1시간 초과 시 검증 실패 → socket.emit(GUEST_FORCE_KICKED, {reason:"complete"}) + JOIN_ROOM 응답 실패 → 클라이언트 layoutMode "lesson-ended" (isErrorLayoutMode, sticky) → 재접속 자체 차단 → "오늘도 재밌었어!" 화면, 이후 재접속 없음 (2.5시간 무한 반복 종료)
왜 처음 설계를 버렸는가 — 판단 전환점
원인 문서(이전 분석)는 "① 진행자 없는 방 녹음 억제, ② 게스트 유휴 타임아웃, ③ 좀비 stop 순서 교정" 3종을 제안했고, 처음엔 이 셋을 그대로 구현해 코드리뷰까지 통과시켰다(P1 1건 수정, P2 4건 잔여로 보고). 그런데 구현을 설명하는 과정에서 사용자가 던진 질문 하나가 훨씬 단순한 근본 수정 지점을 드러냈다.
"재접속시 수업시간 아니면 입장 불가한데, 어떻게 입장된거야?"
이 질문을 좇아 코드를 다시 추적한 결과: 수업시간 hard-cut 검증(validateLessonTime)은 웹 HTTP 층의 /api/validate-lesson에만 있었다. 이건 전화번호+PIN으로 최초 입장할 때 딱 한 번 호출된다. 반면 socket.io 층의 재접속 경로(JOIN_ROOM → validateLessonSessionOwner)는 "이 lessonSessionId가 발급된 게 맞는가"만 확인하고 시간은 아예 보지 않았다. 원인 문서의 로그 팩트("정식 입장은 8/29 11:42 1회뿐, 이후 9/1까지 재입장 없이 JOIN_ROOM만 반복")가 정확히 이 구조를 가리키고 있었는데, 처음 설계 때는 이 구멍을 메우는 대신 그 결과(빈 녹음·좀비 ERROR)만 막으려 했던 것이다.
"그럼 소켓 재접속시에도 시간 검증 추가하면 좀비 세션 간단하게 막을 수 있는거 아냐?"
맞았다. validateLessonSessionOwner가 이미 부르는 내부 API(validate-owner/route.ts)가 getLesson으로 lesson 레코드를 이미 가져오고 있었고, 그 레코드엔 plannedStartTime이 있었다. 새 API 호출 없이 기존 호출 한 곳에 검증 한 줄만 추가하면 되는 구조였다. 레거시(isLegacyMode, 진행자가 수동 시작하는 시간 미고정 수업)는 이번 범위에서 제외하기로 사용자가 확정했다.
"이 코드 다 수정 필요한거야?"
시간 게이트 하나로 무한 반복 자체가 끊기므로, 앞서 구현한 ①(모니터 게이트 녹음 억제)③(좀비 stop 순서/discardIfEmpty)은 "부가 개선"일 뿐 이번 티켓 해결의 필수 조건이 아니었다. 사용자가 최소화를 선택해 ①③과 idle 타임아웃(②) 전부 롤백하고 시간 게이트 2파일만 남겼다.
최종 구현 — 무엇을, 왜 그렇게
1. validate-owner/route.ts — hard-cut 검증 추가
validateCurrentLessonSessionOwner는 원래 소유권 일치만 boolean으로 반환하는 한 줄짜리 함수였다. 소유권 통과 후 plannedStartTime이 문자열이면 validateLessonTime(plannedStartTime, now)의 hard-cut(예정시각+1시간) 결과를 그대로 반환하도록 확장했다(route.ts:103-119). 레코드에 값이 없으면(방어적 타입) 기존처럼 통과시킨다.
알려진 한계(고의로 남김): plannedStartTime이 파싱 불가능한 문자열이면 validateLessonTime 내부의 모든 Date 비교가 NaN이 되어 결국 통과(isValid:true) 처리된다. 이건 이번에 새로 만든 위험이 아니라 최초 입장 게이트(/api/validate-lesson)도 이미 똑같이 신뢰하던 동작이라, 코드리뷰에서 Suggestion으로만 기록하고 손대지 않았다.
2. connection-handlers.ts — force-kick 2곳
JOIN_ROOM 안에는 소유권 검증이 두 번 있다. 첫 번째(694번 줄 부근, JOIN_ROOM 초입)가 실패하면 예전엔 {success:false, error:"invalid lesson session owner"} ack만 보냈다. 이것만으론 안 된다 — socket.io는 ack 실패와 무관하게 계속 재연결을 시도하므로, 거부만 하면 거부 자체가 무한 반복된다. 그래서 응답 전에 socket.emit(GUEST_FORCE_KICKED, {reason:"complete"})를 추가해(connection-handlers.ts:703) 클라이언트를 layoutMode:"lesson-ended"(terminal, sticky)로 보내 재접속 자체를 막았다.
코드리뷰에서 찾은 두 번째 구멍: JOIN_ROOM 안에는 router 준비를 기다리는 동안 소유권을 한 번 더 재확인하는 두 번째 검증(stillOwnsSession, 961번 줄 부근)이 있는데, 이 경로는 실패해도 첫 번째와 똑같은 에러 문구를 클라이언트에 보내면서도 force-kick이 빠져 있었다. 두 검증 사이의 좁은 시간 창(router 생성 대기 중)에 수업시간이 만료되면 이 경로를 타는데, 그러면 여전히 재접속 루프가 안 끊길 수 있었다. 동일하게 force-kick을 추가해(connection-handlers.ts:971) 두 경로 모두 같은 결과를 보장했다.
reason을 "complete"로 재사용한 이유
강제퇴장 사유는 원래 "kick"(관리자 강퇴, "잠깐 준비하고 왔어..." 화면)과 "complete"(수업 정상 종료, "오늘도 재밌었어!" 화면) 둘뿐이었다. 새 사유 타입을 shared 패키지에 추가하는 안(idle_timeout)도 한때 구현했지만, 최소화 결정 이후엔 완전히 걷어내고 기존 "complete"를 그대로 재사용했다 — 새 타입 없이도 "정상 종료처럼 보이는 화면"이 이 상황에 가장 자연스러웠고, shared 타입·웹 훅 타입을 건드릴 필요가 없어졌다.
기각된 접근 (구현했다가 전부 롤백)
아래 셋은 원인 문서의 제안대로 실제 구현·테스트·코드리뷰까지 마쳤던 버전이다. 시간 게이트로 방향을 바꾸며 모두 develop 상태로 되돌렸다. 향후 비슷한 문제에서 재검토할 수 있도록 판단 근거만 남긴다.
| 항목 | 내용 | 롤백 이유 |
|---|---|---|
| ① 모니터 게이트 녹음 억제 | recording-autostart.ts 신규 — 진행자(모니터 claim) 생존 시에만 녹음 자동시작 허용, presence 리스너로 늦은 접속 보완 | 시간 게이트가 재접속 자체를 끊으므로 불필요. 리뷰에서도 "카드뷰 탭 상시 개방 시 게이트 무력화"(P2) 지적됨 |
| ② 게스트 유휴 타임아웃 | guest-idle-timeout.ts 신규 — 발화 시작 신호 3시간 부재 시 서버 5분 스캔이 강제퇴장 | 최대 3시간 지연 + idle Map이 disconnect에서 정리 안 되는 결함(P2)까지 있었는데, 시간 게이트는 다음 재접속(~16분 주기)에서 즉시 끊어 훨씬 빠르고 결함도 없음 |
| ③ 좀비 stop 순서·discardIfEmpty | recordingManager.ts — 좀비 stop을 stale 파일 삭제보다 먼저 await, 빈 녹음은 warn 폐기 | 시간 게이트로 애초에 방치 사이클 자체가 짧아져(최대 ~1h16m) 이 안전망의 실효성이 낮아짐. 일반적 가치는 있으나 이번 범위 밖 |
변경 위치 (최종)
| 파일 | 역할 |
|---|---|
apps/web/app/api/internal/v2/lesson-sessions/validate-owner/route.ts | validateLessonTime import, getLesson 반환 타입에 plannedStartTime 추가, validateCurrentLessonSessionOwner에 hard-cut 분기(103-119번 줄) |
apps/socket/src/sfu-socket/handlers/connection-handlers.ts | 소유권 검증 실패 시 GUEST_FORCE_KICKED emit — 첫 검증(703번 줄), router 대기 중 재검증(971번 줄) 두 곳 |
apps/web/.../validate-owner/route.test.ts | hard-cut 만료/유효 케이스 2건 신규 |
apps/socket/test/connection-handlers.guest-delivery-lease.test.ts | force-kick 신규 테스트 1건 + 두 번째 경로용 기존 테스트 확장 1건 |
최종 diff: 4개 파일, +94/-5. 커밋 0e962e33 단일 커밋, PR #1060 (web, socket 라벨).
검증
| 항목 | 결과 |
|---|---|
웹 route.test.ts (hard-cut 만료/유효 2건 포함) | 6건 통과 |
소켓 connection-handlers.guest-delivery-lease.test.ts | 64건 통과 |
| 소켓 전체 vitest | 17건 실패는 develop과 동일한 기존 실패(이번 diff 무관, router-manager-guest-state·time-end-request-* 등) |
| 소켓 tsc · prettier | 통과 |
- 정상 수업 중 재접속(네트워크 순단 등)이 여전히 문제없이 되는지 — 예정시각+1시간 이내는 영향 없어야 함.
- 방치 재현: 테스트 lesson의
plannedStartTime을 1시간+α 과거로 수동 조정한 뒤, 진행 중이던/client-guest/session페이지를 새로고침(F5). 이 페이지는sessionStorage의 진입 플래그만 확인하고/api/validate-lesson(최초 입장 시간 게이트)을 다시 타지 않으며, 세션 생성 API에도 시간 체크가 없어 새로고침이 정확히 이번에 고친JOIN_ROOM경로만 거치게 만든다. 새 소켓의connect이벤트가joinRoom()을 호출하는 구조라 socket.io 자동 재연결과 동일한 코드 경로다. - 예상 결과: 재입장 실패 +
GUEST_FORCE_KICKED{reason:"complete"}수신 → "오늘도 재밌었어!" 화면(재입장 버튼 없음), 이후 재시도 없음. 첫 새로고침 직후lessonSessionId가 아직 없어 한 번은 통과할 수 있으니 재현 안 되면 한 번 더 새로고침. - 배포 후 Loki에서
Zombie session detected·Failed to force-stop zombie session빈도 감소 확인.
관련 문서
- 좀비 녹음세션 반복 — 방치된 게스트 페이지 며칠 상주 원인 분석 — 이 수정의 출발점이자, 최종안이 아닌 최초 제안 3종이 실린 문서
- 수업 중단 후 아동 재입장 — terminal status 서버 게이트 전환 설계 — 같은 계열의 서버 주도 재입장 게이트 논의 (PPI-1250)