diff --git a/.claude/skills/create/SKILL.md b/.claude/skills/create/SKILL.md new file mode 100644 index 00000000..297af730 --- /dev/null +++ b/.claude/skills/create/SKILL.md @@ -0,0 +1,116 @@ +--- +name: create +description: 스펙 문서를 읽고 지정한 경로에 코드를 생성한다. FSD 레이어 책임 범위, conventions, code-quality 4원칙을 준수하며 테스트까지 작성한다. +arguments: [path, spec] +--- + +# create + +스펙 문서와 대상 경로를 바탕으로 코드를 생성하고, 테스트까지 작성한다. + +**참조 문서:** + +- @docs/architecture.md — FSD 레이어별 책임 범위 및 의존성 방향 +- @docs/conventions.md — 파일 네이밍 및 코드 컨벤션 +- @docs/code-quality.md — 코드 품질 기준 (가독성 / 예측 가능성 / 응집도 / 결합도) +- @docs/testing-guide.md — 테스트 도구 선택 기준 + +--- + +## 실행 절차 + +### 1단계 — 스펙 파악 + +`$spec` 경로의 문서를 읽고 다음을 파악한다. + +- 구현해야 할 기능과 동작 조건 +- 필요한 props / 인터페이스 / 상태 +- 예외 처리 및 에러 케이스 +- 스펙에 명시되지 않은 부분은 architecture.md와 conventions.md를 기준으로 합리적으로 판단하고, 가정한 내용을 결과 보고에 명시한다. + +### 2단계 — FSD 레이어 판단 + +`$path`에서 레이어를 확인하고 `docs/architecture.md` 기준으로 책임 범위를 파악한다. + +- 생성할 코드가 해당 레이어의 책임에 맞는가? +- 의존성 방향이 올바른가? (상위 레이어 → 하위 레이어만 허용) +- 이미 존재하는 인접 파일이 있으면 함께 읽어 패턴을 맞춘다. + +### 3단계 — 코드 생성 + +`docs/conventions.md`의 네이밍 규칙과 `docs/code-quality.md`의 4원칙을 준수하며 코드를 작성한다. + +**가독성:** + +- 동시에 실행되지 않는 분기는 컴포넌트/함수로 분리한다. +- 구현 세부사항은 적절히 추상화한다. +- 복잡한 조건과 매직 넘버에는 이름을 붙인다. +- 중첩 삼항 연산자 대신 if 문을 사용한다. +- 비교 연산은 왼쪽에서 오른쪽으로 읽히도록 작성한다. (`minPrice <= price && price <= maxPrice`) + +**예측 가능성:** + +- 같은 유형의 함수는 반환 타입을 통일한다. (API 훅은 Query 객체, validation 함수는 `{ ok, reason }`) +- 함수 이름·파라미터·반환값으로 예측할 수 없는 로직은 함수 밖으로 분리한다. +- 기존 코드베이스의 이름과 충돌하지 않도록 명확한 이름을 사용한다. + +**응집도:** + +- 함께 수정될 파일은 같은 디렉터리에 둔다. +- 매직 넘버는 상수로 선언해 변경 지점을 하나로 모은다. +- 폼 검증은 필드 레벨/폼 레벨 중 스펙에 맞는 방식을 선택한다. + +**결합도:** + +- 하나의 함수/훅이 하나의 책임만 가지도록 설계한다. +- Props Drilling이 발생하면 Composition 패턴으로 해소한다. +- 공통 추출보다 중복 허용이 나은 경우를 판단한다. (페이지마다 동작이 달라질 가능성이 있으면 중복 허용) + +### 4단계 — 테스트 작성 + +`docs/testing-guide.md`의 레이어별 기준에 따라 테스트를 작성한다. + +**테스트 작성 판단 기준:** + +- 버그가 생기면 치명적인가? +- 이 코드가 바뀔 가능성이 높은가? +- 로직이 복잡한가? + +→ 하나라도 해당되면 Jest + RTL 테스트를 작성한다. + +**레이어별 테스트 도구:** + +| 경로 패턴 | Jest + RTL | Storybook Chromatic | Storybook play() | +| ------------------------------------- | --------------------- | ------------------- | ---------------- | +| `shared/lib`, `shared/utils` | 반드시 | — | — | +| `shared/hooks`, `shared/store` | 반드시 (`renderHook`) | — | — | +| `shared/ui` | 내부 로직이 있을 때만 | 항상 | 검토 | +| `entities/model`, `entities/api` | 반드시 | — | — | +| `entities/ui` | 검토 | 항상 | 항상 | +| `entities/query` | **작성 금지** | — | — | +| `features/ui` | 반드시 | 검토 | 검토 | +| `features/mutation`, `features/hooks` | 반드시 (MSW 모킹) | — | — | +| `widgets/` | 핵심 인터랙션만 | 검토 | 검토 | + +### 5단계 — 검증 + +```bash +# 타입 체크 +pnpm tsc --noEmit + +# lint +pnpm lint {생성한 파일 경로} + +# 테스트 실행 +pnpm test -- {생성한 테스트 파일 경로} +``` + +실패하면 에러를 읽고 수정한다. 모든 항목이 통과할 때까지 반복한다. + +### 6단계 — 결과 보고 + +- 생성한 파일 목록 +- 파일별 역할 요약 +- 스펙에 명시되지 않아 가정한 내용 +- 원칙 간 트레이드오프가 있었던 경우 판단 이유 +- Storybook / Chromatic이 추가로 필요하다 판단되면 언급 diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md new file mode 100644 index 00000000..8024e03c --- /dev/null +++ b/.claude/skills/document-feature/SKILL.md @@ -0,0 +1,115 @@ +--- +name: document-feature +description: 지정한 기능의 코드를 추적해 docs/features/{name}.md 스펙 문서를 생성한다. 이 문서는 이후 변경 요청의 기준점으로 사용된다. +arguments: [feature-name, entry-path] +--- + +# document-feature + +기능 코드를 추적해 스펙 문서를 생성한다. +생성된 문서는 **스펙을 먼저 수정 → Claude에게 구현 요청** 워크플로의 기준점이 된다. + +--- + +## 실행 절차 + +### 1단계 — 진입점 파악 + +`$entry-path`에서 시작해 이 기능이 어디서 호출되는지 찾는다. + +- 버튼 컴포넌트, 메뉴 항목, 페이지 마운트 등 사용자 행동과 연결된 모든 진입점을 찾는다. +- `grep`으로 기능 핵심 훅/컴포넌트 이름을 역추적해 누락된 진입점이 없는지 확인한다. + +### 2단계 — 데이터 흐름 추적 + +진입점에서 시작해 다음 순서로 관련 파일을 모두 읽는다. + +1. **훅** (`features/{domain}/hooks/`) — 모달/페이지 open 로직, 사전 데이터 조회 +2. **폼 훅** (`features/{domain}/hooks/use*Form.ts`) — 폼 상태, 유효성 검사, submit 핸들러 +3. **mutation 훅** (`features/{domain}/mutation/`) — API 호출, 캐시 무효화, 토스트 +4. **UI** (`features/{domain}/ui/`) — 폼 레이아웃, 필드 구성, 서브 컴포넌트 +5. **entity API** (`entities/{domain}/api/`) — 실제 HTTP 호출 함수 +6. **entity 타입** (`entities/{domain}/types/`) — Request / Response 타입 +7. **mock 핸들러** (`features/{domain}/mock/` 또는 `shared/mock/`) — API 스펙 보조 확인 + +파일을 읽으며 다음을 기록한다. + +- 폼 필드 목록과 각 필드의 타입·필수 여부·제약 +- 유효성 검사 규칙과 처리 방식 +- 모달/페이지에 사전 주입되는 데이터와 그 출처 +- API endpoint, request body, response 타입, 캐시 무효화 키 +- 개인/팀 목표처럼 조건에 따라 동작이 달라지는 분기 + +### 3단계 — 문서 생성 + +`docs/features/$feature-name.md`를 아래 구조로 작성한다. +해당 기능에 없는 섹션(예: 폼이 없는 경우 "폼 필드 스펙")은 생략한다. + +```markdown +# {기능명} + +--- + +## 진입점 + +| 위치 | 파일 | 조건 | +| ... | ... | ... | + +진입점이 공통으로 호출하는 함수/훅을 한 줄로 서술한다. + +--- + +## 폼 필드 스펙 ← 폼이 있을 때만 + +| 필드 | 타입 | 필수 | 제약 | 비고 | +| ... | ... | ... | ... | ... | + +조건에 따라 동작이 달라지는 필드(예: 개인/팀 목표)는 별도 표로 설명한다. + +--- + +## 유효성 검사 ← 검사 규칙이 있을 때만 + +| 검사 | 시점 | 처리 | +| ... | ... | ... | + +--- + +## 데이터 흐름 + +텍스트 다이어그램으로 진입점 → 훅 → mutation → API → 후처리 순서를 표현한다. +각 노드에 실제 파일명과 핵심 동작을 함께 기재한다. + +--- + +## API + +엔드포인트, request body 타입(코드 블록), response 타입, 캐시 무효화 키를 기재한다. + +--- + +## 모달/페이지에 사전 주입되는 데이터 ← 데이터 주입이 있을 때만 + +| prop | 출처 | +| ... | ... | + +--- + +## 관련 파일 위치 + +src/ 트리 형태로 관련 파일 경로와 각 파일의 역할을 한 줄로 기재한다. +``` + +**작성 원칙:** + +- 표(table)로 표현할 수 있는 정보는 표로 쓴다. +- 데이터 흐름은 텍스트 다이어그램(`│`, `▼`, `←`)으로 표현한다. +- 코드 스니펫은 타입 정의처럼 변경 시 반드시 함께 수정해야 하는 것만 포함한다. +- 파일 위치 섹션은 "어디를 건드려야 하는가"를 빠르게 파악할 수 있게 작성한다. +- Claude가 이 문서만 읽고 구현 변경을 수행할 수 있을 만큼 구체적으로 쓴다. + +### 4단계 — 결과 보고 + +- 생성한 파일 경로 +- 추적한 파일 목록 +- 문서에 포함하지 못한 불확실한 부분이 있으면 명시 diff --git a/.claude/skills/refactor/SKILL.md b/.claude/skills/refactor/SKILL.md new file mode 100644 index 00000000..8531b5e4 --- /dev/null +++ b/.claude/skills/refactor/SKILL.md @@ -0,0 +1,99 @@ +--- +name: refactor +description: 지정한 경로의 코드를 docs/code-quality.md의 4원칙(가독성·예측 가능성·응집도·결합도) 기준으로 분석하고 개선한다. FSD 레이어와 conventions도 함께 준수한다. +arguments: [path] +--- + +# refactor + +대상 코드를 읽고, 품질 기준에 맞게 개선한 뒤 lint와 테스트로 검증한다. + +**참조 문서:** + +- @docs/code-quality.md — 리팩터링 판단 기준 (가독성 / 예측 가능성 / 응집도 / 결합도) +- @docs/architecture.md — FSD 레이어별 책임 범위 +- @docs/conventions.md — 파일 네이밍 및 코드 컨벤션 + +--- + +## 실행 절차 + +### 1단계 — 대상 파일 파악 + +`$path` 경로 아래의 모든 소스 파일을 읽는다. +연관된 파일(import 대상, 같은 레이어의 인접 파일)도 함께 읽어 전체 맥락을 파악한다. + +### 2단계 — 문제 분석 + +`docs/code-quality.md`의 4원칙 기준으로 개선이 필요한 부분을 파악한다. +각 문제에 대해 **어떤 원칙을 위반하는지**, **왜 문제인지**를 명시한다. + +**가독성 체크리스트:** + +- 동시에 실행되지 않는 코드가 한 함수/컴포넌트에 혼재하는가? +- 구현 세부사항이 불필요하게 노출되어 있는가? +- 로직 유형(query param, state 등)으로 함수를 묶고 있는가? +- 복잡한 조건이나 매직 넘버에 이름이 없는가? +- 중첩 삼항 연산자가 있는가? +- 코드를 위에서 아래로 읽을 때 눈의 이동이 많은가? + +**예측 가능성 체크리스트:** + +- 같은 이름을 가진 함수/변수가 다른 동작을 하는가? +- 유사한 함수의 반환 타입이 일치하지 않는가? +- 함수 이름·파라미터·반환값으로 예측할 수 없는 숨겨진 로직이 있는가? + +**응집도 체크리스트:** + +- 함께 수정될 파일이 서로 다른 디렉터리에 흩어져 있는가? +- 매직 넘버가 하드코딩되어 변경 지점이 여러 곳에 분산되어 있는가? +- 폼의 검증 로직이 적절한 레벨(필드/폼)에서 관리되고 있는가? + +**결합도 체크리스트:** + +- 하나의 함수/훅이 지나치게 넓은 책임을 가지고 있는가? +- 중복을 제거하기 위해 공통 훅/컴포넌트로 묶었지만 페이지마다 동작이 달라질 가능성이 있는가? +- Props Drilling이 발생하고 있는가? + +**FSD/conventions 체크리스트:** + +- 레이어 간 의존성 방향이 올바른가? (상위 레이어 → 하위 레이어) +- 파일명·함수명이 conventions.md를 준수하는가? + +### 3단계 — 리팩터링 계획 수립 + +분석 결과를 바탕으로 수정할 항목을 우선순위와 함께 나열한다. + +> 원칙 간 트레이드오프가 있을 때는 현재 코드의 맥락을 고려해 판단하고, 이유를 명시한다. +> 예: "응집도를 높이면 가독성이 낮아지지만, 이 값은 반드시 함께 수정되어야 하므로 응집도를 우선한다." + +### 4단계 — 리팩터링 실행 + +계획한 순서대로 코드를 수정한다. + +- 한 번에 하나의 원칙씩 수정하고, 각 수정이 다른 원칙을 침해하지 않는지 확인한다. +- import 경로는 **반드시 현재 실제 경로**를 사용한다. (구 경로 사용 금지) +- 수정 범위가 넓다면 파일별로 나누어 단계적으로 진행한다. + +### 5단계 — 검증 + +```bash +# 타입 체크 +pnpm tsc --noEmit + +# lint +pnpm lint {수정한 파일 경로} + +# 관련 테스트 실행 +pnpm test -- {관련 테스트 파일 경로} +``` + +실패하면 에러를 읽고 수정한다. 모든 항목이 통과할 때까지 반복한다. + +### 6단계 — 결과 보고 + +- 수정한 파일 목록 +- 파일별 적용한 원칙과 변경 내용 요약 +- 트레이드오프가 있었던 경우 판단 이유 명시 +- 이번 리팩터링 범위에서 의도적으로 제외한 항목이 있다면 이유 명시 +- 테스트가 추가로 필요하다 판단되면 언급 (`/write-tests` 실행 제안) diff --git a/.claude/skills/write-tests/SKILL.md b/.claude/skills/write-tests/SKILL.md index 90077e12..a0edaf9f 100644 --- a/.claude/skills/write-tests/SKILL.md +++ b/.claude/skills/write-tests/SKILL.md @@ -30,20 +30,39 @@ arguments: [path] 경로에서 레이어를 확인하고 `docs/testing-guide.md`의 기준으로 도구를 결정한다. -| 경로 패턴 | 도구 | -| ------------------------------------- | -------------------------------- | -| `shared/lib`, `shared/utils` | Jest + RTL (필수) | -| `shared/hooks`, `shared/store` | Jest + RTL (`renderHook`) | -| `shared/ui` | Jest + RTL + Storybook play 검토 | -| `entities/model` | Jest + RTL | -| `entities/api` | Jest + RTL (apiClient 모킹) | -| `entities/query` | **테스트 작성 금지** | -| `features/mutation`, `features/hooks` | Jest + RTL (MSW 모킹) | -| `widgets/` | Jest + RTL (핵심 인터랙션만) | +**도구 선택 전 판단 기준 (Jest + RTL):** -Chromatic은 CI 설정이므로 코드로 작성하지 않는다. 해당 컴포넌트라면 주석으로 언급만 한다. +다음 중 하나라도 해당되면 Jest + RTL 테스트를 작성한다. -### 3단계 — 테스트 작성 +- 버그가 생기면 치명적인가? +- 이 코드가 바뀔 가능성이 높은가? +- 로직이 복잡한가? + +**도구 선택 전 판단 기준 (Story 정의):** + +다음 중 하나라도 해당되면 Story를 작성한다. + +- 다른 UI에서 재사용될 가능성이 있는가? +- props나 상태 변경에 따른 UI 변화가 있는가? + +**레이어별 도구 선택표:** + +| 경로 패턴 | Jest + RTL | Storybook Chromatic | Storybook play() | +| ------------------------------------- | --------------------- | ------------------- | ---------------- | +| `shared/lib`, `shared/utils` | 반드시 | — | — | +| `shared/hooks`, `shared/store` | 반드시 (`renderHook`) | — | — | +| `shared/ui` | 내부 로직이 있을 때만 | 항상 | 검토 | +| `entities/model`, `entities/api` | 반드시 | — | — | +| `entities/ui` | 검토 | 항상 | 항상 | +| `entities/query` | **작성 금지** | — | — | +| `features/ui` | 반드시 | 검토 | 검토 | +| `features/mutation`, `features/hooks` | 반드시 (MSW 모킹) | — | — | +| `widgets/` | 핵심 인터랙션만 | 검토 | 검토 | + +> `entities/query` — React Query 훅처럼 외부 라이브러리에 의존하는 레이어는 테스트 작성 금지. +> Chromatic은 CI 설정이므로 코드로 작성하지 않는다. 해당 레이어라면 주석으로 언급만 한다. + +### 3단계 — Jest + RTL 테스트 작성 **파일 위치:** 소스 파일과 같은 디렉터리에 `{SourceFile}.test.tsx` (또는 `.test.ts`) @@ -83,6 +102,25 @@ const mockUseParams = useParams as jest.MockedFunction; mockUseParams.mockReturnValue({ teamId: "1" } as ReturnType); ``` +**훅 반환값 mock 시 타입 단언 규칙:** + +`UseMutationResult`, `UseQueryResult` 같이 필드 수가 많은 타입은 일부 필드만 제공한 객체를 `as ReturnType<...>` 단일 단언으로 캐스팅하면 TS 에러가 발생한다. +테스트에 필요한 필드만 제공할 때는 반드시 `as unknown as ReturnType<...>` 이중 단언을 사용한다. + +```ts +// ❌ 단일 단언 — UseMutationResult와 구조가 충분히 겹치지 않아 TS 에러 +mockUseXxxMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, +} as ReturnType); + +// ✅ 이중 단언 — unknown을 경유해 타입 검사를 우회 +mockUseXxxMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, +} as unknown as ReturnType); +``` + **커스텀 훅 테스트 (renderHook) 기본 패턴:** ```ts @@ -114,7 +152,78 @@ describe("{컴포넌트 또는 훅 이름}", () => { }); ``` -### 4단계 — 실행 및 검증 +### 4단계 — Story 작성 + +2단계에서 Story 기준에 해당한다고 판단한 컴포넌트에 대해 `{SourceFile}.stories.tsx`를 작성한다. + +**파일 위치:** 소스 파일과 같은 디렉터리 + +**기본 구조:** + +```tsx +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { ComponentName } from "./ComponentName"; + +const meta = { + title: "{layer}/{domain}/{ComponentName}", + component: ComponentName, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { args: { ... } }; +export const AnotherVariant: Story = { args: { ... } }; +``` + +**Story 케이스 선정 기준:** + +- props 값에 따른 시각적 상태 변화 (예: status별 색상, 개수별 레이아웃) +- 빈 상태 / 경계값 (예: 담당자 0명, 최대 초과) +- 이미 커버된 케이스는 작성하지 않는다 + +**복잡한 hook 의존성이 있는 경우:** + +React Query가 필요한 컴포넌트는 전역 `preview.tsx`에 `QueryClientProvider` + `Suspense` 데코레이터가 있는지 확인한다. +없으면 story에 로컬 decorator로 추가한다. + +```tsx +decorators: [ + (Story) => ( + + + + ), +], +``` + +MSW로 API를 인터셉트해야 하는 컴포넌트(`useSuspenseQuery` 내부 호출 포함)는 +`.storybook/preview.tsx`에 `beforeAll`로 worker가 시작되는지 먼저 확인한다. +없으면 추가한 뒤 story를 작성한다. + +```tsx +// .storybook/preview.tsx +import { worker } from "@/shared/mock/browser"; +beforeAll: async () => { + await worker.start({ onUnhandledRequest: "bypass" }); +}, +``` + +`useParams`가 필요한 경우 `parameters.nextjs.navigation.segments`로 공급한다. + +```tsx +parameters: { + nextjs: { + appDirectory: true, + navigation: { + segments: { goalId: "1" }, + }, + }, +}, +``` + +### 5단계 — 실행 및 검증 작성 후 아래 명령으로 실행한다. @@ -125,9 +234,8 @@ pnpm test -- {작성한 테스트 파일 경로} 실패하면 에러를 읽고 수정한다. 모든 케이스가 통과할 때까지 반복한다. 테스트 통과 후, 수정 파일에 대한 lint 에러도 확인한다. -### 5단계 — 결과 보고 +### 6단계 — 결과 보고 -- 작성한 파일 목록 +- 작성한 파일 목록 (Jest 테스트 + Story 파일 모두) - 파일별 커버한 시나리오 요약 - 의도적으로 제외한 케이스가 있으면 이유 명시 -- Storybook play가 추가로 필요하다 판단되면 언급 diff --git a/.storybook/main.ts b/.storybook/main.ts index fe1aecaf..6d67e665 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,6 +1,10 @@ import type { StorybookConfig } from "@storybook/nextjs-vite"; +import path from "path"; +import { fileURLToPath } from "url"; import svgr from "vite-plugin-svgr"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const config: StorybookConfig = { stories: [ // 스토리북 파일 경로 설정 @@ -29,6 +33,13 @@ const config: StorybookConfig = { include: "**/*.svg", }), ]; + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + "@": path.resolve(__dirname, "../src"), + }, + }; return config; }, staticDirs: ["../public"], diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 8866056a..00000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,23 +0,0 @@ -import "@/app/globals.css"; - -import type { Preview } from "@storybook/nextjs-vite"; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: "todo", - }, - }, -}; - -export default preview; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 00000000..107c9ce0 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,43 @@ +import "@/app/globals.css"; + +import type { Preview } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Suspense } from "react"; + +import { worker } from "@/shared/mock/browser"; +import ToastProvider from "@/shared/providers/ToastProvider"; + +const preview: Preview = { + beforeAll: async () => { + await worker.start({ onUnhandledRequest: "bypass" }); + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + + + + + ); + }, + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: "todo", + }, + }, +}; + +export default preview; diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json new file mode 100644 index 00000000..4110cb8d --- /dev/null +++ b/.storybook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["*.ts", "*.tsx", "../src/app/globals.css", "../src/global.d.ts"] +} diff --git a/CLAUDE.md b/CLAUDE.md index a272f486..5cdeab9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,5 +137,6 @@ MSW runs in `development` only. Initialized via `initMocks()` in `app/layout.tsx ## References -- Architecture & layer rules: [@docs/architecture.md](@docs/architecture.md) -- Naming & code conventions: [@docs/conventions.md](@docs/conventions.md) +- Architecture & layer rules: @docs/architecture.md +- Naming & code conventions: @docs/conventions.md +- Code quality: @docs/code-quality.md diff --git a/docs/code-quality.md b/docs/code-quality.md new file mode 100644 index 00000000..8d01d289 --- /dev/null +++ b/docs/code-quality.md @@ -0,0 +1,582 @@ +# Frontend Fundamentals — 코드 품질 가이드 + +> Toss 프론트엔드 챕터가 정리한 "변경하기 쉬운 코드"의 기준. +> 좋은 프론트엔드 코드 = **변경하기 쉬운 코드**. 새 요구사항을 구현할 때 수정과 배포가 쉬우면 좋은 코드다. + +--- + +## 핵심 원칙 4가지 + +| 원칙 | 한 줄 정의 | +| -------------------------------- | ---------------------------------------------------------------- | +| **가독성 (Readability)** | 한 번에 파악해야 할 맥락이 적고, 위에서 아래로 자연스럽게 읽힌다 | +| **예측 가능성 (Predictability)** | 이름·파라미터·반환값만 봐도 동작을 예측할 수 있다 | +| **응집도 (Cohesion)** | 함께 수정될 코드는 항상 함께 수정된다 | +| **결합도 (Coupling)** | 코드를 수정했을 때 영향 범위가 제한적이다 | + +> ⚠️ 네 가지를 동시에 만족하기는 어렵다. 상황에 따라 무엇을 우선할지 판단해야 한다. +> 예를 들어 응집도를 높이려고 추상화하면 가독성이 낮아질 수 있고, 중복을 허용하면 결합도는 낮아지지만 응집도도 낮아진다. + +--- + +## 1. 가독성 (Readability) + +코드를 수정하려면 먼저 이해해야 한다. 가독성이 높은 코드는 한 번에 고려할 맥락이 적고, 위에서 아래로 자연스럽게 읽힌다. + +### 1-1. 맥락 줄이기 + +#### A. 함께 실행되지 않는 코드는 분리한다 + +하나의 함수나 컴포넌트 안에 동시에 실행되지 않는 코드가 섞여 있으면 한눈에 파악하기 어렵다. + +```tsx +// ❌ Before: viewer / 일반 유저 로직이 한 컴포넌트에 혼재 +function SubmitButton() { + const isViewer = useRole() === "viewer"; + + useEffect(() => { + if (isViewer) return; + showButtonAnimation(); + }, [isViewer]); + + return isViewer ? ( + Submit + ) : ( + + ); +} + +// ✅ After: 분기를 하나로 모으고 각 컴포넌트는 한 가지 맥락만 담당 +function SubmitButton() { + const isViewer = useRole() === "viewer"; + return isViewer ? : ; +} + +function ViewerSubmitButton() { + return Submit; +} + +function AdminSubmitButton() { + useEffect(() => { + showButtonAnimation(); + }, []); + return ; +} +``` + +#### B. 구현 세부사항을 추상화한다 + +불필요한 세부사항이 노출되면 한 번에 파악해야 할 맥락이 늘어난다. + +```tsx +// ❌ Before: 로그인 체크 로직이 LoginStartPage 안에 그대로 노출 +function LoginStartPage() { + useCheckLogin({ + onChecked: (status) => { + if (status === "LOGGED_IN") location.href = "/home"; + }, + }); + return <>{/* 로그인 관련 컴포넌트 */}; +} + +// ✅ After A — Wrapper 컴포넌트로 분리 +function App() { + return ( + + + + ); +} + +function AuthGuard({ children }) { + const status = useCheckLoginStatus(); + useEffect(() => { + if (status === "LOGGED_IN") location.href = "/home"; + }, [status]); + return status !== "LOGGED_IN" ? children : null; +} + +// ✅ After B — HOC로 분리 +export default withAuthGuard(LoginStartPage); +``` + +관련 로직과 실행 버튼을 함께 추상화하면 각 컴포넌트의 역할이 명확해진다. + +```tsx +// ❌ Before: FriendInvitation 안에 overlay 열기 로직이 노출 +function FriendInvitation() { + const handleClick = async () => { + const canInvite = await overlay.openAsync(/* 복잡한 JSX */); + if (canInvite) await sendPush(); + }; + return ; +} + +// ✅ After: 초대 관련 로직을 InviteButton으로 추상화 +function FriendInvitation() { + return ; +} + +function InviteButton({ name }) { + return ( + + ); +} +``` + +#### C. 로직 유형이 아닌 역할 단위로 함수를 분리한다 + +"이 페이지의 모든 query param을 관리한다"처럼 로직 유형으로 묶으면 책임이 무한정 커진다. + +```tsx +// ❌ Before: 페이지 전체의 query param을 한 훅에서 관리 +function usePageState() { + const [query, setQuery] = useQueryParams({ + cardId: NumberParam, + statementId: NumberParam, + dateFrom: DateParam, + // ... + }); + // ... +} + +// ✅ After: query param별로 훅을 분리 +function useCardIdQueryParam() { + const [cardId, _setCardId] = useQueryParam("cardId", NumberParam); + const setCardId = useCallback((cardId: number) => { + _setCardId({ cardId }, "replaceIn"); + }, []); + return [cardId ?? undefined, setCardId] as const; +} +``` + +--- + +### 1-2. 네이밍 + +#### A. 복잡한 조건에 이름을 붙인다 + +```tsx +// ❌ Before +const result = products.filter((product) => + product.categories.some( + (category) => + category.id === targetCategory.id && + product.prices.some((price) => price >= minPrice && price <= maxPrice), + ), +); + +// ✅ After +const matchedProducts = products.filter((product) => { + return product.categories.some((category) => { + const isSameCategory = category.id === targetCategory.id; + const isPriceInRange = product.prices.some( + (price) => price >= minPrice && price <= maxPrice, + ); + return isSameCategory && isPriceInRange; + }); +}); +``` + +네이밍이 필요한 상황: 로직이 복잡할 때, 재사용이 필요할 때, 단위 테스트가 필요할 때. +네이밍이 불필요한 상황: 로직이 단순할 때(`arr.map(x => x * 2)`), 한 번만 쓰이고 단순할 때. + +#### B. 매직 넘버에 이름을 붙인다 + +```tsx +// ❌ Before: 300이 왜 필요한지 알 수 없음 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} + +// ✅ After +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` + +--- + +### 1-3. 위에서 아래로 읽기 + +#### A. 눈의 이동을 줄인다 + +여러 파일이나 함수를 오가며 읽어야 할수록 맥락을 유지하기 어렵다. + +```tsx +// ❌ Before: canInvite를 파악하려면 getPolicyByRole → POLICY_SET 순서로 이동해야 함 +function Page() { + const user = useUser(); + const policy = getPolicyByRole(user.role); + return ( +
+ + +
+ ); +} +function getPolicyByRole(role) { + /* ... */ +} +const POLICY_SET = { admin: ["invite", "view"], viewer: ["view"] }; + +// ✅ After A: 조건을 직접 노출 +function Page() { + const user = useUser(); + switch (user.role) { + case "admin": + return ( +
+ + +
+ ); + case "viewer": + return ( +
+ + +
+ ); + default: + return null; + } +} + +// ✅ After B: 컴포넌트 안에서 한눈에 파악할 수 있는 객체로 관리 +function Page() { + const user = useUser(); + const policy = { + admin: { canInvite: true, canView: true }, + viewer: { canInvite: false, canView: true }, + }[user.role]; + return ( +
+ + +
+ ); +} +``` + +#### B. 삼항 연산자를 단순화한다 + +```tsx +// ❌ Before: 중첩 삼항 연산자 +const status = + ACondition && BCondition + ? "BOTH" + : ACondition || BCondition + ? ACondition + ? "A" + : "B" + : "NONE"; + +// ✅ After: if 문으로 풀어내기 +const status = (() => { + if (ACondition && BCondition) return "BOTH"; + if (ACondition) return "A"; + if (BCondition) return "B"; + return "NONE"; +})(); +``` + +#### C. 비교 연산은 왼쪽에서 오른쪽으로 읽히도록 작성한다 + +```tsx +// ❌ Before: a를 두 번 확인해야 함 +if (a >= b && a <= c) { +} +if (score >= 80 && score <= 100) { +} + +// ✅ After: 수학의 부등식처럼 b ≤ a ≤ c 순서로 +if (b <= a && a <= c) { +} +if (80 <= score && score <= 100) { +} +if (minPrice <= price && price <= maxPrice) { +} +``` + +--- + +## 2. 예측 가능성 (Predictability) + +이름·파라미터·반환값만 보고도 동작을 예측할 수 있어야 한다. + +### A. 이름이 겹치지 않도록 관리한다 + +```tsx +// ❌ Before: http라는 이름이 라이브러리와 서비스 모듈에 동시 사용 +import { http as httpLibrary } from "@some-library/http"; +export const http = { + async get(url: string) { + const token = await fetchToken(); // 이름만 봐선 토큰 추가를 알 수 없음 + return httpLibrary.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, +}; + +// ✅ After: 역할을 드러내는 이름으로 구분 +export const httpService = { + async getWithAuth(url: string) { + const token = await fetchToken(); + return httpLibrary.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, +}; +``` + +### B. 유사한 함수는 반환 타입을 통일한다 + +```tsx +// ❌ Before: 같은 API 호출 훅인데 반환 타입이 다름 +function useUser() { + return useQuery({ queryKey: ["user"], queryFn: fetchUser }); // Query 객체 반환 +} +function useServerTime() { + return useQuery({ queryKey: ["serverTime"], queryFn: fetchServerTime }).data; // data만 반환 +} + +// ✅ After: 모두 Query 객체를 반환 +function useUser() { + return useQuery({ queryKey: ["user"], queryFn: fetchUser }); +} +function useServerTime() { + return useQuery({ queryKey: ["serverTime"], queryFn: fetchServerTime }); +} +``` + +validation 함수도 마찬가지로 반환 타입을 통일한다. + +```tsx +// ✅ Discriminated Union으로 일관된 반환 타입 정의 +type ValidationResult = { ok: true } | { ok: false; reason: string }; + +function checkIsNameValid(name: string): ValidationResult { + /* ... */ +} +function checkIsAgeValid(age: number): ValidationResult { + /* ... */ +} +``` + +### C. 숨겨진 로직을 드러낸다 + +```tsx +// ❌ Before: fetchBalance를 호출하면 로깅이 발생하지만 이름에서 알 수 없음 +async function fetchBalance(): Promise { + const balance = await http.get("..."); + logging.log("balance_fetched"); // 숨겨진 사이드 이펙트 + return balance; +} + +// ✅ After: 함수는 이름이 나타내는 일만 한다 +async function fetchBalance(): Promise { + return http.get("..."); +} + +// 로깅은 호출부에서 명시적으로 +; +``` + +--- + +## 3. 응집도 (Cohesion) + +함께 수정될 코드는 항상 함께 수정되도록 구조화한다. + +> **가독성과 응집도는 충돌할 수 있다.** 함께 수정하지 않으면 버그가 생길 위험이 크다면 응집도를 우선하고, 위험이 낮다면 가독성(중복 허용)을 우선한다. + +### A. 함께 수정되는 파일은 같은 디렉터리에 둔다 + +```text +// ❌ Before: 모듈 유형별로 분리 → 관련 파일이 여기저기 흩어짐 +src/ + components/ + hooks/ + utils/ + constants/ + +// ✅ After: 함께 변경되는 파일을 도메인 단위로 묶음 +src/ + components/ ← 프로젝트 전체에서 사용 + hooks/ + domains/ + Domain1/ ← Domain1에서만 사용하는 코드 모음 + components/ + hooks/ + utils/ + Domain2/ + components/ + hooks/ +``` + +같은 도메인 내 코드만 서로 참조해야 한다. 다른 도메인의 파일을 import하는 구조라면 경고 신호다. + +### B. 매직 넘버를 제거한다 (응집도 관점) + +```tsx +// ❌ Before: 애니메이션 시간이 300으로 하드코딩 — 애니메이션 변경 시 이 코드를 찾아 함께 바꿔야 함을 알기 어려움 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} + +// ✅ After: 상수로 선언해 변경 지점을 하나로 모음 +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` + +### C. 폼 응집도를 고려한다 + +폼 관리는 **필드 레벨 응집도**와 **폼 레벨 응집도** 중 상황에 맞는 방식을 선택한다. + +**필드 레벨 응집도** — 각 필드가 독립적으로 검증 로직을 가짐 + +```tsx +// react-hook-form의 register 안에 validate를 필드별로 정의 + v ? "" : "이름을 입력해주세요" })} /> + isValidEmail(v) ? "" : "올바른 이메일 형식이 아닙니다" })} /> +``` + +언제 선택: 비동기 검증이 필요하거나 필드를 다른 폼에서 재사용할 때. + +**폼 레벨 응집도** — Zod 등 스키마로 전체 폼 검증을 한 곳에서 관리 + +```tsx +const schema = z.object({ + name: z.string().min(1, "이름을 입력해주세요"), + email: z.string().min(1).email("올바른 이메일 형식이 아닙니다"), +}); +useForm({ resolver: zodResolver(schema) }); +``` + +언제 선택: 필드가 서로 의존하거나, 단일 비즈니스 로직을 구성하는 폼일 때. + +--- + +## 4. 결합도 (Coupling) + +코드를 수정했을 때 영향 범위가 넓을수록 수정하기 어렵다. + +### A. 책임을 개별로 관리한다 + +```tsx +// ❌ Before: "페이지의 모든 query param"을 한 훅이 담당 → 이 훅에 의존하는 컴포넌트가 많아질수록 변경 위험 증가 +function usePageState() { + /* cardId, statementId, dateFrom, dateTo, statusList 전부 */ +} + +// ✅ After: query param별로 책임을 분리 +function useCardIdQueryParam() { + /* cardId만 */ +} +function useDateRangeQueryParam() { + /* dateFrom, dateTo만 */ +} +``` + +### B. 중복 코드를 허용한다 + +공통 훅/컴포넌트로 묶으면 코드는 줄지만, 한쪽 변경이 다른 쪽에 영향을 미칠 위험이 커진다. + +```tsx +// 공통 훅으로 추출 전에 스스로에게 물어본다: +// - 로깅 값이 페이지마다 달라질 가능성이 있는가? +// - 화면을 닫는 동작이 일부 페이지에서만 필요한가? +// - 바텀시트에 보여줄 텍스트/이미지가 달라질 수 있는가? +// → 하나라도 "예"라면 중복을 허용하는 것이 더 안전하다 +``` + +중복 코드 허용이 나은 경우: 페이지마다 동작이 달라질 가능성이 있을 때. +공통 추출이 나은 경우: 동작이 완전히 동일하고 미래에도 동일할 것이 확실할 때. + +### C. Props Drilling을 제거한다 + +```tsx +// ❌ Before: recommendedItems, onConfirm이 ItemEditModal → ItemEditBody → ItemEditList로 전달 +function ItemEditModal({ items, recommendedItems, onConfirm, onClose }) { + return ( + + + + ); +} + +// ✅ After: Composition 패턴으로 중간 컴포넌트 제거 +function ItemEditModal({ items, recommendedItems, onConfirm, onClose }) { + const [keyword, setKeyword] = useState(""); + return ( + + setKeyword(e.target.value)} + /> + + + + ); +} +``` + +--- + +## 판단 흐름 요약 + +``` +코드를 작성하거나 리뷰할 때 아래 순서로 확인한다. + +1. 가독성 — 한 번에 파악해야 할 맥락이 너무 많지 않은가? +2. 예측 가능성 — 이름·파라미터·반환값만 봐도 동작을 예측할 수 있는가? +3. 응집도 — 함께 변경될 코드가 흩어져 있지 않은가? +4. 결합도 — 이 코드를 바꿀 때 영향 범위가 지나치게 넓지 않은가? + +네 가지를 동시에 만족하기 어려울 때는 현재 상황에서 어떤 값을 +우선해야 장기적으로 변경이 쉬운지 팀과 함께 판단한다. +``` + +--- + +> 출처: [Frontend Fundamentals](https://frontend-fundamentals.com) — Toss Frontend Chapter diff --git a/docs/features/features-guide.md b/docs/features/features-guide.md new file mode 100644 index 00000000..9084029c --- /dev/null +++ b/docs/features/features-guide.md @@ -0,0 +1,244 @@ +# features/ — 레이어 가이드 + +`features/`는 **사용자 행동(mutation, form submit, 비즈니스 액션)** 을 담당하는 FSD 레이어다. +데이터를 조회하는 것은 `entities/`의 역할이고, UI를 조합하는 것은 `widgets/`의 역할이다. +`features/`는 그 중간에서 "사용자가 무언가를 했을 때 어떤 일이 일어나는가"를 책임진다. + +--- + +## 현재 슬라이스 목록 + +| 슬라이스 | 담당 도메인 | +| -------------- | ------------------------------------------ | +| `auth` | 로그인·로그아웃·회원가입, 인증 상태 스토어 | +| `goal` | 목표 생성·수정·삭제·즐겨찾기 | +| `notification` | SSE 알림 구독 | +| `team` | 팀 생성 | +| `todo` | 할 일 생성·삭제·상태 변경·상세 보기 | +| `trash` | 휴지통 항목 삭제·복구 | +| `user` | 프로필 수정·이미지 업로드·회원 탈퇴 | + +--- + +## 슬라이스 내부 구조 + +슬라이스마다 필요한 세그먼트만 둔다. 모든 세그먼트가 항상 필요한 것은 아니다. + +``` +features/{domain}/ +├── index.ts — public API (외부에 노출할 것만 명시적으로 export) +├── mutation/ — useMutation 훅 +├── hooks/ — overlay·form 등 사용자 행동 진입점 훅 +├── ui/ — 이 feature 전용 모달·폼 컴포넌트 +├── store/ — 도메인 UI 상태 (전역 상태가 필요한 경우만) +├── constants/ — 이 feature 내에서만 쓰는 상수 +├── utils/ — 이 feature 내에서만 쓰는 순수 함수 +└── mock/ — MSW 핸들러 (개발 환경 전용) +``` + +--- + +## 세그먼트별 규칙 + +### `mutation/` + +`useMutation` 훅 하나 = 파일 하나. 파일명은 `use{Action}Mutation.ts`. + +```ts +// features/goal/mutation/useCreatePersonalGoalMutation.ts + +type UseCreatePersonalGoalMutationOptions = { + onSuccess?: () => void; // UI side effect는 콜백으로 위임 +}; + +export function useCreatePersonalGoalMutation({ + onSuccess, +}: UseCreatePersonalGoalMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, dueDate }: CreatePersonalGoalVariables) => + goalApi.createGoal({ name, dueDate, type: "PERSONAL" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); + onSuccess?.(); // 모달 닫기·라우팅 등은 호출 측에서 결정 + }, + }); +} +``` + +**규칙:** + +- `mutationFn`은 반드시 `entities/{domain}/api/`의 함수를 호출한다. 직접 `fetch`/`axios` 금지. +- `queryClient.invalidateQueries`는 훅 내부 `onSuccess`에서 처리한다. +- navigation·모달 닫기 등 UI side effect는 `onSuccess` 콜백으로 위임하고 훅 내부에서 처리하지 않는다. +- 에러 처리가 필요하면 `throwOnError: false` + `onError` 토스트 조합을 사용한다. + +**낙관적 업데이트**가 필요한 경우 `onMutate` → `onError(rollback)` → `onSettled(invalidate)` 패턴을 쓴다. + +```ts +// features/todo/mutation/usePatchTodoStatusMutation.ts +return useMutation({ + mutationFn: ..., + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + const previousData = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, /* 낙관적 업데이트 */); + return { previousData }; // rollback용으로 context에 저장 + }, + onError: (_err, _vars, context) => { + queryClient.setQueryData(queryKey, context?.previousData); + }, + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ queryKey }); // 성공·실패 무관 최종 동기화 + }, +}); +``` + +--- + +### `hooks/` + +overlay(모달)를 열거나 폼 상태를 관리하는 훅. `use{Domain}{Action}Modal.tsx` 또는 `use{Action}Form.ts` 형태. + +**모달 훅 패턴 (`useOverlay`):** + +```tsx +// features/todo/hooks/useTodoCreateModal.tsx +export const useTodoCreateModal = () => { + const overlay = useOverlay(); + + // 모달에 사전 주입할 데이터를 여기서 조회 + const { + data: { goalName }, + } = useSuspenseQuery(goalQueryOptions.getSummary(goalId)); + + const openTodoCreateModal = () => { + overlay.open( + "todo-create-modal", // overlay ID — 슬라이스 내 고유 상수로 관리 + overlay.close()} + goalName={goalName} + // ... + />, + ); + }; + + return { openTodoCreateModal, closeTodoCreateModal: () => overlay.close() }; +}; +``` + +**규칙:** + +- 모달에 필요한 데이터 조회는 모달 컴포넌트 안이 아니라 **이 훅 안**에서 처리한다. +- overlay ID는 훅 파일 상단에 상수로 선언한다 (`const TODO_CREATE_MODAL_ID = "todo-create-modal"`). +- `onClose`는 항상 props로 전달해 모달 컴포넌트가 닫기 방식에 의존하지 않도록 한다. + +**폼 훅 패턴:** + +```ts +// features/todo/hooks/useCreateTodoForm.ts +export const useCreateTodoForm = ({ goalId, onSuccess, initialAssigneeIds }) => { + const { mutate, isPending } = useCreateTodoMutation(); + const [assigneeIds, setAssigneeIds] = useState(initialAssigneeIds); + const [startDate, setStartDate] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + // 유효성 검사 + if (dueDate < startDate) { showToast("error", "..."); return; } + mutate({ goalId, todoData: { ... } }, { onSuccess }); + }; + + return { assigneeIds, setAssigneeIds, startDate, handleSubmit, isPending }; +}; +``` + +--- + +### `ui/` + +이 feature에서만 사용하는 모달·폼 컴포넌트. `widgets/`에서 재사용하지 않는다. + +- 컴포넌트는 **props만 받아 렌더링**한다. 데이터 조회나 mutation 호출은 하지 않는다. +- 폼 상태는 `hooks/` 훅으로 분리하고 컴포넌트에서는 핸들러만 받는다. +- `index.ts`를 통해 외부에 노출하지 않아도 된다 (훅을 통해 간접 사용되기 때문). + +--- + +### `store/` + +도메인 UI 상태가 필요할 때만 만든다. 전역 UI 상태(`overlay` 등)는 `shared/store/`를 사용한다. + +현재 프로젝트에서 `features/` 내 store는 `auth/store/auth.store.ts` 하나뿐이며, `persist` + `immer` 미들웨어를 사용한다. + +```ts +// features/auth/store/auth.store.ts +export const useAuthStore = create()( + persist( + immer((set) => ({ + user: null, + setUser: (user) => + set((state) => { + state.user = user; + }), + clearUser: () => + set((state) => { + state.user = null; + }), + })), + { name: "taskmate-auth" }, // localStorage 키 + ), +); +``` + +--- + +### `mock/` + +MSW `http` 핸들러. `shared/mock/` 에서 통합 등록되며, 개발 환경에서만 활성화된다. +새 슬라이스에 mock이 필요하면 `{domain}Handlers` 배열을 export하고 `shared/mock/`에 추가한다. + +--- + +## index.ts — Public API + +외부 레이어(widgets 등)에서 필요한 것만 명시적으로 노출한다. + +```ts +// features/todo/index.ts +export type { TodoListSortLabel }; +export { TODO_COLUMN_DEFAULT_SORT_LABEL }; +export { TODO_COLUMN_SORT_LABEL_ORDER }; +export { TODO_LIST_SORT_BY_LABEL }; +export { useTodoCreateModal }; +export { TodoList }; +export { formatDDay }; +``` + +- `export *` 금지 — 무엇이 노출되는지 명확하게 유지한다. +- 내부 구현(mutation 훅·모달 컴포넌트 등)은 feature 안에서만 사용하면 노출하지 않아도 된다. + +--- + +## 금지 사항 + +| 규칙 | 이유 | +| --------------------------------------------------- | ----------------------------------------------------- | +| `fetch`/`axios` 직접 호출 금지 | API 함수는 `entities/{domain}/api/`에만 존재해야 한다 | +| 같은 레이어 cross-slice import 금지 | `features/todo`가 `features/goal`을 import하는 것 등 | +| `shared/`에 도메인 개념 추가 금지 | `shared`는 도메인 무관 원시 단위만 허용한다 | +| mutation 훅 내부에서 navigation·모달 닫기 처리 금지 | side effect는 `onSuccess` 콜백으로 위임한다 | + +--- + +## 새 슬라이스 추가 순서 + +1. `entities/{domain}/types/` — Request/Response 타입 정의 +2. `entities/{domain}/api/` — apiClient 기반 API 함수 +3. `entities/{domain}/query/` — queryOptions, queryKey +4. `features/{domain}/mutation/use{Action}Mutation.ts` — 뮤테이션 훅 +5. `features/{domain}/hooks/use{Action}Modal.tsx` — overlay 훅 (모달이 있을 때) +6. `features/{domain}/ui/` — 모달·폼 컴포넌트 +7. `features/{domain}/index.ts` — 외부 노출 항목 정리 +8. `features/{domain}/mock/` — MSW 핸들러 (개발 환경 필요 시) diff --git a/docs/features/todo/createTodo.md b/docs/features/todo/createTodo.md new file mode 100644 index 00000000..9da44acb --- /dev/null +++ b/docs/features/todo/createTodo.md @@ -0,0 +1,158 @@ +# Create Todo + +--- + +## 진입점 + +| 위치 | 파일 | 조건 | +| ---------------------- | ------------------------------------------ | --------------- | +| Todo 컬럼 하단 버튼 | `features/todo/ui/List/CreateButton.tsx` | 항상 표시 | +| Todo 컬럼 빈 상태 버튼 | `widgets/todo/TodoSection/state/Empty.tsx` | 할 일이 없을 때 | + +두 진입점 모두 `useTodoCreateModal().openTodoCreateModal()`을 호출한다. + +--- + +## 폼 필드 스펙 + +| 필드 | 타입 | 필수 | 제약 | 비고 | +| ------------- | -------- | ---- | ---------------------------- | ------------------ | +| `title` | string | ✅ | — | 할 일 제목 | +| `startDate` | string | ✅ | `YYYY-MM-DD` | 시작 날짜 | +| `dueDate` | string | ✅ | `YYYY-MM-DD`, `>= startDate` | 마감 날짜 | +| `assigneeIds` | number[] | — | 빈 배열 허용 | 담당자 userId 목록 | +| `memo` | string | — | 최대 80자 | 메모 | + +### 담당자 필드 — 개인 목표 vs 팀 목표 + +| 구분 | 조건 (`isPersonal`) | 동작 | +| --------- | ----------------------------- | ------------------------------------------------------- | +| 개인 목표 | `true` (URL에 `teamId` 없음) | 로그인 유저로 고정, 수정 불가 (`AssigneeSelect` 미표시) | +| 팀 목표 | `false` (URL에 `teamId` 있음) | `AssigneeSelect` 드롭다운으로 팀 멤버 중 선택 | + +`initialAssigneeIds`: + +- 개인 목표: `[myInfo.id]` +- 팀 목표: `[]` + +--- + +## 유효성 검사 + +| 검사 | 시점 | 처리 | +| ------------------------------------------------ | ------- | -------------------------------------------------------------------------------------------- | +| 필수 필드 누락 (`title`, `startDate`, `dueDate`) | submit | HTML `required` — 브라우저 기본 검사 | +| `dueDate < startDate` | submit | 브라우저 `min={startDate}` 속성으로 1차 차단, `useToast` 에러 토스트로 2차 차단 후 제출 중단 | +| `memo` 최대 80자 | 입력 중 | `