FSD(Feature-Sliced Design)를 실제 팀에 도입했더니 "이 코드 어디 넣어요?"가 사라졌습니다
프론트엔드 개발을 하다 보면 한 번쯤 이런 상황을 겪습니다. 코드 리뷰에서 "이 컴포넌트 왜 여기 있어요?"라고 물었더니 "그냥 딱히 둘 곳이 없어서요"라는 답이 돌아오는 순간이요. 저도 그랬습니다. components 폴더가 비대해지고, utils 안에 온갖 잡다한 코드가 쌓이고, 신규 팀원이 들어오면 "이건 어디에 만들어요?"를 매번 물어보는 상황이 반복됐습니다. Feature-Sliced Design(FSD)을 팀에 도입한 건 그즈음이었습니다.
이 글은 FSD의 개념 설명에서 끝나지 않습니다. 실제로 팀에 도입했을 때 어디서 헷갈리고, 무엇이 잘 풀렸으며, 어떤 함정이 있었는지를 솔직하게 정리한 후기입니다. FSD를 처음 들어보셨더라도, 도입을 고민 중이더라도, 이미 쓰고 계시더라도 — 이 글 하나로 팀에 FSD를 제안할 근거가 생길 겁니다. 코드 예시는 TypeScript 기준으로 작성했습니다.
핵심 개념
레이어, 슬라이스, 세그먼트 — 세 겹의 구조
FSD를 처음 접했을 때 공식 문서가 생각보다 명쾌해서 놀랐습니다. 개념은 단순합니다. 코드를 레이어(Layer) → 슬라이스(Slice) → 세그먼트(Segment) 순으로 세 겹으로 나누는 겁니다.
레이어는 현재 권장 구조 기준으로 여섯 단계로 고정돼 있습니다. (이전 스펙에 있던 processes 레이어는 deprecated되어 FSD v2에서는 사실상 사용하지 않습니다.)
app → pages → widgets → features → entities → shared방향이 중요합니다. 상위 레이어는 하위 레이어를 참조할 수 있지만, 반대 방향은 불가합니다. entities가 features를 import하는 순간 규칙 위반입니다. 이 단방향 의존성 하나가 FSD의 핵심을 이루고 있다고 해도 과언이 아닙니다.
슬라이스는 각 레이어 안에서 비즈니스 도메인으로 나눈 단위입니다. features 레이어 안에 auth, payment, notification처럼 기능 단위로 폴더가 생깁니다. 세그먼트는 슬라이스 내부를 기술 역할로 분류한 것으로, ui, model, api, lib, config가 표준 구성입니다.
한 가지 알아두면 좋은 점이 있는데, shared 레이어는 다른 레이어와 달리 슬라이스 없이 세그먼트만 존재하는 유일한 레이어입니다. shared/auth나 shared/user처럼 슬라이스를 만드는 것은 FSD 규칙에 맞지 않습니다. 저도 처음에 이 부분에서 헷갈려서 shared/user라는 폴더를 만들었다가 나중에 뒤집어야 했습니다.
src/
├── app/ # 앱 초기화, 라우팅, 전역 설정
├── pages/ # 라우트별 페이지 컴포넌트
│ └── auth/
│ └── ui/
├── widgets/ # 독립적으로 동작하는 복합 UI 블록
├── features/ # 사용자 인터랙션 단위 기능
│ └── login/
│ ├── ui/ # 컴포넌트
│ ├── model/ # 상태, 스토어, 비즈니스 로직
│ └── api/ # API 호출
├── entities/ # 비즈니스 엔티티 (User, Product 등)
└── shared/ # 공통 유틸, UI 컴포넌트, 상수 (슬라이스 없음)
├── ui/
├── lib/
└── config/Public API란? 각 슬라이스의 루트에 위치한
index.ts파일입니다. 외부에서는 이 파일을 통해서만 슬라이스 내부에 접근할 수 있고, 직접 경로 import는 규칙 위반으로 간주합니다.
FSD 2.1의 "Pages First" — 실용성의 승리
2024년에 나온 FSD 2.1 업데이트가 개인적으로 반가웠습니다. 이전에는 "재사용될 수도 있으니 일단 features에 두자"는 식으로 판단해야 했는데, 그게 생각보다 피로했거든요. 2.1은 명확합니다. 재사용되지 않는 코드는 굳이 상위 레이어로 올리지 않아도 됩니다. pages 슬라이스 안에 페이지에 종속된 UI와 로직을 먼저 배치하고, 진짜로 재사용이 생길 때 리팩토링하면 됩니다.
이 변화가 작아 보이지만 실무에서는 꽤 큰 차이를 만듭니다. 매번 "이거 나중에 재사용될까?"를 예측해서 결정하지 않아도 된다는 뜻이니까요.
실전 적용
개념을 알았으니, 실제로 어떻게 나눴는지 보겠습니다.
예시 1: 로그인 기능을 FSD 구조로 나눠보기
"로그인 폼을 만드는데 어떻게 나누면 되나요?"가 FSD를 처음 도입할 때 가장 많이 받은 질문이었습니다. 아래가 실제로 팀에서 정착한 구조입니다.
// features/login/ui/LoginForm.tsx
// 로그인 폼 컴포넌트 — 순수 UI 역할만 담당
import { useLoginModel } from '../model/useLoginModel'
export const LoginForm = () => {
const { isLoading, handleSubmit } = useLoginModel()
return (
<form onSubmit={handleSubmit}>
<input type="email" placeholder="이메일" />
<input type="password" placeholder="비밀번호" />
<button type="submit" disabled={isLoading}>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
)
}// features/login/model/useLoginModel.ts
// 로그인 비즈니스 로직 — UI와 분리
import { useState } from 'react'
import { loginApi } from '../api/loginApi'
import { sessionModel } from '@/entities/session'
interface LoginCredentials {
email: string
password: string
}
export const useLoginModel = () => {
const [isLoading, setIsLoading] = useState(false)
// features → entities 참조는 하위 레이어 접근이므로 규칙상 허용됩니다
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
const credentials: LoginCredentials = { email: '', password: '' }
const token = await loginApi.login(credentials)
sessionModel.setToken(token)
} finally {
setIsLoading(false)
}
}
return { isLoading, handleSubmit }
}// features/login/index.ts
// Public API — 외부에서 접근 가능한 것만 노출
export { LoginForm } from './ui/LoginForm'| 파일 | 레이어/세그먼트 | 역할 |
|---|---|---|
LoginForm.tsx |
features / ui | 입력 UI 렌더링 |
useLoginModel.ts |
features / model | 로그인 상태·로직 |
loginApi.ts |
features / api | 서버 통신 |
index.ts |
features / (root) | Public API |
이 구조를 처음 팀에 제안했을 때 반응이 좋지만은 않았습니다. "파일이 왜 이렇게 많아요?"라는 피드백이 바로 나왔거든요. 그때 제가 한 실수는 작은 기능에도 억지로 네 개 파일을 다 만들려 했던 겁니다. FSD 2.1의 핵심은 "필요할 때 나눠라"인데, 저는 처음부터 규칙을 완벽하게 지키려다 팀 반발을 샀습니다. 그 이후로는 pages 슬라이스 안에 먼저 두고, 재사용이 실제로 생기면 올리는 방식으로 바꿨더니 거부감이 훨씬 줄었습니다.
예시 2: 같은 레이어 슬라이스 간 참조 — @x로 해결하기
FSD에서 가장 자주 맞닥뜨리는 현실적인 문제가 있습니다. entities/user와 entities/product가 서로를 참조해야 하는 상황입니다. 같은 레이어끼리는 상호 참조가 금지돼 있는데, 실무에서는 이런 케이스가 생각보다 자주 생깁니다. FSD 2.1에서 @x 표기로 이걸 공식적으로 해결할 수 있게 됐습니다.
// entities/product/ui/ProductCard.tsx
// ProductCard에서 리뷰어 정보를 표시하기 위해 User 타입이 필요한 상황
import type { User } from '@/entities/user/@x/product'
interface ProductCardProps {
productName: string
reviewer: User
}
export const ProductCard = ({ productName, reviewer }: ProductCardProps) => (
<div>
<h3>{productName}</h3>
<span>리뷰어: {reviewer.name}</span>
</div>
)// entities/user/index.ts — 기본 Public API
export type { User } from './model/types'
export { UserAvatar } from './ui/UserAvatar'
// entities/user/@x/product.ts — product에게만 허용하는 API
export type { User } from './model/types'
// product 슬라이스가 필요한 것만 선택적으로 노출처음엔 복잡해 보이는데, 익숙해지면 "어? 여기서 참조가 생겼네, 그럼 @x 파일을 만들어야겠다"는 판단이 자연스럽게 나옵니다. 숨겨진 의존성이 코드 위에 명시적으로 드러난다는 점이 가장 큰 장점입니다.
다만 실용적인 기준을 하나 추가하면, @x 파일이 특정 슬라이스에 세 개 이상 생기기 시작하면 해당 타입이나 컴포넌트를 상위 레이어(widgets 또는 features)로 올리는 것을 재검토해볼 만합니다. @x가 남용되면 결국 의존성 그래프가 복잡해지고, FSD를 쓰는 이유가 희미해집니다.
예시 3: Steiger로 규칙 위반 자동 감지하기
팀원 전체가 FSD 규칙을 머릿속에 기억하고 있기를 기대하는 건 현실적이지 않습니다. 그래서 린터 도입이 사실상 필수입니다. FSD 공식 팀이 만든 Steiger를 설정하면 CI 단계에서 위반을 잡아낼 수 있습니다.
pnpm add -D steiger @feature-sliced/steiger-plugin// steiger.config.ts
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'
export default defineConfig([
...fsd.configs.recommended,
{
files: ['./src/**'],
rules: {
'fsd/no-cross-imports': 'error', // 같은 레이어 슬라이스 간 직접 참조
'fsd/public-api': 'error', // index.ts 우회 import
'fsd/no-processes': 'warn', // 오래된 processes 레이어 사용
},
},
])저희 팀은 Steiger 도입 첫 주에 기존 코드에서 public-api 위반이 23건 나왔습니다. 알고는 있었지만 이렇게 한눈에 보이니 다들 충격이었고, 오히려 리팩토링 동기부여가 됐습니다.
장단점 분석
개인적으로 이 섹션이 가장 쓰기 어려웠습니다. 도입 초반에는 단점이 크게 느껴지다가, 반 년이 지나고 나니 장점이 훨씬 선명하게 보이거든요. 팀 규모와 프로젝트 성격에 따라 체감이 많이 다를 수 있으니 참고 정도로 보시면 좋겠습니다.
장점
| 항목 | 내용 |
|---|---|
| 코드 위치 명확성 | "이 코드 어디에 넣어요?" 논쟁이 실질적으로 사라집니다 |
| 독립적 병렬 개발 | 슬라이스 단위로 격리되어 팀원 간 충돌 없이 동시 작업이 가능합니다 |
| 온보딩 속도 향상 | 신규 팀원이 구조만 이해하면 코드 위치를 예측할 수 있습니다 |
| 점진적 도입 | 레거시 코드베이스에서 신규 기능부터 부분 적용이 가능합니다 |
| 의존성 통제 | 레이어 규칙으로 예기치 않은 사이드 이펙트를 사전 차단합니다 |
실제로 가장 크게 느꼈던 장점은 온보딩이었습니다. 이전에는 신규 팀원이 들어오면 "이 프로젝트 구조 설명"에 반나절이 걸렸는데, FSD 도입 이후로는 공식 문서 링크 하나와 15분짜리 설명으로 충분해졌습니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 높은 학습 곡선 | 2년 미만 경력자에게 개념 자체가 낯설 수 있습니다 | 팀 내 FSD 사용 가이드 문서를 별도 작성하는 것을 권장합니다 |
| 초기 경계 합의 비용 | "이건 feature인가 entity인가?" 논의가 상당히 길어질 수 있습니다 | 초반에 사례 중심 결정 기준을 문서화해두는 방식이 효과적입니다 |
| 소규모 프로젝트 오버헤드 | MVP, 프로토타입 단계에서는 파일 수만 늘어납니다 | 팀 3인 이상, 6개월 이상 운영 예정인 프로젝트에서 도입을 고려해볼 수 있습니다 |
| 일관성 유지 | 팀원마다 해석이 다르면 구조가 금방 흐트러집니다 | Steiger 같은 린터로 자동화된 규칙 강제를 적용하는 방식이 현실적입니다 |
솔직히 초기 합의 비용은 예상보다 훨씬 컸습니다. "이 기능은 features인가 widgets인가?"를 두고 팀 회의에서 30분 넘게 토론했던 적이 여러 번 있었거든요. 지금 생각하면 그게 다 팀 공통 언어를 만드는 과정이었습니다만, 당시에는 꽤 지쳤습니다.
오버엔지니어링(Over-engineering)이란? 현재 요구사항에 비해 지나치게 복잡한 구조를 적용하는 것을 의미합니다. FSD는 강력한 도구지만, 규모가 작은 프로젝트에 적용하면 오히려 유지보수 비용이 늘어날 수 있습니다.
실무에서 가장 흔한 실수
shared에 뭐든 몰아넣기 — "공통으로 쓸 것 같은데?"라는 이유로shared가 다시 쓰레기통이 됩니다.shared는 비즈니스 로직이 없는 순수 유틸, UI 컴포넌트, 상수만 두는 것을 권장합니다. 거기에 더해shared에는 슬라이스가 없으므로shared/user처럼 도메인 이름을 붙인 폴더를 만드는 것 자체가 규칙에 맞지 않습니다.features와entities경계를 잘못 나누기 —entities는 비즈니스 도메인 자체(User, Order)를,features는 사용자 인터랙션(주문하기, 로그인)을 담습니다. "동사인가 명사인가"로 판단해보시면 대부분 정리됩니다.- 처음부터 전체 전환을 시도하기 — 카카오페이 팀도 점진적 전환을 택했습니다. 기존 코드를 한꺼번에 마이그레이션하다 보면 중간에 반쪽짜리 구조가 오래 살아남게 됩니다.
마치며
FSD는 파일을 어디에 둘지 고민하는 시간을 줄이고, 그 시간을 실제 문제 해결에 쓸 수 있게 해주는 방법론입니다. 물론 처음 도입할 때는 오히려 논의가 더 많아지는 역설적인 경험을 하게 됩니다. 그게 정상입니다. 초반의 그 논의들이 팀의 공통 언어를 만드는 과정이기도 하니까요. 반 년이 지난 지금, 팀에서 FSD를 버리자고 한 사람은 아무도 없습니다.
당장 내일 해볼 수 있는 것부터 시작하면 됩니다:
- 공식 문서의 인터랙티브 예제를 살펴보는 것부터 시작할 수 있습니다 — feature-sliced.design 에서 시각적으로 구조를 탐색해볼 수 있습니다. "Tutorial" 섹션부터 시작하면 개념이 훨씬 빠르게 잡힙니다.
- 신규 기능 하나를 FSD 구조로 만들어보시면 팀에 제안할 실제 근거가 생깁니다 — 기존 코드는 건드리지 않고, 다음에 추가할 기능 하나만
features/[기능명]/ui,model,api구조로 작성해보시면 됩니다. - Steiger를 개발 환경에 먼저 붙여보면 현재 구조의 문제가 한눈에 보입니다 —
pnpm add -D steiger @feature-sliced/steiger-plugin으로 설치하고 기존 코드베이스를 분석해보시면, CI 적용 전에 로컬에서 의존성 문제가 어디에 있는지 바로 확인할 수 있습니다.
참고 자료
- Feature-Sliced Design 공식 문서 | feature-sliced.design
- FSD v2.1 릴리즈 노트 — "Pages come first!" | GitHub
- FSD v2.0 → v2.1 마이그레이션 가이드 | feature-sliced.design
- 카카오페이 기술 블로그 — FSD 아키텍처 적용기 | tech.kakaopay.com
- Steiger — FSD 공식 아키텍처 린터 | GitHub
- eslint-plugin-feature-sliced (conarti) | GitHub
- feature-sliced/awesome — FSD 에코시스템 리소스 | GitHub