App Router 디렉토리 구조를 통일하기
라우트(지면)에 필요한 코드를 해당 라우트 폴더 안에 격리하고, 폴더 역할을 명확히 구분합니다. 일관된 디렉토리 구조는 새 기능을 어디에 추가해야 할지 바로 결정하게 해주고, 코드 탐색 비용을 줄여줍니다.
1. 디렉토리 구조 개요
_, (), @, [...] 등 Next.js 공식 폴더 컨벤션을 따릅니다. _global, _shared 같은 폴더 이름은 우리 팀의 약속입니다.
app/ 아래에는 세 가지 종류의 폴더가 있습니다.
_global/— 앱 전역(레이아웃, 프로바이더, 전역 CSS)_shared/— 두 개 이상의 지면에서 가져다 쓰는 공용 코드(some-page)/— 특정 지면(라우트) 전용 코드
전체 구조는 다음과 같습니다.
📁 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.ts2. 프라이빗 폴더별 역할
각 라우트 폴더 안에 놓을 수 있는 프라이빗 폴더입니다.
| 폴더 | 담당 | 파일 예시 | |
|---|---|---|---|
| ① | _components/ | 이 지면에서만 쓰는 UI 컴포넌트 | CourseCard/CourseCard.tsx |
| ② | _hooks/ | 이 지면에서만 쓰는 커스텀 훅 | useCourseFilter.ts |
| ③ | _services/ | 서비스 함수 및 순수 유틸 함수 | formatCourseDate.service.ts |
| ④ | _data/ | 클라이언트 상태(store), 모델, 상수 | myCourse.store.ts |
| ⑤ | _actions/ | Next.js Server Actions | enrollCourse.action.ts |
| ⑥ | _types/ | TypeScript 타입 정의 | myCourse.type.ts |
| ⑦ | _tests/ | 테스트 파일 | myCourse.spec.ts |
각 도메인 라우트 안에 파일 기능에 따라 위 프라이빗 폴더를 둘 수 있습니다. 단, _components/ 내부의 컴포넌트 폴더 안에 _hooks/, _services/ 등의 프라이빗 폴더를 중첩하는 것은 금지합니다. 특정 컴포넌트에서만 사용하는 훅이더라도 해당 컴포넌트가 속한 도메인 라우트의 _hooks/ 폴더에 둡니다.
📁 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/ 예시
📁 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/ 예시
📁 app/_shared/
├── 📁 user/ 도메인 단위로 폴더 생성
│ ├── 📁 _components/
│ │ └── 📁 UserCard/
│ │ ├── UserCard.tsx
│ │ └── UserCard.css.ts
│ ├── 📁 _hooks/
│ └── 📁 _data/
└── 📁 payment/
├── 📁 _components/
│ └── 📁 PaymentStatusBadge/
│ └── PaymentStatusBadge.tsx
└── 📁 _hooks/
└── usePaymentStatus.ts_shared/ 아래에는 도메인별로 폴더를 만들고, 각 도메인 내부에서 라우트 폴더와 동일하게 _components/, _hooks/ 등 기능별 폴더를 둡니다.
4. 네이밍 규칙
케이스 규칙
| 대상 | 케이스 | 예시 |
|---|---|---|
| 컴포넌트 폴더 및 파일 | PascalCase | CourseCard/CourseCard.tsx |
| 훅, 서비스, 스토어, 쿼리, API 파일 | camelCase | useCourseFilter.ts, myCourse.queries.ts, myCourse.api.ts |
| URL 경로 및 API route | kebab-case | my-course/, api/payment-info/ |
| 스타일 파일 | PascalCase (컴포넌트 파일과 동일) | CourseCard.css.ts |
케이스 규칙의 상세 근거는 이름으로 의도 전달하기 가이드를 참고합니다.
파일 접미사 규칙
파일의 역할을 접미사로 드러냅니다.
| 폴더 | 접미사 | 예시 |
|---|---|---|
_services/ | .service.ts | formatCourseDate.service.ts |
_data/ | .store.ts, .model.ts, .constant.ts | myCourse.store.ts |
_global/_queries/ | .queries.ts | myCourse.queries.ts |
_global/_apis/ | .api.ts | myCourse.api.ts |
_actions/ | .action.ts | enrollCourse.action.ts |
_types/ | .type.ts | myCourse.type.ts |
_tests/ | .spec.ts | myCourse.spec.ts |
_data/에는 관심사별로 파일을 나눕니다. 하나의 파일에 store, model, constant를 모두 넣지 않습니다.
서버 쿼리(.queries.ts)와 API 호출 함수(.api.ts)는 라우트 폴더가 아닌 _global/_queries/, _global/_apis/에 둡니다. 어느 지면에서든 재사용될 수 있도록 한곳에 모읍니다.
서버 쿼리·API 작성 패턴
_apis/는 fetch 호출만 담당하고, _queries/는 TanStack Query의 queryKey와 queryOptions만 정의합니다. _queries/ 안에서 useQuery를 호출하지 않습니다. 사용부에서 useQuery(...) 또는 prefetchQuery(...)로 옵션을 주입해 호출합니다.
// 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[]>
}// 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,
}),
}// 사용부 — 어느 지면에서든 동일하게 호출
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가 생길 수 있습니다.
📁 CourseCard/
├── CourseCard.tsx ← 컴포넌트
├── CourseCard.css.ts ← 스타일
├── 📁 CourseSearch/ ← 하위 UI를 포함하는 경우 내부 depth 허용
│ └── CourseSearch.tsx
└── 📁 CourseModal/ ← 오버레이 등도 같은 방식
├── CourseModal.tsx
└── CourseModal.css.ts5. export 및 임포트 규칙
export 규칙
컴포넌트 파일에서는 하나의 컴포넌트만 export합니다. 파일 이름으로 어떤 컴포넌트인지 바로 파악할 수 있어야 합니다. 내부에서 사용하는 헬퍼 함수나 상수는 같은 파일에 선언할 수 있지만, export하는 컴포넌트는 하나여야 합니다. 서비스, 쿼리, 훅 등 다른 파일은 여러 함수를 함께 선언할 수 있습니다.
default export를 사용하지 않습니다. named export만 사용합니다. 단, Next.js가 요구하는 특수 파일(page.tsx, layout.tsx, error.tsx, loading.tsx 등)은 예외입니다.
// ✗ default export
export default function CourseCard() { ... }
// ✓ named export
export function CourseCard() { ... }배럴 파일 금지
index.ts / index.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% 감소를 보고했습니다. 번들 성능 외에도 순환 참조 문제를 유발하고, 파일 간 의존성을 추적하기 어렵게 만듭니다.
좋은 예시
// page.tsx — 실제 파일 경로로 직접 import
import { CourseCard } from './_components/CourseCard/CourseCard'
import { CourseFilter } from './_components/CourseFilter/CourseFilter'
import { CourseList } from './_components/CourseList/CourseList'번들러가 각 파일의 의존성을 정확히 파악할 수 있고, 사용하지 않는 모듈이 불필요하게 로드되지 않습니다.
임포트 경로
배럴 파일이 없으므로 항상 실제 파일 경로로 임포트합니다.
// 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.json의 paths 설정을 통해 사용하며, 절대 경로를 쓰면 파일을 다른 위치로 옮겼을 때 임포트 경로가 깨지지 않습니다.
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를 금지하는 규칙을 설정할 수 있습니다.
