아동 활동 건너뛰기 (템플릿 허용 설정 → isSkippable) — PPI-1185 신규 기능코드레벨

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

작성일: 2026-08-12 대상: 개발자 — 아동이 활동을 직접 넘기는 경로 파악 커밋: 8b580fcda0ff593d · PR #981 (이전 #970은 브랜치 교체로 닫힘) 갱신: 2026-08-13 — 연타 잠금 조기 해제 사이드 이펙트 수정(a0ff593d) 반영
💬 대화로 먼저 이해하기 — "스탬프 투어 부스" 비유 (비개발자·처음 읽는 사람용)
Q이번에 뭐가 생긴 건가요?
A수업을 스탬프 투어라고 하면, 활동(Activity)이 각 부스예요. 지금까지 아동은 부스를 순서대로 다 거쳐야 했는데, 특정 부스에만 "다음 부스로" 문을 달아 아동이 스스로 넘어갈 수 있게 했습니다.
Q문을 어디에 달지는 누가 정하나요?
A투어 설계도(회기 템플릿)에서 정합니다. 설계도에 "몇 번째 부스에 문을 달지"를 스티커로 붙여두는 방식이라, 저장되는 값은 부스 이름이 아니라 몇 번째인지(인덱스)예요. 관리 화면의 "아동 넘기기 허용" 체크박스가 그 스티커입니다.
Q설계도의 스티커가 실제 수업에는 어떻게 전달되죠?
A수업을 만들 때 설계도를 복사해서 오늘 쓸 부스 안내판을 찍어냅니다. 이때 스티커가 안내판에 도장(isSkippable)으로 눌려요. 그래서 이미 만들어진 수업의 안내판은 설계도를 나중에 고쳐도 바뀌지 않습니다 — 이게 가장 헷갈리는 지점이에요.
Q진행자는 이걸 어떻게 알 수 있나요?
A진행자 상황판(카드뷰·스텝 표)에 파란 "아동 넘기기 가능" 배지가 뜹니다. 이게 없으면 아동이 갑자기 활동을 넘겼을 때 진행자가 오작동으로 오해하게 되니까요.
Q수정할 때 조심할 곳은요?
A스티커가 "몇 번째"로 저장되니, 설계도에서 부스를 지우거나 순서를 바꾸면 스티커가 엉뚱한 부스로 옮겨갈 수 있어요. 그걸 막는 재매핑 로직이 이 변경의 절반입니다. 아래 "인덱스 재매핑" 절을 보세요.

개요 · 범위

회기 템플릿에서 활동 단위로 "아동 넘기기 허용"을 설정하면, 아동 화면에 건너뛰기 버튼이 뜨고 아동이 그 활동의 남은 스텝을 통째로 건너뛰어 다음 활동으로 이동할 수 있다. 허용되지 않은 활동은 기존과 동일하게 진행된다(기본값 OFF).

구성: 순수 유틸(lib/activity-skip.ts, 신규) + 템플릿 설정 UI(lesson-template-form) + 저장 정규화(API 2곳 · DDB) + 회기 생성 시 스냅샷(lesson-utils) + 아동 실행 경로(use-guest-page-session · guest-layout) + 진행자 표시 배지(SkippableActivityBadge 2곳).
핵심 한 줄: 아동 화면에 버튼이 뜨는 조건의 유일한 소스는 템플릿 설정이다. isSkippable을 쓰는 코드는 lesson-utils.ts:108 한 곳뿐이고, 그 값은 템플릿의 skippableActivityIndices에서만 온다. 다른 경로(활동 직접 편집·소켓 이벤트·URL 파라미터)로는 켤 수 없다.

전체 데이터 흐름 — 설정이 버튼이 되기까지

값의 형태가 두 번 바뀐다. 템플릿에서는 "몇 번째 활동"(인덱스 배열), 실제 활동에서는 "이 활동이 되는지"(불리언 플래그)다.

① 설정 (진행자·관리자)

회기 템플릿 편집 화면의 활동별 체크박스

skippableActivityIndices: number[]

lesson-template-form.tsx:776

② 저장 (서버 정규화)

중복·범위 밖 인덱스 제거 후 DDB 템플릿 문서에 기록

normalizeSkippableActivityIndices()

api/lesson-templates · db-queries.ts:2046

③ 스냅샷 (회기 생성)

템플릿 복사 시 인덱스 → 플래그로 변환해 각 활동 문서에 굳힘

isSkippable?: true

lesson-utils.ts:105–108

④ 수업 중 (아동·진행자)

아동: 버튼 표시 + skipCurrentActivity()
진행자: 배지 표시

guest-layout.tsx:334 · session-progress.tsx:241

[설정 시점] 체크박스 ON (2번째 활동) ↓ 폼 상태 skippableActivityIndices = [1] ↓ PUT /api/lesson-templates/{id} 서버 정규화 normalize([1], activityTemplates.length) → [1] ↓ DynamoDB LessonTemplate { activityTemplates: [A,B,C], skippableActivityIndices: [1] } [회기 생성 시점] createActivitiesFromTemplate() ↓ i=0,1,2 순회하며 isActivityIndexSkippable([1], i) Activity A { } Activity B { isSkippable: true } Activity C { } ↓ DynamoDB 활동 문서 3개 (여기서 값이 굳는다 = 스냅샷) [수업 중] 아동이 활동 B 진행 ↓ useGuestPageSession → isCurrentActivitySkippable = true ↓ showSkipActivityButton = hasConfirmedReady && true && !activeSelection ↓ 아동 클릭 skipCurrentActivity() → goToNextStep(undefined, true) → 이동 성공? → cancelResponse() ↓ └ 실패(이동 대상 없음) → false 반환, AI 발화 유지 활동 C 진입 (B의 남은 스텝 전부 건너뜀)
왜 인덱스로 저장하고 플래그로 복사하나: 템플릿의 activityTemplates활동 템플릿 ID 배열이고, 같은 템플릿 ID가 한 회기에 두 번 이상 들어갈 수 있다. 따라서 ID를 키로 쓰면 "두 번째로 나오는 그 활동만 허용"을 표현할 수 없어 위치(인덱스)로 지목해야 한다. 반면 생성된 활동은 개별 문서이므로 자기 자신의 불리언이 자연스럽다. jumpConfigs(키가 String(index))가 이미 쓰는 것과 동일한 패턴이다.

주석 달린 코드 — 파일:줄 그대로 열어보기

아래 블록은 실제 소스와 동일하고, // ① 주석만 이 문서에서 덧붙인 설명이다. 소스에는 이 주석이 없다(PPI 컨벤션상 주석 최소화). 각 블록 위 파일:줄로 바로 열 수 있다.

VSCode에서 한 번에 열기 — 아래를 그대로 붙여넣으면 이 문서가 다루는 6개 지점이 순서대로 열린다.

# repo 루트에서
code -g apps/web/lib/activity-skip.ts:1
code -g apps/web/lib/lesson-utils.ts:105
code -g apps/web/entities/guest-page-session/model/use-guest-page-session.ts:2698
code -g apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:334
code -g apps/web/components/sections/lesson-template-form.tsx:234
code -g apps/web/lib/db-queries.ts:2042

① 순수 유틸 — 인덱스 정규화와 재매핑

apps/web/lib/activity-skip.ts:1–41 (신규)
export function normalizeSkippableActivityIndices(
  indices: unknown,          // ① unknown: API body로 들어온 값을 신뢰하지 않는다
  activityCount: number,     // ② "현재 활동 수" 기준으로 범위를 자른다
): number[] {
  if (!Array.isArray(indices) || activityCount <= 0) return [];
                                     // ③ 미설정(undefined)·빈 템플릿 → 빈 배열 (기본값 OFF)
  return Array.from(
    new Set(                         // ④ 중복 제거: 체크 토글 경합으로 같은 인덱스가 두 번 들어와도 안전
      indices.filter(
        (index): index is number =>
          Number.isInteger(index) && index >= 0 && index < activityCount,
                                     // ⑤ 비정수·음수·활동 수 초과 인덱스를 버린다
                                     //    (활동을 줄인 뒤 남은 옛 인덱스가 여기서 정리됨)
      ),
    ),
  ).sort((a, b) => a - b);           // ⑥ 정렬: 저장값 비교·diff를 안정화
}

export function remapSkippableActivityIndicesAfterRemove(
  indices: number[],
  removedIndex: number,
): number[] {
  return indices
    .filter((index) => index !== removedIndex)         // ⑦ 지워진 활동의 설정은 함께 삭제
    .map((index) => (index > removedIndex ? index - 1 : index));
                                                       // ⑧ 뒤쪽 활동들은 한 칸 앞으로 밀리므로 인덱스도 -1
}

export function remapSkippableActivityIndicesAfterReorder(
  indices: number[],
  newOrder: number[],   // ⑨ newOrder[새 위치] = 이전 위치. 드래그 결과의 순서 배열
): number[] {
  const skippableIndices = new Set(indices);

  return newOrder.flatMap((oldIndex, newIndex) =>
    skippableIndices.has(oldIndex) ? [newIndex] : [],
  );                        // ⑩ 설정이 "이전 위치"에 있었으면 그 활동의 "새 위치"로 옮긴다
}                           //    flatMap+빈배열 = filter+map을 한 번에 (해당 없으면 결과에서 빠짐)

export function isActivityIndexSkippable(
  indices: number[] | undefined,
  activityIndex: number,
): boolean {
  return indices?.includes(activityIndex) === true;
}                           // ⑪ === true: undefined를 false로 확정 (옵셔널 체이닝 결과가 undefined일 수 있음)
왜 유틸로 분리했나: 정규화·재매핑 규칙이 폼(클라이언트)과 API(서버) 양쪽에서 동일해야 하고, 인덱스 어긋남은 UI 조작 없이는 재현이 어렵다. 순수 함수로 빼서 activity-skip.test.mjs(node:test 4케이스)로 검증한다.

② 회기 생성 시 스냅샷 — 인덱스가 플래그로 굳는 지점

apps/web/lib/lesson-utils.ts:105–108 (createActivitiesFromTemplate 내부)
          createdAt: currentEpochTime,
          updatedAt: currentEpochTime,
          ...(activityTemplate.isSpare && { isSpare: true }),
          ...(isActivityIndexSkippable(
            lessonTemplate.skippableActivityIndices,  // ① 템플릿의 인덱스 배열
            i,                                       // ② 현재 만들고 있는 활동의 순번
          ) && { isSkippable: true }),                // ③ 참일 때만 필드를 넣는다(false는 저장 안 함)
          ...(activityJumpConfig && { jumpConfig: activityJumpConfig }),
        };
여기가 이 기능의 시점 경계다. 이 줄은 회기를 만들 때 단 한 번 실행된다. 이후 템플릿을 수정해도 이미 생성된 활동 문서는 그대로다. 호출 지점은 4곳 — lesson-form.tsx(회기 생성/수정), user-form.tsx(사용자 등록 시 일괄 생성), use-lesson-completion.ts(수업 완료 후 다음 회기 생성), batch-overwrite-modal.tsx(일괄 덮어쓰기). 즉 설정을 기존 회기에 반영하려면 이 4경로 중 하나로 활동을 재생성해야 한다.

③ 아동측 실행 — skipCurrentActivity

apps/web/entities/guest-page-session/model/use-guest-page-session.ts:2701–2740
const skipCurrentActivity = useCallback(() => {
  if (terminatedRef.current) return false;
                                        // ① 수업 종료 후 종료 화면에서 눌려도 진행되지 않게
  const currentActivity = activities[currentActivityIndex];
  if (!currentActivity?.isSkippable) {   // ② 실행측 게이트 — UI를 우회해 호출돼도 여기서 막힌다
    logger.warn("Child activity skip ignored", {
      roomId,
      activityIndex: currentActivityIndex,
      reason: currentActivity ? "not-skippable" : "activity-not-found",
                                        // ③ 거부 사유를 남긴다 → 운영 로그로 원인 판별 가능
    });
    return false;                        // ④ 불리언 반환: 호출자(guest-layout)가 잠금을 되돌릴 수 있게
  }

  logger.info("Child activity skip requested", {
    roomId,
    activityIndex: currentActivityIndex,
    stepIndex: currentStepIndex,        // ⑤ 몇 번째 스텝에서 넘겼는지 = 얼마나 남기고 건너뜀
    activityId: currentActivity.id,
    activityTitle: currentActivity.title,
  });

  if (!goToNextStep(undefined, true)) {
                                        // ⑥ 2번째 인자 forceNextActivity=true
                                        //    → 전환 기준 스텝을 "마지막 스텝"으로 올려 남은 스텝을 통째로 건너뛴다
                                        //    (1번째 인자 triggerText는 undefined → conditional jump 키워드 매칭 없음)
    logger.warn("Child activity skip ignored", {
      roomId,
      activityIndex: currentActivityIndex,
      reason: "no-next-target",
                                        // ⑦ 마지막 활동 등 이동 대상이 없는 경우
    });
    return false;
  }

  aiSessionRef.current?.cancelResponse();
                                        // ⑧ 이동이 확정된 뒤에만 AI 발화를 끊는다
                                        //    (같은 동기 블록이라 전환 전 취소와 실효는 동일)
  return true;
}, [activities, currentActivityIndex, currentStepIndex, goToNextStep, roomId]);
순서가 뒤집힌 이유 (PR #981 리뷰 반영): 초기 구현은 cancelResponse() 먼저, 이동 나중이었다. 그러면 마지막 활동에서 눌렀을 때 이동은 실패하면서 AI 발화만 끊기고, 호출자는 true를 받아 analytics 이벤트와 1.5초 잠금을 소비하는 먹통 버튼이 됐다. goToNextStep이 이동 성공 여부를 반환하도록 바꿔(기존 호출자 6곳은 반환값을 무시) 실패를 구분한다.
forceNextActivity의 실제 효과 (goToNextStep, 같은 파일 2572–2583): transitionStepIndex = forceNextActivity ? 활동.steps.length - 1 : currentStepIndex. 즉 "지금 마지막 스텝에 있는 것처럼" 계산해 isLastStepOfActivity를 참으로 만들고, 이후 jumpConfig 기반 다음 활동 결정 로직을 그대로 탄다. 건너뛰기 전용 이동 경로를 새로 만들지 않았다는 뜻 — 예비 활동 스킵·조건부 점프 규칙이 그대로 적용된다.
긴급종료 콘텐츠(PPI-1193)와의 관계 — 별도 방어 불필요: 건너뛰기도 findNextStepSkippingSpare(lib/spare-content-utils.ts)를 그대로 타고, 이 함수는 현재 활동이 긴급종료가 아니면 후속 긴급종료 활동 구간을 통째로 건너뛴다. 따라서 아동이 건너뛰기 버튼으로 시간종료 전용 콘텐츠에 진입할 수 없다. 앞으로 "특정 활동에는 진입 금지" 규칙을 추가할 때도 이 공용 탐색 함수에 넣으면 건너뛰기 경로에 자동 반영된다.

④ 아동측 표시 조건과 연타 방지

apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:349–352
const showSkipActivityButton =
  session.hasConfirmedReady &&              // ① 입장·준비 확인 전(대기 화면)에는 숨김
  session.isCurrentActivitySkippable &&     // ② 유일한 허용 소스 = 템플릿 설정 스냅샷
  !activeSelection;                         // ③ "선택 후 넘기기" 화면과 버튼이 겹치는 것을 방지
apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:248–275
const handleSkipActivity = useCallback(() => {
  if (skipLockRef.current) return;      // ① ref로 즉시 차단 — setState는 비동기라 연타를 못 막는다

  skipLockRef.current = true;
  setIsSkippingActivity(true);           // ② state는 disabled 표시용(시각 피드백)
  const skipped = session.skipCurrentActivity();

  if (!skipped) {                          // ③ 훅이 거부하면 잠금 즉시 원복 (영구 비활성 방지)
    skipLockRef.current = false;
    setIsSkippingActivity(false);
    return;
  }

  trackEvent("ClientGuest:ActivitySkipped", {
    userId, roomId: props.roomId, lessonIndex: props.lessonIndex,
    activityIndex: session.currentActivityIndex,
    activityTitle: session.currentActivityTitle,
                                           // ④ 사후 분석용: 어느 활동을 아동이 실제로 건너뛰는지
  });

  skipUnlockTimerRef.current = setTimeout(() => {
    skipLockRef.current = false;
    setIsSkippingActivity(false);
    skipUnlockTimerRef.current = null;
  }, 1500);                              // ⑤ 유일한 잠금 해제 경로 — 전환 완료 여부와 무관하게 1.5초 유지
}, [...]);

④-1 조기 해제 사이드 이펙트 — 활동 인덱스 기반 해제 effect 제거 (a0ff593d)

초기 구현에는 "활동이 실제로 바뀌면 즉시 잠금을 풀어주는" effect가 이중 안전망으로 들어 있었다. 실제로는 안전망이 아니라 연타 방지를 무력화하는 유일한 경로였다.

1번째 탭 → skipLockRef=true, 1500ms 타이머 등록 → skipCurrentActivity() 성공 = setCurrentActivityIndex(+1) → 리렌더 → useEffect[session.currentActivityIndex] 발화 (수 ms 내) → skipLockRef=false + clearTimeout(타이머) 2번째 탭(≈200ms 뒤) → 잠금 없음 → 다음 활동까지 연속 스킵
왜 항상 발화했나: 건너뛰기 성공은 정의상 setCurrentActivityIndex(next.activityIndex)를 부른다(use-guest-page-session.ts goToNextStep). 즉 성공한 스킵에서는 인덱스가 반드시 바뀌므로 effect가 언제나 타이머보다 먼저 잠금을 풀었다. 1.5초 쿨다운은 사실상 죽은 코드였고, 실제로 막히는 것은 같은 tick의 동기 중복 호출뿐이었다.
// 제거된 코드 (a0ff593d)
// useEffect(() => {
//   skipLockRef.current = false;
//   setIsSkippingActivity(false);
//   clearTimeout(skipUnlockTimerRef.current);
// }, [session.currentActivityIndex]);

// 현재: 해제 경로는 두 갈래뿐
//   ① skipCurrentActivity()가 false → 즉시 해제 (먹통 버튼 방지)
//   ② 성공 → 1500ms 타이머만 해제 (다음 활동도 skippable이어도 1.5초 잠김)
//   언마운트 cleanup은 타이머만 정리한다
설계 교훈: "상태가 바뀌었으니 잠금을 풀어도 된다"는 조건은 쿨다운의 목적과 충돌한다. 쿨다운은 전환이 끝났는지가 아니라 사람의 연타 간격을 막으려는 것이므로, 해제 조건에 전환 완료를 섞으면 안 된다.

⑤ 템플릿 폼 — 재매핑을 붙인 두 지점

apps/web/components/sections/lesson-template-form.tsx:234–236 (활동 삭제) / 445–447 (드래그 재정렬)
// 활동 삭제 핸들러 — jumpConfigs 재매핑과 같은 자리에 나란히 붙였다
setSelectedActivityTemplates(newTemplates);
setSkippableActivityIndices((prev) =>
  remapSkippableActivityIndicesAfterRemove(prev, index),
);

// 드래그 재정렬 핸들러 — newOrder는 jumpConfigs 재매핑이 이미 만들어둔 배열을 재사용
setJumpConfigs(remapped);
setSkippableActivityIndices((prev) =>
  remapSkippableActivityIndicesAfterReorder(prev, newOrder),
);
setDraggedIndex(null);
jumpConfigs 옆인가: 두 설정 모두 활동 인덱스에 매달려 있어 같은 조작에서 같이 어긋난다. 향후 인덱스 기반 설정을 추가할 때도 이 두 핸들러가 유일한 반영 지점이다.

⑥ DynamoDB 업데이트 식 — 빠뜨리면 "저장이 안 되는" 증상

apps/web/lib/db-queries.ts:2042, 2046 (updateLessonTemplate)
UpdateExpression:
  "set title = :title, activityTemplates = :activityTemplates, " +
  "skippableActivityIndices = :skippableActivityIndices, " // ① 여기에 없으면 체크해도 저장 안 됨
  "jumpConfigs = :jumpConfigs, sessionReport = :sessionReport, updatedAt = :updatedAt",
ExpressionAttributeValues: {
  ":title": template.title,
  ":activityTemplates": template.activityTemplates,
  ":skippableActivityIndices": template.skippableActivityIndices || [],
                                  // ② || [] — 기존 템플릿 문서에는 이 속성이 아예 없다
                                  //    DDB는 undefined를 거부하므로 방어 필수
  ...
DynamoDB UpdateExpression은 명시한 속성만 쓴다. 새 필드를 타입·폼·API에 다 넣고도 이 한 줄을 빼면 "체크는 되는데 새로고침하면 풀린다"는 증상이 된다. 앞으로 LessonTemplate에 필드를 추가할 때 반드시 함께 확인할 지점.

버튼 표시 조건 — 진리표와 이중 게이트

표시(UI)와 실행(훅) 두 단계가 같은 플래그로 각각 막는다. UI만 막으면 상태 경합으로 잘못된 호출이 통과할 수 있고, 훅만 막으면 아동에게 눌리지 않는 버튼이 보인다.

상황isSkippablehasConfirmedReadyactiveSelection버튼클릭 시
템플릿에서 허용한 활동, 정상 진행 중truetrue없음표시다음 활동으로 이동
허용 안 한 활동 (기본)undefinedtrue없음숨김
입장 대기 / 준비 확인 전truefalse없음숨김
"선택 후 넘기기" 화면 노출 중truetrue있음숨김
클릭 직후 1.5초 — 전환이 끝나 다음 활동에 있어도 유지truetrue없음표시 (disabled)무시 (skipLockRef)
수업 종료 후truetrue없음거부 (terminatedRef)
표시 게이트 (guest-layout.tsx:334) 실행 게이트 (use-guest-page-session.ts:2699~2703) hasConfirmedReady ─┐ terminatedRef.current ─┐ isSkippable ───────┼─→ 버튼 렌더 activity?.isSkippable ─┼─→ 진행 / WARN 로그 후 false !activeSelection ──┘ skipLockRef (레이아웃) ─┘

인덱스 재매핑 — 이 변경의 절반이 여기에 있다

설정이 "몇 번째 활동"으로 저장되므로, 템플릿에서 활동을 지우거나 순서를 바꾸면 설정이 다른 활동으로 옮겨간다. 아래는 재매핑이 무엇을 고치는지 눈으로 본 것.

케이스 1 — 활동 삭제 (AfterRemove)

변경 전 — 설정 [0, 2, 3] (인사·게임·마무리가 허용)

0 ✓허용인사
1대화
2 ✓허용게임
3 ✓허용마무리

1번(대화) 삭제 → 재매핑 없으면 [0, 2, 3]이 그대로 남아 "게임"의 허용이 사라지고 범위 밖 3이 남는다

0 ✓허용인사
삭제됨대화
1 ✗게임
2 ✓허용마무리

재매핑 적용 remapAfterRemove([0,2,3], 1) → [0,1,2]설정이 원래 활동을 따라간다

0 ✓허용인사
1 ✓허용게임
2 ✓허용마무리

케이스 2 — 드래그 재정렬 (AfterReorder)

변경 전 — 설정 [0, 2]

0 ✓허용인사
1대화
2 ✓허용게임

"게임"을 맨 앞으로 → newOrder = [2, 0, 1] (새 위치 0에 이전 2번이 옴)

0 ← 이전 2게임
1 ← 이전 0인사
2 ← 이전 1대화

재매핑 결과 remapAfterReorder([0,2], [2,0,1]) → [0,1] — 게임·인사가 계속 허용

테스트로 고정된 계약 (lib/activity-skip.test.mjs, node --test):
  • normalize(undefined, 3) → [] · isActivityIndexSkippable(undefined, 0) → false — 기본값 OFF
  • normalize([2,0,2,-1,3,1.5], 3) → [0,2] — 중복·음수·초과·소수 제거 후 정렬
  • remapAfterRemove([0,2,3], 1) → [0,1,2] / ([0,2,3], 2) → [0,2]
  • remapAfterReorder([0,2], [2,0,1]) → [0,1]
  • remapAfterReorder([2,4], [0,2,4]) → [1,2] — 같은 활동 템플릿이 여러 번 배치된 상태에서 한 번에 여러 순번이 제거되는 경우

실행: npm run test:activity-skip (tsx --test). 처음에는 스크립트에 등록되지 않아 CI에서 돌지 않았고, .ts를 import하므로 node --test만으로는 실패한다.

순번 어긋남 방어 3중 — 폼 재매핑만으로는 부족했다

인덱스 기반 저장의 위험은 "활동 목록을 편집하는 경로가 폼 하나가 아니다"라는 점이다. 폼 밖 경로는 폼의 재매핑을 타지 않는다. PR #981 리뷰에서 실제 도달 가능한 경로가 하나 발견됐고, 이후 서버 계약까지 함께 막았다.

파일:줄막는 것
① 폼 편집lesson-template-form.tsx:234, 445폼 안에서의 활동 삭제·드래그 재정렬
② 폼 밖 활동 제거components/pages/lesson-template.tsx:116–130회기 템플릿 화면의 활동 표 삭제 버튼 — 이 경로가 재매핑을 빠뜨리고 있었다
③ 서버 계약app/api/lesson-templates/[id]/route.ts:64–89활동 목록이 바뀌는 요청에 재계산된 설정이 없으면 400 SKIPPABLE_ACTIVITY_INDICES_REQUIRED
②가 왜 위험했나 — 필수 활동이 건너뛰기 가능해진다
활동 [A, B, C, D] 중 C만 허용 → skippableActivityIndices = [2]
  → 활동 표에서 B 삭제 (activityTemplates만 필터, 설정은 [2] 그대로 전송)
  → 서버 normalize([2], 3) = [2]        // 범위 내라 통과, 재매핑은 하지 않음
  → 순번 2 = D → 아동이 D를 건너뛸 수 있고 C는 불가
백로그 문제 정의의 "모든 활동을 임의로 건너뛸 수 있게 하면 필수 활동까지 누락된다"를 직접 위반하는 상태였다.
②의 수정 방식 — 새 헬퍼를 만들지 않았다: 남는 활동의 기존 순번 목록이 곧 새 순서이므로, 재정렬 헬퍼를 그대로 재사용한다. 같은 활동 템플릿이 여러 번 배치된 경우 모두 제거되는 기존 삭제 동작은 유지된다.
const survivingIndices = selectedTemplate.activityTemplates
  .map((id, index) => ({ id, index }))
  .filter(({ id }) => id !== activityTemplate.id)
  .map(({ index }) => index);

skippableActivityIndices: remapSkippableActivityIndicesAfterReorder(
  selectedTemplate.skippableActivityIndices || [],
  survivingIndices
)
③에서 "서버가 알아서 재계산"을 택하지 않은 이유: 같은 활동 템플릿의 중복 배치가 허용되므로 이전·새 목록 diff로는 삭제와 재정렬을 구분할 수 없다. [A,B,A] → [A,A]에서 어느 위치가 남았는지 복원 불가이고, 잘못 추측하면 설정을 오히려 옮긴다. 그래서 호출자가 재계산해 보내도록 강제한다. 기존 설정이 비어 있으면(잘못 옮겨갈 값이 없으면) 통과시켜 기존 호출자를 막지 않는다.
남는 한계: 설정이 전송되기만 하면 서버는 그 값이 올바르게 재매핑됐는지 검증할 수 없다(범위 검사만). 근본 해결은 활동에 안정적인 식별자를 부여해 순번 대신 ID로 저장하는 것이며, 스키마 변경이라 별도 티켓 사안이다. 같은 이유로 jumpConfigs는 폼 밖 제거 경로에서 여전히 재매핑되지 않는다(이번 변경 이전부터 존재하는 결함).

진행자측 표시 — 왜 배지가 필요한가

아동이 스스로 활동을 넘기면, 진행자 화면에서는 이유 없이 활동이 전환된 것처럼 보인다. 오작동 오해와 불필요한 재현 조사를 막기 위해 "여기는 아동이 넘길 수 있는 활동"임을 미리 알린다.

위치파일:줄표시 규칙
모니터 스텝 표 (활동/스텝 목록)monitor-step-table.tsx:150–167활동 첫 스텝 행에 제목 아래로 배지. 첫 스텝이 아니어도 아동이 현재 그 스텝에 있으면 다시 표시 — 스크롤로 활동 제목 행이 시야에서 벗어나도 정보가 사라지지 않게
세션 카드 / 1대1 포커스뷰 진행 상황session-progress.tsx:180, 241–245현재 활동이 허용이면 현재 스텝 표시 아래 배지. isAiStep일 때 우측 UI와 겹쳐 mr-16 여백
공통 배지 컴포넌트shared/ui/skippable-activity-badge.tsx"아동 넘기기 가능" + ArrowRightIcon, title에 설명 툴팁. 두 화면이 같은 표식을 쓰도록 shared/ui에 둠
모니터 스텝 표의 마크업이 바뀌었다: 기존 isFirstStepOfActivity ? <>…</> : "" 삼항이 && + flex-col 컨테이너로 교체됐다. 배지를 제목 아래 줄에 세로로 놓기 위한 변경이므로, 이 셀의 레이아웃을 건드릴 때 두 배지 렌더 분기(첫 스텝 / 현재 스텝)를 함께 봐야 한다.

함정 · 주의

로그로 판별하기

로그 / 이벤트출처의미
Child activity skip requested게스트 logger.info버튼이 눌린 시점. activityIndex·stepIndex로 "몇 번째 스텝을 남기고 넘겼는지" 확인. 뒤에 skip ignored가 없으면 전환 성공
Child activity skip ignored게스트 logger.warn거부. reason: "not-skippable"은 허용 안 된 활동에서 호출된 것(UI 우회·상태 경합 의심), "activity-not-found"는 활동 배열이 비어 있는 상태, "no-next-target"은 이동할 다음 활동이 없는 경우(마지막 활동)
ClientGuest:ActivitySkippedLogRocket trackEvent아동이 실제로 어떤 활동을 건너뛰는지 집계. 특정 활동에 집중되면 그 활동 설계 재검토 신호
Auto-finish transition trace (forceNextActivity: true)게스트 logger.infogoToNextStep 진입 추적. 건너뛰기로 인한 전환은 이 필드가 true
"아동이 갑자기 활동을 넘겼다"는 신고 판별 순서:Child activity skip requested가 있으면 아동이 버튼을 누른 것(정상 동작) → ② 없는데 활동이 전환됐다면 auto-finish나 진행자 조작 경로 → ③ skip ignored WARN이 반복되면 표시 게이트와 실행 게이트의 판단이 갈린 것(상태 동기화 문제). "버튼을 눌렀는데 안 넘어간다"는 신고는 requested 직후의 ignored / no-next-target 쌍으로 확정한다.

파일 · 라인 레퍼런스

파일/심볼역할
lib/activity-skip.ts (신규, L1–41)정규화 · 삭제/재정렬 재매핑 · 조회 (순수 함수 4개)
lib/activity-skip.test.mjs (신규)node:test 5케이스 — 기본값·정규화·삭제·재정렬·다중 제거. npm run test:activity-skip
types/db/lesson-template.types.ts (L28)skippableActivityIndices?: number[]
types/db/activity.types.ts (L69)isSkippable?: boolean
lib/lesson-utils.ts (L105–108)템플릿 → 활동 스냅샷 (인덱스 → 플래그)
lib/db-queries.ts (L2042, L2046)DDB UpdateExpression에 필드 추가
app/api/lesson-templates/route.ts (L44) · [id]/route.ts (L60–89)POST/PUT 서버측 정규화 (PUT은 새 활동 수 기준) + 활동 목록 변경 시 설정 동반 전송 강제
components/pages/lesson-template.tsx (L116–130)폼 밖 활동 제거 경로의 재매핑 (남는 순번을 새 순서로 간주)
components/sections/lesson-template-form.tsx (L47, 68, 182, 234, 263, 445, 776)상태 · 로드 · 저장 · 삭제/재정렬 재매핑 · 체크박스
entities/guest-page-session/.../use-guest-page-session.ts (L2573, 2701, 2976, 3024)goToNextStep(이동 성공 여부 반환) · skipCurrentActivity · isCurrentActivitySkippable 노출
widgets/guest/guest-layout/ui/guest-layout.tsx (L217, 235, 334, 339)잠금 해제 effect · 핸들러 · 표시 조건 · 오버레이 버튼(PC/모바일)
shared/ui/skippable-activity-badge.tsx (신규)진행자 공통 "아동 넘기기 가능" 배지
components/sections/monitor-step-table.tsx (L150–167) · features/session/ui/session-progress.tsx (L180, 241)배지 표시 2곳
public/skip-forward-{desktop,mobile}.svg (신규)버튼 아이콘 (모바일은 원형 배경 포함, rotate-90 적용)

관련 문서