이 글은 제가 현장에서 쓰는 Clean F.S.D(Feature‑Sliced Design × Server‑first) 기준을 정리한 실전 가이드입니다.
한 줄 원칙은 “읽기는 Entities, 쓰기는 Features” 입니다.
핵심 규칙(요약)
Entities ↔ Features의 구분 = 읽기(Read) ↔ 쓰기(Write)
- Entities(읽기)
- RSC(Server Component) + SELECT 전용.
- 입력(데이터)이 같으면 항상 같은 마크업을 내는 순수 뷰.
- 상태/이벤트/라우터/부수효과 없음(JS 번들·하이드레이션 제로 지향).
- Features(쓰기)
- 모든 쓰기(도메인 DB, URL, 클라이언트 상태, 브라우저/네트워크 부수효과).
- 상호작용/핸들러/로직의 소유권.
- 클라 컴포넌트(버튼/폼/옵저버)와 서버 액션을 여기에 둠.
Features 쓰기: 두 가지 종류
- 도메인(server 데이터) 쓰기
- 예: Quiz 생성/수정/삭제, User 등록 등 DB 쓰기
- 구성(권장)
features/<feature>/<intent>/action/*.ts — Server Action(POST/PUT/DELETE)
features/<feature>/<intent>/model/type.server.ts — 서버 타입/스키마
features/<feature>/<intent>/ui.tsx — 클라 폼/버튼/제출
- 상태(client) 쓰기
- 예: Infinite Scroll, Filter, Sort, Search, Pagination …
- 구성(권장)
features/<feature>/<intent>/model/state.ts — 클라 상태/훅
features/<feature>/<intent>/ui.tsx — 관찰자/토글/검색 입력 등
- 필요 시
shared/lib/url/*(쿼리 빌더)와 조합
왜 이렇게 나누나? (현실적인 이유)
- 번들/성능: Entities는 RSC로 남겨 JS/하이드레이션 제로. 버튼 로직 섞이면 번들↑.
- 재사용/조합: 읽기 레이어를 페이지 독립으로 유지 → 다른 화면에서도 그대로 재사용.
- 캐싱/불변성: Entity는 “같은 입력 = 같은 출력”이라 캐시/스트리밍이 단순.
- 테스트 경계: Entity는 스냅샷/접근성, Feature는 상호작용(E2E)로 관심 분리가 선명.
현장의 룰 오브 썸(Thumb)
on* 핸들러가 보이면 Features로 옮긴다.
- 라우터/URL을 만지면 Features다.
- 링크는 Entities에 둬도 된다.
- 단, 링크로 조건 변경(필터/커서)을 만들면 그 href 계산은 Feature가 책임.
- 모호하면 “Entities=읽기, Features=쓰기”로 판단.
결론: 클릭 이벤트가 있다는 이유만으로 “모든 버튼=Features”는 아니다.
다만 현실적으로 버튼=행동 이 성립하므로 대부분 버튼은 Features, 예외는 단순 링크 정도.
분류표(빠르게 보기)
| 상황 |
예시 |
분류 |
이유/가이드 |
| 상세 페이지로 이동 |
<Link href="/books/123"> |
Entities |
“자원 링크”는 서버 우선 흐름 보존. |
| 리스트 정렬/필터 토글 |
“제목 ↑/↓”, 검색 적용 |
Features |
URL/상태를 쓴다(query 변경). |
| 페이지네이션/무한 스크롤 |
다음 페이지/커서 |
Features |
URL/상태 또는 관찰자 로직 소유. |
| 삭제/수정/생성 |
행별 Delete |
Features |
도메인 쓰기(서버 액션). |
| 복사/다운로드 |
클립보드, CSV 생성 |
Features |
클라 부수효과/스트리밍. |
| 단순 파일 링크 |
정적 CSV <a download> |
Entities or Features |
정적 경로면 Entities, 접근 제어나 준비 로직 있으면 Features. |
| 모달 열기 |
“자세히 보기” 모달 |
Features |
클라 상태 소유. |
모달의 경우(경계 잡기)
- 모달 열기 버튼이 단순 자원 접근이면 → Entities(버튼)
- 모달이 쓰기 진입 경로(생성/수정/삭제)면 → Features(버튼)
- 기능 묶음을 한 폴더에 모아 경계 고정 → 유지보수/테스트 용이
치트시트(프로덕션에서 자주 나오는 케이스)
핵심 질문 두 개만 기억하세요:
“무엇을 쓰는가(상태·URL·도메인·부수효과)?” + “행동의 소유권은 누구인가?”
A. 탐색/내비게이션
| 케이스 |
분류 |
설명 |
| 자원 상세로 이동(/posts/123) |
Entities |
순수 읽기 흐름. RSC 내부 <Link> 허용. |
| 뷰 상태 바꾸는 링크(필터/정렬/커서) |
Features |
URL 쿼리 쓰기. href 계산은 Feature 소유. |
| 언어/로케일 전환(경로 기반) |
Entities |
경로 자체가 자원 변화. 쿼리/쿠키 토글은 Features. |
| 브레드크럼/사이드 내비(정적) |
Entities |
조건/권한 분기는 Feature에서 props로 주입. |
| 프리페치(hover/onView) |
Features |
클라 부수효과/상태 쓰기. |
B. 폼/입력/쓰기 플로우
| 케이스 |
분류 |
설명 |
| Create/Update/Delete |
Features |
서버 액션 POST/PUT/DELETE. |
| Optimistic Update |
Features |
롤백/토스트 등 UX 로직 포함. |
| Draft/Autosave |
Features |
로컬/IndexedDB/쿠키 쓰기. |
| Inline Edit(셀) |
Features |
입력·검증·제출 모두 행동 소유. Entity 테이블엔 슬롯으로 주입. |
| 파일 업로드 |
Features |
presigned URL, 진행률/재시도/취소. |
| CSV/Excel 내보내기 |
Entities(정적) / Features(온디맨드) |
온디맨드는 서버 작업 트리거=쓰기. |
C. 읽기 뷰 + 상호작용
| 케이스 |
분류 |
설명 |
| 테이블/카드/차트 표시 |
Entities |
DTO → 마크업(상태/이벤트 없음). |
| 차트 줌/브러시/범례 토글 |
Features |
상태/이벤트 소유. 프리미티브는 shared/ui. |
| 멀티 선택/체크박스 |
Features |
선택 상태/일괄 액션 소유. |
| 태깅/즐겨찾기/핀 |
Features |
도메인 쓰기 또는 URL/로컬 상태 쓰기. |
| 툴팁/호버 디테일 |
Features |
클라 상태/이벤트. 완전 CSS‑only면 Entities 가능. |
D. 실시간/오프라인/동기화
| 케이스 |
분류 |
설명 |
| SSE/WebSocket 구독 |
Features |
연결/해제/reconnect/백오프. |
| Presence/Typing |
Features (+ Entities 뷰) |
구독/머지=Feature, 표시=Entity. |
| 오프라인 캐시/재동기화 |
Features |
SW/IndexedDB/Retry 정책. |
| PWA 설치/푸시 |
Features |
권한·토큰 관리·브라우저 API. |
E. 접근 제어/보안
| 케이스 |
분류 |
설명 |
| Auth 가드(로그인/권한) |
Features |
라우트 가드/리다이렉트/서버 액션 가드. |
| 버튼 가시성/활성 제어 |
Features |
“행동 가능 여부” 판단=Feature, Entity엔 표시만 전달. |
| 세션/토큰 갱신 |
Features |
백그라운드 갱신/부수효과. |
| CAPTCHA/봇 방지 |
Features |
제출 전 검증/토큰 교환. |
F. 라우팅/레이아웃(Next.js)
| 케이스 |
분류 |
설명 |
| Parallel/Intercepted로 “읽기 상세를 모달처럼” |
Entities(링크/닫기 링크) |
자원 이동=읽기. |
| 쿼리 기반 모달/패널 토글 |
Features |
URL 쓰기. |
| Middleware 리다이렉트(i18n/A‑B/권한) |
Features |
정책/실험 로직. |
| Metadata/SEO |
Entities(정적) / Features(동적/실험) |
정적 메타=Entities, 실험/트래킹 결합=Feature. |
G. 검색/저장된 보기
| 케이스 |
분류 |
설명 |
| 검색 결과 리스트 |
Entities |
서버 읽기+렌더. |
| Search‑as‑you‑type |
Features |
디바운스/취소/하이라이트 상태. |
| 저장된 필터/뷰 |
Features |
사용자별 구성 저장=도메인 쓰기. |
H. 설정/환경/개인화
| 케이스 |
분류 |
설명 |
| 테마 토글 |
Features |
로컬/쿠키/시스템 프리퍼런스 동기화. |
| 사용자 환경설정 |
Features |
클라/서버 저장. |
| i18n 언어 선택 |
Entities or Features |
경로 기반=Entities, 상태/쿠키 토글=Features. |
I. 통합/결제/백그라운드
| 케이스 |
분류 |
설명 |
| Webhook/Queue 트리거 |
Features |
제출→잡 등록=쓰기. |
| 결제 체크아웃 버튼 |
Features |
세션 생성/리다이렉트/실패·취소 핸들. |
| 상태 폴링/진행 표시 |
Features |
폴링 주기/중단/리트라이. |
| 감사 로그(Audit) 보기 |
Entities |
읽기 전용(단, 내보내기 버튼은 Features). |
J. 에러/로딩/빈 상태/스켈레톤
| 케이스 |
분류 |
설명 |
| 로딩 스켈레톤/빈 상태 문구 |
Entities |
순수 표시(입력→마크업). |
| 토스트/재시도 버튼 |
Features |
상호작용/부수효과. |
| 에러 바운더리 |
Features(대개 Client) |
복구/재시도/로그 전송. |
K. 분석/로그/실험
| 케이스 |
분류 |
설명 |
| 뷰 임프레션 로깅 |
Features |
클라 부수효과/전송. |
| 클릭 트래킹/전환 계측 |
Features |
행동과 결합(트래킹을 Entity에 심지 말 것). |
| A/B 테스트 스위치 |
Features |
배정/변형 노출 제어. |
L. 지도/위치/DnD/캘린더
| 케이스 |
분류 |
설명 |
| 지도/캘린더 표시 |
Entities |
가능하면 서버 스냅샷/이미지, 아니면 프리미티브 래퍼. |
| 핀 이동/드래그 정렬/캘린더 생성 |
Features |
상호작용/도메인 쓰기. |
| 지오로케이션 권한/현재 위치 |
Features |
브라우저 권한/부수효과. |
M. SEO/정적 산출물
| 케이스 |
분류 |
설명 |
| 사이트맵/robots/OG(정적) |
Entities |
읽기 산출물. |
| OG 이미지 온디맨드 |
Features |
파라미터 설계/트리거(실험 결합 시). |
N. 페이지 보호/리디렉션
| 케이스 |
분류 |
설명 |
| 권한 없는 접근 → 리디렉션 |
Features |
미들웨어/서버 컴포넌트 가드. |
| 임시 배너/공지 닫기 |
Features |
로컬/쿠키 상태 쓰기(배너 뷰 자체는 Entities). |
폴더 구조 가이드(요약)
src/
├─ app/ # 라우팅(RSC) · 조합 지점
│ └─ (routes …)
├─ entities/
│ └─ <domain>/<read-view>/ # RSC + SELECT 전용 도메인 함수
│ ├─ action/get*.ts # Server(GET): 읽기 전용
│ └─ ui/*.tsx # 이벤트/상태 없는 순수 뷰
├─ features/
│ └─ <feature>/<intent>/ # 상호작용(상태/URL/도메인 쓰기)
│ ├─ action/*.ts # Server(POST/PUT/DELETE …)
│ ├─ model/state.ts # (옵션) 클라 상태/훅
│ └─ ui.tsx # Client 컴포넌트(버튼/폼/옵저버/링크)
├─ widgets/ # 특정 조합 캡슐화(서버 컴포넌트)
├─ shared/
│ ├─ ui/ # 프리미티브(표시 전용)
│ ├─ lib/ # url builders, schema, formatters
│ ├─ auth/, db/, config/ # 공용 클라/서버 유틸
│ └─ analytics/, experiments/ # 계측·실험(Feature가 사용)
결정 트리(헷갈릴 때)
- 이 컴포넌트가 어떤 형태로든 “쓰기”(DB/URL/클라 상태/부수효과)를 유발하는가?
→ 예: Features / 아니오: 2로
- 같은 입력을 넣으면 항상 같은 마크업을 내나?
→ 예: Entities / 아니오(시간/환경/권한에 따라 달라짐): 대개 Features에서 판단 후 Entities에 props로 주입
- 링크인가?
- 자원 이동이면 Entities
- 뷰 조건 변경(쿼리/패널/모달 토글)이면 Features
실무에서 자주 빠지는 함정(피해야 함)
- Entity 안에서
useSearchParams/router.* 호출 → 경계 붕괴.
- Entity에
onClick 하나쯤… → 번들/캐싱/테스트 전부 꼬인다.
- GET을 Server Action으로 남발 → 캐시/리트라이/중복 제거/DevTools 부재.
- (필요 시 API Route + React Query를 Feature에서 사용해 클라 캐싱/리트라이를 명시적으로 관리)
- 트래킹/실험 코드를 Entity에 심음 → 나중에 제거/변경 어려움, 테스트 흔들림.
- 권한 로직을 Entity에서 분기 → UI 조각은 표시만, “허용/차단”은 Feature가 결정.
주: 제 원칙은 Server‑first + 최소 API 라우트이지만, 디버깅/리트라이/오프라인 요구가 큰 상호작용적 읽기라면, Feature 레이어에서 API Route + 클라 캐시로 전술 전환을 고려합니다. “순수 읽기(Entity)”와 “상호작용적 읽기(Feature)”를 구분하세요.
세 줄 요약
- Entities = 읽기 전용 RSC 뷰 + SELECT 함수
(입력→마크업, 상태/이벤트/라우터/부수효과 없음)
- Features = 모든 쓰기(도메인·URL·클라 상태·부수효과) + 상호작용의 소유권
- 모호하면 “행동의 소유권”과 “순수성(같은 입력=같은 출력)”으로 결정하라.
핵심은 경계 유지와 소유권 분리입니다. 경계가 선명해질수록 번들·캐시·테스트가 쉬워지고, 유지보수 비용이 내려갑니다.