[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)을 수정하지 않는 것이 규칙입니다 — 필요한 의존성과 라우트를 여기서 전부 선언합니다.

포함 범위:

  1. 빌드·런타임 기반 — Vite + React 19 + TypeScript(strict: true). @ 경로 별칭(web/src). 스크립트: dev·build·test·lint·typecheck. Vite dev proxy /apihttp://localhost:8080 (BE에 CORS 설정을 추가하지 않기 위함).
  2. 의존성 전량 선언@tanstack/react-query, zustand, react-router, tailwindcss, vitest, @testing-library/react, @testing-library/user-event, jsdom, msw. 후행 티켓이 의존성을 추가하지 않아도 되게 여기서 끝냅니다.
  3. 테마 토큰 (의무)src/theme/tokens.css에 설계 문서 “테마 토큰 정의” 표의 전 토큰을 :root {}.dark {} 두 블록으로 선언합니다. tailwind.config.ts가 CSS 변수를 참조하도록 colors를 매핑합니다(background: 'var(--background)' 형태). 원색을 컴포넌트에서 쓸 수 없도록 Tailwind 기본 팔레트를 비웁니다.
  4. 테마 전환useThemeStore(Zustand + persist): system/light/dark. index.html 인라인 스크립트가 첫 페인트 전에 <html> 클래스를 적용해 플래시를 방지합니다.
  5. API 클라이언트 골격src/api/client.ts: fetch 래퍼, JSON 직렬화, 상태 코드 → ApiError { status, code, message } 정규화. 인증 헤더는 없습니다(NFR-7). 컴포넌트가 직접 호출하지 못하도록 훅 경유 규칙을 lint 규칙(no-restricted-imports)으로 강제합니다.
  6. API DTO 타입 전량src/types/api.ts에 BE TDD “API 계약(P0)” 표의 요청·응답 타입을 선언합니다. 계약 요청 목록(#1~#9)에 해당하는 필드는 설계 문서의 요청안 기준으로 선언하고, 각 항목에 근거 주석과 “BE 확정 시 갱신” 표시를 남깁니다. 후행 티켓이 타입을 각자 만들지 않게 하는 것이 목적입니다.
  7. Query 규약QueryClient 기본 옵션(staleTime 30초, retry 1회, 4xx 재시도 안 함), src/api/queryKeys.ts에 계층형 queryKey 팩토리.
  8. 토스트useToastStore(Zustand) + ToastViewport. “정말 전역”인 두 스토어(테마·토스트)만 만들고 그 외 전역 상태를 만들지 않습니다.
  9. 라우트 스텁 (Single Writer 장치)src/routes.tsx에 설계의 전 라우트를 lazy()미리 선언하고, 각 페이지 파일을 “준비 중” 스텁으로 생성합니다. 각 화면 티켓은 자기 스텁 파일만 대체하므로 라우터 파일 수정이 0건이 되어 wave 내 충돌이 원천 차단되고, 동시에 부분 머지가 안전해집니다(점진 공개).
  10. AppShell 스텁 — 레이아웃 골격만. 내비게이션·404·에러 바운더리 완성은 FE-17.
  11. 테스트 셋업 — Vitest(jsdom) + Testing Library + MSW 서버 부트스트랩(src/test/setup.ts). 핸들러 자체는 FE-04가 채웁니다.
  12. 색 하드코딩 차단 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: booleanapplicationId: 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이 존재한다