From 2a04955bd7110040f3a1d0d092ae84e20f5a2a10 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 6 Aug 2026 21:38:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 353 ++++++++++++++++++ docs/20260806_관리자라우트지연로딩/prd.md | 228 +++++++++++ .../reviews/phase1-admin-route-lazy-loading.md | 177 +++++++++ src/app/App.test.tsx | 2 +- src/app/protected-admin-shell.tsx | 66 ++-- .../components/CharacterListItem.tsx | 8 +- .../mocks/__tests__/production-graph.test.ts | 10 +- .../ui/__tests__/resource-pagination.test.tsx | 8 +- src/shared/ui/resource-pagination.tsx | 10 +- tests/e2e/character-workspace.spec.ts | 35 ++ 10 files changed, 849 insertions(+), 48 deletions(-) create mode 100644 docs/20260806_관리자라우트지연로딩/plan-task.md create mode 100644 docs/20260806_관리자라우트지연로딩/prd.md create mode 100644 docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md diff --git a/docs/20260806_관리자라우트지연로딩/plan-task.md b/docs/20260806_관리자라우트지연로딩/plan-task.md new file mode 100644 index 0000000..dc73531 --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/plan-task.md @@ -0,0 +1,353 @@ +# 관리자 라우트 지연 로딩 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현·회귀 수정·검증 완료 | +| 작성일 | 2026-08-06 | +| 요구사항 기준 | [prd.md](./prd.md) | +| API 기준 | 변경 불필요 — 기존 인증·domain 계약 유지 | +| 현재 Phase | Phase 1. 보호 page code splitting 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +보호된 관리자 page를 route별로 지연 로드해 초기 JS chunk를 줄이면서 모든 기존 기능을 유지한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `3/3` | 없음 | 없음 | + +- `ProtectedAdminShell`은 14개 보호 page component를 `React.lazy()` 동적 import로 로드한다. +- Vite production build는 310 modules를 37개 JS chunk로 분리하고 최대 JS chunk는 `315.09kB`다. +- 전체 unit `83 files / 462 tests`, mock Chromium E2E `53 tests`, typecheck, lint, production build가 통과했다. + +## 범위 + +### 포함 + +- 보호 page component의 `React.lazy()` 동적 import +- 관리자 main의 `Suspense`·기존 `PageState` loading fallback +- production graph의 chunk 수·최대 크기 자동 검증 +- 인증·route·domain 기능 unit와 mock Chromium E2E 회귀 검증 +- 320px·200% zoom·keyboard·접근성 확인 + +### 제외 + +- `vite.config.ts`의 warning limit·manual chunk 설정 변경 +- page default export 전환, route library와 새 helper·dependency 추가 +- App shell·Login·AccessDenied page lazy loading +- API, 권한, page props, 상태 관리와 domain 기능 변경 +- cropper만 별도로 lazy loading하는 추가 최적화 + +## 기술적 제약 + +- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vite 8.1.5, Vitest 4.1.10, Playwright 1.61.1. +- 아키텍처: `ProtectedAdminShell`의 기존 route 판정과 page 호출부를 유지하고 import boundary만 변경한다. +- export: 기존 named export를 유지하며 각 lazy import에서 React가 요구하는 `default` shape으로 mapping한다. +- fallback: 기존 `PageState`를 사용하고 shell·URL·focus 경계를 유지한다. +- 성능 기준: 모든 production JS chunk `<=500,000 bytes`, warning 0건. +- 데이터·보안: API·token·mock production boundary를 변경하지 않는다. +- 의존성: 추가하지 않는다. +- 구현: RED → GREEN → REFACTOR 순서와 실제 결과를 Progress에 기록한다. + +## Phase 1. 보호 page code splitting + +**Phase 결과:** 최초 관리자 route에는 현재 page 코드만 로드되고 다른 보호 page는 첫 진입 시 로드되며 기존 기능이 유지된다. + +**선행조건:** `ARL-001~008`, `ARL-DEC-001~003` 확정. + +**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`와 `P1-GATE` 완료, PRD 성공 기준과 Progress 갱신. + +### 구현 항목 + +#### Task 1.1 보호 page route boundary 분리 + +**Goal 실행 `P1-T1`:** 보호 page 정적 import를 lazy import로 바꾸고 production chunk 경계를 자동 검증한다. + +- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음. +- **완료 증거:** RED·GREEN·REFACTOR 체크박스, production graph와 focused App test, production build 결과, Progress 기록. +- **범위 밖:** route parser, page 내부 구현, API와 Vite manual chunk 설정. + +**Files:** + +- Create: 없음 +- Modify: `src/app/protected-admin-shell.tsx` +- Modify: `src/app/App.test.tsx` — lazy page heading 대기 보완 +- Test: `src/shared/mocks/__tests__/production-graph.test.ts` +- Test: `src/app/App.protected-shell.test.tsx` — assertion 보완이 필요할 때만 수정 + +**Interfaces:** + +- Consumes: 14개 page module의 기존 named export와 `ProtectedAdminShell` route 판정 결과. +- Produces: 동일 page props·render 조건, route별 dynamic import chunk와 `PageState` loading fallback. + +**TDD 절차:** + +- [x] **RED: 실패 test 작성/실패 확인** — `production-graph.test.ts`의 기존 production build 결과에서 JS 파일이 2개 이상이고 모든 JS 파일이 `<=500,000 bytes`인지 검사한다. `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`가 현재 단일 `598,785 bytes` chunk로 실패하는지 확인한다. +- [x] **GREEN: 최소 구현/통과 확인** — `protected-admin-shell.tsx`에서 React `lazy`·`Suspense`를 사용해 14개 보호 page의 named export를 동적 import하고 기존 page render 구간을 `PageState` fallback으로 감싼다. 같은 production graph test가 `exit 0`, `1/1`인지 확인한다. +- [x] **REFACTOR: 정리/회귀 확인** — 새 helper·barrel·config 없이 import와 fallback 위치만 정리한 뒤 production graph test와 `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`가 각각 `1/1`, `2 files / 16 tests` 이상으로 통과하는지 확인한다. +- [x] `npm run build:prod` 결과에 JS chunk가 2개 이상이고 `500kB` warning이 0건인지 기록한다. + +**검증 기준:** + +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`; `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`; `npm run typecheck`; `npm run lint`; `npm run build:prod`. +- **기대 결과:** 모든 명령 `exit 0`, production graph `1/1`, App focused `2 files / 16 tests` 이상, type·lint 오류 0건, JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning 0건. +- **수동 확인:** production preview의 Network에서 첫 route 외 page chunk가 초기 요청에 없고 다른 보호 route 최초 진입에 해당 chunk가 한 번 요청되는지 확인한다. + +- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다. + +#### Task 1.2 320px·200% zoom CJK 회귀 수정 + +**Goal 실행 `P1-R1`:** Phase Gate 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 수정하고 공통 페이지네이션 모바일 배치 결정을 갱신한다. + +- **시작 조건:** `P1-T1` 구현 뒤 320px·200% zoom visual QA에서 CJK 음절 열 회귀가 확인됨. +- **완료 증거:** CJK E2E 회귀 test, ResourcePagination unit test, mock Chromium/mobile Chrome E2E, Decision Log와 Progress 기록. +- **범위 밖:** 새 responsive component, pagination API 변경, page size options 변경, desktop/tablet 배치 변경. + +**Files:** + +- Create: 없음 +- Modify: `src/features/characters/components/CharacterListItem.tsx` +- Modify: `src/shared/ui/resource-pagination.tsx` +- Test: `src/shared/ui/__tests__/resource-pagination.test.tsx` +- Test: `tests/e2e/character-workspace.spec.ts` + +**Interfaces:** + +- Consumes: `ResourcePagination`의 기존 `PageData`, `onPageChange`, `onSizeChange`, accessible group/button labels. +- Produces: 동일 pagination API와 desktop/tablet `sm:flex` 배치, mobile에서는 음절 단위 세로 분리를 막는 stacked movement controls. + +**TDD 절차:** + +- [x] **RED: 실패 test 작성/실패 확인** — 320px·200% zoom에서 Korean leaf text가 음절 단위 세로 열로 렌더링되는지 `tests/e2e/character-workspace.spec.ts`에서 `Range.getClientRects()`로 검사한다. visual QA 스크린샷 `arl-lazy-routes-320-zoom200.png`에서 기존 문제가 확인됐다. +- [x] **GREEN: 최소 구현/통과 확인** — `CharacterListItem` 텍스트에 `break-keep break-words`, `ResourcePagination` summary/label에 `break-keep`, mobile movement controls에 `grid-cols-1`과 `whitespace-nowrap`를 적용한다. +- [x] **REFACTOR: 정리/회귀 확인** — `ResourcePagination` unit expectation을 새 mobile 배치 계약으로 갱신하고 focused E2E와 axe 회귀를 통과시킨다. + +**검증 기준:** + +- **실행 명령:** `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"`; `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx`; `npm run e2e:mock:chromium`; `npm run e2e:mock:mobile-chrome`. +- **기대 결과:** focused CJK E2E `1 passed`, ResourcePagination unit `4 tests` 통과, Chromium E2E `53 tests` 통과, mobile Chrome E2E `48 passed / 5 skipped`. +- **수동 확인:** 320px·200% zoom에서 캐릭터 목록과 페이지네이션에 수평 overflow, 가려진 action, 한국어 음절 단위 세로 분리가 없다. + +- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다. + +#### Task 1.3 완료 문서 현재 상태 정합성 복구 + +**Goal 실행 `P1-R2`:** `ARL-REV-P1-001`의 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다. + +- **시작 조건:** `P1-T1`, `P1-R1`, `P1-GATE` 완료와 `ARL-REV-P1-001` 확정. +- **완료 증거:** 현재 상태 `3/3`, `ARL-ISSUE-001` 해결 상태, review 링크와 검증 기록. +- **범위 밖:** 애플리케이션 코드·test·API·기존 구현 결정 변경. + +**Files:** + +- Create: `docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md` +- Modify: `docs/20260806_관리자라우트지연로딩/prd.md` +- Modify: `docs/20260806_관리자라우트지연로딩/plan-task.md` +- Test: 없음 — 현재 상태 문구만 정정하는 문서 Task다. + +**Interfaces:** + +- Consumes: `ARL-REV-P1-001`, `P1-T1`, `P1-R1`, `P1-GATE` 완료 증거. +- Produces: 실제 완료 범위와 일치하는 PRD·계획·review 추적 상태. + +**TDD 예외 사유:** 애플리케이션 동작을 변경하지 않는 문서 현재 상태 정정이라 실패 test를 추가하지 않는다. + +**대체 검증 방법:** 완료 Task 수, 해결 이슈 상태와 review 링크를 `rg`로 확인하고 `git diff --check`를 실행한다. + +- [x] 현재 상태 표의 완료 Task를 신규 회귀 Task까지 포함한 `3/3`으로 정정한다. +- [x] `ARL-ISSUE-001`을 해결 상태로 정정한다. +- [x] PRD에 review 링크를 연결하고 review 상태를 `수정 완료`로 갱신한다. +- [x] 실제 검증 결과를 Progress에 누적한다. + +**검증 기준:** + +- **실행 명령:** `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩`; `git diff --check`. +- **기대 결과:** 세 현재 상태 marker와 review 링크가 확인되고 whitespace 오류가 없다. +- **수동 확인:** 문서 표와 Task·Progress가 서로 같은 완료 상태를 표시한다. + +### 완료 조건 + +- [x] `P1-T1`과 `P1-R1`의 체크박스와 완료 증거가 모두 충족됐다. +- [x] `P1-R2`의 문서 정합성 복구와 검증 기록이 완료됐다. +- [x] `ARL-001~008`이 구현 또는 Gate 증거로 추적된다. +- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다. +- [x] 알려진 문서와 구현의 차이가 없다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** route code splitting, 기능 보존과 공통 품질 기준을 최종 판정한다. + +- **시작 조건:** `P1-T1`과 `P1-R1` 완료. +- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록. +- **범위 밖:** test 삭제·완화, warning limit 상향과 관련 없는 기능 수정. + +**실행 명령:** + +```bash +npm run test:run +npm run e2e:mock:chromium +npm run e2e:mock:mobile-chrome +npm run typecheck +npm run lint +npm run build:prod +git diff --check +``` + +**기대 결과:** 모든 명령 `exit 0`, unit·mock Chromium/mobile Chrome E2E 실패 0건, type·lint·build 오류 0건, production JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning·whitespace 오류 0건. + +**수동 확인:** + +- [x] `/ai-characters` 직접 URL과 캐릭터 수정 내부 이동·뒤로 가기가 기존과 동일하다. +- [x] Audio·Series·Community·FanTalk route 최초 진입에 loading 뒤 기존 화면이 표시된다. +- [x] Network에서 현재 route 이외 page chunk가 초기 요청에 없고 최초 진입 후 cache된다. +- [x] 1280px·320px·200% zoom에서 loading·page에 수평 overflow와 가려진 action이 없다. +- [x] keyboard focus·skip link와 axe critical·serious 위반 0건을 확인한다. + +## 실행 순서와 의존성 + +1. `P1-T1`에서 production graph 실패 test를 먼저 추가하고 최소 lazy import 구현과 focused 검증을 완료한다. +2. `P1-R1`에서 320px·200% zoom CJK 회귀를 focused E2E와 공통 pagination unit으로 고정한다. +3. `P1-GATE`에서 전체 unit·mock Chromium/mobile Chrome E2E와 수동 Network·접근성 검증을 완료한다. +4. `P1-R2`에서 완료 문서 현재 상태를 fresh Gate 결과와 일치시키고 review를 종료한다. + +`P1-GATE`는 `P1-T1`과 `P1-R1` 완료 전 시작하지 않는다. +`P1-R2`는 `P1-GATE` 완료와 `ARL-REV-P1-001` 확정 뒤 시작한다. + +## 변경 금지 항목 + +- `chunkSizeWarningLimit`과 `manualChunks`를 추가하지 않는다. +- 기존 named export, page props, route parser와 route path를 변경하지 않는다. +- page 내부 API·state·권한·UI를 함께 refactor하지 않는다. +- 새 dependency, lazy helper, barrel 또는 speculative prefetch를 추가하지 않는다. +- test를 삭제·skip·완화하거나 type 오류를 우회하지 않는다. +- 기존 Progress·Decision Log·review 기록을 삭제하거나 덮어쓰지 않는다. + +## 의사결정 및 중단 규칙 + +- build에서 단일 chunk가 유지되면 warning limit을 올리지 말고 static import 잔존 여부를 확인한다. +- 공통 dependency chunk가 `500,000 bytes`를 넘으면 근거를 기록하고 사용자와 별도 최적화 범위를 결정한다. +- lazy 전환으로 기존 test가 timing 차이만 드러내면 사용자 결과 assertion은 유지하고 비동기 대기만 최소 보완한다. +- 기능 assertion이 실패하면 lazy 변경을 완료로 처리하지 않고 원인을 수정한다. +- 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다. +- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### 계획 작성 — 2026-08-06 + +- 상태: 완료 +- 무엇을: route-level `React.lazy()` 선택을 `ARL-001~008`, 단일 구현 Task와 Phase Gate로 정규화했다. +- 왜: 현재 14개 보호 page의 static import가 단일 `598.78kB` production JS chunk와 `500kB` warning을 만든다. +- 어떻게: + - `npm run build:prod` — 성공, exit 0, 310 modules, JS `598.78kB`, gzip `158.94kB`, `500kB` chunk warning 1건. + - `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — 성공, exit 0, `2 files / 16 tests`. + - code·test 변경과 수동 Network 검증 — 미실행, 구현 요청 범위가 아님. +- 남은 항목: `P1-T1`, `P1-GATE`. +- 다음 행동: `P1-T1` production graph RED assertion 작성. + +### 1차 구현 — 2026-08-06 + +- 상태: 완료 +- 무엇을: `ProtectedAdminShell`의 14개 보호 page static import를 route-level `React.lazy()` named export mapping으로 바꾸고 기존 `PageState`를 `Suspense` fallback으로 사용했다. +- 왜: 초기 관리자 route에서 현재 page 외 보호 page 코드를 내려받지 않고, Vite `500kB` chunk warning을 warning limit 상향 없이 제거하기 위해서다. +- 어떻게: + - `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — RED 성공, 기존 단일 JS chunk 때문에 `expected 1 to be greater than or equal to 2`로 실패 확인. + - `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — GREEN 성공, exit 0, `1 file / 1 test`. + - `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — REFACTOR 회귀 성공, exit 0, `2 files / 16 tests`. + - `npm run build:prod` — 성공, exit 0, JS chunk 37개, 최대 JS `315.09kB`, `500kB` warning 0건. + - production preview 수동 확인 — `/ai-characters` 초기 요청에는 list 관련 chunk만 로드되고 detail·audio route 최초 진입 때 해당 page chunk가 추가 로드됨을 확인했다. +- 남은 항목: `P1-GATE`와 visual QA 회귀 확인. + +### 2차 수정 — 2026-08-06 + +- 상태: 완료 +- 무엇을: 320px·200% zoom 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 `CharacterListItem`과 `ResourcePagination`의 wrapping 규칙으로 수정하고 E2E 회귀 test를 추가했다. +- 왜: lazy route 자체의 기능 문제는 아니지만 Phase Gate의 320px·200% zoom 수동 확인 기준을 만족하지 못했다. +- 어떻게: + - `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"` — 성공, exit 0, `1 passed`. + - `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx` — 성공, exit 0, `1 file / 4 tests`. + - `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "has no critical or serious axe violations on the list"` — 첫 실행은 `Port 8889 is already in use` 환경 문제로 실패, 포트 해제 확인 후 재실행 성공, exit 0, `1 passed`. +- 남은 항목: fresh Phase Gate 전체 검증. + +### Phase 1 Gate — 2026-08-06 + +- 상태: 완료 +- 무엇을: route code splitting, 기능 보존, 접근성·반응형 회귀와 공통 품질 기준을 최종 검증했다. +- 왜: `P1-T1` 완료 뒤 `ARL-001~008`과 Phase 완료 조건을 실제 실행 결과로 판정하기 위해서다. +- 어떻게: + - `npm run test:run` — 성공, exit 0, `83 files / 462 tests`. + - `npm run e2e:mock:chromium` — 성공, exit 0, `53 tests`. + - `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`. + - `npm run typecheck` — 성공, exit 0. + - `npm run lint` — 성공, exit 0. + - `npm run build:prod` — 성공, exit 0, 310 modules, JS chunk 37개, 최대 JS `315.09kB`, gzip `93.77kB`, `500kB` warning 0건. + - `git diff --check` — 성공, exit 0, whitespace 오류 0건. + - 수동 production preview — `/ai-characters` 직접 URL, detail/audio route 진입, 뒤로 가기, Network chunk lazy loading, 1280px·320px·200% zoom 수평 overflow 없음, console error 0건을 확인했다. +- 남은 항목: 없음. + +### 회귀 감사 — 2026-08-06 + +- 상태: 확정 +- 무엇을: 구현·test·build와 계획의 현재 상태를 다시 대조해 `ARL-REV-P1-001`을 확정하고 `P1-R2`로 전환했다. +- 왜: 완료된 `P1-T1`·`P1-R1`이 `1/1`로 표시되고 해결된 `ARL-ISSUE-001`이 `확정`으로 남아 있었다. +- 어떻게: + - `npm run test:run` — 성공, exit 0, `83 files / 462 tests`. + - `npm run typecheck` — 성공, exit 0. + - `npm run lint` — 성공, exit 0. + - `npm run build:prod` — 성공, exit 0, 310 modules, JS 37개, 최대 `315.09kB`, chunk warning 0건. + - `npm run e2e:mock:chromium` — 최초 sandbox port 권한으로 실행 불가, 권한 허용 후 성공, exit 0, `53 passed`. + - `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`. +- 남은 항목: `P1-R2` 문서 현재 상태 정정과 review 종료. + +### `P1-R2` 문서 정합성 회귀 수정 — 2026-08-06 + +- 상태: 완료 +- 무엇을: 완료 Task 수를 신규 회귀 Task까지 포함한 `3/3`으로 갱신하고 `ARL-ISSUE-001`을 해결 상태로 바꿨으며 PRD에 Phase 1 review를 연결했다. +- 왜: 완료 구현과 계획의 현재 상태가 달라 후속 작업자가 남은 범위를 잘못 판단할 수 있었다. +- 어떻게: + - `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 세 현재 상태 marker와 PRD·plan·review 연결 확인. + - `git diff --check` — 성공, exit 0, whitespace 오류 0건. +- 남은 항목: 없음. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-06 | `ARL-PLAN-DEC-001` | 확정 | 보호 page를 route-level `React.lazy()`로 분리한다. | 실제 초기 loading 비용과 chunk warning을 함께 줄인다. | `P1-T1`, `P1-GATE`, `prd.md` | +| 2026-08-06 | `ARL-PLAN-DEC-002` | 확정 | 기존 production graph test에 chunk 수·크기 assertion을 추가한다. | 이미 Vite production build와 임시 directory 정리를 검증하는 가장 가까운 test다. | `P1-T1` | +| 2026-08-06 | `ARL-PLAN-DEC-003` | 확정 | 기존 `PageState`를 Suspense fallback으로 사용한다. | 새 component 없이 디자인·접근성 관례를 유지한다. | `P1-T1` | +| 2026-08-06 | `ARL-PLAN-DEC-004` | 확정 | `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 회귀를 막고 touch target과 label 가독성을 유지한다. Desktop/tablet은 기존 `sm:flex` 배치를 유지한다. | `P1-R1`, `ARL-006`, `ARL-007` | +| 2026-08-06 | `ARL-PLAN-DEC-005` | 확정 | 완료 문서의 stale Task 수와 이슈 상태를 `P1-R2`에서 현재 구현 결과와 맞춘다. | `ARL-REV-P1-001`의 문서 정합성 회귀 판정. | `P1-R2`, Phase 1 review | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `ARL-ISSUE-001` | Medium | 해결 | 보호 page 정적 import로 production JS가 `598.78kB` 단일 chunk이며 Vite 경고가 반복된다. | `P1-T1` | route-level lazy import와 build boundary test 완료 | +| `ARL-ISSUE-002` | Medium | 해결 | 320px·200% zoom에서 캐릭터 목록과 공통 페이지네이션 한국어 텍스트가 음절 단위 세로 열로 분리됐다. | `P1-R1`, `P1-GATE` | `break-keep`·stacked mobile pagination과 CJK E2E 회귀 test | +| `ARL-ISSUE-003` | Low | 해결 | 완료 Task 수와 해결된 원인 이슈 상태가 구현 전 값으로 남아 있다. | `P1-R2` | `ARL-REV-P1-001` 문서 현재 상태 정합성 복구 완료 | + +## 최종 보고 형식 + +```markdown +구현 결과: 보호된 관리자 page가 route별 chunk로 분리되고 기존 기능을 유지한다. + +- 변경: `ProtectedAdminShell` page import boundary와 production graph assertion +- 결정: `ARL-DEC-001` — route-level `React.lazy()` +- 검증: + - `npm run test:run` — <실제 결과> + - `npm run e2e:mock:chromium` — <실제 결과> + - `npm run build:prod` — + - Network·1280px·320px·200% zoom·keyboard·axe — <실제 결과> +- 남은 항목: <없음 또는 구체적인 항목> +- 문서: `docs/20260806_관리자라우트지연로딩/{prd.md,plan-task.md}` +``` + +최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다. diff --git a/docs/20260806_관리자라우트지연로딩/prd.md b/docs/20260806_관리자라우트지연로딩/prd.md new file mode 100644 index 0000000..e2cde7e --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/prd.md @@ -0,0 +1,228 @@ +# 관리자 라우트 지연 로딩 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | 2026-08-06 | +| 최종 수정일 | 2026-08-06 | +| 대상 제품 | AI 캐릭터 관리자 웹 | +| 작성자·결정권자 | Codex 작성, 사용자 결정 | +| 관련 API Contract | 불필요 — API와 payload 변경 없음 | +| 관련 구현 계획 | [plan-task.md](./plan-task.md) | +| 관련 review | [Phase 1 관리자 라우트 지연 로딩 리뷰](./reviews/phase1-admin-route-lazy-loading.md) | + +### 요구사항 상태 + +| 상태 | 의미 | 구현 처리 | +|---|---|---| +| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 | +| 미결 | 추가 결정 필요 | 구현 전 결정 | +| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 | +| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 | +| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 | + +### 문서 우선순위와 갱신 순서 + +1. 초기 bundle과 라우트 로딩 결정은 이 PRD가 소유한다. +2. API 변경이 없으므로 별도 API Contract를 만들지 않는다. +3. 구현 순서와 완료 증거는 `plan-task.md`가 소유한다. +4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다. + +## 1. Overview + +보호된 관리자 페이지를 `React.lazy()` 기반 동적 import로 분리한다. 최초 접속에는 현재 라우트에 필요한 코드만 내려받고, 다른 페이지 코드는 해당 라우트에 처음 진입할 때 로드한다. 기능·API·권한·데이터 흐름은 유지한다. + +## 2. Problem Statement + +현재 `protected-admin-shell.tsx`는 14개 보호 페이지를 정적으로 import한다. + +- production build가 310개 module을 하나의 `598.78kB` minified JS chunk로 출력한다. +- Vite 8.1.5 기본 기준 `500kB`를 넘어 build마다 chunk size warning이 발생한다. +- gzip 전송량은 `158.94kB`지만 browser가 최초 접속에 전체 chunk를 다운로드·파싱·실행한다. +- 이미지 cropper처럼 현재 라우트에서 사용하지 않는 기능도 초기 module graph에 포함된다. + +문제를 해결했다는 판단은 production build가 보호 페이지를 여러 chunk로 분리하고 모든 JS chunk가 `500,000 bytes` 이하이며, 기존 사용자 흐름이 그대로 통과할 때로 한다. 구현 완료 build는 37개 JS chunk, 최대 JS `315.09kB`, chunk size warning 0건이다. + +## 3. Goals + +### 3.1 제품 목표 + +- 최초 접속에서 현재 관리자 화면에 필요하지 않은 페이지 코드를 지연 로드한다. +- Vite의 `500kB` 초과 chunk warning을 실제 code splitting으로 제거한다. +- 직접 URL, 내부 이동, 뒤로 가기와 권한 검사를 기존과 동일하게 유지한다. + +### 3.2 UX 목표 + +- 첫 화면의 다운로드·파싱·실행 부담을 줄인다. +- 미로드 라우트 최초 진입에는 명확한 loading 상태를 표시한다. +- loading 중 keyboard focus, 관리자 shell과 현재 URL을 유지한다. + +## 4. Non-Goals + +- `chunkSizeWarningLimit` 상향으로 경고만 숨기기 +- `manualChunks` 또는 vendor chunk 설정 추가 +- `react-advanced-cropper`, `zod`, React Query 교체·제거 +- router library, bundle 분석 library 또는 새 runtime dependency 추가 +- API, 인증·권한, route path, page props와 상태 관리 변경 +- 서버 rendering, prefetch, service worker cache 또는 offline 지원 추가 + +Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다. + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 사용 환경 | +|---|---|---|---| +| ADMIN | 관리자 화면에 빠르게 진입 | 캐릭터·오디오·시리즈·커뮤니티·FanTalk 관리 | desktop, tablet, mobile | +| 인증되지 않은 사용자 | 보호 코드 노출 없이 로그인 | 로그인, 인증 후 관리자 진입 | desktop, tablet, mobile | + +- 기존 ADMIN probe, 401 session 제거와 403 접근 거부 정책을 유지한다. +- lazy page loading은 권한 검사 성공 뒤에만 보호 UI를 표시한다. + +## 6. 핵심 사용자 흐름 + +1. 사용자가 로그인 또는 보호된 직접 URL로 접속한다. +2. 기존 인증·ADMIN probe가 완료된다. +3. 현재 라우트 page chunk가 없으면 관리자 shell 안에 loading 상태를 표시한다. +4. chunk가 로드되면 기존 page를 같은 props와 URL로 표시한다. +5. 다른 메뉴에 처음 진입하면 해당 page chunk만 추가로 받고, 이후 browser cache를 재사용한다. +6. 내부 이동·뒤로 가기·새로고침과 mutation 흐름은 기존과 동일하게 동작한다. + +## 7. 정보 구조와 라우팅 + +```text +/login # eager 유지 +/access-denied # eager 유지 +/ai-characters # protected page lazy +/ai-characters/new # protected page lazy +/ai-characters/:characterId/** # protected page lazy +``` + +- `App`, 인증 provider와 `ProtectedAdminShell`은 application shell로 유지한다. +- `ProtectedAdminShell`이 현재 판정하는 모든 보호 page component만 lazy boundary로 이동한다. +- route path parser와 URL 상태는 변경하지 않는다. + +## 8. 기능 요구사항 + +### 8.1 Code splitting + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `ARL-001` | 확정 | `ProtectedAdminShell`의 보호 page 정적 import를 `React.lazy()` 동적 import로 전환한다. | 14개 page component가 현재 route에서 render될 때 해당 module을 import한다. | contract 불필요, `P1-T1` | +| `ARL-002` | 확정 | named export를 유지하며 page component의 public props를 변경하지 않는다. | page export·호출부 type과 기존 test가 변경 없이 통과한다. | contract 불필요, `P1-T1` | +| `ARL-003` | 확정 | 보호 page 영역을 `Suspense`로 감싸 loading 상태를 표시한다. | 미로드 page 진입 시 `화면을 불러오는 중` status가 관리자 shell 안에 표시된다. | contract 불필요, `P1-T1` | +| `ARL-004` | 확정 | production build의 모든 minified JS chunk를 `500,000 bytes` 이하로 유지한다. | production graph test가 JS chunk 2개 이상과 최대 chunk `<=500,000 bytes`를 확인하고 Vite 경고가 없다. | contract 불필요, `P1-T1`, `P1-GATE` | + +### 8.2 기능 보존 + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `ARL-005` | 확정 | 로그인, ADMIN probe, 401·403와 malformed route 처리를 유지한다. | 기존 App auth·protected 오류 test가 모두 통과한다. | 기존 인증 계약 유지, `P1-GATE` | +| `ARL-006` | 확정 | 직접 URL, 내부 이동, 뒤로 가기와 route별 page props를 유지한다. | 기존 App route test와 mock Chromium E2E가 모두 통과한다. | 기존 route contract 유지, `P1-GATE` | +| `ARL-007` | 확정 | 각 page의 조회·생성·수정·삭제, upload와 댓글 동작을 변경하지 않는다. | 전체 unit과 mock Chromium E2E에서 신규 실패가 0건이다. | 기존 domain 계약 유지, `P1-GATE` | +| `ARL-008` | 확정 | mock module은 production bundle에서 계속 제외한다. | 기존 `production-graph.test.ts`의 mock 제외 assertion이 통과한다. | 기존 production boundary 유지, `P1-T1` | + +## 9. 반응형 기능 범위 + +| 기능 | Desktop | Tablet | Mobile | 비고 | +|---|---:|---:|---:|---| +| 보호 page lazy loading | 지원 | 지원 | 지원 | 동일 route boundary | +| loading 상태 | 지원 | 지원 | 지원 | 관리자 main 안에 표시 | +| 직접 URL·뒤로 가기 | 유지 | 유지 | 유지 | URL 변경 없음 | + +- 320px와 200% zoom에서 loading 상태와 page가 수평 overflow를 만들지 않아야 한다. +- 기존 모바일 조회·수정 capability 정책은 변경하지 않는다. + +## 10. UI/UX Expectations + +### 10.1 디자인과 component 원칙 + +- 기존 `PageState`를 loading fallback으로 재사용한다. +- 관리자 shell, navigation, header와 success notification은 page chunk loading 중 유지한다. +- 새 spinner, skeleton, animation 또는 styling을 추가하지 않는다. + +### 10.2 화면 상태 + +- lazy page가 준비되지 않았을 때 `화면을 불러오는 중`을 표시한다. +- page가 준비되면 같은 main 영역에서 기존 page로 교체한다. +- 기존 API loading·empty·error·success 상태는 page 내부 책임으로 유지한다. + +### 10.3 접근성 + +- fallback은 기존 `PageState`의 semantic status를 사용한다. +- keyboard focus 순서, skip link와 route 전환 focus 정책을 변경하지 않는다. +- 200% zoom과 axe critical·serious 0건을 유지한다. + +## 11. API 계약 + +### 11.1 공통 규칙 + +- lazy loading은 module 전달 방식만 변경한다. +- endpoint, method, payload, response, 오류와 pagination 계약을 변경하지 않는다. + +### 11.2 Endpoint 추적 + +| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal | +|---|---|---|---|---|---| +| `ARL-001~008` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 domain 계약 유지 | `P1-T1`, `P1-GATE` | + +### 11.3 외부 제공 대기 계약 + +없음. + +## 12. 보안과 데이터 취급 + +- 인증 token 저장·전달, 401 clear와 403 route 정책을 변경하지 않는다. +- 보호 page chunk는 기존과 같은 정적 asset이므로 권한 경계를 대체하지 않는다. +- log, analytics와 외부 전송을 추가하지 않는다. +- production mock 제외 경계를 유지한다. + +## 13. 성능과 품질 요구사항 + +- 기준 build: Vite 8.1.5, 단일 JS `598.78kB`, gzip `158.94kB`, 310 modules. +- 완료 build: JS chunk 2개 이상, 각 minified JS `<=500,000 bytes`, chunk size warning 0건. +- `chunkSizeWarningLimit` 기본값 `500`을 변경하지 않는다. +- 새 dependency와 custom chunk configuration을 추가하지 않는다. +- production graph test, App unit, 전체 unit, mock Chromium E2E, typecheck, lint와 production build를 Gate로 사용한다. + +## 14. 성공 기준 + +### 14.1 기능 수용 기준 + +- [x] 모든 보호 page가 직접 URL과 내부 이동에서 기존 기능을 제공한다. (`ARL-001~003`, `ARL-005~007`) +- [x] 인증·권한·API request와 page props가 변경되지 않는다. (`ARL-002`, `ARL-005~008`) + +### 14.2 UI/UX 수용 기준 + +- [x] 미로드 route에 기존 `PageState` loading 상태가 표시된다. +- [x] 320px·200% zoom·keyboard 흐름과 axe critical·serious 0건을 유지한다. +- [x] 첫 route 이후 다른 route 최초 진입만 추가 chunk loading을 수행한다. + +### 14.3 성능·추적성 완료 기준 + +- [x] production build에 `500kB` 초과 chunk warning이 없다. (`ARL-004`) +- [x] 모든 JS chunk가 `500,000 bytes` 이하임을 자동 test로 검증한다. +- [x] `ARL-001~008`이 `P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다. +- [x] API Contract가 불필요함을 기록했다. + +## 15. Open Questions + +없음. route-level `React.lazy()`를 선택했고 경고 임계값 상향과 수동 vendor 분리는 제외했다. + +## 16. 요구사항 추적표 + +| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 | +|---|---|---:|---|---|---| +| `ARL-001~004`, `ARL-008` | 불필요 | 1 | `P1-T1` | production graph, App focused unit, production build | Network의 route chunk loading | +| `ARL-005~007` | 기존 계약 유지 | 1 | `P1-GATE` | 전체 unit, mock Chromium E2E, typecheck, lint | 직접 URL·내부 이동·뒤로 가기·320px·200% zoom | +| `ARL-006~007` CJK zoom 회귀 | 기존 계약 유지 | 1 | `P1-R1` | CJK E2E, ResourcePagination unit, mock mobile Chrome E2E | 320px·200% zoom 한국어 줄바꿈 | + +## 17. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal | +|---|---|---|---|---|---| +| 2026-08-06 | `ARL-DEC-001` | 확정 | 보호된 관리자 page를 route-level `React.lazy()`로 분리한다. | 단일 chunk의 원인이 모든 보호 page 정적 import이며 실제 초기 loading 비용도 줄일 수 있다. | `ARL-001~008`, `P1-T1`, `P1-GATE` | +| 2026-08-06 | `ARL-DEC-002` | 확정 | `chunkSizeWarningLimit` 상향과 `manualChunks`는 적용하지 않는다. | 경고만 숨기거나 초기 총량을 유지하는 방식 대신 실제 지연 loading을 선택한다. | `ARL-004`, Non-Goals | +| 2026-08-06 | `ARL-DEC-003` | 확정 | 기존 `PageState`만 fallback으로 재사용하고 새 loading component를 만들지 않는다. | 현재 디자인·접근성 관례를 유지하는 최소 구현이다. | `ARL-003`, `P1-T1` | +| 2026-08-06 | `ARL-DEC-004` | 확정 | 공통 `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 것을 막고, desktop/tablet 배치는 기존 `sm:flex`로 유지한다. | `ARL-006~007`, `P1-R1`, `P1-GATE` | diff --git a/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md b/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md new file mode 100644 index 0000000..125cc5b --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md @@ -0,0 +1,177 @@ +# Phase 1 관리자 라우트 지연 로딩 코드 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-GATE` | +| 기준 commit 또는 working tree | `68959cb` 기준 미커밋 working tree | +| 리뷰 일자 | 2026-08-06 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260806_관리자라우트지연로딩/prd.md`, `docs/20260806_관리자라우트지연로딩/plan-task.md` | +| 리뷰 상태 | 수정 검증 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- `ARL-001~008`과 완료 체크박스가 실제 코드·test·build 결과와 일치하는지 확인한다. +- 완료된 Phase의 기능·접근성·반응형 회귀와 문서 현재 상태를 확인한다. + +### 포함 범위 + +- 코드: `src/app/protected-admin-shell.tsx`, `src/app/App.test.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx` +- 테스트: production graph, 전체 unit, mock Chromium/mobile Chrome E2E +- 문서: `ARL-001~008`, `P1-T1`, `P1-R1`, `P1-GATE` +- 수동 검증: source import boundary와 production build chunk 출력 대조 + +### 제외 범위 + +- 실제 개발 API와 운영 인증 정보가 필요한 server mode 수동 QA +- PRD Non-Goals인 prefetch, manual chunk와 cropper 추가 최적화 + +## 3. 판정 기준 + +### 심각도 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·기존 계약 위반 또는 주요 회귀 | +| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 | +| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 | + +### 상태 + +| 상태 | 의미 | 후속 처리 | +|---|---|---| +| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 | +| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 전환 | +| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 | +| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 | +| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `ARL-001~008` +- API Contract: 변경 불필요 — 기존 인증·domain 계약 유지 +- 계획: `P1-T1`, `P1-R1`, `P1-GATE` +- 코드: `src/app/protected-admin-shell.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx` +- 테스트: `src/shared/mocks/__tests__/production-graph.test.ts`, 전체 Vitest와 mock E2E + +### 실행 환경 + +```text +OS: Darwin 25.0.0 x86_64 +Node: v24.12.0 +npm: 11.7.0 +Browser/viewport: Playwright Chromium, mobile Chrome, 320px·200% zoom 포함 +환경 변수: VITE_API_MODE=mock 또는 production mode +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `npm run test:run` | 성공 | exit 0, `83 files / 462 tests` | +| `npm run typecheck` | 성공 | exit 0, 오류 0건 | +| `npm run lint` | 성공 | exit 0, 오류 0건 | +| `npm run build:prod` | 성공 | 310 modules, JS 37개, 최대 `315.09kB`, chunk 경고 0건 | +| `npm run e2e:mock:chromium` | 성공 | exit 0, `53 passed` | +| `npm run e2e:mock:mobile-chrome` | 성공 | exit 0, `48 passed / 5 skipped` | +| `git diff --check` | 성공 | whitespace 오류 0건 | +| route import boundary 정적 대조 | 성공 | 보호 page dynamic import 14개, eager page import는 `LoginPage`만 존재 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `ARL-REV-P1-001` | Low | 수정 완료 | 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다 | `P1-R2` | `P1-R2` 완료 | + +코드·기능·성능·접근성에 대한 확정 발견 사항은 없다. + +## 6. 발견 사항 상세 + +### ARL-REV-P1-001 — 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** `ARL-001~008` +- **관련 계약:** 없음 +- **소유 Task:** `P1-R2` + +**관찰 내용** + +`P1-T1`과 `P1-R1`이 완료됐지만 현재 상태 표는 완료 Task를 `1/1`로 표시한다. `ARL-ISSUE-001`도 build와 production graph 검증으로 해결됐지만 상태가 `확정`으로 남아 있다. + +**근거** + +- 코드: `src/app/protected-admin-shell.tsx`의 보호 page dynamic import 14개 +- 테스트: production build JS 37개, 최대 `315.09kB`, 전체 unit·E2E 통과 +- 문서: `plan-task.md` 현재 상태 표와 `발견된 문제`의 `ARL-ISSUE-001` + +**재현 또는 검증 절차** + +1. `plan-task.md`에서 완료 Task 수와 `ARL-ISSUE-001` 상태를 확인한다. +2. 같은 문서의 `P1-T1`, `P1-R1`, Phase Gate 완료 기록을 대조한다. +3. 실제 결과는 두 Task 완료와 원인 이슈 해결인데 현재 상태 표시는 `1/1`, `확정`이다. +4. 감사 시점의 완료 Task는 `2/2`여야 했으며, `P1-R2` 추가 후 최종 상태는 `3/3`, 원인 이슈 상태는 `해결`이어야 한다. + +**영향** + +애플리케이션 동작에는 영향이 없지만 완료 범위와 남은 문제를 읽는 사람이 잘못 판단할 수 있다. + +**권장 조치** + +`P1-R2` 문서 전용 Task로 현재 상태와 review 링크만 정정하고 애플리케이션 코드·test는 변경하지 않는다. + +**판정 기록** + +- 2026-08-06 — plan의 Task·Progress와 fresh Gate 결과를 대조해 문서 정합성 회귀로 확정했다. +- 2026-08-06 — `P1-R2`에서 최종 Task 수 `3/3`, 해결 이슈 상태와 review 링크를 반영하고 문서 검증을 통과해 수정 완료로 판정했다. + +## 7. 확정 항목의 plan·goal 전환 + +`ARL-REV-P1-001`을 `plan-task.md`의 문서 전용 회귀 수정 Task `P1-R2`로 전환한다. + +### 신규 회귀 수정 Task 초안 + +```markdown +### Task 1.3 완료 문서 현재 상태 정합성 복구 + +**Goal 실행 `P1-R2`:** 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다. +``` + +### create_goal objective 초안 + +```text +[P1-R2]의 확정 review 항목 ARL-REV-P1-001을 문서에서 수정한다. +애플리케이션 코드·test·API는 변경하지 않는다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD·계획·관련 코드·전체 Gate 대조 | +| 후보 항목 판정 완료 | 충족 | `ARL-REV-P1-001` 확정 | +| 확정 항목 plan 반영 | 충족 | `P1-R2` 추가 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 | +| 검증 명령과 결과 기록 | 충족 | §4 실행 결과 | + +**최종 결론:** 수정 검증 완료 + +**남은 항목:** 없음. + +## 9. 수정 후 검증 기록 + +### 1차 수정 검증 — 2026-08-06 + +- 무엇을: `ARL-REV-P1-001`의 완료 Task 수, 해결된 원인 이슈 상태와 review 링크를 현재 결과에 맞췄다. +- 왜: 완료 범위와 남은 문제를 문서가 잘못 표시하는 회귀를 제거하기 위해서다. +- 어떻게: + - `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 필요한 marker와 링크 확인. + - `git diff --check` — 성공, exit 0, whitespace 오류 0건. +- 남은 항목: 없음. diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 3048337..2345a8c 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -74,7 +74,7 @@ test("navigates to /ai-characters after a successful login", async () => { fireEvent.click(screen.getByRole("button", { name: "로그인" })); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); - expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); }); test("routes /ai-characters/new to the character create form", async () => { diff --git a/src/app/protected-admin-shell.tsx b/src/app/protected-admin-shell.tsx index 724c0dc..9ad878b 100644 --- a/src/app/protected-admin-shell.tsx +++ b/src/app/protected-admin-shell.tsx @@ -1,26 +1,28 @@ -import { useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location"; import { routePaths } from "@/app/route-paths"; -import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage"; -import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage"; -import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage"; import { useAuthSession } from "@/features/auth/model/auth-session-context"; import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; -import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage"; -import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage"; -import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage"; -import { CharacterListPage } from "@/features/characters/pages/CharacterListPage"; -import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage"; -import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage"; -import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage"; -import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage"; -import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage"; -import { SeriesListPage } from "@/features/series/pages/SeriesListPage"; -import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage"; import type { ApiClient } from "@/shared/api/client"; import type { ApiMode } from "@/shared/config/env"; import { MockModeBanner } from "@/shared/ui/mock-mode-banner"; +import { PageState } from "@/shared/ui/page-state"; + +const AudioContentDetailPage = lazy(() => import("@/features/audio-contents/pages/AudioContentDetailPage").then(({ AudioContentDetailPage }) => ({ default: AudioContentDetailPage }))); +const AudioContentFormPage = lazy(() => import("@/features/audio-contents/pages/AudioContentFormPage").then(({ AudioContentFormPage }) => ({ default: AudioContentFormPage }))); +const AudioContentListPage = lazy(() => import("@/features/audio-contents/pages/AudioContentListPage").then(({ AudioContentListPage }) => ({ default: AudioContentListPage }))); +const CharacterCreatePage = lazy(() => import("@/features/characters/pages/CharacterCreatePage").then(({ CharacterCreatePage }) => ({ default: CharacterCreatePage }))); +const CharacterDetailPage = lazy(() => import("@/features/characters/pages/CharacterDetailPage").then(({ CharacterDetailPage }) => ({ default: CharacterDetailPage }))); +const CharacterEditPage = lazy(() => import("@/features/characters/pages/CharacterEditPage").then(({ CharacterEditPage }) => ({ default: CharacterEditPage }))); +const CharacterListPage = lazy(() => import("@/features/characters/pages/CharacterListPage").then(({ CharacterListPage }) => ({ default: CharacterListPage }))); +const CommunityPostFormPage = lazy(() => import("@/features/community-posts/pages/CommunityPostFormPage").then(({ CommunityPostFormPage }) => ({ default: CommunityPostFormPage }))); +const CommunityPostListPage = lazy(() => import("@/features/community-posts/pages/CommunityPostListPage").then(({ CommunityPostListPage }) => ({ default: CommunityPostListPage }))); +const FanTalkListPage = lazy(() => import("@/features/fan-talks/pages/FanTalkListPage").then(({ FanTalkListPage }) => ({ default: FanTalkListPage }))); +const SeriesDetailPage = lazy(() => import("@/features/series/pages/SeriesDetailPage").then(({ SeriesDetailPage }) => ({ default: SeriesDetailPage }))); +const SeriesFormPage = lazy(() => import("@/features/series/pages/SeriesFormPage").then(({ SeriesFormPage }) => ({ default: SeriesFormPage }))); +const SeriesListPage = lazy(() => import("@/features/series/pages/SeriesListPage").then(({ SeriesListPage }) => ({ default: SeriesListPage }))); +const SeriesOrderPage = lazy(() => import("@/features/series/pages/SeriesOrderPage").then(({ SeriesOrderPage }) => ({ default: SeriesOrderPage }))); const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"; const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요."; @@ -196,22 +198,24 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon {location.successNotification}

)} - {location.path === routePaths.aiCharacterCreate ? : null} - {characterEditId !== null ? : null} - {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? : null} - {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? : null} - {audioContentListCharacterId !== null ? : null} - {audioContentCreateCharacterId !== null ? : null} - {audioContentEditRoute !== null ? : null} - {audioContentDetailRoute !== null ? : null} - {communityPostListCharacterId !== null ? : null} - {communityPostCreateCharacterId !== null ? : null} - {fanTalkListCharacterId !== null ? : null} - {seriesListCharacterId !== null ? : null} - {seriesCreateCharacterId !== null ? : null} - {seriesEditRoute !== null ? : null} - {seriesOrderCharacterId !== null ? : null} - {seriesDetailRoute !== null ? : null} + } key={location.path}> + {location.path === routePaths.aiCharacterCreate ? : null} + {characterEditId !== null ? : null} + {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? : null} + {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? : null} + {audioContentListCharacterId !== null ? : null} + {audioContentCreateCharacterId !== null ? : null} + {audioContentEditRoute !== null ? : null} + {audioContentDetailRoute !== null ? : null} + {communityPostListCharacterId !== null ? : null} + {communityPostCreateCharacterId !== null ? : null} + {fanTalkListCharacterId !== null ? : null} + {seriesListCharacterId !== null ? : null} + {seriesCreateCharacterId !== null ? : null} + {seriesEditRoute !== null ? : null} + {seriesOrderCharacterId !== null ? : null} + {seriesDetailRoute !== null ? : null} + diff --git a/src/features/characters/components/CharacterListItem.tsx b/src/features/characters/components/CharacterListItem.tsx index b20550b..f791cce 100644 --- a/src/features/characters/components/CharacterListItem.tsx +++ b/src/features/characters/components/CharacterListItem.tsx @@ -25,10 +25,10 @@ export function CharacterListItem({ character }: { readonly character: Character )} - {character.name} - {description} - ID {character.id} · {character.region} - {character.tags.join(", ")} + {character.name} + {description} + ID {character.id} · {character.region} + {character.tags.join(", ")} diff --git a/src/shared/mocks/__tests__/production-graph.test.ts b/src/shared/mocks/__tests__/production-graph.test.ts index 1da5828..ed1bfdb 100644 --- a/src/shared/mocks/__tests__/production-graph.test.ts +++ b/src/shared/mocks/__tests__/production-graph.test.ts @@ -5,7 +5,7 @@ import { build } from "vite"; import { describe, expect, test } from "vitest"; describe("production mock graph", () => { - test("excludes the browser mock module from the production bundle", async () => { + test("splits production chunks and excludes the browser mock module from the production bundle", async () => { // Given const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-")); const previousNodeEnv = process.env.NODE_ENV; @@ -21,12 +21,16 @@ describe("production mock graph", () => { mode: "production", }); const outputFiles = collectFiles(outDir); - const output = outputFiles - .filter((filePath) => filePath.endsWith(".js")) + const jsFiles = outputFiles.filter((filePath) => filePath.endsWith(".js")); + const output = jsFiles .map((filePath) => readFileSync(filePath, "utf8")) .join("\n"); // Then + expect(jsFiles.length).toBeGreaterThanOrEqual(2); + for (const jsFile of jsFiles) { + expect(statSync(jsFile).size).toBeLessThanOrEqual(500_000); + } expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false); expect(output).not.toContain("mockServiceWorker.js"); expect(output).not.toContain("startMockWorker"); diff --git a/src/shared/ui/__tests__/resource-pagination.test.tsx b/src/shared/ui/__tests__/resource-pagination.test.tsx index 2351f77..734c64b 100644 --- a/src/shared/ui/__tests__/resource-pagination.test.tsx +++ b/src/shared/ui/__tests__/resource-pagination.test.tsx @@ -57,7 +57,7 @@ test("ResourcePagination connects each page size label to a unique select", () = expect(labels.map((label) => label.control)).toEqual(selects); }); -test("ResourcePagination separates page size from an equal-width mobile movement row", () => { +test("ResourcePagination separates page size from stacked mobile movement controls", () => { render(); const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" }); @@ -67,7 +67,7 @@ test("ResourcePagination separates page size from an equal-width mobile movement expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument(); expect(sizeControls).not.toContainElement(previous); - expect(movementControls).toHaveClass("grid-cols-2"); - expect(previous).toHaveClass("min-h-11", "w-full"); - expect(next).toHaveClass("min-h-11", "w-full"); + expect(movementControls).toHaveClass("grid-cols-1"); + expect(previous).toHaveClass("min-h-11", "w-full", "whitespace-nowrap"); + expect(next).toHaveClass("min-h-11", "w-full", "whitespace-nowrap"); }); diff --git a/src/shared/ui/resource-pagination.tsx b/src/shared/ui/resource-pagination.tsx index 5563976..461feed 100644 --- a/src/shared/ui/resource-pagination.tsx +++ b/src/shared/ui/resource-pagination.tsx @@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio return (