feat(ai-character): 관리자 라우트 지연 로딩 적용

This commit is contained in:
Yu Sung
2026-08-06 21:38:12 +09:00
parent 68959cb33b
commit 2a04955bd7
10 changed files with 849 additions and 48 deletions

View File

@@ -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`<chunk ·최대 크기·warning >
- Network·1280px·320px·200% zoom·keyboard·axe — <실제 결과>
- 남은 항목: <없음 또는 구체적인 항목>
- 문서: `docs/20260806_관리자라우트지연로딩/{prd.md,plan-task.md}`
```
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.

View File

@@ -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` |

View File

@@ -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건.
- 남은 항목: 없음.

View File

@@ -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 () => {

View File

@@ -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,6 +198,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
{location.successNotification}
</p>
)}
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : 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 ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
@@ -212,6 +215,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
</Suspense>
</main>
</div>
</div>

View File

@@ -25,10 +25,10 @@ export function CharacterListItem({ character }: { readonly character: Character
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
)}
<span className="min-w-0 flex-1">
<span className="block font-semibold">{character.name}</span>
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">{description}</span>
<span className="mt-2 block text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
<span className="mt-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span>
<span className="block break-keep break-words font-semibold">{character.name}</span>
<span className="mt-1 line-clamp-2 block break-keep break-words text-sm text-muted-foreground">{description}</span>
<span className="mt-2 block break-keep break-words text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
<span className="mt-1 block break-keep break-words text-xs text-muted-foreground">{character.tags.join(", ")}</span>
</span>
</span>
</a>

View File

@@ -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");

View File

@@ -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(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
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");
});

View File

@@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<p className="break-keep text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
<label className="break-keep text-sm font-semibold" htmlFor={pageSizeId}>
</label>
<select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
@@ -25,11 +25,11 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
))}
</select>
</div>
<div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group">
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
<div aria-label="페이지 이동" className="grid grid-cols-1 gap-2 sm:flex" role="group">
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button>
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button>
</div>

View File

@@ -2,6 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
// allow: SIZE_OK - Character workspace browser scenarios share one authenticated E2E surface.
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
async function loginThroughMockMode(page: Page): Promise<void> {
@@ -42,6 +43,27 @@ async function zoomTo200Percent(page: Page): Promise<void> {
});
}
async function expectNoKoreanSyllableColumns(root: Locator): Promise<void> {
const syllableColumns = await root.evaluate((element) => Array.from(element.querySelectorAll<HTMLElement>("*"))
.filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? ""))
.filter((candidate) => {
const style = getComputedStyle(candidate);
return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0;
})
.flatMap((candidate) => {
const range = document.createRange();
range.selectNodeContents(candidate);
const lineWidths = Array.from(range.getClientRects(), (rect) => rect.width).filter((width) => width > 0);
const fontSize = Number.parseFloat(getComputedStyle(candidate).fontSize);
const koreanSyllableCount = candidate.textContent?.match(/[가-힣]/g)?.length ?? 0;
const isSyllableColumn = koreanSyllableCount >= 3 && lineWidths.length >= 3 && Math.max(...lineWidths) <= fontSize * 2.25;
return isSyllableColumn ? [{ lineWidths, text: candidate.textContent?.trim() }] : [];
}));
expect(syllableColumns).toEqual([]);
}
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
@@ -193,6 +215,19 @@ test("mobile cards preserve the selection name and table-visible core metadata",
await expect(lunaCard).toContainText("상담, 힐링");
});
test("mobile zoom keeps Korean list and pagination text out of syllable columns", async ({ page }) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
await loginThroughMockMode(page);
// When
await zoomTo200Percent(page);
// Then
await expectNoKoreanSyllableColumns(page.getByRole("region", { name: "AI 캐릭터 목록" }));
await expectNoKoreanSyllableColumns(page.getByRole("navigation", { name: "페이지" }));
});
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");