[FE-01] 웹 스캐폴딩 · 테마 토큰 · API 클라이언트 · 라우트 스텁
작업 내용 (설계 의도)
근거 설계: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-공고알림앱-design-fe-web.md
변경 사항
대상 레포는 README와 마커만 있는 빈 레포이고 BE-01이 루트에 Gradle 프로젝트를 만듭니다. FE는 같은 레포의 web/ 디렉토리에 Vite SPA로 자리 잡아 BE 파일과 경로가 겹치지 않게 합니다(설계 “레포 배치 결정”).
후행 FE 티켓 16건 전부가 이 티켓 산출물에 의존하므로, 연관된 병목을 하나로 묶어 wave 1을 단일 티켓으로 끝냅니다. 이후 wave에서는 이 티켓이 만든 설정 파일(vite.config.ts·tailwind.config.ts·tsconfig.json·package.json·routes.tsx·tokens.css)을 수정하지 않는 것이 규칙입니다 — 필요한 의존성과 라우트를 여기서 전부 선언합니다.
포함 범위:
- 빌드·런타임 기반 — Vite + React 19 + TypeScript(
strict: true).@경로 별칭(web/src). 스크립트:dev·build·test·lint·typecheck. Vite dev proxy/api→http://localhost:8080(BE에 CORS 설정을 추가하지 않기 위함). - 의존성 전량 선언 —
@tanstack/react-query,zustand,react-router,tailwindcss,vitest,@testing-library/react,@testing-library/user-event,jsdom,msw. 후행 티켓이 의존성을 추가하지 않아도 되게 여기서 끝냅니다. - 테마 토큰 (의무) —
src/theme/tokens.css에 설계 문서 “테마 토큰 정의” 표의 전 토큰을:root {}와.dark {}두 블록으로 선언합니다.tailwind.config.ts가 CSS 변수를 참조하도록colors를 매핑합니다(background: 'var(--background)'형태). 원색을 컴포넌트에서 쓸 수 없도록 Tailwind 기본 팔레트를 비웁니다. - 테마 전환 —
useThemeStore(Zustand +persist):system/light/dark.index.html인라인 스크립트가 첫 페인트 전에<html>클래스를 적용해 플래시를 방지합니다. - API 클라이언트 골격 —
src/api/client.ts:fetch래퍼, JSON 직렬화, 상태 코드 →ApiError { status, code, message }정규화. 인증 헤더는 없습니다(NFR-7). 컴포넌트가 직접 호출하지 못하도록 훅 경유 규칙을 lint 규칙(no-restricted-imports)으로 강제합니다. - API DTO 타입 전량 —
src/types/api.ts에 BE TDD “API 계약(P0)” 표의 요청·응답 타입을 선언합니다. 계약 요청 목록(#1~#9)에 해당하는 필드는 설계 문서의 요청안 기준으로 선언하고, 각 항목에 근거 주석과 “BE 확정 시 갱신” 표시를 남깁니다. 후행 티켓이 타입을 각자 만들지 않게 하는 것이 목적입니다. - Query 규약 —
QueryClient기본 옵션(staleTime30초,retry1회, 4xx 재시도 안 함),src/api/queryKeys.ts에 계층형 queryKey 팩토리. - 토스트 —
useToastStore(Zustand) +ToastViewport. “정말 전역”인 두 스토어(테마·토스트)만 만들고 그 외 전역 상태를 만들지 않습니다. - 라우트 스텁 (Single Writer 장치) —
src/routes.tsx에 설계의 전 라우트를lazy()로 미리 선언하고, 각 페이지 파일을 “준비 중” 스텁으로 생성합니다. 각 화면 티켓은 자기 스텁 파일만 대체하므로 라우터 파일 수정이 0건이 되어 wave 내 충돌이 원천 차단되고, 동시에 부분 머지가 안전해집니다(점진 공개). - AppShell 스텁 — 레이아웃 골격만. 내비게이션·404·에러 바운더리 완성은 FE-17.
- 테스트 셋업 — Vitest(jsdom) + Testing Library + MSW 서버 부트스트랩(
src/test/setup.ts). 핸들러 자체는 FE-04가 채웁니다. - 색 하드코딩 차단 lint —
#hex·rgb(·Tailwind 원색 클래스 사용을 에러로 처리하는 규칙을 추가합니다(no-hardcoded-color강제).
BE와의 파일 경계: 이 티켓은 web/**만 생성합니다. 루트 docker-compose.yml·build.gradle.kts는 건드리지 않습니다(compose에 web 서비스 추가는 FE-17).
의존
- 없음 (wave 1)
다이어그램
처리 흐름
sequenceDiagram participant Dev as 개발자 participant V as Vite participant H as index.html participant A as App participant R as RouterProvider Dev->>V: npm run dev V->>H: 문서 서빙 H->>H: 인라인 스크립트가 dark 클래스 선적용 H->>A: 마운트 A->>A: QueryProvider · ToastViewport 구성 A->>R: routes.tsx (lazy 스텁 전량) R-->>Dev: "준비 중" 스텁 렌더
클래스 의존
flowchart LR subgraph Theme["theme"] Tokens[tokens.css] Store[useThemeStore] end subgraph Api["api"] Client[client.ts] Keys[queryKeys.ts] Types[types/api.ts] end subgraph App["app"] Root[App.tsx] Routes[routes.tsx] Shell[AppShell 스텁] Toast[useToastStore] end Root --> Routes Root --> Toast Routes --> Shell Shell --> Store Store --> Tokens Client --> Types Client --> Keys
테스트 케이스
tsc --noEmit·lint·vite build가 모두 exit 0으로 완료된다- 테마 스토어 기본값이
system이고prefers-color-scheme: dark에서<html>에dark클래스가 붙는다 - 테마를
light로 오버라이드하면dark클래스가 제거되고localStorage에 값이 저장된다 - 저장된 오버라이드가 있으면 재마운트 시 시스템 설정보다 우선 적용된다
- API 클라이언트가 500 응답을
ApiError로 정규화하고status를 보존한다 - API 클라이언트가 4xx 응답의
code·message를 파싱해 노출한다 - API 클라이언트가 네트워크 실패를
ApiError로 감싸 던진다 - QueryClient 기본 설정이 4xx에 재시도하지 않고 5xx에 1회 재시도한다
- 알 수 없는 경로가 404 스텁으로 라우팅된다
- 선언된 전 라우트가 “준비 중” 스텁으로 렌더되고 콘솔 에러가 0건이다
- 토스트 스토어에 메시지를 추가하면 뷰포트에 렌더되고 지정 시간 후 제거된다
- 소스 전체에 색 하드코딩(
#hex·rgb()이 0건임을 lint가 검증한다
2차 갱신 반영 (2026-07-22)
BE 계약 응답 + 애그리게이터 P0 편입으로 이 티켓의 산출물이 확장됩니다. 파일 소유 경계(설정·타입·라우트)는 그대로입니다.
DTO 타입 추가·수정 (src/types/api.ts)
- 공고 목록 아이템:
applied: boolean→applicationId: number | null,sourceType: 'COMPANY_BOUND' | 'AGGREGATOR',alternateSourceCount: number추가 - 공고 상세:
alternateSources: { jobSourceId, platform, postingUrl, isRepresentative }[]추가 (계약 요청 #11 확정 대기 — 요청안 shape로 선언) - 회사:
companyOrigin: 'WATCHED' | 'DISCOVERED',brokenSourceCount: number추가. 회사 목록 응답에totalCount·page(페이지네이션) - 지원 상세·전이 응답:
allowedNextStatuses: string[]추가 - 재평가 응답:
skippedCount추가 - 애그리게이터:
POST /api/aggregator-sources요청{ platform: 'SARAMIN' | 'JUMPIT', searchCategoryCode, searchKeyword? }, 승격POST /api/companies/{id}/promotion - 수집 이력:
guardApplied제거,abnormal: boolean단일 필드
라우트 스텁 추가 (src/routes.tsx)
/aggregator-sources/new스텁 페이지 선언 (FE-18이 대체)
추가 테스트 케이스
/aggregator-sources/new가 “준비 중” 스텁으로 라우팅된다- 공고 목록 DTO 타입에
applicationId·sourceType·alternateSourceCount가 존재하고applied필드가 없다 - 회사 DTO 타입에
companyOrigin·brokenSourceCount가 존재한다 - 수집 이력 DTO 타입에
guardApplied필드가 없고abnormal이 존재한다
3차 갱신 반영 (2026-07-22, BE 최종 확정)
DTO 타입 추가·수정 (src/types/api.ts)
- 카테고리 응답:
GET /api/aggregator-sources/categories?platform=→{ categories: { code: string; label: string }[] } - 애그리게이터 소스 목록:
GET /api/aggregator-sources→{ sources: { id, platform, searchCategoryCode, searchCategoryLabel, searchKeyword: string | null, disabled: boolean }[] } alternateSources[]shape 확정 —{ jobSourceId: number; platform: string; postingUrl: string; isRepresentative: boolean }(대표 포함 그룹 전체). 기존 “확정 대기” 주석 제거.
라우트 스텁 추가 (src/routes.tsx)
/aggregator-sources(소스 관리, S-13) 스텁 추가 —/aggregator-sources/new(등록, S-12)와 함께 FE-18이 대체
추가 테스트 케이스
/aggregator-sources(관리)와/aggregator-sources/new(등록)가 각각 “준비 중” 스텁으로 라우팅된다- 카테고리 응답 타입이
{code, label}[]이다 - 애그리게이터 소스 목록 아이템 타입에
disabled: boolean이 존재한다