FSD를 알아보자 (3): Next.js App Router에 적용하기
이 글은 FSD 시리즈 3편입니다. Next.js App Router에 FSD를 적용할 때의 실무 기준을 정리합니다.
FSD 개념은 이해했지만 Next.js App Router에 적용하려고 하면 바로 헷갈리는 지점이 생깁니다. 특히 FSD의 pages와 Next.js의 app 디렉터리 차이, 그리고 Server Component와 Client Component 경계가 대표적입니다.
FSD의 pages와 Next.js app 디렉터리는 다릅니다
Next.js App Router를 쓰면 "이미 app 폴더가 있는데 왜 또 pages 레이어가 필요하지?"라는 질문이 자연스럽게 나옵니다.
둘은 역할이 다릅니다.
app/: Next.js가 이해하는 프레임워크 레벨 라우팅/런타임 계층pages/: FSD에서 말하는 화면 조립 레이어
즉:
app/(routes)/todos/page.tsx는 Next.js 라우트 파일pages/todos/ui/todos-page.tsx는 실제 화면을 조합하는 FSD 레이어
이렇게 나누면 프레임워크 파일은 얇게 유지되고, 화면 로직은 FSD 구조 안에서 일관되게 관리됩니다.
Next.js App Router 기준 폴더 구조
아래는 Next.js App Router와 FSD를 함께 사용할 때 자주 나오는 구조입니다.
src/
app/
layout.tsx
providers/
index.tsx
query-client.tsx
api/
todos/
route.ts
todos/[id]/
route.ts
(routes)/
todos/
page.tsx
pages/
todos/
ui/
todos-page.tsx
index.ts
widgets/
features/
entities/
shared/이 구조에서 중요한 점은 app이 비즈니스 기능을 담는 공간이 아니라는 것입니다. app은 Next.js 런타임과 연결되는 엔트리 포인트에 가깝고, 실제 도메인 구조는 여전히 FSD 레이어 안에서 관리하는 편이 자연스럽습니다.
app 레이어는 어떤 역할을 맡을까?
Next.js App Router를 사용할 때 app에는 보통 아래 책임이 들어갑니다.
layout.tsx,template.tsx,loading.tsx,error.tsx- 전역 Provider 연결
- Route Handler (
route.ts) - 페이지 단위 메타데이터
- 서버 컴포넌트 진입점
app/layout.tsx
import type { Metadata } from 'next';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'Marco Log',
description: 'Frontend architecture and engineering notes',
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}app/providers/index.tsx
'use client';
import { ReactNode } from 'react';
import { QueryProvider } from './query-client';
export function Providers({ children }: { children: ReactNode }) {
return <QueryProvider>{children}</QueryProvider>;
}app/(routes)/todos/page.tsx
import type { Metadata } from 'next';
import { TodosPage } from '@/pages/todos';
export const metadata: Metadata = {
title: 'FSD Todo 예제',
description: 'Feature-Sliced Design으로 구성한 Todo 화면',
};
export default function Page() {
return <TodosPage />;
}이처럼 app은 가능한 한 얇게 유지하고, 실제 화면 구성은 pages로 내려보내는 편이 유지보수에 유리합니다.
Server Component와 Client Component 경계는 어떻게 볼까?
FSD를 도입한다고 해서 App Router의 렌더링 규칙이 사라지는 것은 아닙니다. 오히려 레이어 책임과 렌더링 위치를 함께 봐야 구조가 안정적입니다.
정리하면 보통 이렇게 가져갑니다.
app의page.tsx,layout.tsx는 기본적으로 Server Component- 사용자 입력, 상태 변경, 이벤트 처리가 필요한
features/*/ui는 Client Component - 순수 타입, 헬퍼, 도메인 계산은 서버/클라이언트 어디서든 재사용 가능
- React Query, Zustand 같은 클라이언트 상태 도구는 Provider 또는 Client Component 안에서만 사용
예를 들어:
pages/todos/ui/todos-page.tsx는 서버 컴포넌트로 유지 가능features/add-todo/ui/add-todo-form.tsx는use client필요entities/todo/lib/get-todo-left-count.ts는 어디서든 재사용 가능
즉, FSD의 레이어는 "책임 기준", App Router의 Server/Client 구분은 "렌더링 실행 위치 기준"입니다. 이 두 축을 같이 가져가야 합니다.
Route Handler는 어디에 속할까?
Next.js에서 BFF처럼 route.ts를 둘 때는 보통 app/api/* 아래에 둡니다. 이 파일은 FSD의 features나 entities가 아니라, Next.js 런타임 엔트리 포인트에 가깝습니다.
app/api/todos/route.ts
import { NextResponse } from 'next/server';
const todos = [{ id: '1', title: 'FSD 글 작성하기', completed: false, createdAt: '2026-03-22' }];
export async function GET() {
return NextResponse.json(todos);
}
export async function POST(request: Request) {
const body = await request.json();
const newTodo = {
id: crypto.randomUUID(),
title: body.title,
completed: false,
createdAt: new Date().toISOString(),
};
todos.unshift(newTodo);
return NextResponse.json(newTodo, { status: 201 });
}반면 entities/todo/api/todo.api.ts는 이 Route Handler를 호출하는 데이터 접근 계층으로 둘 수 있습니다.
app/api/*는 Next.js 서버 진입점entities/*/api는 도메인 관점의 데이터 접근 계층
이렇게 역할을 나누면 프레임워크 코드와 도메인 코드를 섞지 않게 됩니다.
Todo 추가 요청 흐름
sequenceDiagram
participant user as User
participant feature as features/add-todo
participant entityApi as entities/todo/api
participant routeHandler as app/api/todos/route.ts
participant dataSource as DataSource
participant uiLayer as widgets/pages
user->>feature: Todo 입력 후 추가 버튼 클릭
feature->>entityApi: createTodo(title)
entityApi->>routeHandler: POST /api/todos
routeHandler->>dataSource: Todo 저장
dataSource-->>routeHandler: 저장된 Todo 반환
routeHandler-->>entityApi: JSON 응답
entityApi-->>feature: mutation success
feature->>uiLayer: todos 쿼리 invalidate
uiLayer-->>user: 갱신된 목록 렌더링이 흐름을 보면 feature는 사용자 행동을 시작하고, entities/*/api는 도메인 기준의 데이터 접근을 담당하며, app/api는 서버 엔트리 포인트 역할을 한다는 점이 더 명확해집니다.
FSD에서 Public API가 중요한 이유
FSD를 적용할 때 자주 놓치는 부분이 index.ts를 통한 Public API입니다.
예를 들어 entities/todo/index.ts는 이렇게 만들 수 있습니다.
export { getTodos, createTodo, updateTodo, removeTodo } from './api/todo.api';
export { getTodoLeftCount } from './lib/get-todo-left-count';
export { TodoItemCard } from './ui/todo-item-card';
export type { Todo, TodoFilter } from './model/todo.types';그러면 외부에서는 내부 구현 경로를 몰라도 됩니다.
import { getTodos, TodoItemCard, type Todo } from '@/entities/todo';이 방식의 장점은 분명합니다.
- 외부 코드가 내부 폴더 구조에 덜 의존함
- 리팩토링 시 import 경로 수정 범위가 줄어듦
- 슬라이스별 진입점이 명확해짐
프로젝트가 커질수록 이 차이는 더 크게 느껴집니다.
기존 프로젝트에 점진적으로 도입하는 방법
처음부터 전체 구조를 다 갈아엎을 필요는 없습니다. 그렇게 하면 비용이 커집니다.
추천하는 방식은 다음과 같습니다.
- 가장 복잡한 화면 하나를 고릅니다.
- 그 화면의 핵심 도메인 entity를 분리합니다.
- 사용자 액션을 feature 단위로 분해합니다.
- 마지막에 widget과
pages레이어를 정리합니다.
예를 들어 Todo 페이지가 현재 거대한 단일 파일이라면:
Todo타입과 API 호출을entities/todo로 이동add,toggle,delete로직을features/*로 이동- 화면 조합 부분만 남겨서
widgets/todo-list,widgets/todo-toolbar구성
이렇게 한 화면씩 옮기면 리스크를 크게 줄일 수 있습니다.
FSD 도입 시 주의할 점
1. 폴더 이름만 바꾸고 책임은 그대로 두지 말 것
폴더 이름만 entities, features로 바꾸고 실제 코드는 여전히 페이지에 몰려 있으면 의미가 없습니다.
2. 너무 잘게 쪼개지 말 것
모든 버튼을 무조건 feature로 만들 필요는 없습니다. 비즈니스 의미가 없는 단순 UI 조각은 shared/ui가 더 적절할 수 있습니다.
3. entity와 feature를 혼동하지 말 것
todo는 entity이고, toggle-todo는 feature입니다. 이 기준이 흔들리면 구조가 금방 애매해집니다.
4. Public API 없이 내부 경로를 직접 import하지 말 것
이 규칙이 무너지면 슬라이스 경계가 금방 흐려집니다.
실무 도입 체크리스트
구조 체크리스트
- 화면 하나를 이해하기 위해
components,hooks,utils,api를 계속 오가고 있지 않은가? - 특정 도메인(
todo,user,comment) 관련 코드가 여러 공용 폴더에 흩어져 있지 않은가? - 페이지 컴포넌트가 데이터 조회, mutation, 필터링, UI 렌더링을 한 번에 처리하고 있지 않은가?
- 공용 레이어(
shared)가 특정 도메인 지식을 알고 있지 않은가? entities가features를 참조하거나, 하위 레이어가 상위 레이어를 import하고 있지 않은가?
Next.js App Router 체크리스트
app디렉터리와 FSD의pages레이어를 같은 개념으로 보고 있지 않은가?page.tsx,layout.tsx가 필요 이상으로 비대해지지 않았는가?use client가 필요한 코드를 무분별하게 상위 레이어로 끌어올리고 있지 않은가?route.ts와entities/*/api의 역할이 섞여 있지 않은가?- Provider, metadata, 라우팅 엔트리 포인트가
app에 일관되게 모여 있는가?
도입 순서 체크리스트
- 가장 복잡한 페이지 하나를 골랐는가?
- 핵심 도메인 entity를 먼저 분리했는가?
- 사용자 액션을 feature 단위로 나눴는가?
- 재사용 가능한 화면 블록만 widget으로 묶었는가?
- 페이지는 조립 역할만 하도록 가볍게 만들었는가?
- 각 슬라이스에 Public API(
index.ts)를 두었는가?
마무리
FSD를 Next.js App Router에 적용할 때는 app과 pages의 역할을 분리하고, Server Component와 Client Component 경계를 함께 설계하는 것이 중요합니다.
결국 핵심은 같습니다. 프레임워크 엔트리 포인트는 얇게 유지하고, 도메인과 기능 구조는 FSD 레이어 안에서 관리하는 것입니다.
