긴급종료 콘텐츠 흐름 추가 (PPI-1193)

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

ff31ff24 charles-na · 2026-08-13 Feature 28 files +1,563 −200

PR #984 (feat, 28 files, +1,563/−200, 브랜치 PPI-1193develop). 수업 시간이 다 됐을 때 진행자가 "시간종료"를 누르면 진입하는 전용 긴급종료 콘텐츠 흐름을 추가한다. 기존에는 제목에 "체크아웃"이 포함된 활동을 찾아 점프하는 휴리스틱이었고, 이를 명시적 isEmergencyExit 플래그로 표시한 연속 콘텐츠 묶음 + 메시지 탭에서 관리하는 전역 종료 조건 + jumpConfig.endLesson에 의한 회기 자동 완료로 대체했다.

리뷰 시 가장 먼저 볼 지점lib/spare-content-utils.tsfindNextStepSkippingSpare()다. "정상 흐름에서는 묶음째 건너뛰고, 진입 후에는 묶음 안에서만 순차 진행하다 멈춘다"는 이 기능의 모든 판정이 이 한 함수에 들어 있고, 5번째 파라미터 allowExitEmergencyFlow의 기본값(false)이 호출부마다 다른 동작을 만든다.

무엇이, 왜 바뀌었나

기존 시간종료는 activities.find(a => a.title?.includes("체크아웃"))로 목적지를 찾았다. 제목 규칙에 의존하므로 회기마다 마무리 콘텐츠를 다르게 구성할 수 없고, 마무리 후 회기를 끝내는 것도 진행자 수동 조작이었다. 이번 변경은 그 두 가지를 데이터 모델로 끌어올린다.

흐름 — 시간종료 클릭에서 회기 완료까지

1

진행자가 "시간종료" 클릭 → 확인 모달

features/session/ui/session-card.tsx app/monitor-dashboard/[group]/[roomId]/page.tsx

타이머 자동 발동은 없다. 두 화면 모두 확인 모달을 거쳐 endConversation()을 호출한다.

2

소켓 중계 mode: "checkout"

apps/socket · avatar-handlers.ts

방 참여자·host/monitor 여부를 검증한 뒤 게스트에게 전달. 페이로드 계약은 변경 없음.

3

게스트가 목적지 결정 + {{긴급종료}} 발송

entities/guest-page-session/model/use-guest-page-session.ts

findFirstEmergencyExitActivityIndex()가 있으면 그쪽, 없으면 종전 체크아웃 탐색. autoFinish 조건을 전역 조건으로 교체한다.

4

핑퐁이 마무리 발화 → 조건 매칭 → 긴급종료 진입

lib/spare-content-utils.ts · findNextStepSkippingSpare()

묶음 내부에서만 순차 진행. 분기 설정으로 외부에서 진입하는 경로는 차단된다.

5

마지막 스텝 통과 → 회기 COMPLETED

components/pages/host.tsx PUT /api/lessons/{userId}/{index}

isFinalEmergencyExitStep()이 true면 진행자 조작 없이 completeLesson(ClassStatus.COMPLETED).

시간종료 클릭 → 확인 모달 → socket(checkout) → 목적지 = 첫 긴급종료 콘텐츠 → sendTextMessage("{{긴급종료}}") → 핑퐁이 마무리 발화 → 전역 조건(그룹 내 키워드 2개 이상) 매칭 → 긴급종료 묶음 진입 → 순차 진행 → 마지막 스텝(endLesson=true) → 회기 COMPLETED

코드로 보는 핵심 지점

1. 묶음째 스킵 vs 묶음 안 순차 — 이 기능의 심장

같은 함수가 호출부에 따라 정반대로 동작한다. allowExitEmergencyFlow는 기본값이 false라, 이 값을 넘기지 않는 게스트 자동 진행(use-guest-page-session.ts:2666)은 긴급종료 묶음을 빠져나가지 못하고, true를 명시한 진행자 수동 이동(use-advance-step.ts:141)만 탈출할 수 있다.

apps/web/lib/spare-content-utils.ts export function findNextStepSkippingSpare( activities, currentActivityIndex, currentStepIndex, cumulativeElapsedSeconds, + allowExitEmergencyFlow = false, ) { while (candidateIndex < activities.length) { + if (candidate.isEmergencyExit) { + if (currentActivity.isEmergencyExit) { // 묶음 안 → 순차 진행 + if (candidate.steps.length > 0) return { activityIndex: candidateIndex, stepIndex: 0 }; + candidateIndex++; continue; + } + while (activities[candidateIndex]?.isEmergencyExit) candidateIndex++; // 정상 흐름 → 묶음째 스킵 + continue; + } + + const followsConfiguredSequence = + currentActivity.jumpConfig?.jumpMode === "default" && + currentActivity.jumpConfig.defaultNextActivityIndex == null && + currentActivity.jumpConfig.endLesson !== true; + if (currentActivity.isEmergencyExit && !allowExitEmergencyFlow && !followsConfiguredSequence) { + return null; // 긴급종료 흐름 종료 — 더 진행하지 않음 + }

followsConfiguredSequence가 예외 통로다. 회기 템플릿에서 마지막 긴급종료 콘텐츠에 "순차 진행"을 남겨두면 endLessonundefined라 이 조건이 참이 되고, 긴급종료가 끝난 뒤 일반 콘텐츠로 계속 흘러간다. 설계상 의도된 동작이지만 드롭다운 기본값이 "순차 진행"이라 설정 함정이 되기 쉽다(아래 관전 포인트 2번).

2. 시간종료 목적지가 제목 휴리스틱에서 플래그로

apps/web/entities/guest-page-session/model/use-guest-page-session.ts - const checkoutIdx = mode === "checkout" + const emergencyExitIdx = mode === "checkout" + ? findFirstEmergencyExitActivityIndex(activitiesRef.current) : null; + const checkoutIdx = mode === "checkout" && emergencyExitIdx == null ? activitiesRef.current.findIndex( (a, idx) => idx !== currentIdx && a.title?.includes("체크아웃")) : -1; + const hasEmergencyExit = emergencyExitIdx != null; + const keywords = hasEmergencyExit + ? resolveEmergencyExitKeywords( + activitiesRef.current[emergencyExitIdx]?.emergencyExitConditions, + endConversationTarget.keywords) // 조건 없으면 스텝 종료 문구로 폴백 + : endConversationTarget.keywords; if (mode === "checkout") { - session.sendTextMessage("대화종료"); + session.sendTextMessage(hasEmergencyExit ? "{{긴급종료}}" : "대화종료"); }

기존 체크아웃 탐색이 emergencyExitIdx == null 가드 뒤로 밀렸을 뿐 삭제되지 않았다는 점이 롤아웃 안전성의 핵심이다. emergencyExitConditions는 DB에 저장되지 않고 getActivities() 응답에만 주입되는 파생 필드다.

3. 회기 자동 완료 — ref 우회 참조

completeLessonhandleNextStep보다 아래에서 정의되므로 ref로 우회 참조하고, in-progress ref로 중복 호출을 막는다.

apps/web/components/pages/host.tsx - const handleNextStep = useCallback((triggerText?: string) => { + const handleNextStep = useCallback((triggerText?: string, allowExitEmergencyFlow = true) => { + if (currentIndex !== -1 && !allowExitEmergencyFlow) { + if (isFinalEmergencyExitStep(activities, currentActivityIndex, currentRow.stepIndex)) { + setAutoConfirm(false); + if (!emergencyLessonCompletionInProgressRef.current) { + void completeEmergencyLessonRef.current?.(ClassStatus.COMPLETED) + .finally(() => { emergencyLessonCompletionInProgressRef.current = false; }); + } + trackEvent("Host:EmergencyExitLessonCompleted", { ... }); + return; + } + } ... - handleNextStep(triggerText); + handleNextStep(triggerText, false); // 자동 전환 경로만 묶음에 갇힌다

4. 시간종료 중복 방어 제거 — 이번 PR에서 가장 파급이 넓은 삭제

같은 종료 액션을 다시 눌러 재시도할 수 있게 하려고 송신측 dedup을 걷어냈다. 삭제된 주석 자체가 이 가드가 막던 상황을 적어두고 있다.

apps/web/entities/monitor-controls/model/use-guest-controls.ts -const END_CONVERSATION_DEDUP_WINDOW_MS = 1500; -const lastEndConversationEmitAtByKey = new Map<string, number>(); - // 시간종료는 여러 화면에서 같은 액션이 겹쳐 발사될 수 있어 짧게 중복을 막는다. - if (mode === "checkout") { - if (now - lastAt < END_CONVERSATION_DEDUP_WINDOW_MS) return true; - lastEndConversationEmitAtByKey.set(dedupKey, now); - } apps/web/entities/guest-page-session/model/use-guest-page-session.ts (수신측) - const isNextActivityRetry = mode === "next-activity" && pendingMode === "next-activity"; + const isSameModeRetry = pendingMode === mode; - if (pendingMode != null && !isNextActivityRetry) { + if (pendingMode != null && !isSameModeRetry) {

레이어별 변경 요약

레이어파일핵심 변경
타입types/db/emergency-exit-condition.types.ts (신규)전역 조건 예약키 상수 + EmergencyExitConditionSettings
타입types/db/activity.types.ts, activity-template.types.ts, lesson-template.types.tsisEmergencyExit, emergencyExitConditions(파생), jumpConfig.endLesson
APIapp/api/emergency-exit-conditions/route.ts (신규 68줄)GET(member)/PUT(admin·dev). normalizePhrases()가 공백·중복 제거 후 하위 조건 2개 미만이면 400
APIapp/api/activity-templates/route.ts, [id]/route.ts예비·긴급종료 동시 설정 시 400 ACTIVITY_TEMPLATE_CONTENT_ROLE_CONFLICT
DBlib/db-queries.ts전역 조건 get/upsert, attachEmergencyExitConditions()(실패 시 warn 후 통과), set/update 4곳에서 역할 배타 정규화
로직lib/spare-content-utils.ts묶음 스킵·순차 진행 판정 + isFinalEmergencyExitStep, findFirstEmergencyExitActivityIndex, isLessonEndActivity, 점프 타깃 차단
로직lib/conversation-end.tsresolveEmergencyExitKeywords() — 무효 조건 걸러내고 비면 스텝 종료 문구로 폴백
로직lib/lesson-utils.ts템플릿 → 활동 복사 시 isEmergencyExit·endLesson 전파
세션entities/guest-page-session/.../use-guest-page-session.ts시간종료 목적지 전환, {{긴급종료}} 발송, same-mode 재시도 허용
세션entities/monitor-controls/.../use-guest-controls.tscheckout dedup(1.5초) 제거
세션features/session/model/use-advance-step.ts긴급종료 중 직접 타깃 지정을 순차 진행으로 대체, 외부에서의 진입 거부
진행자components/pages/host.tsx자동 전환 경로 분리(allowExitEmergencyFlow), 마지막 스텝에서 회기 COMPLETED
UIfeatures/session/ui/session-progress.tsxNext 계산을 getNextStepInfo()에 위임, 예비/긴급종료/종료 배지 공통 컴포넌트
UIcomponents/sections/step-table.tsx, monitor-step-table.tsx긴급종료 행 클릭 잠금 + 툴팁, 배지 3종, "→ 종료"·"→ 순차 진행" 표기
어드민components/sections/emergency-exit-conditions-section.tsx (신규 206줄)조건 그룹 추가·삭제, 하위 조건 칩 입력, 2개 미만 저장 차단
어드민components/sections/lesson-template-form.tsx"종료"(endLesson) 옵션은 마지막 긴급종료 콘텐츠에만 노출, 연속 배치 검증
어드민components/sections/activity-form.tsx, activity-template-form.tsx상호 배타 체크박스. 활동 수정 모드에서는 역할 읽기 전용(ff31ff24)
테스트lib/spare-content-utils.test.ts(신규 276줄), conversation-end.test.mjs스킵·순차·종료 판정 및 조건 폴백 케이스

리뷰 관전 포인트

동작 확인

시간종료 중복 발사 방어가 사라졌다. 삭제된 주석이 "여러 화면에서 같은 액션이 겹쳐 발사될 수 있어"라고 명시하는데, 카드뷰와 1:1 모니터 양쪽에 같은 버튼이 있다. 확인 모달의 ConfirmAlertdisabled prop을 지원하지만 호출부 두 곳(session-card.tsx:1313, monitor-dashboard/[group]/[roomId]/page.tsx:2154)에서 넘기지 않아, 모달이 닫히기 전 연타하면 {{긴급종료}}가 2회 발송돼 마무리 발화가 겹칠 수 있다. 진행자 중복 접속은 이번 범위 밖으로 결정됐고, 연타 케이스는 미해결로 남았다.

설정 함정

"종료"를 고르지 않으면 회기가 끝나지 않는다. 회기 템플릿 저장 시 마지막 긴급종료 콘텐츠에 jumpConfig가 자동 주입되는데(lesson-template-form.tsx:537), 드롭다운 기본값 "순차 진행"을 그대로 두면 endLessonundefined가 되어 isFinalEmergencyExitStep이 false → 회기 미종료 + 일반 콘텐츠로 이어짐. 설계상 의도된 동작(긴급종료 뒤에 콘텐츠를 이어붙일 수 있게 열어둠)이지만, 기본값이 함정 쪽이라 운영 가이드가 필요하다.

검증 누락

연속 배치 검증이 회기 템플릿에만 있다. lesson-template-form.tsx:131이 "긴급종료 콘텐츠는 서로 떨어지지 않게 연달아 배치해주세요"를 강제하지만, 활동을 직접 추가하거나 activity-table에서 순서를 바꾸는 경로에는 같은 검증이 없다. 묶음이 쪼개지면 정상 흐름 스킵은 각 연속 구간별로만 동작하고, 진입 후 순차 진행은 중간의 일반 활동에서 emergency-exit-flow-completed로 멈춰 일부만 재생된다. ff31ff24에서 활동 수정 폼의 역할 체크박스를 읽기 전용으로 잠가 일부 해소했으나, 생성과 재정렬 경로는 남아 있다.

UX 불일치

확인 모달 문구가 옛 동작을 설명한다. 두 모달 모두 "현재 활동을 마무리하고 체크아웃으로 넘어갈까요?"인데 실제 목적지는 긴급종료 콘텐츠다. 기능에는 영향이 없지만 진행자가 보는 안내와 실동작이 어긋난다.

영향 범위

web 단독, 소켓 계약 불변. apps/socket·stt·terraform·워크플로우 변경이 0건이라 배포 시차 불일치가 없다. host-session-end-conversation의 페이로드도 그대로고 송신측 dedup만 빠졌다. 긴급종료 콘텐츠가 없는 기존 회기는 체크아웃 탐색 폴백으로 종전과 동일하게 동작한다.

영향 없음 확인

예약키를 얹은 ppi-chat-preset 테이블은 getChatPresets()userId = GLOBAL_PRESET_KEY Query라 새 항목이 프리셋 목록에 새지 않는다. isSpare를 항상 명시 기록하도록 바뀌었지만 이 속성으로 필터하는 소비처는 없다. completeLesson이 재호출돼도 checkAutoCreate가 기존 회차 확인 다이얼로그를 거치므로 회차 중복 생성은 발생하지 않는다.

표시 로직

session-progress.tsx의 Next 표시가 "현재 인덱스 +1" 자체 계산에서 getNextStepInfo() 위임으로 바뀌었다. 예비 콘텐츠 스킵까지 반영되므로 긴급종료와 무관한 기존 회기의 Next 표시도 달라진다. 다만 useMemo 의존성이 [activities, currentStep, getNextStepInfo]라 누적 경과시간이 흐르는 동안에는 갱신되지 않는다(표시 한정).

테스트

신규 테스트 2종(spare-content-utils.test.ts 276줄, conversation-end.test.mjs)은 package.json 스크립트·CI에 연결돼 있지 않아 자동 실행되지 않는다(리포 전반 관행과 동일). 로컬 실행은 통과 확인됨 — tsx --test lib/spare-content-utils.test.ts pass 1/fail 0.

미확인 — 실기기·실세션 필요

관련 문서