Next.js App Router와 Feature-Sliced Design(FSD): `app`·`pages` 디렉터리 충돌의 원인과 격리 전략
저도 처음에 src/pages를 만들었더니 Next.js가 Pages Router로 인식해버리는 상황에서 꽤 당황했습니다. 폴더 이름이 겹쳐서 생기는 문제처럼 보이지만, 사실 이건 두 체계가 파일 시스템을 바라보는 방식이 근본적으로 다르기 때문에 생기는 충돌입니다. FSD는 "이 코드가 어떤 책임을 갖는가"로 폴더를 분류하고, Next.js는 "이 파일이 어떤 URL을 담당하는가"로 폴더를 분류합니다. 같은 이름, 다른 세계관의 충돌인 셈입니다.
이 글을 읽고 나면, Next.js의 라우팅 규칙을 건드리지 않으면서 FSD 6개 레이어를 그대로 유지하는 디렉터리 구조를 잡을 수 있습니다. 핵심은 단 하나입니다: Next.js가 제어하는 라우팅 폴더는 프로젝트 루트에, FSD 레이어 전체는 src/ 아래에 격리하는 것입니다. 이 원칙 위에서 Provider 연결, Server Action 배치, 캐시 태그 관리까지 실제 코드로 살펴보겠습니다.
FSD가 생소하다면 공식 문서의 Introduction을 먼저 훑어보시는 것을 권장합니다. 이 글은 레이어·슬라이스·세그먼트 개념을 기본으로 알고 있다는 전제 위에서 Next.js App Router와의 통합에 집중합니다.
핵심 개념
FSD가 코드를 바라보는 방식
FSD는 프론트엔드 애플리케이션을 책임 단위로 계층화하는 아키텍처 방법론입니다. 코드를 아래 6개 레이어 순서로 구성하고, 상위 레이어는 하위 레이어만 import할 수 있다는 단방향 의존성 규칙이 핵심입니다.
app → pages → widgets → features → entities → shared
(상위, 많이 의존) (하위, 적게 의존)여기서 화살표는 "import 방향"입니다. app이 pages를 import할 수 있지만, pages가 app을 import하는 건 규칙 위반입니다. FSD에서 import 방향과 의존 방향은 같습니다 — 화살표를 따라가면 의존이 흐르는 방향입니다.
FSD 레이어 한 줄 요약:
app은 앱 진입점(Provider, 글로벌 설정),pages는 페이지 단위 UI 조합,widgets는 독립적인 UI 블록,features는 사용자 인터랙션 단위,entities는 도메인 모델,shared는 재사용 유틸리티입니다.
한 가지 미리 알아두면 좋은 것이 있습니다. 팀 규모가 조금 있다면 Steiger라는 공식 FSD 아키텍처 린터를 CI에 도입하면 레이어 간 잘못된 import를 자동으로 잡아줍니다. 이 글 마무리에서 설치 방법까지 다루니, "나중에 자동화도 가능하구나" 하고 기억해두시면 읽는 맥락이 생깁니다.
Next.js와의 명칭 충돌: 단순한 폴더 이름 문제가 아닌 이유
두 체계가 같은 폴더명을 완전히 다른 의미로 씁니다.
| 폴더명 | FSD에서의 의미 | Next.js에서의 의미 |
|---|---|---|
app/ |
최상위 진입 레이어 (Provider, 글로벌 설정) | App Router 라우팅 루트 |
pages/ |
페이지 단위 UI 조합 레이어 | Pages Router 라우팅 루트 (레거시) |
Next.js는 프로젝트 루트의 app/과 pages/를 예약된 라우팅 디렉터리로 취급합니다. 그래서 src/pages에 FSD pages 레이어를 두면 Next.js가 이를 Pages Router로 인식해 빌드가 깨지거나 두 라우터가 충돌하는 상황이 생깁니다. 이름이 겹쳐서가 아니라, 두 체계가 파일 시스템을 다른 관점으로 해석하기 때문에 생기는 충돌입니다.
격리 전략: 권장 디렉터리 구조
해결책은 의외로 단순합니다. Next.js가 제어하는 라우팅 폴더는 프로젝트 루트에 두고, FSD 레이어 전체는 src/ 아래에 모아두는 것입니다.
project-root/
├── app/ # Next.js App Router (라우팅 전용, 최대한 얇게)
│ ├── layout.tsx
│ ├── (auth)/
│ │ └── login/
│ │ └── page.tsx # src/pages/login을 re-export만 함
│ └── dashboard/
│ └── page.tsx
├── pages/ # ★ 빈 폴더 — .gitkeep 하나만 있어도 됨
│ └── .gitkeep
└── src/
├── app/ # FSD app 레이어 (Providers, 글로벌 스타일, i18n)
│ ├── providers/
│ └── styles/
├── pages/ # FSD pages 레이어
├── widgets/
├── features/
├── entities/
└── shared/루트에 빈 pages/ 폴더가 필요한 이유를 짚고 넘어가겠습니다. Next.js 공식 문서에 따르면 src/app을 사용하면 루트에 app/이 없어도 App Router가 활성화됩니다. 루트 pages/가 비어 있을 때 src/pages가 Pages Router로 인식되지 않는 동작은 공식 문서보다는 커뮤니티에서 관찰된 동작에 가깝습니다. 실제 프로젝트에 적용 전 FSD 공식 Next.js 가이드에서 최신 동작을 확인해보시는 것을 권장합니다.
⚠️ 주의: 이 빈
pages/폴더를 "안 쓰는 폴더네" 하고 삭제하면src/pages가 Next.js Pages Router로 인식됩니다..gitkeep과 함께 폴더가 필요한 이유를 팀 내에 공유해두면 실수를 줄일 수 있습니다.
이 구조가 맞는 프로젝트 규모
솔직히 FSD는 모든 프로젝트에 어울리는 구조가 아닙니다. 기능이 20개 미만이거나 혼자 또는 소규모 팀이 단기간에 만드는 프로젝트라면 레이어·슬라이스·세그먼트 개념을 팀 전체가 내재화하는 비용이 얻는 이득보다 클 수 있습니다. 기능이 꾸준히 늘어나고 팀원이 3명 이상이며 코드베이스를 1년 이상 유지보수할 계획이 있다면, 그때부터 FSD의 장점이 초기 비용을 넘기기 시작합니다.
실전 적용
예시 1: app/layout.tsx에서 FSD app 레이어 연결하기
왜 이 패턴이 필요할까요? React의 Context API는 클라이언트 트리에서만 동작합니다. Server Component는 서버에서 정적 결과물로 렌더링되기 때문에 지속적인 React 컴포넌트 트리가 없고, Context 값을 구독하거나 전파하는 메커니즘이 동작하지 않습니다. 그래서 QueryClientProvider나 ThemeProvider처럼 Context를 생성하는 Provider는 반드시 'use client'로 선언된 Client Component 안에 있어야 합니다.
app/layout.tsx는 최대한 얇게 유지하고, 실제 Provider 조합은 src/app/providers/에 위임하면 이 경계를 명확하게 관리할 수 있습니다.
// src/app/providers/index.tsx
'use client'; // Context는 Server Component에서 동작하지 않으므로 필수
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class">{children}</ThemeProvider>
</QueryClientProvider>
);
}// app/layout.tsx — Next.js 라우팅 레이어, 비즈니스 로직 없음
import { Providers } from '@/app/providers';
import '@/app/styles/globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}'use client'를 src/app/providers/ 내에서만 선언하는 관례를 팀 내에서 정해두면, 어디까지 Server Component이고 어디서부터 Client Component인지 경계가 명확해집니다.
예시 2: FSD pages 레이어를 Next.js 라우트에 연결하기
app/(라우트)/page.tsx 파일에서 직접 데이터 페칭이나 상태 관리를 넣기 시작하면 FSD 구조가 빠르게 무너집니다. 라우팅 파일은 "이 URL에서 어떤 컴포넌트를 보여줄 것인가"만 결정하고, 실제 UI 조합은 src/pages/ 슬라이스에 위임하는 것이 이상적입니다.
// src/pages/product-list/index.tsx — FSD pages 레이어
import { ProductFilters } from '@/features/product-filter';
import { ProductGrid } from '@/widgets/product-grid';
export function ProductListPage() {
return (
<main>
<ProductFilters />
<ProductGrid />
</main>
);
}// app/products/page.tsx — 라우팅만 담당, re-export 한 줄
export { ProductListPage as default } from '@/pages/product-list';이렇게 하면 나중에 라우팅 구조가 바뀌어도 src/pages/ 슬라이스를 건드릴 필요가 없습니다.
참고:
export { X as default }문법은 Next.js에서 정상 동작하지만, 일부 Turbopack 환경에서 문제가 보고된 사례가 있습니다. 팀 환경에 따라export default function Page() { return <ProductListPage />; }형태로 직접 선언하는 방식이 더 안전할 수 있습니다.
예시 3: 예시 2의 패턴에 Server Action 연결하기
예시 2처럼 src/pages/login/이 LoginForm을 조합한다면, 인증 mutation 로직은 어디에 두어야 할까요? 인증이라는 사용자 인터랙션의 주체가 features/auth 슬라이스이므로, Server Action도 그 슬라이스 안에 배치하는 것이 FSD 원칙에 맞습니다. 캐시 무효화(revalidateTag) 책임도 해당 도메인이 함께 소유합니다.
// src/entities/user/api/tags.ts — 캐시 태그를 상수로 관리
export const USER_CACHE_TAG = 'user' as const;// src/features/auth/api/login-action.ts
'use server';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { authenticate } from '@/entities/user/api'; // 실제 인증 API 호출
import { USER_CACHE_TAG } from '@/entities/user/api/tags';
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
await authenticate({ email, password });
revalidateTag(USER_CACHE_TAG);
redirect('/dashboard');
}// src/features/auth/ui/LoginForm.tsx
import { loginAction } from '../api/login-action';
export function LoginForm() {
return (
<form action={loginAction}>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">로그인</button>
</form>
);
}캐시 태그 패턴:
revalidateTag에 넘기는 문자열을 하드코딩하면 나중에 오타나 불일치가 생기기 쉽습니다.entities/{slice}/api/tags.ts에서 상수로 export하고 가져다 쓰면 타입 안전성과 추적 가능성이 모두 높아집니다.
예시 4: pagesLayer 대안 명칭 전략
루트에 빈 pages/ 폴더를 계속 관리하는 게 팀에 따라 번거롭게 느껴질 수 있습니다. FSD의 pages 레이어 폴더명 자체를 바꾸는 방식도 FSD 공식 문서에서 안내하고 있습니다.
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/app/*": ["./src/app/*"],
"@/pages/*": ["./src/pagesLayer/*"], // 실제 폴더명은 pagesLayer
"@/widgets/*": ["./src/widgets/*"],
"@/features/*": ["./src/features/*"],
"@/entities/*": ["./src/entities/*"],
"@/shared/*": ["./src/shared/*"]
}
}
}src/pagesLayer/로 실제 폴더를 만들고 tsconfig의 paths alias를 @/pages/*로 유지하면, 코드 내 import 경로는 동일하게 쓰면서 Next.js 충돌을 피할 수 있습니다. 단, IDE에서 "Go to definition"을 따라가면 pagesLayer 폴더가 열립니다. 코드에서 보이는 경로(@/pages/...)와 실제 폴더명(pagesLayer)이 다르기 때문에, 팀에 처음 합류한 동료에게는 미리 공유해두는 것이 좋습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명확한 의존성 방향 | 상위 → 하위 단방향 import로 순환 의존을 구조적으로 차단 |
| 팀 자율성 | 슬라이스 단위로 팀이 독립적으로 작업 가능, 충돌 최소화 |
| 코드 탐색 용이 | "어떤 레이어의 어떤 슬라이스"로 위치를 바로 예측 가능 |
| RSC 친화적 | Server Component 기본, 레이어별 Client 경계를 명확히 설정 가능 |
| 확장성 | 기능 추가 시 기존 코드 영향 최소화 |
실무에서 이 장점이 실감나는 순간은 보통 온보딩 때입니다. 새 팀원이 "이 기능 코드 어디 있어요?"라고 물었을 때 "features/auth 슬라이스 보시면 됩니다"로 대화가 끝납니다. 코드베이스를 처음 보는 사람도 레이어 이름만으로 전체 구조를 어느 정도 예측할 수 있게 됩니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 소규모 프로젝트엔 과도 | 기능이 20개 미만이면 보일러플레이트 비용이 이득보다 큼 | 규모가 커질 시점을 기준으로 도입 검토 |
| 학습 곡선 | 레이어·슬라이스·세그먼트를 팀 전체가 내재화해야 일관성 유지 가능 | 팀 온보딩 문서 준비, Steiger로 자동 검증 |
| Next.js 명칭 충돌 | 루트 pages/ 빈 폴더 관리, tsconfig paths 정렬 등 초기 설정 실수 잦음 |
프로젝트 템플릿으로 미리 구성해두기 |
| RSC ↔ Client 경계 혼란 | Context Provider는 반드시 Client Component여야 함 | src/app/providers/에서만 'use client' 선언하는 관례 정립 |
| 과도한 슬라이스 분리 | 너무 작은 단위로 나누면 entities/features 경계가 모호해져 팀 내 논쟁 발생 | "사용자가 직접 발생시키는 인터랙션은 features, 도메인 데이터 모델은 entities" 기준 적용 |
실무에서 가장 흔한 실수
-
루트
pages/빈 폴더 삭제: "안 쓰는 폴더네" 하고 지우면src/pages가 Next.js Pages Router로 인식됩니다..gitkeep과 함께 이 폴더가 있어야 하는 이유를 주석이나README에 남겨두면 팀원이 실수로 삭제하는 상황을 줄일 수 있습니다. -
app/라우팅 파일에 비즈니스 로직 작성:app/dashboard/page.tsx에 직접 데이터 페칭이나 상태 관리를 넣기 시작하면 FSD 구조가 빠르게 무너집니다. 라우팅 파일은 re-export 한 줄이 이상적입니다. -
src/app/providers/파일에'use client'누락: Context를 생성하는 Provider는 반드시 Client Component여야 합니다. 서버에서 렌더링되면 Context가 동작하지 않아 런타임 오류가 발생합니다. Provider 파일마다'use client'선언을 명확히 해두는 것이 좋습니다.
마치며
Next.js App Router와 FSD를 함께 쓰는 핵심 원칙은 하나입니다: Next.js 라우팅은 프로젝트 루트에, FSD 레이어 전체는 src/ 안에 격리. 이 구조를 처음부터 잡아두면 기능이 늘어나도 "이걸 어디에 두어야 하지?"라는 질문이 사라집니다. 팀원 전체가 같은 기준으로 코드 위치를 예측하게 되고, 리뷰와 온보딩에 드는 대화 비용도 눈에 띄게 줄어드는 경험을 하게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
디렉터리 구조 잡기:
src/아래에app,pages,widgets,features,entities,shared폴더를 만들고, 프로젝트 루트에pages/.gitkeep을 생성합니다.tsconfig.json의paths에 레이어별 alias도 이 시점에 함께 설정해두면 이후 작업이 편해집니다. -
app/layout.tsx→src/app/providers/연결: 기존에layout.tsx에 직접 작성했던 Provider 코드를src/app/providers/index.tsx로 옮기고,'use client'선언을 추가한 뒤layout.tsx에서 import하는 구조로 전환할 수 있습니다. -
Steiger 설치 및 CI 연동:
pnpm add -D steiger로 설치하고 CI 파이프라인에steiger ./src명령을 추가하면 레이어 간 잘못된 import를 자동으로 잡아주기 시작합니다. 설치 전 공식 저장소에서 현재 패키지명과 플러그인 구성을 확인해보시는 것을 권장합니다 — 패키지 구성이 업데이트될 수 있습니다.
참고 자료
- Feature-Sliced Design 공식 — Next.js와 함께 사용하기
- Feature-Sliced Design 공식 블로그 — The Ultimate Next.js App Router Architecture
- DEV Community — How to deal with NextJS App Router and FSD problem
- HackerNoon — How to Fix the NextJS App Router and FSD Problem
- GitHub — feature-sliced/steiger (공식 FSD 아키텍처 린터)
- GitHub — yunglocokid/FSD-Pure-Next.js-Template
- Next.js 공식 — src 폴더 규칙
- Next.js 공식 — 프로젝트 구조
- StackBlitz — Using Next.js App Router with FSD (라이브 예제)