AI와 사람에게 모두 친화적인 React 구조는 무엇일까 사람이 읽고 유지보수하기 좋으면서 AI가 맥락을 잃지 않고 도와줄 수 있는 React 프로젝트 구조를 고민하는 글 dev react frontend architecture ai

AI와 사람에게 모두 친화적인 React 구조는 무엇일까

시작하며

AI를 이용해 개발하는 요즘, AI가 작성한 코드도 테스트 하네스가 잘 잡아주겠지 생각하며 넘어가는 경우가 많다. 업무 특성상 백엔드뿐 아니라 프론트도 자주 다루게 되는데, 그러다 보니 기능 구현 과정에서 생성되는 파일도 많아진다. 자칫 잠깐 정신을 놓거나 구현량이 많아 귀찮아지면, 대충 보고 “잘 구현됐겠지” 하고 넘어갈 때도 있다.

결국 스펙과 구현을 빠르게 파악할 수 있는 구조가 중요해진 것 같다.

사람이 이해하기 쉬운 구조와 AI가 이해하기 쉬운 구조는 완전히 같지는 않다. 하지만 보편적으로 사람이 이해하기 쉬운 구조란 흐름과 책임이 잘 정리된 구조이고, 이는 AI 또한 맥락을 쉽게 파악하도록 도와준다.

그래서 나는 구조적 흐름을 잡고 가독성을 중요시하는 페이지 우선 슬라이스 구조를 기본 컨벤션으로 사용하고 있다.

핵심

폴더 구조의 목적은 파일을 예쁘게 정렬하는 것이 아니다.

어떤 기능을 이해하고 수정할 때, 관련 파일을 찾기 위해 저장소 여러 곳을 돌아다니지 않게 만드는 것이 목적이다.

기존의 전역 코드 종류별 구조는 프로젝트가 커질수록 한 기능을 여러 폴더로 흩어놓는다.

src/
├── components/
├── hooks/
├── pages/
├── services/
├── utils/
└── types/

예를 들어 주문 목록 기능이 다음처럼 분리된다.

pages/OrdersPage.tsx
components/orders/OrdersTable.tsx
hooks/useOrders.ts
services/orderService.ts
utils/orderUtils.ts
types/order.ts

주문 목록 하나를 이해하려고 최상위 폴더 여섯 개를 오가게 된다.

이를 다음처럼 바꾼다.

pages/orders-list/
├── OrdersListPage.tsx
├── ui/
├── model/
├── api/
└── lib/

주문 목록 관련 코드는 우선 orders-list 안에서 찾는다.

가장 중요한 배치 원칙

한 컴포넌트 전용
→ 해당 컴포넌트 옆

한 페이지 전용
→ 해당 `page` 내부

여러 페이지에서 사용하는 동일한 사용자 행동
→ `feature`

여러 페이지에서 사용하는 동일한 도메인 개념
→ `entity`

비즈니스와 무관한 범용 코드
→ `shared`

이를 한 줄로 표현하면 다음과 같다.

컴포넌트 로컬
→ 페이지 로컬
→ `feature` 또는 `entity`
→ `shared`

중요한 점은 처음부터 공용화하지 않는 것이다.

새 코드는 페이지에서 시작하고, 실제 재사용이 생겼을 때만 공용 계층으로 승격한다.


추천 기본 구조

작은 프로젝트는 다음 세 계층으로 시작해도 충분하다.

src/
├── app/
├── pages/
└── shared/

기능이 늘고 실제 cross-page 재사용이 생기면 다음을 추가한다.

src/
├── app/
├── pages/
├── features/
├── entities/
└── shared/

의존 방향은 아래로만 흐른다.

app

pages

features

entities

shared

예를 들어 다음은 허용한다.

pages → features
pages → entities
pages → shared
features → entities
features → shared
entities → shared

반대로 다음은 금지하는 것을 기본값으로 한다.

shared → entities
entities → features
features → pages
page A → page B의 내부 파일

각 계층의 의미

app: 애플리케이션 실행 환경

특정 업무 기능이 아니라 애플리케이션 전체를 구동하는 코드다.

app/
├── router/
├── providers/
├── styles/
└── App.tsx

들어갈 수 있는 것:

라우터
QueryClient Provider
인증 Provider
전역 Store 설정
Error Boundary
전역 스타일
테마
앱 초기화

들어가면 안 되는 것:

주문 취소 로직
상품 가격 계산
검색 필터 상태
회원 초대 폼

판단 질문은 다음과 같다.

특정 제품 기능을 제거해도 애플리케이션 실행에 필요한 코드인가?

그렇다면 app에 가까울 가능성이 높다.


pages: URL 단위 화면

/orders           → pages/orders-list
/orders/:orderId  → pages/order-detail
/settings/profile → pages/profile-settings

페이지는 화면을 조립하고 페이지 단위 실행 흐름을 연결한다.

pages/orders-list/
├── OrdersListPage.tsx
├── ui/
├── model/
├── api/
├── lib/
└── index.ts

모든 폴더를 무조건 만들지는 않는다. 실제 파일이 없다면 해당 폴더도 만들지 않는다.


features: 사용자가 수행하는 행동

feature는 보통 동사형으로 표현된다.

cancel-order
save-search
invite-member
change-password
export-orders
apply-coupon

예시:

features/cancel-order/
├── ui/
│   ├── CancelOrderButton.tsx
│   └── CancelOrderDialog.tsx
├── model/
│   └── useCancelOrder.ts
├── api/
│   └── cancelOrder.mutation.ts
├── lib/
│   └── getCancelErrorMessage.ts
└── index.ts

단, 주문 취소가 주문 상세 페이지에서만 사용된다면 아직 feature로 만들지 않는다.

pages/order-detail/
├── ui/CancelOrderDialog.tsx
├── model/useCancelOrder.ts
└── api/cancelOrder.mutation.ts

나중에 주문 목록에서도 같은 주문 취소 기능을 사용할 때 features/cancel-order로 승격한다.


entities: 제품의 핵심 명사

entity는 여러 화면에서 반복되는 도메인 개념이다.

order
user
workspace
product
invoice
message

예시:

entities/order/
├── ui/
│   ├── OrderStatusBadge.tsx
│   └── OrderAmount.tsx
├── model/
│   ├── order.types.ts
│   ├── order.schema.ts
│   ├── order.mapper.ts
│   └── canCancelOrder.ts
├── api/
│   ├── order.api.ts
│   ├── order.queries.ts
│   └── order.keys.ts
└── index.ts

구분 예시는 다음과 같다.

OrderStatusBadge
→ 주문 목록과 상세에서 동일한 의미로 사용
→ entity
OrdersTable
→ 주문 목록 페이지의 구체적인 표현
→ page
CancelOrder
→ 사용자가 주문을 취소하는 행동
→ feature

shared: 비즈니스와 무관한 범용 코드

적절한 예:

Button
Dialog
Input
Spinner
apiClient
formatCurrency
formatDate
useMediaQuery
useDebouncedValue

부적절할 가능성이 높은 예:

OrderFilters
CancelOrderButton
WorkspaceInviteForm
useOrderPermission
ProductPriceCard

다음 질문으로 판단한다.

이 코드를 전혀 다른 제품으로 복사해도 이름과 의미가 자연스러운가?

자연스럽다면 shared일 가능성이 높다.


페이지 내부 세그먼트

ui: 화면 표현

pages/orders-list/ui/
├── OrdersToolbar.tsx
├── OrderFilters.tsx
├── OrdersPagination.tsx
├── EmptyOrders.tsx
└── OrdersTable/
    ├── OrdersTable.tsx
    ├── OrderRow.tsx
    └── OrdersTable.test.tsx

한 페이지에서만 쓰는 업무 컴포넌트는 해당 페이지의 ui에 둔다.

컴포넌트마다 무조건 폴더를 만들지는 않는다.

작은 컴포넌트:

ui/
├── EmptyOrders.tsx
├── OrderFilters.tsx
└── OrdersPagination.tsx

하위 컴포넌트, 테스트, 스타일 등이 생기면 폴더로 확장한다.

ui/OrdersTable/
├── OrdersTable.tsx
├── OrderRow.tsx
├── OrdersTable.test.tsx
└── OrdersTable.module.css

model: 상태와 화면 동작

model/
├── useOrdersListPage.ts
├── useOrderFilters.ts
├── useOrderSelection.ts
├── ordersFilters.ts
└── ordersList.schema.ts

들어갈 수 있는 것:

페이지 상태
URL 검색 조건
선택 상태
페이지네이션
폼 스키마
reducer 또는 state machine
페이지 이벤트 조합
여러 query와 mutation의 조합
파생 값

페이지 모델 훅은 페이지 파일을 읽기 쉽게 만드는 역할을 한다.

export function useOrdersListPage() {
    const filters = useOrderFilters();
    const selection = useOrderSelection();
    const ordersQuery = useOrdersQuery(filters.value);
    function openOrder(orderId: string) {
        navigate(`/orders/${orderId}`);
    }
    return {
        filters: filters.value,
        changeFilters: filters.change,
        selectedOrderIds: selection.ids,
        toggleOrder: selection.toggle,
        orders: ordersQuery.data?.items ?? [],
        isLoading: ordersQuery.isLoading,
        openOrder,
    };
}

다만 모든 로직을 하나의 useOrdersListPage에 넣어 수백 줄로 만들면 안 된다.

useOrderFilters
useOrderSelection
useOrdersPagination
useOrderActions

이처럼 책임별로 나누고 상위 훅에서 조합한다.


api: 서버 통신과 서버 상태

api/
├── ordersList.api.ts
├── ordersList.query.ts
├── ordersList.keys.ts
└── ordersList.mapper.ts

위치는 재사용 범위에 따라 달라진다.

한 페이지에서만 사용하는 조회
→ pages/orders-list/api
여러 화면에서 사용하는 주문 조회
→ entities/order/api
주문 취소라는 사용자 행동
→ features/cancel-order/api
범용 HTTP client
→ shared/api

UI 컴포넌트가 URL과 HTTP 메서드를 직접 다루지 않도록 한다.


lib: 순수한 보조 로직

lib/
├── buildOrdersSearchParams.ts
├── parseOrderFilters.ts
└── sortOrders.ts

피해야 할 형태:

utils.ts
helpers.ts
common.ts
misc.ts

특히 다음처럼 서로 관련 없는 로직을 한 파일에 모으면 안 된다.

export function formatDate() {}
export function calculatePrice() {}
export function validateOrder() {}
export function downloadCsv() {}
export function isAdmin() {}

대신 소유권과 책임을 드러낸다.

shared/lib/date/formatDate.ts
shared/lib/currency/formatCurrency.ts
pages/orders-list/lib/buildOrdersSearchParams.ts
entities/order/model/canCancelOrder.ts
features/export-orders/lib/createOrdersCsv.ts

페이지 컴포넌트의 가독성 기준

좋은 페이지는 화면의 실행 순서가 보인다.

export function OrdersListPage() {
    const page = useOrdersListPage();
    return (
        <PageLayout title="주문 관리">
            <OrderFilters value={page.filters} onChange={page.changeFilters} />
            <OrdersTable
                orders={page.orders}
                isLoading={page.isLoading}
                onSelectOrder={page.openOrder}
            />
            <OrdersPagination
                page={page.page}
                totalPages={page.totalPages}
                onPageChange={page.changePage}
            />
        </PageLayout>
    );
}

이 파일에서는 다음 시나리오가 바로 읽힌다.

필터
→ 주문 목록
→ 주문 선택
→ 페이지네이션

페이지 파일에 다음이 동시에 들어오면 분리를 검토한다.

저수준 HTTP 요청
query key 생성
큰 폼 validation
테이블 행 세부 렌더링
mutation과 캐시 갱신
여러 modal 상태
도메인 계산
날짜·통화 포맷

단순히 줄 수를 줄이기 위해 분리하지는 않는다.

이름을 붙일 수 있는 독립적인 책임이 생겼을 때 분리한다.


주문 화면으로 보는 전체 배치 예시

src/
├── app/
│   ├── router/
│   └── providers/

├── pages/
│   ├── orders-list/
│   │   ├── OrdersListPage.tsx
│   │   ├── ui/
│   │   │   ├── OrderFilters.tsx
│   │   │   ├── OrdersPagination.tsx
│   │   │   └── OrdersTable/
│   │   ├── model/
│   │   │   ├── useOrdersListPage.ts
│   │   │   └── useOrderFilters.ts
│   │   ├── api/
│   │   │   └── ordersList.query.ts
│   │   └── lib/
│   │       └── buildOrdersSearchParams.ts
│   │
│   └── order-detail/

├── features/
│   └── cancel-order/
│       ├── ui/
│       ├── model/
│       └── api/

├── entities/
│   └── order/
│       ├── ui/
│       │   └── OrderStatusBadge.tsx
│       ├── model/
│       │   └── order.types.ts
│       └── api/
│           └── order.queries.ts

└── shared/
    ├── ui/
    │   ├── Button/
    │   └── Dialog/
    ├── api/
    │   └── apiClient.ts
    ├── hooks/
    │   └── useMediaQuery.ts
    └── lib/
        └── currency/

빠른 파일 위치 판단표

코드권장 위치이유
주문 목록 필터pages/orders-list/model해당 페이지 전용 상태
주문 목록 테이블pages/orders-list/ui해당 페이지의 구체적인 표현
주문 상태 Badgeentities/order/ui여러 화면에서 같은 주문 의미
주문 취소 Dialogfeatures/cancel-order/ui여러 페이지에서 쓰는 사용자 행동
주문 조회 Queryentities/order/api공유되는 주문 데이터
주문 목록 전용 집계 Querypages/orders-list/api특정 페이지 전용 응답
일반 Buttonshared/ui비즈니스와 무관
통화 포맷shared/lib/currency범용 순수 함수
주문 취소 가능 여부entities/order/model주문 도메인 규칙
CSV 주문 내보내기page 또는 feature실제 사용 범위에 따라 결정

SKILL을 작성했다

앞서 말한 구조를 처음 보일러플레이트에 적용해도 좋지만, 팀 내 컨벤션이나 SKILL로 관리해도 좋다.

SKILL.md는 500줄 이하를 권장하고 있기에, 메인 파일을 487줄로 유지하고 상세 컨벤션은 별도 reference로 분리했다.

각 파일의 역할은 다음과 같다.

SKILL.md
→ AI가 실제 작업에서 따라야 하는 판정 순서, 절차, 금지 사항, 검증 방법

references/convention.md
→ 사람이 읽는 상세 설명, 근거, 코드 예시, 마이그레이션, 리뷰 체크리스트

README.md
→ 패키지 구성 안내

스킬은 다음 작업에 활성화되도록 작성했다.

React 폴더 구조 설계
새 파일 위치 결정
큰 페이지·훅 분리
components/hooks/utils 정리
page → feature/entity/shared 승격 판단
import 계층 검토
deep import와 barrel export 검토
기존 프로젝트의 점진적 리팩터링

에이전트의 실행 절차도 포함돼 있다.

저장소 구조 조사
→ 관련 기능 흐름 추적
→ 각 파일의 소유권 분류
→ 변경 전후 트리 작성
→ 최소 범위로 이동
→ import와 public API 수정
→ typecheck/lint/test/build
→ 최종 구조와 예외 보고

AGENTS.md에는 전체 컨벤션을 다시 복사하지 말고 짧은 안내만 둔다.

## Frontend architecture

React 파일을 생성하거나 이동할 때 다음을 따른다.

- 팀 문서: `docs/frontend-architecture.md`
- Agent Skill: `react-page-first-architecture`
- 기본 의존 방향: `app → pages → features → entities → shared`
- 새 코드는 페이지 로컬에서 시작한다.
- 실제 cross-page 재사용이 확인된 후에만 feature, entity, shared로 승격한다.

원문 보기

아래 토글에서 이 글과 함께 둔 SKILL.mdconvention.md 원문을 확인할 수 있다.

SKILL.md 원문 보기
---
name: react-page-first-architecture
description: React 프론트엔드 코드를 가독성 우선 page-first vertical slice 컨벤션으로 생성, 배치, 리뷰, 리팩터링한다. 폴더 구조 설계, 파일 위치 결정, 큰 페이지·훅·컴포넌트·API·유틸 분리, app/pages/features/entities/shared 간 이동, 전역 components/hooks/utils 난립 방지, import 경계와 public API 검증 작업에 사용한다.
compatibility: React와 TypeScript 프로젝트용. Vite/React Router 구조에 맞게 라우트 배치를 조정한다. 저장소 트리와 소스 파일 접근이 필요하다.
metadata:
  version: "1.0.0"
  convention: "page-first-vertical-slice"
---

# React Page-first Architecture

가독성을 최우선으로 프론트엔드 코드를 배치한다. 코드를 실제 소유자 가까이에 두고, 새 코드는 페이지 내부에서 시작하며, 재사용과 소유권이 확인된 후에만 공용 계층으로 승격한다.

광범위한 구조 변경, 팀 컨벤션 설명, 위치 판단이 애매한 작업에서는 [상세 컨벤션](convention.md)을 읽는다.

## 기준 충돌 시 우선순위

다음 순서를 따른다.

1. 사용자의 명시적 요구사항
2. 저장소의 `AGENTS.md`, 아키텍처 문서, lint 규칙, 인접 코드
3. 프레임워크가 강제하는 구조
4. 이 Skill의 기본값

이미 일관된 구조가 있다면 조용히 다른 구조로 교체하지 않는다. 충돌을 보고하고, 기존 구조 안에서 가장 작은 일관된 변경을 수행한다.

## 핵심 규칙

1. 새 코드는 실제 사용처와 최대한 가까이 둔다.
2. 새 기능은 해당 페이지 내부에서 시작한다.
3. 재사용을 예상해 feature, entity, shared를 미리 만들지 않는다.
4. 업무 코드는 전역 파일 종류가 아니라 제품 책임으로 묶는다.
5. 페이지 컴포넌트는 화면의 주요 사용자 흐름이 위에서 아래로 읽혀야 한다.
6. 의존 방향은 다음을 따른다.

```text
app → pages → features → entities → shared
```

7. 아래 계층은 위 계층을 import하지 않는다.
8. 같은 계층의 서로 다른 slice 간 import도 기본적으로 금지한다.
9. 외부 모듈은 slice의 public API만 사용하며 내부 deep import를 하지 않는다.
10. 서버 상태는 query/cache 계층에 두고 근거 없이 로컬·전역 상태로 복제하지 않는다.
11. Effect는 외부 시스템 동기화에만 사용한다.

## 기본 구조

작게 시작한다.

```text
src/
├── app/
├── pages/
└── shared/
```

실제 cross-page 재사용이 생겼을 때만 추가한다.

```text
src/
├── app/
├── pages/
├── features/
├── entities/
└── shared/
```

페이지는 필요한 세그먼트만 가진다.

```text
pages/orders-list/
├── OrdersListPage.tsx
├── ui/
├── model/
├── api/
├── lib/
└── index.ts
```

빈 폴더를 만들지 않는다.

## 파일 위치 결정 절차

모든 새 파일과 이동 대상 파일을 다음 순서로 분류한다.

### 1. 컴포넌트 로컬

한 컴포넌트에서만 사용하면 그 컴포넌트 옆에 둔다.

```text
pages/orders-list/ui/OrdersTable/
├── OrdersTable.tsx
├── OrderRow.tsx
└── OrdersTable.test.tsx
```

### 2. 페이지 로컬

한 페이지의 여러 코드가 사용하면 해당 page의 세그먼트에 둔다.

```text
pages/orders-list/
├── ui/OrderFilters.tsx
├── model/useOrderFilters.ts
├── api/ordersList.query.ts
└── lib/buildOrdersSearchParams.ts
```

### 3. Feature

다음 조건을 모두 만족할 때만 `features/<verb-noun>`로 승격한다.

- 둘 이상의 페이지에서 사용한다.
- 동일한 사용자 행동이나 능력을 표현한다.
- UI, 상태, API 또는 규칙을 하나의 경계로 묶을 가치가 있다.
- 사용하는 곳이 같은 이유로 함께 변경된다.

예시:

```text
features/cancel-order
features/save-search
features/invite-member
features/export-orders
```

### 4. Entity

여러 페이지에서 같은 의미로 사용하는 안정적인 도메인 명사는 `entities/<noun>`로 승격한다.

예시:

```text
entities/order
entities/user
entities/workspace
```

적절한 구성:

- 공유 도메인 타입
- DTO와 제품 모델 mapper
- 도메인 query와 API
- 도메인 불변 규칙
- 여러 화면에서 같은 의미로 사용하는 표시 컴포넌트

### 5. Shared

다른 제품으로 옮겨도 이름과 의미가 자연스러운 비즈니스 비종속 코드만 둔다.

적절한 예:

```text
shared/ui/Button
shared/api/apiClient.ts
shared/hooks/useMediaQuery.ts
shared/lib/currency/formatCurrency.ts
```

대체로 부적절한 예:

```text
shared/ui/OrderFilters
shared/hooks/useOrderPermission
shared/lib/cancelOrder.ts
```

### 6. App

라우터, Provider, 전역 스타일, Error Boundary, query client, store 설정, 앱 초기화처럼 애플리케이션 실행과 전역 조립을 담당하는 코드를 둔다.

## 세그먼트별 책임

### `ui`

소유 모듈의 React 컴포넌트와 표현 로직을 둔다.

- UI 컴포넌트에 저수준 HTTP 요청을 직접 작성하지 않는다.
- 파일 하나뿐인 모든 컴포넌트에 폴더와 `index.ts`를 만들지 않는다.
- 하위 컴포넌트, 테스트, 스타일, 스토리 또는 private 경계가 생길 때 폴더로 확장한다.

### `model`

상태, 커스텀 훅, 스키마, reducer/state machine, 이벤트 조합, 파생 동작을 둔다.

- 거대한 page hook은 책임별 훅으로 분리한 뒤 상위 훅에서 조합한다.
- 순수 계산을 훅으로 감싸지 않는다.
- 사용자 행동은 이벤트 핸들러에 둔다.
- props나 query 데이터로 계산 가능한 값을 별도 state로 복제하지 않는다.

### `api`

요청, query/mutation, query key, DTO, mapper, 캐시 정책을 둔다.

소유권 기준:

```text
한 페이지 전용 endpoint      → page/api
공유 도메인 조회             → entity/api
여러 페이지의 사용자 행동    → feature/api
범용 HTTP 기반 코드          → shared/api
```

### `lib`

현재 모듈이 소유하는 작고 순수한 보조 로직을 둔다.

권장:

```text
buildOrdersSearchParams.ts
parseOrderFilters.ts
createOrdersCsv.ts
```

금지 기본값:

```text
utils.ts
helpers.ts
common.ts
misc.ts
```

위 이름을 사용해야 한다면 파일이 하나의 명확한 책임만 가지는지 확인한다.

## 페이지 데이터 조합 규칙

기본 흐름은 다음과 같다.

일반 API 함수
→ Query 또는 Loader 계층
→ 필요한 경우 페이지 조합 훅
→ 페이지 컴포넌트

- 일반 API 함수는 React와 무관한 Promise 함수로 작성한다.
- Query 훅은 캐시, 로딩, 오류, 재조회 정책을 담당한다.
- 페이지 조합 훅은 여러 상태와 행동의 조정 책임이 있을 때만 만든다.
- 단순한 페이지는 Query 훅을 페이지에서 직접 호출할 수 있다.
- 페이지를 JSX 전용으로 만들기 위해 무조건 훅을 추출하지 않는다.
- 컴포넌트 전용 UI 상태는 해당 컴포넌트에 남긴다.
- 서버 데이터를 별도 useState에 복제하지 않는다.
- 자체적으로 완결되는 Mutation은 해당 feature 또는 UI가 소유할 수 있다.
- 프레임워크 Loader를 사용하는 경우 Query 훅을 강제하지 않는다.

## 페이지 가독성 규칙

페이지 컴포넌트는 저수준 구현보다 화면의 주요 흐름을 보여야 한다.

```tsx
export function OrdersListPage() {
  const page = useOrdersListPage();

  return (
    <PageLayout title="주문 관리">
      <OrderFilters
        value={page.filters}
        onChange={page.changeFilters}
      />
      <OrdersTable
        orders={page.orders}
        isLoading={page.isLoading}
        onSelectOrder={page.openOrder}
      />
      <OrdersPagination
        page={page.page}
        onPageChange={page.changePage}
      />
    </PageLayout>
  );
}
```

페이지에 다음 책임이 여러 개 섞이면 분리를 검토한다.

- 저수준 HTTP 세부사항
- query key 생성
- 큰 폼 validation
- 테이블 행 렌더링 세부사항
- 복잡한 mutation과 캐시 갱신
- 서로 무관한 modal 상태
- 도메인 계산
- 일반 포맷 함수

줄 수를 줄이기 위해 무조건 추출하지 않는다. 이름 붙일 수 있는 독립 책임이 생겼을 때 추출한다.

## React 상태와 훅 규칙

리뷰할 때 다음 실행 모델로 추적한다.

```text
render-time
→ event-time
→ state/query 변경
→ rerender
→ commit/effect-time
```

확인 사항:

- 컴포넌트 본문과 커스텀 훅은 render-pure하다.
- 훅은 최상위에서 조건 없이 호출된다.
- 사용자 행동은 이벤트 핸들러에서 시작한다.
- Effect는 외부 시스템을 동기화하고 필요하면 cleanup을 가진다.
- 파생 값은 Effect와 state로 동기화하지 않고 계산한다.
- 서버 데이터는 명시적 편집 draft가 아닌 한 query/cache에 남긴다.
- 커스텀 훅은 이름, 입력, 출력, 부작용, 재렌더 조건을 설명할 수 있다.

## Import 규칙

허용:

```text
app      → pages, features, entities, shared
pages    → features, entities, shared
features → entities, shared
entities → shared
shared   → 외부 패키지와 shared 내부
```

기본 금지:

- `page A`가 `page B`의 private 파일 import
- feature가 다른 feature import
- entity가 feature 또는 page import
- shared가 비즈니스 계층 import
- 외부 소비자가 slice 내부 파일 deep import

같은 계층 cross-import가 생기면 다음 순서로 해결한다.

1. 불필요한 결합을 제거한다.
2. 실제로 하나의 책임이면 모듈을 합친다.
3. 공통 개념을 더 낮은 계층으로 이동한다.
4. 도메인이나 프레임워크상 정말 필요할 때만 좁은 명시적 계약을 만든다.

## Public API 규칙

외부 소비자가 있는 page, feature, entity 경계에서 `index.ts`를 사용한다.

```ts
export { CancelOrderButton } from "./ui/CancelOrderButton";
export { useCancelOrder } from "./model/useCancelOrder";
```

다음은 사용하지 않는다.

```ts
export * from "./ui/CancelOrderButton";
export * from "./model/useCancelOrder";
```

같은 모듈 내부에서는 자기 `index.ts`를 통하지 않고 실제 파일을 직접 import한다.

## 이름 기본값

```text
폴더:                 kebab-case
React 컴포넌트:       PascalCase.tsx
훅:                   usePurpose.ts
query:                *.query.ts 또는 *.queries.ts
mutation:             *.mutation.ts
schema:               *.schema.ts
type:                 *.types.ts
mapper:               *.mapper.ts 또는 *.mappers.ts
테스트:               *.test.ts(x)
E2E:                  *.spec.ts
```

일관된 기존 네이밍이 있으면 새로운 규칙을 섞지 않고 기존 규칙을 따른다.

## 프레임워크별 조정

### Vite 또는 React Router

라우터 설정은 `app/router`에 두고, 각 라우트는 `pages` 모듈을 가리키게 한다.

## 생성·수정 작업 절차

1. 편집 전 조사한다.
   - 프레임워크와 라우팅 방식
   - 현재 `src` 트리
   - import alias
   - 아키텍처 문서와 저장소 규칙
   - 관련 코드의 실제 사용처
   - lint, typecheck, test, build 명령
2. 기능 흐름을 추적한다.
   - 라우트 진입점
   - 페이지 컴포넌트
   - 주요 UI
   - 상태와 훅
   - API/query/mutation
   - 도메인·공용 의존성
   - 관련 테스트
3. 모든 변경 파일을 위치 결정 절차로 분류한다.
4. 여러 폴더를 이동하는 리팩터링은 사용자가 직접 실행을 요구하지 않은 한 기존/제안 트리와 import 영향을 먼저 제시한다.
5. 가장 작은 일관된 변경을 수행한다.
6. 구조 이동과 제품 동작 변경을 가능한 한 분리한다.
7. import와 public API를 갱신하고 dead file/export를 제거한다.
8. 검증한다.
   - typecheck
   - lint
   - 관련 단위·컴포넌트 테스트
   - 관련 E2E 또는 수동 흐름
   - 가능한 경우 production build
9. 최종 diff를 아래 체크리스트로 검토한다.
10. 변경된 소유권, 검증 결과, 남은 예외를 보고한다.

## 리뷰 시 찾아야 할 코드 점검

파일 경로와 구체적인 이유를 함께 보고한다.

- 전역 `components`, `hooks`, `services`, `utils`에 있는 업무 코드
- 한 페이지 전용인데 `features`, `entities`, `shared`에 있는 코드
- 제품 업무 용어가 포함된 shared 모듈
- 저수준 구현 때문에 사용자 흐름이 보이지 않는 page 파일
- 상태, 네트워크, navigation, modal 책임이 섞인 거대한 훅
- catch-all helper 파일
- 모듈 경계를 넘는 deep import
- 역방향 또는 불필요한 같은 계층 import
- barrel로 인한 순환 참조
- 서버 상태 중복 보관
- 파생 값 또는 사용자 행동을 처리하는 Effect
- 소유 API와 멀리 떨어진 캐시 정책
- 파일 하나뿐인데 중첩 폴더와 `index.ts`가 반복되는 구조
- 마크업 모양만 비슷하다는 이유로 만든 성급한 공용 컴포넌트

## 완료 전 검증 체크리스트

- [ ] 모든 변경 파일의 소유자를 설명할 수 있다.
- [ ] 페이지 전용 코드는 페이지 안에 남아 있다.
- [ ] feature/entity/shared 승격이 실제 사용처로 증명된다.
- [ ] 페이지 컴포넌트가 화면 시나리오처럼 읽힌다.
- [ ] UI, model, API, 순수 로직이 불필요하게 섞이지 않았다.
- [ ] 의존 방향이 올바르다.
- [ ] public export가 좁고 명시적이다.
- [ ] dead import, export, file이 없다.
- [ ] 상태와 Effect 배치가 React 실행 모델에 맞다.
- [ ] 단위·컴포넌트 테스트는 구현 가까이에 있다.
- [ ] cross-page 사용자 흐름은 E2E로 검증한다.
- [ ] 저장소 검증 명령이 통과했거나 실패 내용을 정확히 보고한다.

## 구조 분석 결과 형식

리뷰 또는 재구성 계획 요청에는 다음 형식을 사용한다.

````markdown
## 현재 문제
- [file/path] — [구체적인 가독성 또는 소유권 문제]

## 소유권 분류
| 코드 | 현재 소유자 | 권장 소유자 | 근거 |
|---|---|---|---|

## 제안 트리
```text
[tree]
```

## Import 변경
- [기존 import] → [새 public import]

## 실행 순서
1. [안전한 이동 단계]
2. [다음 단계]

## 검증
- `[command]` — [결과 또는 검증 범위]

## 예외와 위험
- [예외, 이유, 후속 조치]
````

## 구현 완료 결과 형식

```markdown
## 구조 변경
- [module] — [소유권이 어떻게 바뀌었는지]

## 런타임 동작
- 유지 / 변경 — [설명]

## 수행한 검증
- `[command]` — 성공/실패

## 남은 문제
- [구체적인 미해결 항목 또는 "없음"]
```
convention.md 원문 보기
# React 프론트엔드 폴더 구조 컨벤션

> **방식:** 가독성 우선 Page-first Vertical Slice  
> **적용 대상:** React + TypeScript 기반 웹 애플리케이션  
> **핵심 목표:** 한 기능을 이해하고 수정할 때 저장소 여러 곳을 오가지 않도록 관련 코드를 가까이 둔다.

---

## 1. 이 컨벤션이 해결하려는 문제

프로젝트 초기에 다음과 같은 구조는 단순해 보인다.

```text
src/
├── components/
├── hooks/
├── pages/
├── services/
├── utils/
├── stores/
└── types/
```

하지만 기능이 늘어나면 하나의 화면이 여러 최상위 폴더에 흩어진다.

```text
src/pages/OrdersPage.tsx
src/components/orders/OrdersTable.tsx
src/components/orders/OrderFilters.tsx
src/hooks/useOrders.ts
src/hooks/useOrderFilters.ts
src/services/orderService.ts
src/utils/orderUtils.ts
src/types/order.ts
```

이 구조에서는 `주문 목록` 기능 하나를 파악하기 위해 여러 폴더를 계속 이동해야 한다. 또한 전역 `components`, `hooks`, `utils`가 서로 무관한 파일을 모아두는 창고가 되기 쉽다.

이 컨벤션은 코드를 **문법적 종류가 아니라 제품 기능과 책임**으로 묶는다.

```text
src/pages/orders-list/
├── OrdersListPage.tsx
├── ui/
├── model/
├── api/
└── lib/
```

주문 목록 화면을 이해하려면 우선 `orders-list` 폴더 하나를 보면 된다.

---

## 2. 핵심 원칙

### 원칙 1. 코드는 사용하는 곳 가까이에 둔다

한 컴포넌트에서만 사용하는 파일은 그 컴포넌트 옆에 둔다. 한 페이지에서만 사용하는 파일은 그 페이지 안에 둔다.

```text
pages/orders-list/ui/OrdersTable/
├── OrdersTable.tsx
├── OrderRow.tsx
└── OrdersTable.test.tsx
```

`OrderRow`가 `OrdersTable`에서만 사용된다면 전역 컴포넌트나 별도 feature로 이동하지 않는다.

### 원칙 2. 페이지에서 시작한다

새 기능의 기본 위치는 해당 페이지다. 처음부터 `features`, `entities`, `shared`로 나누지 않는다.

```text
pages/order-detail/
├── OrderDetailPage.tsx
├── ui/
├── model/
├── api/
└── lib/
```

여러 페이지에서 동일한 의미로 재사용되는 것이 확인된 후에만 공용 계층으로 올린다.

### 원칙 3. 재사용은 추측하지 않고 증명한다

“나중에 쓸 것 같다”는 이유로 공용화하지 않는다.

```text
한 컴포넌트 전용
→ 컴포넌트 옆

한 페이지 전용
→ page 내부

여러 페이지의 동일한 사용자 행동
→ feature

여러 페이지의 동일한 도메인 개념
→ entity

비즈니스와 무관한 범용 코드
→ shared
```

### 원칙 4. 페이지 파일은 시나리오의 목차처럼 읽혀야 한다

페이지 컴포넌트는 저수준 구현을 담는 파일이 아니다. 화면의 주요 블록과 사용자 흐름이 위에서 아래로 읽혀야 한다.

```tsx
export function OrdersListPage() {
  const page = useOrdersListPage();

  return (
    <PageLayout title="주문 관리">
      <OrderFilters
        value={page.filters}
        onChange={page.changeFilters}
      />

      <OrdersTable
        orders={page.orders}
        isLoading={page.isLoading}
        onSelectOrder={page.openOrder}
      />

      <OrdersPagination
        page={page.page}
        totalPages={page.totalPages}
        onPageChange={page.changePage}
      />
    </PageLayout>
  );
}
```

이 파일만 읽어도 다음 흐름이 보여야 한다.

```text
필터 변경
→ 주문 목록 표시
→ 주문 선택
→ 페이지 이동
```

### 원칙 5. 의존성은 위에서 아래로만 흐른다

```text
app
 ↓
pages
 ↓
features
 ↓
entities
 ↓
shared
```

허용 예시:

```text
pages → features, entities, shared
features → entities, shared
entities → shared
```

금지 예시:

```text
shared → entities
entities → features
features → pages
page A → page B 내부 파일
```

같은 계층의 서로 다른 모듈 간 import도 기본적으로 금지한다. 필요하면 두 모듈을 합치거나 공통 개념을 더 낮은 계층으로 이동한다.

### 원칙 6. 폴더 이름은 책임을 드러낸다

권장:

```text
ui
model
api
lib
config
```

지양:

```text
helpers
common
misc
stuff
utils.ts
```

`utils`라는 이름 자체가 금지되는 것은 아니다. 다만 서로 관련 없는 로직을 한 파일에 넣는 용도로 사용하지 않는다.

---

## 3. 기본 폴더 구조

### 작은 프로젝트의 시작 구조

```text
src/
├── app/
├── pages/
└── shared/
```

```text
src/
├── app/
│   ├── router/
│   ├── providers/
│   ├── styles/
│   └── App.tsx
│
├── pages/
│   ├── orders-list/
│   │   ├── OrdersListPage.tsx
│   │   ├── ui/
│   │   ├── model/
│   │   ├── api/
│   │   └── lib/
│   └── order-detail/
│       ├── OrderDetailPage.tsx
│       ├── ui/
│       ├── model/
│       ├── api/
│       └── lib/
│
└── shared/
    ├── ui/
    ├── api/
    ├── hooks/
    ├── lib/
    ├── config/
    └── types/
```

비어 있는 `features`, `entities` 폴더를 미리 만들지 않는다.

### 재사용이 늘어난 프로젝트

```text
src/
├── app/
├── pages/
├── features/
├── entities/
└── shared/
```

예시:

```text
src/
├── app/
│   ├── router/
│   ├── providers/
│   ├── styles/
│   └── App.tsx
│
├── pages/
│   ├── orders-list/
│   └── order-detail/
│
├── features/
│   ├── cancel-order/
│   └── export-orders/
│
├── entities/
│   └── order/
│
└── shared/
    ├── ui/
    ├── api/
    ├── hooks/
    ├── lib/
    └── config/
```

---

## 4. 계층별 책임

## 4.1 `app`: 애플리케이션 실행과 전역 조립

`app`에는 특정 업무 기능이 아니라 애플리케이션 전체를 구동하는 코드가 들어간다.

적절한 항목:

- 라우터 설정
- 전역 Provider
- Query Client 설정
- 전역 상태 Store 설정
- Error Boundary
- 전역 스타일과 테마
- 앱 초기화
- 인증 초기 부트스트랩

```text
app/
├── router/
│   ├── routes.tsx
│   └── ProtectedRoute.tsx
├── providers/
│   ├── AppProviders.tsx
│   ├── QueryProvider.tsx
│   └── AuthProvider.tsx
├── styles/
│   └── globals.css
└── App.tsx
```

부적절한 항목:

- 주문 취소 규칙
- 회원 초대 폼 상태
- 상품 가격 계산
- 특정 페이지의 검색 필터

판단 질문:

> 특정 제품 기능을 제거해도 애플리케이션 구동에 필요한 코드인가?

그렇다면 `app`일 가능성이 높다.

---

## 4.2 `pages`: URL 단위 화면과 페이지 조립

페이지는 라우트 단위의 사용자 화면이다.

```text
/orders           → pages/orders-list
/orders/:orderId  → pages/order-detail
/settings/profile → pages/profile-settings
```

페이지는 다음을 담당한다.

- 화면의 주요 구역 조합
- 페이지 전용 상태와 이벤트 연결
- 페이지 전용 데이터 조회
- loading, error, empty 상태 조합
- 라우팅 파라미터와 URL 검색 조건 처리

페이지 내부 기본 세그먼트:

```text
pages/orders-list/
├── OrdersListPage.tsx
├── ui/
├── model/
├── api/
├── lib/
└── index.ts
```

모든 세그먼트를 항상 만들 필요는 없다. 파일이 없으면 폴더도 만들지 않는다.

---

## 4.3 `features`: 여러 페이지에서 재사용되는 사용자 행동

Feature는 보통 동사형으로 표현된다.

```text
cancel-order
save-search
invite-member
change-password
export-orders
apply-coupon
```

예시:

```text
features/cancel-order/
├── ui/
│   ├── CancelOrderButton.tsx
│   └── CancelOrderDialog.tsx
├── model/
│   └── useCancelOrder.ts
├── api/
│   └── cancelOrder.mutation.ts
├── lib/
│   └── getCancelErrorMessage.ts
└── index.ts
```

다음 조건을 만족할 때 feature로 승격한다.

1. 둘 이상의 페이지에서 사용한다.
2. 사용자의 하나의 명확한 행동을 표현한다.
3. UI, 상태, API 또는 규칙을 하나의 기능 경계로 묶을 가치가 있다.
4. 두 사용처가 우연히 비슷한 것이 아니라 같은 이유로 함께 변경된다.

한 페이지에서만 쓰는 기능은 페이지 안에 둔다.

```text
pages/order-detail/
├── ui/CancelOrderDialog.tsx
├── model/useCancelOrder.ts
└── api/cancelOrder.mutation.ts
```

주문 목록에서도 같은 취소 기능이 필요해질 때 `features/cancel-order`로 이동한다.

---

## 4.4 `entities`: 여러 화면에서 공유되는 도메인 명사

Entity는 제품에서 지속적으로 등장하는 핵심 명사다.

```text
user
order
product
workspace
invoice
message
project
```

예시:

```text
entities/order/
├── ui/
│   ├── OrderStatusBadge.tsx
│   └── OrderAmount.tsx
├── model/
│   ├── order.types.ts
│   ├── order.schema.ts
│   ├── order.mappers.ts
│   └── canCancelOrder.ts
├── api/
│   ├── order.api.ts
│   ├── order.queries.ts
│   └── order.keys.ts
└── index.ts
```

적절한 항목:

- 여러 화면에서 동일한 의미로 쓰이는 도메인 타입
- 도메인 표시 컴포넌트
- 도메인 단위 query와 API
- 도메인 불변 규칙
- API DTO와 제품 모델 사이 mapper

구분 예시:

```text
OrderStatusBadge → 여러 화면에서 동일한 주문 상태 표현 → entity
OrdersTable      → 주문 목록 페이지의 구체적인 화면 구성 → page
CancelOrder      → 사용자가 주문을 취소하는 행동 → feature
```

Entity도 처음부터 만들지 않는다. 실제로 여러 화면에서 같은 도메인 의미가 반복될 때 만든다.

---

## 4.5 `shared`: 비즈니스에 종속되지 않는 범용 코드

적절한 항목:

```text
Button
Dialog
Input
Spinner
API client
날짜·통화 포맷
환경 변수 파서
useMediaQuery
useDebouncedValue
```

부적절한 항목:

```text
OrderFilters
CancelOrderButton
WorkspaceInviteForm
ProductPriceCard
useOrderPermission
```

판단 질문:

> 이 코드를 전혀 다른 제품으로 복사해도 이름과 의미가 자연스러운가?

자연스럽다면 `shared`일 가능성이 높다.

```text
shared/
├── ui/
│   ├── Button/
│   ├── Dialog/
│   └── Spinner/
├── api/
│   ├── apiClient.ts
│   └── ApiError.ts
├── hooks/
│   ├── useDebouncedValue.ts
│   └── useMediaQuery.ts
├── lib/
│   ├── date/
│   ├── currency/
│   └── validation/
└── config/
    ├── env.ts
    └── routes.ts
```

`shared`는 가장 낮은 계층이므로 `pages`, `features`, `entities`를 import해서는 안 된다.

---

## 5. 페이지 내부 세그먼트

## 5.1 `ui`: 화면 표현

`ui`에는 해당 모듈에서 사용하는 React 컴포넌트와 표현 로직을 둔다.

```text
pages/orders-list/ui/
├── OrdersToolbar.tsx
├── OrderFilters.tsx
├── OrdersPagination.tsx
├── EmptyOrders.tsx
└── OrdersTable/
    ├── OrdersTable.tsx
    ├── OrderRow.tsx
    └── OrdersTable.test.tsx
```

규칙:

- 해당 페이지에서만 쓰는 업무 컴포넌트는 page의 `ui`에 둔다.
- 컴포넌트마다 무조건 폴더를 만들지 않는다.
- 하위 컴포넌트, 테스트, 스타일, 스토리가 생길 때 폴더로 확장한다.
- 컴포넌트는 props를 통해 필요한 값과 이벤트를 명시적으로 받는 것을 기본으로 한다.
- 저수준 HTTP 요청을 UI 컴포넌트 안에 직접 작성하지 않는다.

작은 컴포넌트:

```text
ui/
├── EmptyOrders.tsx
├── OrderFilters.tsx
└── OrdersPagination.tsx
```

복잡해진 컴포넌트:

```text
ui/OrdersTable/
├── OrdersTable.tsx
├── OrderRow.tsx
├── OrdersTable.test.tsx
└── OrdersTable.module.css
```

---

## 5.2 `model`: 상태, 훅, 스키마, 화면 동작

`model`은 모듈의 상태와 동작을 관리한다.

적절한 항목:

- 페이지 전용 커스텀 훅
- 로컬 상태 조합
- URL 상태와 화면 상태 동기화
- 폼 스키마
- reducer 또는 state machine
- 선택 상태와 페이지네이션 상태
- 파생 값
- 여러 query와 mutation의 조합

```text
model/
├── useOrdersListPage.ts
├── useOrderFilters.ts
├── useOrderSelection.ts
├── ordersFilters.ts
└── ordersList.schema.ts
```

### 페이지 모델 훅

페이지 컴포넌트가 화면 조합에 집중할 수 있도록 상태와 이벤트를 묶는다.

```tsx
export function useOrdersListPage() {
  const filters = useOrderFilters();
  const selection = useOrderSelection();
  const ordersQuery = useOrdersQuery(filters.value);

  function openOrder(orderId: string) {
    navigate(`/orders/${orderId}`);
  }

  return {
    filters: filters.value,
    changeFilters: filters.change,
    selectedOrderIds: selection.ids,
    toggleOrder: selection.toggle,
    orders: ordersQuery.data?.items ?? [],
    isLoading: ordersQuery.isLoading,
    openOrder,
  };
}
```

주의:

- 모든 로직을 하나의 `usePageModel`에 몰아넣지 않는다.
- 훅이 여러 독립 책임을 가지면 역할별 훅으로 나눈 뒤 상위 훅에서 조합한다.
- 단순 계산은 훅으로 만들지 않고 순수 함수 또는 렌더 중 파생 값으로 둔다.
- 사용자 행동은 이벤트 핸들러에서, 외부 시스템 동기화만 Effect에서 처리한다.
- props와 서버 데이터로 계산할 수 있는 값을 별도 state로 복제하지 않는다.

### 훅 이름

좋은 이름:

```text
useOrderFilters
useOrderSelection
useCancelOrder
useOrdersQuery
```

피해야 할 이름:

```text
useCommon
useUtils
useData
usePageLogic
```

이름만 보고 책임을 추측할 수 있어야 한다.

---

## 5.3 `api`: 네트워크와 서버 상태

`api`에는 서버 통신과 서버 상태를 다루는 코드를 둔다.

적절한 항목:

- HTTP 요청 함수
- Query와 Mutation
- query key
- DTO 타입
- 응답 mapper
- 캐시 invalidate 정책
- 페이지 전용 집계 요청

```text
api/
├── ordersList.api.ts
├── ordersList.query.ts
├── ordersList.keys.ts
└── ordersList.mapper.ts
```

재사용 범위에 따라 위치를 결정한다.

```text
한 페이지 전용 조회
→ pages/orders-list/api

여러 화면에서 공유하는 주문 조회
→ entities/order/api

주문 취소라는 사용자 행동
→ features/cancel-order/api

전역 HTTP client
→ shared/api
```

규칙:

- API 응답 타입과 화면 모델 타입이 다르면 mapper를 둔다.
- 서버 상태를 무의미하게 `useState`나 전역 store에 복제하지 않는다.
- query key와 invalidate 정책은 해당 API 경계 가까이에 둔다.
- UI 컴포넌트에서 저수준 URL과 HTTP 메서드를 직접 다루지 않는다.

---

## 5.4 `lib`: 순수 보조 로직

`lib`에는 가능하면 React, 브라우저, 네트워크에 의존하지 않는 순수 함수를 둔다.

```text
lib/
├── buildOrdersSearchParams.ts
├── parseOrdersFilters.ts
└── sortOrders.ts
```

```ts
export function buildOrdersSearchParams(
  filters: OrdersFilters,
): URLSearchParams {
  const params = new URLSearchParams();

  if (filters.status) {
    params.set("status", filters.status);
  }

  if (filters.keyword) {
    params.set("keyword", filters.keyword);
  }

  params.set("page", String(filters.page));
  return params;
}
```

피해야 할 형태:

```ts
// utils.ts
export function formatDate() {}
export function validateOrder() {}
export function buildSearchParams() {}
export function calculatePrice() {}
export function downloadCsv() {}
export function isAdmin() {}
```

대신 의미와 소유권을 드러낸다.

```text
shared/lib/date/formatDate.ts
shared/lib/currency/formatCurrency.ts
pages/orders-list/lib/buildOrdersSearchParams.ts
entities/order/model/canCancelOrder.ts
features/export-orders/lib/createOrdersCsv.ts
```

---

## 6. 파일 배치 결정표

새 파일을 만들기 전 아래 순서로 판단한다.

| 질문 | 위치 |
|---|---|
| 한 컴포넌트에서만 사용하는가? | 해당 컴포넌트 옆 |
| 한 페이지의 여러 코드에서 사용하는가? | 해당 page의 `ui/model/api/lib` |
| 여러 페이지에서 동일한 사용자 행동으로 쓰이는가? | `features/<verb-noun>` |
| 여러 페이지에서 동일한 도메인 객체로 쓰이는가? | `entities/<noun>` |
| 비즈니스와 무관한 범용 코드인가? | `shared` |
| 앱 실행과 전역 조립 코드인가? | `app` |

### 예시 1: 검색 필터

```text
주문 목록에서만 사용하는 필터
→ pages/orders-list/model/useOrderFilters.ts

여러 목록 화면에서 공통으로 쓰는 단순 debounce 훅
→ shared/hooks/useDebouncedValue.ts

검색 조건 저장 기능을 여러 페이지에서 사용
→ features/save-search/
```

### 예시 2: 주문 상태 표시

```text
주문 목록과 상세에서 동일하게 사용
→ entities/order/ui/OrderStatusBadge.tsx
```

### 예시 3: CSV 내보내기

```text
주문 목록에서만 단순 파일 생성
→ pages/orders-list/lib/createOrdersCsv.ts

여러 주문 관련 화면에서 권한·API·진행 상태와 함께 사용
→ features/export-orders/
```

---

## 7. 상태 배치 규칙

상태의 위치는 “얼마나 전역적으로 보이는가”가 아니라 **누가 소유하고 어떤 생명주기를 가지는가**로 판단한다.

| 상태 종류 | 기본 위치 |
|---|---|
| 한 컴포넌트의 열림/닫힘 | 컴포넌트 로컬 state |
| 한 페이지의 필터·선택·페이지네이션 | page `model` |
| URL로 공유·복원해야 하는 조건 | page `model`의 URL state |
| 서버에서 가져온 데이터 | query/cache 계층 |
| 폼 편집 중 값 | 폼 또는 컴포넌트 로컬 state |
| 전 앱 인증 세션·테마 | `app` provider/store |
| 여러 화면의 명확한 사용자 기능 상태 | 해당 feature `model` |

원칙:

- 파생 가능한 값을 별도 state로 저장하지 않는다.
- 서버 상태와 로컬 상태를 중복 보관하지 않는다.
- 전역 store는 편의가 아니라 실제 공유 수명주기가 있을 때 사용한다.
- 상태를 공유해야 한다면 먼저 가장 가까운 공통 소유자를 찾는다.

---

## 8. React 실행 흐름과 훅 배치 규칙

파일 배치와 실행 시점을 함께 읽을 수 있어야 한다.

```text
render-time
→ 컴포넌트 본문, 커스텀 훅 호출, 파생 값, JSX

event-time
→ onClick, onChange, onSubmit, mutation 시작

commit/effect-time
→ 외부 시스템 동기화, 구독 설정과 cleanup

network/cache
→ 요청, 응답, 캐시 갱신, 재렌더
```

규칙:

1. 렌더 중에는 순수 계산만 한다.
2. 사용자 행동은 이벤트 핸들러에 둔다.
3. Effect는 외부 시스템과의 동기화에만 사용한다.
4. props나 state로 계산 가능한 값은 Effect로 다시 state에 저장하지 않는다.
5. 커스텀 훅은 입력, 출력, 부작용, 재렌더 조건이 명확해야 한다.
6. 조건문이나 반복문 안에서 훅을 호출하지 않는다.
7. 구독, 타이머, 전역 이벤트, 소켓은 cleanup을 제공한다.

---

## 9. 이름 규칙

### 폴더

업무 모듈 폴더는 `kebab-case`를 사용한다.

```text
orders-list
order-detail
cancel-order
saved-search
```

### React 컴포넌트

`PascalCase`를 사용한다.

```text
OrdersListPage.tsx
OrderStatusBadge.tsx
CancelOrderDialog.tsx
```

### 훅

`use` + 책임을 사용한다.

```text
useOrdersQuery.ts
useOrderFilters.ts
useCancelOrder.ts
```

### 파일 접미사

접미사는 파일의 역할을 추가로 설명할 때 사용한다.

```text
order.types.ts
order.schema.ts
order.mapper.ts
orders.query.ts
cancelOrder.mutation.ts
orders.keys.ts
OrdersTable.test.tsx
orders.e2e.spec.ts
```

팀이 이미 다른 규칙을 사용한다면 새 접미사를 혼합하지 말고 기존 규칙을 우선한다.

---

## 10. `index.ts`와 Public API

`index.ts`는 모든 폴더에 만드는 파일이 아니다. 외부에 공개되는 모듈 경계에서만 사용한다.

```text
features/cancel-order/
├── ui/
├── model/
├── api/
└── index.ts
```

```ts
// features/cancel-order/index.ts
export { CancelOrderButton } from "./ui/CancelOrderButton";
export { CancelOrderDialog } from "./ui/CancelOrderDialog";
export { useCancelOrder } from "./model/useCancelOrder";
```

외부 import:

```ts
import {
  CancelOrderButton,
  useCancelOrder,
} from "@/features/cancel-order";
```

피해야 할 외부 deep import:

```ts
import { useCancelOrder } from
  "@/features/cancel-order/model/useCancelOrder";
```

규칙:

- 페이지, feature, entity 등 외부 경계에만 `index.ts`를 둔다.
- 외부로 필요한 항목만 명시적으로 export한다.
- `export *`는 사용하지 않는다.
- 같은 모듈 내부에서는 자기 `index.ts`로 우회하지 말고 실제 파일을 직접 import한다.
- barrel 때문에 순환 참조가 생기면 환경별 public API 또는 직접 import를 검토한다.

---

## 11. 타입 배치

타입도 사용하는 코드 가까이에 둔다.

```text
entities/order/model/order.types.ts
pages/orders-list/model/ordersFilters.ts
features/cancel-order/model/cancelOrder.types.ts
```

한 컴포넌트에서만 쓰는 props 타입은 같은 파일에 둬도 된다.

```tsx
type OrderFiltersProps = {
  value: OrdersFilters;
  onChange: (filters: OrdersFilters) => void;
};
```

전역 `src/types`는 여러 모듈이 공유하는 정말 범용적인 타입에만 제한적으로 사용한다. 가능하면 해당 entity나 feature가 타입을 소유한다.

---

## 12. 테스트 배치

단위 테스트와 컴포넌트 테스트는 구현 옆에 둔다.

```text
ui/OrdersTable/
├── OrdersTable.tsx
├── OrderRow.tsx
└── OrdersTable.test.tsx
```

```text
model/
├── ordersFilters.ts
└── ordersFilters.test.ts
```

여러 페이지를 관통하는 사용자 흐름은 별도 E2E 폴더에 둔다.

```text
e2e/
├── order-list.spec.ts
├── order-cancel.spec.ts
└── authentication.spec.ts
```

```text
구현 단위 검증
→ 구현 가까이

전체 사용자 흐름 검증
→ e2e
```

---

## 13. 프레임워크별 적용

## 13.1 Vite + React Router

```text
src/
├── app/
│   └── router/routes.tsx
├── pages/
│   ├── orders-list/
│   └── order-detail/
├── features/
├── entities/
└── shared/
```

라우트 파일은 페이지를 연결하는 역할만 수행하고, 페이지 구현은 `pages`에 둔다.

```tsx
const OrdersListPage = lazy(() =>
  import("@/pages/orders-list").then((module) => ({
    default: module.OrdersListPage,
  })),
);
```

## 13.2 Next.js App Router

Next.js의 `app` 디렉터리는 라우터이므로 라우트 로컬 코드를 가까이 둘 수 있다.

```text
src/
├── app/
│   └── (dashboard)/
│       └── orders/
│           ├── page.tsx
│           ├── loading.tsx
│           ├── error.tsx
│           ├── _ui/
│           ├── _model/
│           └── _lib/
├── features/
├── entities/
└── shared/
```

규칙:

- `page.tsx`는 라우트 진입점과 조합에 집중한다.
- 해당 라우트에서만 쓰는 코드는 `_ui`, `_model`, `_lib`처럼 비라우트 폴더에 둔다.
- 여러 라우트에서 재사용되는 사용자 행동과 도메인 코드는 `features`, `entities`로 이동한다.
- Server Component와 Client Component의 경계를 public API가 흐리지 않도록 주의한다.

---

## 14. 피해야 할 구조와 코드 냄새

### 전역 업무 `components`

```text
src/components/
├── OrderTable.tsx
├── ProductCard.tsx
├── UserForm.tsx
└── WorkspaceSelector.tsx
```

대신 각 페이지, feature, entity가 소유하게 한다. `shared/ui`에는 디자인 시스템 수준의 범용 UI만 둔다.

### 전역 업무 `hooks`

```text
src/hooks/
├── useOrders.ts
├── useCancelOrder.ts
├── useProductSearch.ts
└── useWorkspacePermission.ts
```

대신:

```text
pages/orders-list/model/useOrderFilters.ts
features/cancel-order/model/useCancelOrder.ts
entities/order/api/useOrdersQuery.ts
shared/hooks/useMediaQuery.ts
```

### 거대한 `utils.ts`

서로 관련 없는 함수가 한 파일에 있으면 소유권과 변경 이유가 사라진다.

### 과도한 feature 분해

```text
features/
├── show-order-status/
├── select-order/
├── open-order/
├── filter-orders/
└── paginate-orders/
```

한 페이지의 세부 동작이라면 page의 `ui/model/lib`에 둔다.

### 거대한 페이지 훅

```text
useOrdersPage.ts // 수백 줄, 여러 독립 책임
```

역할별로 나눈 뒤 상위 훅에서 조합한다.

```text
useOrderFilters
useOrderSelection
useOrdersPagination
useOrderActions
```

### 의미 없는 공용화

두 파일의 코드 모양이 비슷하다는 이유만으로 공용 컴포넌트를 만들지 않는다. 변경 이유와 제품 의미가 같을 때만 합친다.

---

## 15. 기존 프로젝트 마이그레이션 절차

전체 저장소를 한 번에 재구성하지 않는다.

### 1단계. 페이지 하나를 선택한다

변경이 잦거나 구조가 가장 복잡한 페이지 하나를 선택한다.

### 2단계. 현재 흐름을 추적한다

```text
라우트 진입
→ 페이지 컴포넌트
→ 주요 UI
→ 상태와 훅
→ API
→ 테스트
```

### 3단계. 페이지 전용 코드를 모은다

기존:

```text
src/pages/OrdersPage.tsx
src/components/OrderTable.tsx
src/hooks/useOrders.ts
src/services/orderService.ts
src/utils/orderUtils.ts
```

이동:

```text
src/pages/orders-list/
├── OrdersListPage.tsx
├── ui/OrdersTable.tsx
├── model/useOrdersListPage.ts
├── api/ordersList.query.ts
└── lib/buildOrdersSearchParams.ts
```

### 4단계. 진짜 공유 코드만 승격한다

```text
OrderStatusBadge
→ entities/order/ui

CancelOrderDialog + mutation
→ features/cancel-order

Button, Dialog
→ shared/ui

formatCurrency
→ shared/lib/currency
```

### 5단계. import 경계를 정리한다

- page 간 직접 import 제거
- feature의 page import 제거
- entity의 feature import 제거
- shared의 비즈니스 import 제거
- 외부 deep import를 public API import로 변경

### 6단계. 검증한다

- typecheck
- lint
- 관련 단위·컴포넌트 테스트
- E2E 또는 주요 수동 흐름
- production build

리팩터링 중 제품 동작을 함께 변경하지 않는 것을 기본으로 한다.

---

## 16. 코드 리뷰 체크리스트

### 파일 배치

- [ ] 새 파일이 실제 사용처 가까이에 있는가?
- [ ] 한 페이지 전용 코드가 불필요하게 공용 계층에 있지 않은가?
- [ ] 여러 페이지의 동일 개념이 특정 page 내부에 갇혀 있지 않은가?
- [ ] `shared`에 비즈니스 용어가 들어간 파일이 없는가?
- [ ] `features`가 실제 사용자 행동을 나타내는가?
- [ ] `entities`가 실제로 공유되는 도메인 개념인가?

### 가독성

- [ ] 페이지 파일에서 화면의 주요 실행 흐름이 읽히는가?
- [ ] UI, 상태, 네트워크, 순수 로직의 책임이 섞이지 않았는가?
- [ ] 이름이 파일의 책임을 설명하는가?
- [ ] 거대한 page hook 또는 `utils.ts`가 생기지 않았는가?

### 의존성

- [ ] import가 허용된 방향으로만 흐르는가?
- [ ] 다른 page 내부 파일을 직접 import하지 않는가?
- [ ] 모듈 외부에서 deep import하지 않는가?
- [ ] 불필요한 같은 계층 cross-import가 없는가?
- [ ] 순환 참조가 없는가?

### React 상태와 훅

- [ ] 서버 상태를 로컬 state에 중복 저장하지 않는가?
- [ ] 파생 값을 Effect로 다시 state에 저장하지 않는가?
- [ ] 사용자 행동이 불필요하게 Effect에 들어가 있지 않은가?
- [ ] 구독과 타이머에 cleanup이 있는가?
- [ ] 커스텀 훅의 입력·출력·부작용이 명확한가?

### 검증

- [ ] 변경된 책임에 맞는 테스트가 가까이에 있는가?
- [ ] typecheck, lint, 테스트, build가 통과하는가?
- [ ] 폴더 이동만 한 경우 제품 동작이 바뀌지 않았는가?

---

## 17. 예외 처리

이 문서는 절대 규칙이 아니라 기본값이다. 다만 예외는 다음을 남긴다.

```text
무슨 규칙을 어기는가?
왜 기본 구조보다 나은가?
영향 범위는 무엇인가?
언제 다시 검토할 것인가?
```

프레임워크의 파일 기반 라우팅, 번들러 환경 경계, 성능상 코드 분할, 레거시 호환성처럼 실질적인 이유가 있을 때 예외를 허용한다.

단순히 “지금 옮기기 귀찮다” 또는 “나중에 쓸 것 같다”는 예외 사유가 아니다.

---

## 18. 한 장 요약

```text
1. 새 코드는 우선 사용하는 페이지 가까이에 둔다.
2. 페이지 파일은 사용자 시나리오의 목차처럼 읽히게 만든다.
3. page 내부를 ui / model / api / lib로 나눈다.
4. 여러 페이지의 동일한 행동만 feature로 올린다.
5. 여러 페이지의 동일한 도메인 개념만 entity로 올린다.
6. 비즈니스와 무관한 코드만 shared에 둔다.
7. app → pages → features → entities → shared 방향을 지킨다.
8. 전역 components / hooks / utils에 업무 코드를 쌓지 않는다.
9. index.ts는 외부 공개 경계에서만 사용한다.
10. 재사용을 예상하지 말고 실제 사용처가 생긴 뒤 승격한다.
```

### 가장 중요한 판단 문장

> 이 파일을 수정하려는 사람이 가장 먼저 찾아볼 장소는 어디인가?

그 장소와 파일의 실제 위치가 같아야 한다.

---

## 참고 근거

- React, **Thinking in React**: UI를 컴포넌트 계층으로 나누고 상태와 데이터 흐름을 연결하는 설계 방식
- Redux, **Style Guide**: 관련 로직을 기능 폴더에 함께 두는 feature-folder 접근
- Feature-Sliced Design, **Layers / Public API / Pages-first guidance**: 계층 의존 방향, 모듈 공개 API, 과도한 선분해 방지
- Next.js, **Project Structure**: 파일 배치의 유연성, private folders와 route groups를 이용한 route-local 코드 배치
- Agent Skills Specification: `SKILL.md`의 metadata와 점진적 공개 방식

Comments

0 comments