
FSD? Feature-Sliced Design은 러시아 개발자 커뮤니티에서 시작된, 프론트엔드 아키텍처의 난제인 의존성 지옥을 해결하기 위해 나타난 프론트엔드 아키텍처 방법론이다. 2021년경부터 본격적으로 문서화되기 시작했으며, 대규모 프론트엔드 애플리케이션의 복잡도를 관리하기 위해 만들어졌다.
왜 위와 같은 문제가 발생할까?
기존에 사용하는 아키텍처를 살펴보자.
src/
components/
Button.tsx
UserCard.tsx
ProductCard.tsx
Header.tsx
hooks/
useUser.ts
useProduct.ts
utils/
formatDate.ts
api/
userApi.ts
productApi.ts
보통 위와 같은 구조로, 훅과 유틸리티, 컴포넌트별로 구분해놓은 모습이다. 조금 더 세분화된 폴더 구조를 가져갈 수는 있겠지만 전체적인 구조는 일관된 패턴을 다른다. 위와 같은 구조의 문제점은 다음과 같다.
필자가 FSD를 사용하게 된 경위는 4번 항목이였다. 중-대규모의 프로젝트에서 특정 컴포넌트를 수정해야 하는 경우에, 관련된 훅이나 유틸 함수를 여러 폴더에서 찾아 헤매야 했다.
FSD는 위와 같은 문제들을 일부 해결해줄 수 있는데, 이름에서 알 수 있듯이 구조 단위로 코드를 그룹화할 수 있다. 또한 상위 레이어만 하위 레이어를 import 함으로써, 예측 가능한 의존성 및 새 기능 추가 시에 기존 코드의 영향을 최소화할 수 있다.
하지만 이론적인 장/단점을 안다고 해서 바로 와닿지는 않을 것이다. 이러한 아키텍쳐는 결국 직접 써봐야 진가를 알 수 있다. 그렇다면 FSD는 어떻게 설계할 수 있을까?
FSD는 7개의 표준화된 레이어로 구성된다.
app/ # 앱 초기화, 전역 설정
processes/ # (deprecated) 복잡한 비즈니스 프로세스
pages/ # 페이지 레벨 컴포넌트
widgets/ # 독립적인 UI 블록
features/ # 사용자 인터랙션, 비즈니스 기능
entities/ # 비즈니스 엔티티
shared/ # 재사용 가능한 유틸리티
그리고 앞선 설명과 같이 의존성이 존재하며, 위에서 아래로만 흐른다.
app → pages → widgets → features → entities → shared
이러한 레이어들의 내부 구조는 슬라이스로 나뉜다. 슬라이스(slice)는 비즈니스 도메인을 의미한다 (ex. auth, cart, review ...)
슬라이스의 네이밍 규칙은 다음과 같다.
또한, 이러한 슬라이스(auth, cart ...)들의 내부는 세그먼트로 구성이 된다.
features/
auth/
ui/ # React 컴포넌트
model/ # 비즈니스 로직, 상태 관리
api/ # API 호출
lib/ # 내부 유틸리티
config/ # 설정값
consts/ # 상수
세그먼트들이 많은데, 이러한 요소를 전부 만들 필요는 없으며 때에 따라 필요한 것들만 만들면 된다. 하지만 이러한 아키텍처를 처음 도입할때에는, 미리 만들어놓으면 추후에 헷갈리는 상황이 줄어든다.
또 중요한 포인트는, Public API 패턴을 사용하는 것이다.
Public API - 각 레이어(ex. entities/features/widgets)가 외부에 공개할 것만 index.ts로 명시적으로 노출하고, 내부 구현은 숨기는 패턴
FSD는 암묵적으로 레이어의 경계를 보호해야 하며 내부 구현을 은닉하는 플로우를 따르는데, Public API 패턴을 사용해서 이러한 레이어 간 의존성을 안정적으로 통제할 수 있다.
import { LoginForm } from '@/features/auth/login';
import { useUser } from '@/entities/user';
features/auth/login/
ui/
model/
api/
index.ts <- public api
위와 같은 패턴을 모든 레이어에서 사용하게 된다.
다음은 각 레이어별 분석이다.
shared/
ui/ # UI 킷 (Button, Input, Modal 등)
lib/ # 유틸리티 함수
api/ # API 클라이언트 설정
config/ # 환경 변수
types/ # 공통 타입
hooks/ # 범용 커스텀 훅
자주 사용되는 유틸리티 함수나, 훅, UI 컴포넌트가 위치하는 레이어이다.
공유가 필요한 파일/폴더만 위치해야하며, 처음엔 다른 레이어에서 시작하다가 중복이 많아질 경우 해당 레이어로 옮기거나, 처음부터 공통 컴포넌트의 기획을 확실히 할 수도 있다.
entities/
user/
model/ # User 타입, 상태
ui/ # UserCard, UserAvatar
api/ # getUserById 등
product/
model/
ui/ # ProductCard
api/
order/
FSD의 핵심 레이어 중 하나이다. entitiy라는 레이어는 여러 아키텍처에서 각각 지향하고 있는 역할이 다른데,

DDD와 비교하면 어떨까?

위와 같이, FSD에서는 비즈니스 데이터 단위를 다루게 된다.
FSD의 Entity는 도메인 데이터를 프론트엔드에서 표현하기 위한 모델이다. 관련 사용자 행동과 관련된 비즈니스 규칙은 Feature 레이어에서 처리하게 된다.
여기서 가장 헷갈릴만한 부분은, 위 표와 같이 UI/상태/데이터를 어디까지 표현할 수 있냐는 것이다. 사실 위의 세 항목은 웬만한 코드를 다 지칭할 수 있기 때문이다. 여기서 가장 중요한 포인트는, 코드가 Entity인지, Feature인지를 다루는 것이다. 나머지 레이어의 경우는 명확한 목적성을 가지고 있다. 예를 들어, 공통 엔티티인 shared, 이후에 나올 widgets.. pages... 와 같은 레이어는 헷갈릴 상황이 보다 적다.
그렇다면 Feature 레이어는 어떤 레이어일까?
사용자가 수행하는 액션, 비즈니스 기능
features/
auth/
login/ # 로그인 기능
logout/ # 로그아웃 기능
register/ # 회원가입 기능
product/
add-to-cart/ # 장바구니 담기
toggle-favorite/ # 찜하기
review/
write-review/ # 리뷰 작성
비즈니스 모델(규칙/행동)을 처리하는 레이어다. 비즈니스 모델(규칙/행동)이 생기는 순간 Entity가 아닌 Feature에 정의해야한다.
구체적으로, 다음과 같은 상황이 발생할 경우 Feature 레이러로 분리해야 한다.
이 유저는 로그아웃이 가능한가?
성공하면 A, 실패하면 B
버튼 클릭 → 요청 → 결과 처리
위와 같이 행동과 의사결정이 들어가면 비즈니스 모델(규칙/행동)이다. 따라서,

상기 표를 기준하여 레이어를 구분할 수 있다.
또한, Feature는 Entity를 읽고/갱신할 수 있지만 Entity는 Feature를 전혀 모른다. -> 의존성 철학
한번 더 정리해봤을때,
entities는 보여주기만 하고 비즈니스 로직은 최소화 -> 결정,흐름을 포함한 비즈니스 로직이 없을 뿐이지, 도메인 데이터를 UI에 사용하기 위한 최소환의 변환 로직은 가진다. ex) mapper, format helper...
API 응답을 entities/model/types로 변환하는 mapper 함수 만들기
엔티티는 다른 엔티티를 import 가능 (ex. Order가 User 참조) -> 공식 문서에서는 의존 관계가 복잡해지면 상위 레이어나, 조합하는 것을 권장하고 있음.
feature는 동사형으로 네이밍 (add-to-cart, toggle-like)
한 feature는 한 가지 일만 해야 함 (srp)
feature끼리는 import 금지 (의존성 지옥 방지, Best Practice)
여기서 Entity 항목의 mapper 함수는 서버에서 내려오는 원본 JSON을 그대로 쓰지 않고 Entity가 정의한 타입으로 변환하는 것을 의미한다.
그렇다면, Entity Model이 ViewModal의 역할을 한다고 할 수 있을까?
정확히 말하면 ViewModel과 거의 흡사한 기능을 수행한다고 말할 수 있는데,
위와 같은 ViewModel의 역할을 Entity Model이 일부 수행하기 때문이다. 그러나, 전통적인 ViewModel과 차이가 존재하는데, 일반적인 VM은 상태와 행동 모두를 포함하지만, FSD의 Entity Model은 상태와 표현만 담당한다는 것이다. 그렇다면 행동은 누가 담당하게 되는걸까? 바로 Feature Model이다.
즉, 전통적인 VM = Entity Model + Feature Model
의도적으로 FSD에서는 역할군을 레이어별로 쪼갠 것이다.
다음은, 실제 Entity, Feature의 예제이다.
Entity
// entities/user/model/types.ts
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
// entities/user/model/store.ts (zustand)
import { create } from 'zustand';
import { User } from './types';
interface UserStore {
currentUser: User | null;
setUser: (user: User) => void;
}
export const useUserStore = create<UserStore>((set) => ({
currentUser: null,
setUser: (user) => set({ currentUser: user }),
}));
// entities/user/ui/UserCard.tsx
import { User } from '../model/types';
interface UserCardProps {
user: User;
}
export const UserCard = ({ user }: UserCardProps) => {
return (
<div className="user-card">
{user.avatar && <img src={user.avatar} alt={user.name} />}
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
};
features
// features/auth/login/ui/LoginForm.tsx
import { useState } from 'react';
import { Button } from '@/shared/ui';
import { useLoginMutation } from '../api/useLoginMutation';
export const LoginForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { mutate: login, isPending } = useLoginMutation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호"
/>
<Button type="submit" disabled={isPending}>
{isPending ? '로그인 중' : '로그인'}
</Button>
</form>
);
};
// features/auth/login/api/useLoginMutation.ts
import { useMutation } from '@tanstack/react-query';
import { authApi } from '@/shared/api';
import { useUserStore } from '@/entities/user';
export const useLoginMutation = () => {
const setUser = useUserStore((state) => state.setUser);
return useMutation({
mutationFn: authApi.login,
onSuccess: (user) => {
setUser(user);
// 토큰 저장..
},
});
};
실제로 처음 도입했을 때는 entity / feature 구분이 가장 어렵다.
하지만 한두 번 기능을 분리해보면, 이후부터는 구조를 고민하는 시간이 줄어든다. 실제 개발을 진행하면서 두 레이어의 차이를 더 명확하게 이해하고 구분할 수 있다.
또한, 의존성 역전이 일어날때는 어떻게 처리해야 할까?
Entity 레이어에서 Feature 레이어의 비즈니스 로직이 들어가있는 컴포넌트가 필요한 경우가 있을 때, Entity가 Feature를 참조하는 경우 의존성(참조) 규칙에 위배된다.
이럴 경우, 직접 임포트하기보다는 Slot 패턴을 활용할 수 있다. 엔티티는 자리를 비워두고 실제 액션은 상위 레이어에서 주입하는 방식이다.
// entities/user/ui/UserCard.tsx
import { ReactNode } from 'react';
interface UserCardProps {
user: User;
actionSlot?: ReactNode; // 슬롯
}
export const UserCard = ({ user, actionSlot }: UserCardProps) => {
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
{/* 해당 구멍에 외부에서 주입해준 컴포넌트를 렌더링 */}
{actionSlot && <div className="actions">{actionSlot}</div>}
</div>
);
};
// widgets/user-list/ui/UserList.tsx
import { UserCard } from '@/entities/user';
import { FollowButton } from '@/features/user/follow'; // 이게 필요했음
export const UserList = ({ users }) => {
return (
<div className="user-list">
{users.map((user) => (
<UserCard
key={user.id}
user={user}
// 슬롯에 Feature 삽입
actionSlot={<FollowButton userId={user.id} />}
/>
))}
</div>
);
};
위와 같은 Slot 패턴을 사용하게 되면 의존성을 지키고, 재사용성과 테스트 시 용이성이 증가한다.
다음 아티클에서 남은 레이어인 widgets, pages, app을 소개하겠다.