Skip to content

App Router 디렉토리 구조를 통일하기

라우트(지면)에 필요한 코드를 해당 라우트 폴더 안에 격리하고, 폴더 역할을 명확히 구분합니다. 일관된 디렉토리 구조는 새 기능을 어디에 추가해야 할지 바로 결정하게 해주고, 코드 탐색 비용을 줄여줍니다.

1. 디렉토리 구조 개요

_, (), @, [...]Next.js 공식 폴더 컨벤션을 따릅니다. _global, _shared 같은 폴더 이름은 우리 팀의 약속입니다.

app/ 아래에는 세 가지 종류의 폴더가 있습니다.

  • _global/ — 앱 전역(레이아웃, 프로바이더, 전역 CSS)
  • _shared/ — 두 개 이상의 지면에서 가져다 쓰는 공용 코드
  • (some-page)/ — 특정 지면(라우트) 전용 코드

전체 구조는 다음과 같습니다.

text
📁 app/
├── 📁 _global/                           ⓐ 앱 전역
│   ├── 📁 _components/
│   │   └── 📁 Gnb/
│   │       ├── Gnb.tsx
│   │       └── Gnb.css.ts
│   ├── 📁 _hooks/
│   ├── 📁 _queries/                      ← 모든 서버 쿼리 (queryKey + queryOptions)
│   │   └── myCourse.queries.ts
│   ├── 📁 _apis/                         ← 모든 API 호출 함수
│   │   └── myCourse.api.ts
│   ├── 📁 _data/
│   ├── 📁 _providers/
│   └── 📁 _styles/
├── 📁 _shared/                           ⓑ 공용 코드 (도메인 단위)
│   ├── 📁 user/
│   │   └── 📁 _components/
│   │       └── 📁 UserCard/
│   │           ├── UserCard.tsx
│   │           └── UserCard.css.ts
│   └── 📁 payment/
│       ├── 📁 _components/
│       │   └── 📁 PaymentStatusBadge/
│       │       └── PaymentStatusBadge.tsx
│       └── 📁 _hooks/
│           └── usePaymentStatus.ts
└── 📁 (some-page)/                       라우트 그룹 (URL에 포함되지 않음)
    └── 📁 (withLogin)/
        └── 📁 (withGnb)/
            └── 📁 my-course/             /my-course 경로
                ├── 📁 _components/       ① 이 지면 전용 컴포넌트
                │   └── 📁 CourseCard/
                │       ├── CourseCard.tsx
                │       ├── CourseCard.css.ts
                │       ├── 📁 CourseSearch/    내부 depth 허용
                │       │   └── CourseSearch.tsx
                │       └── 📁 CourseModal/
                │           ├── CourseModal.tsx
                │           └── CourseModal.css.ts
                ├── 📁 _hooks/            ② 이 지면 전용 훅
                │   └── useCourseFilter.ts
                ├── 📁 _services/         ③ 유틸 함수
                │   └── formatCourseDate.service.ts
                ├── 📁 _data/             ④ 클라이언트 상태, 모델, 상수
                │   ├── myCourse.store.ts
                │   ├── myCourse.model.ts
                │   └── myCourse.constant.ts
                ├── 📁 _actions/          ⑤ Server Actions
                │   └── enrollCourse.action.ts
                ├── 📁 _types/            ⑥ 타입
                │   └── myCourse.type.ts
                ├── 📁 _tests/            ⑦ 테스트
                │   └── myCourse.spec.ts
                ├── layout.tsx
                ├── layout.css.ts
                ├── page.tsx
                └── page.css.ts

2. 프라이빗 폴더별 역할

각 라우트 폴더 안에 놓을 수 있는 프라이빗 폴더입니다.

폴더담당파일 예시
_components/이 지면에서만 쓰는 UI 컴포넌트CourseCard/CourseCard.tsx
_hooks/이 지면에서만 쓰는 커스텀 훅useCourseFilter.ts
_services/서비스 함수 및 순수 유틸 함수formatCourseDate.service.ts
_data/클라이언트 상태(store), 모델, 상수myCourse.store.ts
_actions/Next.js Server ActionsenrollCourse.action.ts
_types/TypeScript 타입 정의myCourse.type.ts
_tests/테스트 파일myCourse.spec.ts

각 도메인 라우트 안에 파일 기능에 따라 위 프라이빗 폴더를 둘 수 있습니다. 단, _components/ 내부의 컴포넌트 폴더 안에 _hooks/, _services/ 등의 프라이빗 폴더를 중첩하는 것은 금지합니다. 특정 컴포넌트에서만 사용하는 훅이더라도 해당 컴포넌트가 속한 도메인 라우트의 _hooks/ 폴더에 둡니다.

text
📁 my-course/
├── 📁 _components/
│   └── 📁 CourseCard/
│       ├── CourseCard.tsx
│       └── 📁 _hooks/           ✗ 금지 — 컴포넌트 내부에 프라이빗 폴더 중첩
│           └── useCourseCard.ts
├── 📁 _hooks/
│   └── useCourseCard.ts          ✓ 도메인 라우트의 _hooks/에 배치

3. _global vs _shared 구분하기

두 폴더 모두 앱 전체에서 쓰인다는 점은 같지만, 성격이 다릅니다.

_global/_shared/
용도앱 부팅 시 무조건 실행하거나 어느 지면에서든 호출 가능특정 지면에서 필요할 때 import
주요 내용Provider, 전역 CSS, 전역 에러 바운더리, 전역 레이아웃 컴포넌트, 서버 쿼리(_queries/), API 호출 함수(_apis/)공용 컴포넌트, 공용 훅, 공용 유틸
삭제 시 영향앱 전체가 망가짐해당 파일을 import한 곳만 영향 받음

서버 쿼리와 API 호출 함수는 무조건 _global/에 둡니다. 어느 지면에서든 동일한 쿼리·API가 재사용될 수 있고, 호출 위치를 한곳에 모아두면 캐시 키 충돌과 중복 정의를 막을 수 있기 때문입니다.

판단 기준

  • 두 개 이상의 지면에서 import한다면_shared/
  • 앱 루트(app/layout.tsx)에 선언되어야 한다면_global/
  • 어느 쪽인지 모호하다면_shared/로 먼저 두고, 전역으로 올릴 필요가 생길 때 이동합니다.

Kent C. Dodds가 제시한 "코드를 관련된 위치에 최대한 가까이 배치하라"는 co-location 원칙에 따라, 처음에는 가장 좁은 범위(해당 라우트의 프라이빗 폴더)에 두고, 공유가 필요해질 때 _shared/로 올리는 방향이 유지보수에 유리합니다.

공유 코드는 최소 공통 부모가 아닌 _shared/로 올립니다. 예를 들어 my-course/payment/에서 같은 훅을 쓴다면, 두 라우트의 공통 부모 폴더에 있는 _hooks/에 두는 것이 아니라 곧바로 _shared/로 이동합니다. 라우트 트리에서 최소 공통 부모를 찾는 것은 구조가 복잡해질수록 판단 비용이 커지기 때문입니다.

_global/ 예시

text
📁 app/_global/
├── 📁 _components/
│   ├── 📁 Gnb/
│   │   └── Gnb.tsx
│   └── 📁 GlobalErrorBoundary/
│       └── GlobalErrorBoundary.tsx
├── 📁 _providers/
│   └── GlobalProviders.tsx
├── 📁 _hooks/
├── 📁 _queries/                ← 서버 쿼리 정의 (queryKey + queryOptions)
│   ├── myCourse.queries.ts
│   └── user.queries.ts
├── 📁 _apis/                   ← API 호출 함수
│   ├── myCourse.api.ts
│   └── user.api.ts
├── 📁 _data/
└── 📁 _styles/
    └── global.css

_shared/ 예시

text
📁 app/_shared/
├── 📁 user/                      도메인 단위로 폴더 생성
│   ├── 📁 _components/
│   │   └── 📁 UserCard/
│   │       ├── UserCard.tsx
│   │       └── UserCard.css.ts
│   ├── 📁 _hooks/
│   └── 📁 _data/
└── 📁 payment/
    ├── 📁 _components/
    │   └── 📁 PaymentStatusBadge/
    │       └── PaymentStatusBadge.tsx
    └── 📁 _hooks/
        └── usePaymentStatus.ts

_shared/ 아래에는 도메인별로 폴더를 만들고, 각 도메인 내부에서 라우트 폴더와 동일하게 _components/, _hooks/ 등 기능별 폴더를 둡니다.

4. 네이밍 규칙

케이스 규칙

대상케이스예시
컴포넌트 폴더 및 파일PascalCaseCourseCard/CourseCard.tsx
훅, 서비스, 스토어, 쿼리, API 파일camelCaseuseCourseFilter.ts, myCourse.queries.ts, myCourse.api.ts
URL 경로 및 API routekebab-casemy-course/, api/payment-info/
스타일 파일PascalCase (컴포넌트 파일과 동일)CourseCard.css.ts

케이스 규칙의 상세 근거는 이름으로 의도 전달하기 가이드를 참고합니다.

파일 접미사 규칙

파일의 역할을 접미사로 드러냅니다.

폴더접미사예시
_services/.service.tsformatCourseDate.service.ts
_data/.store.ts, .model.ts, .constant.tsmyCourse.store.ts
_global/_queries/.queries.tsmyCourse.queries.ts
_global/_apis/.api.tsmyCourse.api.ts
_actions/.action.tsenrollCourse.action.ts
_types/.type.tsmyCourse.type.ts
_tests/.spec.tsmyCourse.spec.ts

_data/에는 관심사별로 파일을 나눕니다. 하나의 파일에 store, model, constant를 모두 넣지 않습니다.

서버 쿼리(.queries.ts)와 API 호출 함수(.api.ts)는 라우트 폴더가 아닌 _global/_queries/, _global/_apis/에 둡니다. 어느 지면에서든 재사용될 수 있도록 한곳에 모읍니다.

서버 쿼리·API 작성 패턴

_apis/는 fetch 호출만 담당하고, _queries/는 TanStack Query의 queryKeyqueryOptions만 정의합니다. _queries/ 안에서 useQuery를 호출하지 않습니다. 사용부에서 useQuery(...) 또는 prefetchQuery(...)로 옵션을 주입해 호출합니다.

ts
// app/_global/_apis/myCourse.api.ts
export async function fetchMyCourses() {
  const res = await fetch('/api/my-course')
  if (!res.ok) throw new Error('Failed to fetch courses')
  return res.json() as Promise<MyCourse[]>
}
ts
// app/_global/_queries/myCourse.queries.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchMyCourses } from '@/app/_global/_apis/myCourse.api'

export const myCourseQueries = {
  all: () => ['myCourse'] as const,
  list: () =>
    queryOptions({
      queryKey: [...myCourseQueries.all(), 'list'],
      queryFn: fetchMyCourses,
    }),
}
tsx
// 사용부 — 어느 지면에서든 동일하게 호출
import { useQuery } from '@tanstack/react-query'
import { myCourseQueries } from '@/app/_global/_queries/myCourse.queries'

export function CourseList() {
  const { data } = useQuery(myCourseQueries.list())
  // ...
}

이 패턴 덕분에 같은 쿼리를 여러 지면에서 재사용해도 queryKey가 충돌하지 않고, 서버 컴포넌트의 prefetchQuery와 클라이언트 컴포넌트의 useQuery가 동일한 정의를 공유합니다.

컴포넌트 폴더 구조

컴포넌트는 PascalCase 폴더로 분리합니다. 폴더 안에 tsx, 스타일, svg 등 관련 파일을 함께 둡니다. 스타일 파일도 해당 컴포넌트의 일부이므로 컴포넌트 파일과 동일한 PascalCase로 작성합니다. 컴포넌트 내부에 depth가 생길 수 있습니다.

text
📁 CourseCard/
├── CourseCard.tsx          ← 컴포넌트
├── CourseCard.css.ts       ← 스타일
├── 📁 CourseSearch/       ← 하위 UI를 포함하는 경우 내부 depth 허용
│   └── CourseSearch.tsx
└── 📁 CourseModal/        ← 오버레이 등도 같은 방식
    ├── CourseModal.tsx
    └── CourseModal.css.ts

5. export 및 임포트 규칙

export 규칙

컴포넌트 파일에서는 하나의 컴포넌트만 export합니다. 파일 이름으로 어떤 컴포넌트인지 바로 파악할 수 있어야 합니다. 내부에서 사용하는 헬퍼 함수나 상수는 같은 파일에 선언할 수 있지만, export하는 컴포넌트는 하나여야 합니다. 서비스, 쿼리, 훅 등 다른 파일은 여러 함수를 함께 선언할 수 있습니다.

default export를 사용하지 않습니다. named export만 사용합니다. 단, Next.js가 요구하는 특수 파일(page.tsx, layout.tsx, error.tsx, loading.tsx 등)은 예외입니다.

tsx
// ✗ default export
export default function CourseCard() { ... }

// ✓ named export
export function CourseCard() { ... }

배럴 파일 금지

index.ts / index.tsx 배럴 파일은 만들지 않습니다.

좋지 않은 예시

tsx
// _components/index.ts — 모든 컴포넌트를 re-export
export { CourseCard } from './CourseCard/CourseCard'
export { CourseFilter } from './CourseFilter/CourseFilter'
export { CourseList } from './CourseList/CourseList'

// page.tsx — 배럴 파일을 통해 import
import { CourseCard, CourseFilter, CourseList } from './_components'

문제

배럴 파일은 하나의 export만 필요해도 해당 파일이 re-export하는 모든 모듈을 불러오게 만듭니다. Vercel 공식 블로그에서도 이를 번들 성능의 주요 병목으로 분석하고 있으며, Atlassian은 Jira 프론트엔드에서 배럴 파일을 제거한 후 빌드 시간 75% 감소를 보고했습니다. 번들 성능 외에도 순환 참조 문제를 유발하고, 파일 간 의존성을 추적하기 어렵게 만듭니다.

좋은 예시

tsx
// page.tsx — 실제 파일 경로로 직접 import
import { CourseCard } from './_components/CourseCard/CourseCard'
import { CourseFilter } from './_components/CourseFilter/CourseFilter'
import { CourseList } from './_components/CourseList/CourseList'

번들러가 각 파일의 의존성을 정확히 파악할 수 있고, 사용하지 않는 모듈이 불필요하게 로드되지 않습니다.

임포트 경로

배럴 파일이 없으므로 항상 실제 파일 경로로 임포트합니다.

tsx
// page.tsx 또는 layout.tsx에서의 임포트 예시

// 같은 라우트의 코드 → 상대 경로
import { CourseCard } from './_components/CourseCard/CourseCard'
import { useCourseFilter } from './_hooks/useCourseFilter'
import { formatCourseDate } from './_services/formatCourseDate.service'
import { myCourseStore } from './_data/myCourse.store'
import type { MyCourse } from './_types/myCourse.type'

// 다른 지면과 공유하는 코드 → 절대 경로 (@/ alias)
import { UserCard } from '@/app/_shared/user/UserCard/UserCard'
import { Gnb } from '@/app/_global/_components/Gnb/Gnb'

// 서버 쿼리·API → 항상 _global에서 import
import { myCourseQueries } from '@/app/_global/_queries/myCourse.queries'
import { fetchMyCourses } from '@/app/_global/_apis/myCourse.api'

같은 라우트 폴더 내부 코드는 상대 경로(./_components/...)로, _shared/_global/의 코드는 절대 경로(@/app/_shared/...)로 임포트합니다. @/ alias는 tsconfig.jsonpaths 설정을 통해 사용하며, 절대 경로를 쓰면 파일을 다른 위치로 옮겼을 때 임포트 경로가 깨지지 않습니다.

6. 주의사항 및 자동화

주의사항

  • _shared/의 경계 판단이 모호할 수 있습니다. 한 지면에서만 쓰이는 코드를 "나중에 공유될 것 같다"는 이유로 미리 _shared/에 넣지 않습니다. 실제로 두 번째 지면에서 import가 필요해지는 시점에 이동합니다.
  • 배럴 파일 금지는 앱 코드에 적용됩니다. NPM 패키지로 배포하는 라이브러리에서는 진입점으로서 index.ts가 필요할 수 있습니다.
  • 깊은 중첩 경로의 import가 길어질 수 있습니다. @/ alias를 활용하면 ../../../../_shared/... 같은 상대 경로를 피할 수 있습니다.

자동화

이 가이드의 일부 원칙은 린트 룰로 강제할 수 있습니다:

  • no-barrel-files (eslint-plugin-no-barrel-files): 배럴 파일(index.ts에서 re-export만 하는 파일) 생성을 차단합니다.
  • no-barrel-import (eslint-plugin-no-barrel-import): 배럴 파일을 통한 import를 차단합니다.
  • no-restricted-imports (ESLint 내장): 특정 경로 패턴의 import를 금지하는 규칙을 설정할 수 있습니다.