FSD를 알아보자 (2): Todo 앱으로 나눠보기
이 글은 FSD 시리즈 2편입니다. Todo 앱 예제로 레이어를 실제로 나눠봅니다.
1편에서 FSD의 개념과 레이어 구조를 정리했다면, 이번 글에서는 Todo 앱을 기준으로 각 레이어를 실제로 어떻게 나누는지 살펴보겠습니다.
Todo 앱 요구사항을 먼저 나눠보자
예시를 구체적으로 하기 위해 아래와 같은 Todo 앱을 만든다고 가정해보겠습니다.
- Todo 목록 조회
- Todo 추가
- Todo 완료/미완료 토글
- Todo 제목 수정
- Todo 삭제
- 전체 / 진행 중 / 완료 필터
- 남은 Todo 개수 표시
이 요구사항을 FSD 관점으로 바꾸면 아래처럼 볼 수 있습니다.
도메인(Entity)
- Todo
사용자 액션(Feature)
- add todo
- toggle todo
- edit todo
- delete todo
- filter todos
화면 블록(Widget)
- todo list
- todo toolbar
- todo summary
이 단계에서 먼저 나눠두면, 이후에 코드를 어디에 둘지 판단하기가 훨씬 쉬워집니다.
Todo 앱에 FSD를 적용한 폴더 구조
아래는 Todo 앱에 FSD를 적용했을 때의 예시 구조입니다.
src/
pages/
todos/
ui/
todos-page.tsx
index.ts
widgets/
todo-list/
ui/
todo-list-widget.tsx
index.ts
todo-toolbar/
ui/
todo-toolbar.tsx
index.ts
todo-summary/
ui/
todo-summary.tsx
index.ts
features/
add-todo/
model/
use-add-todo.ts
ui/
add-todo-form.tsx
index.ts
toggle-todo/
ui/
toggle-todo-checkbox.tsx
index.ts
delete-todo/
ui/
delete-todo-button.tsx
index.ts
filter-todos/
model/
todo-filter.store.ts
ui/
todo-filter-tabs.tsx
index.ts
entities/
todo/
api/
todo.api.ts
lib/
get-todo-left-count.ts
model/
todo.types.ts
ui/
todo-item-card.tsx
index.ts
shared/
api/
base-api.ts
ui/
button.tsx
input.tsx
spinner.tsx처음 보면 파일 수가 많아 보일 수 있습니다. 하지만 실제로는 변경 이유가 비슷한 코드끼리 가까이 모이기 때문에 유지보수는 더 쉬워집니다.
1. shared: 공통 인프라부터 분리하기
가장 아래에는 특정 도메인을 모르는 공통 코드를 둡니다.
shared/api/base-api.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export async function request<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE_URL}${input}`, {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
throw new Error('API request failed');
}
return response.json() as Promise<T>;
}이 코드는 Todo를 전혀 모릅니다. 그래서 shared에 두는 것이 자연스럽습니다.
2. entities/todo: Todo 도메인 정의하기
이제 Todo라는 도메인을 정의합니다. 핵심은 Todo 자체를 설명하는 타입, API, UI, 헬퍼를 여기에 두는 것입니다.
entities/todo/model/todo.types.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
export type TodoFilter = 'all' | 'active' | 'completed';entities/todo/api/todo.api.ts
import { request } from '@/shared/api/base-api';
import type { Todo } from '../model/todo.types';
export function getTodos() {
return request<Todo[]>('/todos');
}
export function createTodo(title: string) {
return request<Todo>('/todos', {
method: 'POST',
body: JSON.stringify({ title }),
});
}
export function updateTodo(id: string, payload: Partial<Todo>) {
return request<Todo>(`/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export function removeTodo(id: string) {
return request<{ id: string }>(`/todos/${id}`, {
method: 'DELETE',
});
}entities/todo/lib/get-todo-left-count.ts
import type { Todo } from '../model/todo.types';
export function getTodoLeftCount(todos: Todo[]) {
return todos.filter((todo) => !todo.completed).length;
}entities/todo/ui/todo-item-card.tsx
import type { Todo } from '../model/todo.types';
interface TodoItemCardProps {
todo: Todo;
actionSlot?: React.ReactNode;
}
export function TodoItemCard({ todo, actionSlot }: TodoItemCardProps) {
return (
<div className="flex items-center justify-between rounded-md border p-4">
<span className={todo.completed ? 'text-gray-400 line-through' : 'text-gray-900'}>
{todo.title}
</span>
{actionSlot}
</div>
);
}여기서 중요한 점은 TodoItemCard가 Todo를 표현할 뿐, "완료 버튼 클릭 시 어떤 API를 호출할지"는 모른다는 점입니다. 그런 책임은 feature에 있어야 합니다.
3. features: 사용자 액션을 기능으로 나누기
Todo 앱에서 가장 가치가 큰 구간은 보통 여기입니다. 사용자의 행동을 기능 단위로 분리하면 페이지가 훨씬 가벼워집니다.
features/add-todo
Todo 추가는 Todo 도메인 자체라기보다 사용자의 액션에 가깝습니다.
features/add-todo/model/use-add-todo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTodo } from '@/entities/todo';
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}features/add-todo/ui/add-todo-form.tsx
'use client';
import { FormEvent, useState } from 'react';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import { useAddTodo } from '../model/use-add-todo';
export function AddTodoForm() {
const [title, setTitle] = useState('');
const { mutate: addTodo, isPending } = useAddTodo();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
addTodo(trimmedTitle, {
onSuccess: () => setTitle(''),
});
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="할 일을 입력하세요"
/>
<Button type="submit" disabled={isPending}>
추가
</Button>
</form>
);
}features/toggle-todo
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTodo } from '@/entities/todo';
interface ToggleTodoCheckboxProps {
id: string;
completed: boolean;
}
export function ToggleTodoCheckbox({ id, completed }: ToggleTodoCheckboxProps) {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: () => updateTodo(id, { completed: !completed }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<input type="checkbox" checked={completed} disabled={isPending} onChange={() => mutate()} />
);
}features/filter-todos
필터 역시 Todo 도메인 자체보다는 "사용자가 목록을 어떻게 보고 싶은가"에 가까우므로 feature에 두는 편이 자연스럽습니다.
import { create } from 'zustand';
import type { TodoFilter } from '@/entities/todo';
interface TodoFilterState {
filter: TodoFilter;
setFilter: (filter: TodoFilter) => void;
}
export const useTodoFilterStore = create<TodoFilterState>((set) => ({
filter: 'all',
setFilter: (filter) => set({ filter }),
}));4. widgets: 화면 블록으로 조합하기
이제부터는 실제 화면 조합이 시작됩니다. widget은 여러 feature와 entity를 엮는 역할을 합니다.
widgets/todo-list/ui/todo-list-widget.tsx
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { TodoItemCard, getTodos } from '@/entities/todo';
import { ToggleTodoCheckbox } from '@/features/toggle-todo';
import { DeleteTodoButton } from '@/features/delete-todo';
import { useTodoFilterStore } from '@/features/filter-todos';
import { Spinner } from '@/shared/ui/spinner';
export function TodoListWidget() {
const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'],
queryFn: getTodos,
});
const { filter } = useTodoFilterStore();
const filteredTodos = useMemo(() => {
if (filter === 'active') return todos.filter((todo) => !todo.completed);
if (filter === 'completed') return todos.filter((todo) => todo.completed);
return todos;
}, [todos, filter]);
if (isLoading) return <Spinner />;
return (
<div className="space-y-3">
{filteredTodos.map((todo) => (
<TodoItemCard
key={todo.id}
todo={todo}
actionSlot={
<div className="flex items-center gap-3">
<ToggleTodoCheckbox id={todo.id} completed={todo.completed} />
<DeleteTodoButton id={todo.id} />
</div>
}
/>
))}
</div>
);
}이 코드만 봐도 역할이 분리된 지점이 분명합니다.
- 데이터 모델은
entities/todo - 사용자 액션은
features/toggle-todo,features/delete-todo - 공용 로딩 UI는
shared/ui/spinner
widgets/todo-toolbar/ui/todo-toolbar.tsx
import { AddTodoForm } from '@/features/add-todo';
import { TodoFilterTabs } from '@/features/filter-todos';
export function TodoToolbar() {
return (
<section className="space-y-4">
<AddTodoForm />
<TodoFilterTabs />
</section>
);
}widgets/todo-summary/ui/todo-summary.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getTodoLeftCount, getTodos } from '@/entities/todo';
export function TodoSummary() {
const { data: todos = [] } = useQuery({
queryKey: ['todos'],
queryFn: getTodos,
});
const leftCount = getTodoLeftCount(todos);
const completedCount = todos.length - leftCount;
return (
<section className="flex gap-4 rounded-md bg-gray-50 p-4 text-sm">
<span>전체: {todos.length}</span>
<span>진행 중: {leftCount}</span>
<span>완료: {completedCount}</span>
</section>
);
}여기서 "남은 Todo 수 계산"은 entities/todo의 도메인 로직을 재사용하고, 이를 화면에 어떻게 보여줄지는 widget이 담당합니다.
5. pages: 페이지는 조립에 집중하기
FSD를 잘 적용한 페이지는 의외로 단순합니다.
pages/todos/ui/todos-page.tsx
import { TodoListWidget } from '@/widgets/todo-list';
import { TodoSummary } from '@/widgets/todo-summary';
import { TodoToolbar } from '@/widgets/todo-toolbar';
export function TodosPage() {
return (
<main className="mx-auto max-w-2xl space-y-6 py-10">
<header className="space-y-2">
<h1 className="text-3xl font-bold">Todo List</h1>
<p className="text-gray-500">FSD 구조로 만든 할 일 관리 예제</p>
</header>
<TodoToolbar />
<TodoSummary />
<TodoListWidget />
</main>
);
}페이지는 가능한 한 아래 역할만 맡는 편이 좋습니다.
- 라우트 단위 레이아웃 구성
- SEO 메타데이터 연결
- widget 조합과 배치
FSD에서 파일 위치를 판단하는 기준
FSD를 적용하다 보면 결국 "이 코드는 어디에 둬야 하지?"라는 질문을 자주 하게 됩니다.
Q1. TodoItem은 entity일까, widget일까?
보통 단일 Todo를 표현하는 UI라면 entities/todo/ui에 두는 편이 좋습니다.
- Todo 도메인을 직접 표현하고
- 다른 화면에서도 재사용 가능하며
- 사용자 액션을 조합하지 않아도 단독으로 의미가 있기 때문입니다
반면 여러 기능 버튼과 필터, 요약, 목록을 한 번에 합친 큰 블록이라면 widget에 가깝습니다.
Q2. useTodosQuery는 어디에 둬야 할까?
Todo 목록 조회는 Todo 도메인을 다루는 기본 데이터 접근이므로 entities/todo에 둘 수 있습니다.
하지만 특정 페이지 전용으로 필터, 정렬, URL 동기화까지 포함한다면 widget이나 page 쪽으로 올라갈 수 있습니다.
Q3. "남은 Todo 수 계산"은 feature일까?
보통은 entities/todo/lib/get-todo-left-count.ts 같은 위치가 더 자연스럽습니다. 사용자의 행동이라기보다 도메인 계산 규칙에 가깝기 때문입니다.
Q4. 필터 상태는 entity일까 feature일까?
대부분은 feature에 두는 편이 낫습니다. all / active / completed는 Todo 모델의 속성이라기보다, 사용자가 화면을 어떻게 탐색하느냐에 더 가깝습니다.
마무리
FSD는 예제를 통해 보면 훨씬 이해가 쉬워집니다. Todo 앱처럼 단순한 구조에서도:
- Todo 자체는
entities - Todo를 추가/삭제/토글하는 행동은
features - 여러 기능을 묶은 화면 블록은
widgets - 화면 조합은
pages
처럼 나누면 책임이 분명해집니다.
다음 글에서는 이 구조를 Next.js App Router와 어떻게 연결해서 실무적으로 적용할지 이어서 살펴보겠습니다.
