Skip to content

지역성 기반 폴더 구조

Overview

프로젝트가 커지면서 "이 파일을 어디에 둬야 하지?"라는 고민이 늘어납니다. Feature 기반 폴더 구조는 지역성(Locality)과 Page First 원칙을 바탕으로 변경에 강하고 명확한 구조를 만듭니다.

이 글에서는 제가 실무에서 사용하고 있는 구조를 공유합니다. 정답은 아니지만, 파일 위치만으로 사용 범위를 파악할 수 있고, 페이지 삭제 시 관련 파일을 한 번에 제거할 수 있어서 유용합니다.

Next.js Pages Router 기준으로 설명하며, 다른 프레임워크에도 동일하게 적용 가능합니다.

1. 문제 인식

흔히 사용하는 역할 중심 구조는 프로젝트가 커질수록 문제가 발생합니다.

tsx
// 역할 중심 구조
src/
├── components/
│   ├── auth/          # 100개 파일
│   ├── dashboard/     # 150개 파일
│   └── settings/      # 80개 파일
├── hooks/
├── utils/
└── types/
문제설명
파일 찾기 어려움관련 파일이 components, hooks, types에 흩어짐
삭제 비용 증가페이지 삭제 시 연관 파일을 하나씩 찾아야 함
영향 범위 불명확이 컴포넌트를 수정하면 어디까지 영향을 주는지 파악 어려움

이 문제를 해결하기 위해 지역성 원칙과 Page First 원칙을 적용합니다.

2. 핵심 원칙

지역성 (Locality)

사용하는 곳과 가장 가까운 위치에 파일을 배치합니다.

tsx
// ❌ Bad: 모든 컴포넌트를 최상위에
src/
├── components/
│   ├── DashboardStats.tsx      // dashboard에서만 사용
│   ├── UserProfile.tsx         // user 페이지에서만 사용
│   └── Button.tsx              // 전역에서 사용
└── pages/
    ├── dashboard/
    └── user/
tsx
// ✅ Good: 사용처 가까이
src/
├── components/
│   └── Button.tsx              // 전역 공통만
└── pages/
    ├── dashboard/
    │   └── components/
    │       └── DashboardStats.tsx
    └── user/
        └── components/
            └── UserProfile.tsx

효과:

  • 폴더 위치가 사용 범위를 보장
  • 변경 영향 범위가 명확
  • 페이지 삭제 시 폴더만 제거하면 됨

Page First

처음엔 페이지 로컬에 생성하고, 재사용이 필요할 때 상위로 이동합니다.

tsx
// 1단계: 페이지 로컬에 생성
src/pages/product-detail/
├── components/
│   └── PriceSection.tsx    // 여기서 시작
└── ProductDetailPage.tsx

// 2단계: 두 번째 사용처가 생기면 상위로 이동
src/
├── components/
│   └── PriceSection.tsx    // 공통으로 이동
└── pages/
    ├── product-detail/
    └── checkout/           // 여기서도 사용

상위로 올릴 때 확인:

  1. 실제로 두 곳 이상에서 사용하는가?
  2. 도메인 지식을 제거할 수 있는가?
  3. props 인터페이스가 명확한가?
원칙역할
지역성"어디에" 둘 것인가 (위치)
Page First"언제" 상위로 올릴 것인가 (시점)

3. 전체 구조

위 원칙을 적용한 구조입니다.

tsx
pages/                 # Next.js 라우팅
├── _app.tsx
├── _document.tsx
├── index.tsx
├── auth/
└── task/

src/
├── components/        # 전역 공통 UI
├── hooks/             # 전역 커스텀 훅
├── utils/             # 전역 순수 유틸
├── lib/               # 전역 인프라 (queryClient, auth 등)
├── constants/         # 전역 상수
├── contexts/          # 전역 context
├── models/            # 전역 서버 타입 정의
├── types/             # 전역 클라이언트 타입 정의
├── queries/           # 전역 React Query 쿼리
├── mutations/         # 전역 React Query 뮤테이션
├── remotes/           # API 클라이언트 및 공통 API
├── stores/            # 전역 상태
├── modules/           # 기능 패키지
└── pages/             # 페이지별 리소스
    └── {page}/
        ├── components/
        ├── hooks/
        ├── queries/
        ├── mutations/
        ├── remotes/
        ├── models/
        ├── types/
        ├── utils/
        ├── constants/
        └── {Page}.tsx

두 개의 pages:

  • /pages: Next.js 라우팅 (프레임워크 요구)
  • /src/pages: 페이지별 컴포넌트, 훅, 로직

핵심은 전역과 로컬(페이지)로 나뉜다는 점입니다. src/ 바로 아래에 있는 폴더들은 전역이고, src/pages/{page}/ 아래에 있는 폴더들은 해당 페이지 전용입니다. 같은 이름의 폴더(components, hooks 등)가 양쪽에 있을 수 있습니다.

대부분의 폴더는 역할 중심 구조와 동일하지만, models, types, modules는 헷갈릴 수 있어서 아래에서 설명합니다.

4. 헷갈리기 쉬운 폴더

models vs types

서버와 통신하려면 서버의 요청/응답 타입을 알아야 합니다. 이 타입을 models에 정의합니다. 개발하다 보면 서버와 상관없이 클라이언트에서만 필요한 타입이 생깁니다. 이런 타입은 types에 정의합니다.

구분용도예시
models서버 요청/응답 타입 (DTO)사용자 정보, 태스크 목록
types클라이언트 내부 타입form 상태, UI 상태, 필터 조건
tsx
// models/task.schema.ts - 서버에서 내려주는 타입
import { z } from 'zod';

export const TaskSchema = z.object({
  id: z.string(),
  title: z.string(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']),
});

export type Task = z.infer<typeof TaskSchema>;
tsx
// types/task.type.ts - 클라이언트에서만 쓰는 타입
export type TaskFilter = {
  status: Task['status'] | 'ALL';
  searchQuery: string;
  sortBy: 'date' | 'title';
};

export type TaskListViewMode = 'list' | 'grid';

modules

개발하다 보면 로컬(페이지)에 만들었지만, 다른 페이지에서도 같은 기능이 필요한 경우가 생깁니다. UI만 필요하면 components로 올리면 되는데, UI와 로직, API 호출까지 함께 필요한 경우가 있습니다. 이럴 때 modules를 사용합니다.

tsx
src/modules/
├── step-renderer/       # 단계형 UI + 상태 관리
│   ├── StepRenderer.tsx
│   ├── useStep.ts
│   └── index.ts
└── image-uploader/      # 이미지 업로드 UI + 로직
    ├── ImageUploader.tsx
    ├── useImageUpload.ts
    └── index.ts
구분componentsmodules
상태statelessstateful
포함순수 UI만UI + 로직 + 상태
tsx
// components/ - 순수 UI
export function Button({ onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

// modules/ - UI + 로직 + 상태
export function ImageUploader({ onUpload }: ImageUploaderProps) {
  const { upload, preview, isUploading } = useImageUpload();

  return (
    <div>
      {preview && <img src={preview} />}
      <input type="file" onChange={upload} />
      {isUploading && <Spinner />}
    </div>
  );
}

modules는 최대한 사용하지 않다가 어쩔 수 없을 때만 사용합니다.

5. 네이밍 컨벤션

파일/폴더

대상규칙예시
디렉토리kebab-casetask-detail/
컴포넌트PascalCaseTaskHeader.tsx
페이지PascalCase + PageTaskDetailPage.tsx
use + camelCaseuseTaskDetail.ts

접미사 규칙

접미사용도위치
.query.tsReact Query 쿼리queries/
.mutation.tsReact Query 뮤테이션mutations/
.schema.tszod 스키마models/
.type.ts타입 정의types/
.store.ts상태 저장소stores/

6. 개발 흐름

1단계: 페이지 로컬에서 시작

tsx
src/pages/order-history/
├── components/
│   ├── OrderList.tsx
│   └── OrderItem.tsx
├── hooks/
│   └── useOrderHistory.ts
└── OrderHistoryPage.tsx

페이지 내에서 필요한 컴포넌트, 훅, 타입을 모두 로컬에 만듭니다.

2단계: 재사용 필요 시 상위로 이동

tsx
src/
├── components/
│   └── OrderItem.tsx        # 공통으로 이동
└── pages/
    ├── order-history/
    │   ├── components/
    │   │   └── OrderList.tsx
    │   └── hooks/
    │       └── useOrderHistory.ts
    └── order-detail/         # 여기서도 OrderItem 사용

실제로 다른 페이지에서 사용할 때 공통 폴더로 올립니다.

정리

원칙설명
지역성사용처와 가깝게 (위치)
Page First로컬 먼저, 필요 시 상위로 (시점)
점진적 추상화실제 필요 확인 후 추상화