우리는 **Clean FSD (Feature-Sliced Design)**를 기반으로, CQRS (Read/Write 분리) 패턴과 Next.js App Router의 장점을 결합한 아키텍처를 따릅니다.
- Lego Architecture: 하위 레이어(Entities, Features)는 독립적인 레고 블록이며, 상위 레이어(Widgets, Pages)는 이를 조립하는 역할을 합니다.
- Server First: 읽기(Read) 동작은 **RSC(Server Component)**를 통해 최적화하고, 쓰기(Write) 동작은 Client Component로 상호작용을 처리합니다.
- Separation of Concerns: 프레임워크 설정(
app)과 비즈니스 화면 구현(pages)을 철저히 분리합니다.
src/
├── app/ # [Framework Layer] 라우팅, 메타데이터, 레이아웃 (Shell)
├── pages/ # [Page Layer] 실제 페이지 화면 조립 (Implementation)
├── widgets/ # [Composition Layer] Feature와 Entity를 결합한 독립 블록
├── features/ # [Write Layer] 사용자 행동, 뮤테이션 (Verbs)
├── entities/ # [Read Layer] 도메인 데이터, 조회 (Nouns)
└── shared/ # [Generic Layer] 도메인을 모르는 순수 공통 로직
| 레이어 | 역할 | 성격 | 주요 포함 내용 | 참조 가능 범위 |
|---|---|---|---|---|
| App | 라우팅 (Shell) | Framework | page.tsx, layout.tsx, Metadata | pages 이하 전체 |
| Pages | 화면 조립 | Business | 페이지 단위 UI 조합 | widgets 이하 전체 |
| Widgets | 블록 조립 | Composition | Header, PostDetail, Sidebar | features, entities, shared |
| Features | 쓰기 (Write) | Action (CUD) | 로그인 폼, 좋아요 버튼, useMutation | entities, shared |
| Entities | 읽기 (Read) | Data (R) | 유저 프로필 뷰, fetchUser, Type | shared |
| Shared | 공통 | Generic | Button, Axios, Utils | 불가 (최하위) |
데이터 조회와 표시를 담당합니다.
// entities/post/api/getPost.ts
export const getPost = async (id: string) => {
const response = await api.get(`/posts/${id}`);
return response.data;
};
// entities/post/ui/PostContent.tsx
export const PostContent = ({ post }: { post: Post }) => {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
};사용자 행동과 데이터 변경을 담당합니다.
// features/post-interaction/api/likePost.ts
export const likePost = async (postId: string) => {
await api.post(`/posts/${postId}/like`);
};
// features/post-interaction/ui/LikeButton.tsx
"use client";
export const LikeButton = ({ postId }: { postId: string }) => {
const { mutate } = useMutation({ mutationFn: likePost });
return (
<button onClick={() => mutate(postId)}>
좋아요
</button>
);
};Entity와 Feature를 조합하여 독립적인 블록을 만듭니다.
// widgets/PostViewer/index.tsx
import { PostContent } from "@/entities/post/ui/PostContent";
import { LikeButton } from "@/features/post-interaction/ui/LikeButton";
export const PostViewer = async ({ postId }: { postId: string }) => {
const post = await getPost(postId); // Server Component
return (
<div>
<PostContent post={post} />
<LikeButton postId={postId} />
</div>
);
};App → Pages → Widgets → Features → Entities → Shared
↘ ↗
→ Entities → Shared
- ❌ Entities → Features (읽기에서 쓰기 참조 금지)
- ❌ Features → Features (Feature 간 참조 금지)
- ❌ Entities → Entities (Entity 간 참조 금지)
- ❌ 하위 레이어 → 상위 레이어 (역방향 참조 금지)
만약 Feature에서 다른 Feature의 로직이 필요하다면:
- 해당 로직을 Shared로 추출하거나
- 상위 레이어(Widgets 또는 Pages)에서 조합합니다.
Next.js의 라우팅과 비즈니스 로직을 분리하여 유지보수성을 극대화합니다.
1. src/app/post/[id]/page.tsx (껍데기)
import { Metadata } from 'next';
import { PostPage } from '@/pages/post-detail'; // 실제 구현체 import
import { getPost } from '@/entities/post/api'; // API import
// 메타데이터용 Fetch (Request Memoization으로 중복 호출 걱정 X)
export async function generateMetadata({ params }): Promise<Metadata> {
const data = await getPost(params.id);
return { title: data.title };
}
// Params만 전달하고 빠짐
export default function Page({ params }) {
return <PostPage id={params.id} />;
}2. src/pages/post-detail/ui/PostPage.tsx (알맹이)
import { PostViewerWidget } from '@/widgets/PostViewer';
// 순수 비즈니스 로직 및 조립
export const PostPage = ({ id }) => {
return (
<main>
<PostViewerWidget postId={id} />
</main>
);
};상황: 게시글을 보고(Read), 좋아요를 누른다(Write).
-
Read (Entity):
entities/postapi/getPost.ts: 데이터를 가져옴.ui/PostContent/: 데이터를 보여주기만 함 (Server Component 권장).
-
Write (Feature):
features/post-interactionapi/likePost.ts: 서버에 좋아요 요청을 보냄.ui/LikeButton/: 클릭 이벤트를 받음 (Client Component 필수).
-
Composition (Widget):
widgets/PostViewerEntity와Feature를 import 하여 한 덩어리로 만듦.props를 통해 Entity의 ID를 Feature에게 전달.
코드를 작성하다가 아래 상황이 발생하면 리팩토링을 진행합니다.
-
중복 발견: 특정 UI나 로직이 2곳 이상의 슬라이스에서 똑같이 필요하다.
- → 도메인 로직이 없으면 **
shared**로 이동. - → 도메인 로직이 있으면 **
widgets**으로 이동.
- → 도메인 로직이 없으면 **
-
참조 위반:
import경로가 상위로 가거나, 옆집(다른 슬라이스)을 가리킨다.- → 해당 로직을 **상위 레이어(
Widgets,Pages)**로 끌어올려서 조합하는 형태로 변경.
- → 해당 로직을 **상위 레이어(
-
파일 비대화: 하나의 컴포넌트 파일이 너무 길어진다.
- → 내부 로직은
model/useLogic.ts로 분리. - → 하위 UI는 같은 폴더 내 컴포넌트로 분리.
- → 내부 로직은
entities/post/
├── api/
│ └── getPost.ts
├── ui/
│ └── PostContent.tsx
└── types/
└── Post.ts
features/post-interaction/
├── api/
│ └── likePost.ts
└── ui/
└── LikeButton.tsx
widgets/PostViewer/
└── index.tsx
pages/post-detail/
└── ui/
└── PostPage.tsx
app/post/[id]/
└── page.tsx
entities/post/types/Post.ts
export type Post = {
id: string;
title: string;
content: string;
likeCount: number;
};features/post-interaction/ui/LikeButton.tsx
"use client";
export const LikeButton = ({ postId, initialCount }: Props) => {
const [count, setCount] = useState(initialCount);
const { mutate } = useMutation({
mutationFn: likePost,
onSuccess: () => setCount(prev => prev + 1),
});
return (
<button onClick={() => mutate(postId)}>
❤️ {count}
</button>
);
};기존 코드베이스를 Clean FSD로 마이그레이션할 때는 다음 순서를 따릅니다:
- Shared 추출: 도메인 무관한 공통 유틸리티 분리
- Entities 생성: 데이터 조회 API와 타입 정의
- Features 생성: 사용자 행동과 뮤테이션 로직
- Widgets 조합: Entity + Feature를 조합한 독립 블록
- Pages 조립: 비즈니스 화면 로직
- App 정리: 라우팅과 메타데이터만 남김
- Entity (Read): 기본적으로 Server Component (데이터 페칭 최적화)
- Feature (Write): 반드시 Client Component (이벤트 핸들링 필요)
- Widget: Entity(Server) + Feature(Client) 조합
- Entity → Entity, Feature → Feature는 금지
- 공통 로직은 Shared로 추출
- 조합이 필요하면 Widgets에서 처리
- 새로운 기능부터 Clean FSD 적용
- 레거시 코드는
_legacy/폴더로 이동 - 수정이 필요할 때 리팩토링 진행