Dev Notes
Web Vitals
Web Vitals
Dev Notes
DocsFeature Sliced Design 아키텍처 가이드
Web Vitals
Web Vitals
GitHub
Architecture45분

Feature Sliced Design 아키텍처 가이드

비즈니스 도메인 중심의 프론트엔드 프로젝트 구조화 방법론

2025년 9월 12일
architectureFSDfrontendscalabilitypatterns

적용 환경: React, Vue, Angular 등 모든 프론트엔드 프레임워크

들어가며

프로젝트를 시작할 때 보통 components, hooks, utils 같은 폴더를 만듭니다. 처음에는 깔끔해 보이지만, 규모가 커질수록 문제가 보이기 시작합니다.

components 폴더에 파일이 50개가 넘어가면, UserProfile을 수정하려고 열었다가 관련된 hook은 hooks/useUser.ts에, 타입은 types/user.ts에, API 호출은 api/userApi.ts에 흩어져 있다는 것을 깨닫게 됩니다. 하나의 기능을 고치려면 4개 폴더를 오가야 합니다.

새 팀원이 "유저 관련 코드 전체를 파악하고 싶은데요"라고 했을 때, "일단 components에서 User로 시작하는 것들 찾고, hooks에서 useUser 시리즈 보고, utils에도 좀 있고..." 이렇게 설명해야 합니다.

또 한가지 문제는 바로 의존성입니다. UserCard가 PostList를 import하고, PostList가 다시 UserAvatar를 import하는 식으로 얽히기 시작하면, 파일 하나를 수정했을 때 어디가 깨질지 예측하기 어려워집니다. "이 구조가 어떻게 되어 있어요?"라는 질문에 "그냥... 필요한 곳에서 import하면 돼요"라고밖에 답할 수 없는 상황이 됩니다.

이 문서는 "기술 종류"가 아니라 "무엇을 하는 코드인가"를 기준으로 폴더를 나누고, 명확한 의존성 규칙으로 스파게티 코드를 방지하는 Feature Sliced Design(FSD)을 소개합니다.

핵심 구조: Layers, Slices, Segments

FSD는 코드를 세 가지 축으로 조직합니다.

축의미예시
Layer코드의 영향 범위app, pages, features, entities, shared
Slice비즈니스 도메인user, post, comment, cart
Segment기술적 목적ui, model, api, lib

Layer는 "이 코드가 얼마나 넓은 범위에 영향을 미치는가"를 나타냅니다. 전역 설정을 담당하는 app 레이어부터, 재사용 가능한 인프라인 shared 레이어까지 위에서 아래로 영향력이 좁아집니다.

Slice는 비즈니스 도메인 단위입니다. 소셜 미디어 앱이라면 user, post, comment가 각각 하나의 슬라이스가 됩니다. 이커머스라면 product, cart, order가 슬라이스가 됩니다.

Segment는 기술적 목적에 따른 분류입니다. 컴포넌트는 ui, 상태와 타입은 model, 서버 통신은 api에 둡니다.

src
app/
providers/
styles/
pages/
home/
profile/
widgets/
header/
post-card/
features
like-post
ui/
model/
api/
index.ts
auth/
entities/
user/
post/
shared/
ui/
api/
lib/

6개의 레이어

FSD는 6개의 레이어를 정의합니다. 각 레이어는 자신보다 아래에 있는 레이어만 import할 수 있다는 규칙이 있습니다.

shared: 비즈니스 로직 없는 인프라

프로젝트 전체에서 재사용되지만 비즈니스 로직이 없는 코드들입니다. 디자인 시스템의 Button, Input 같은 기본 컴포넌트가 여기 속합니다. API 클라이언트, 날짜 포맷팅 유틸리티, 상수 정의도 마찬가지입니다.

TypeScript
// shared/ui/Button.tsx - 비즈니스 로직 없음
export const Button = ({ children, variant = 'primary', ...props }) => (
  <button className={`btn btn-${variant}`} {...props}>{children}</button>
);
 
// shared/lib/format.ts
export const formatDate = (date: Date) =>
  new Intl.DateTimeFormat('ko-KR').format(date);

shared는 유일하게 슬라이스가 없는 레이어입니다. 비즈니스 도메인과 무관하기 때문입니다. 세그먼트(ui, api, lib, config)로만 구분합니다.

entities: 비즈니스 개념의 표현

프로젝트에서 다루는 핵심 개념들이 여기 속합니다. 소셜 미디어라면 User, Post, Comment가 엔티티입니다. 각 엔티티 폴더에는 해당 개념의 타입 정의, 기본 UI 컴포넌트, API 호출이 들어갑니다.

TypeScript
// entities/user/model/types.ts
export interface User {
  id: string;
  username: string;
  avatar?: string;
}
 
// entities/user/ui/UserAvatar.tsx
export const UserAvatar = ({ user, size = 'md' }: UserAvatarProps) => (
  <img src={user.avatar || '/default.png'} alt={user.username} />
);

엔티티를 "명사"로 생각하면 이해하기 쉽습니다. 시스템이 다루는 대상 자체를 표현하기 때문입니다. 한 가지 주의할 점은 같은 레이어의 엔티티끼리는 서로 import할 수 없다는 것입니다. post 엔티티에서 user 엔티티를 직접 가져오면 안 됩니다. 둘을 조합해야 한다면 상위 레이어에서 처리해야 합니다.

features: 사용자가 수행하는 동작

사용자 인터랙션을 처리하는 기능 단위입니다. 로그인, 게시글 좋아요, 댓글 작성 같은 동작들이 여기에 해당합니다. 엔티티가 "명사"라면 피처는 "동사"입니다. "좋아요를 누른다", "로그인한다", "검색한다" 같은 액션을 캡슐화합니다.

TSX
// features/like-post/ui/LikeButton.tsx
import { Heart } from '@/shared/ui/icons';
import { useLike } from '../model/useLike';
 
export const LikeButton = ({ postId, initialLiked, initialCount }) => {
  const { isLiked, count, toggle } = useLike({ postId, initialLiked, initialCount });
 
  return (
    <button onClick={toggle} className={isLiked ? 'liked' : ''}>
      <Heart filled={isLiked} />
      <span>{count}</span>
    </button>
  );
};

모든 인터랙션을 피처로 만들 필요는 없습니다. 여러 페이지에서 재사용되는 경우에만 features 레이어에 배치하고, 특정 페이지에서만 쓰이는 로직은 해당 page 내부에 두는 편이 낫습니다.

widgets: 조합된 독립 UI 블록

entities와 features를 조합해서 완성된 UI 블록을 만드는 레이어입니다. 헤더, 사이드바, 포스트 카드처럼 여러 페이지에서 재사용되는 큰 단위가 여기 속합니다.

TSX
// widgets/post-card/ui/PostCard.tsx
import { PostPreview } from '@/entities/post';
import { UserAvatar } from '@/entities/user';
import { LikeButton } from '@/features/like-post';
 
export const PostCard = ({ post, author }) => (
  <article>
    <header>
      <UserAvatar user={author} size="sm" />
      <span>{author.username}</span>
    </header>
    <PostPreview post={post} />
    <footer>
      <LikeButton postId={post.id} initialLiked={post.isLiked} initialCount={post.likeCount} />
    </footer>
  </article>
);

widgets 레이어가 있어서 entities 간의 직접 import 금지 규칙이 현실적으로 가능해집니다. post와 user를 함께 보여주어야 할 때, entity끼리 엮지 않고 widget에서 조합하면 됩니다.

pages: 화면 단위

라우트에 대응하는 화면입니다. widgets, features, entities를 조합해서 완성된 페이지를 구성합니다. 데이터 페칭, 로딩 상태, 에러 처리도 이 레이어에서 담당합니다.

TSX
// pages/home/ui/HomePage.tsx
import { Header } from '@/widgets/header';
import { PostCard } from '@/widgets/post-card';
import { useFeedPosts } from '../api/useFeedPosts';
 
export const HomePage = () => {
  const { posts, isLoading, error } = useFeedPosts();
 
  if (error) return <ErrorDisplay error={error} />;
 
  return (
    <div>
      <Header />
      <main>
        {isLoading ? <PostSkeleton count={5} /> : (
          posts.map(post => <PostCard key={post.id} post={post} author={post.author} />)
        )}
      </main>
    </div>
  );
};

app: 애플리케이션 초기화

프로젝트 전체에 영향을 미치는 설정입니다. 프로바이더 구성, 라우팅 설정, 전역 스타일이 포함됩니다.

TSX
// app/providers/index.tsx
export const Providers = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  </QueryClientProvider>
);

의존성 규칙

FSD의 핵심은 명확한 의존성 방향입니다. 두 가지 규칙만 지키면 됩니다.

첫째, 상위 레이어는 하위 레이어만 import합니다. pages는 widgets, features, entities, shared를 가져올 수 있지만, app은 가져올 수 없습니다. features는 entities와 shared만 가져올 수 있습니다.

둘째, 같은 레이어의 슬라이스끼리는 서로 import할 수 없습니다. entities/post에서 entities/user를 직접 가져오면 안 됩니다. 둘을 함께 써야 한다면 상위 레이어인 widgets에서 조합합니다.

이 규칙들이 중요한 이유는 순환 의존성의 원천 차단에 있습니다. A가 B를 import하고 B가 다시 A를 import하는 상황이 구조적으로 불가능해집니다. 코드를 수정할 때 영향 범위도 예측할 수 있습니다.

TypeScript
// ✅ 올바른 예: widgets → entities
// widgets/post-card/ui/PostCard.tsx
import { PostPreview } from "@/entities/post";
import { UserAvatar } from "@/entities/user";
TypeScript
// ❌ 잘못된 예: entities → entities
// entities/post/ui/PostCard.tsx
import { UserAvatar } from "@/entities/user"; // 규칙 위반!

Public API

각 슬라이스는 index.ts 파일을 통해 외부에 공개할 것만 내보냅니다. 이를 Public API라고 부릅니다. 외부에서는 슬라이스의 내부 구조를 알 필요 없이 index.ts에서 제공하는 것만 사용합니다.

TypeScript
// features/auth/index.ts
export { LoginForm } from './ui/LoginForm';
export { LogoutButton } from './ui/LogoutButton';
export { useAuth } from './model/useAuth';
export type { LoginCredentials } from './model/types';
 
// 내부 구현은 내보내지 않음
// - ./api/authApi.ts
// - ./model/authStore.ts

Public API의 장점은 리팩토링의 자유입니다. 슬라이스 내부 구조를 완전히 바꿔도 index.ts만 유지하면 외부에 영향이 없습니다.

코드를 어디에 둘지 고민될 때

새로운 코드를 작성할 때 어느 레이어에 둘지 헷갈린다면 아래 질문들을 순서대로 확인하면 됩니다.

질문Yes라면
비즈니스 로직이 전혀 없는 범용 코드인가?shared
시스템이 다루는 핵심 개념(명사)을 표현하는가?entities
사용자 동작(동사)을 처리하는가?features
여러 entities/features를 조합한 독립 UI인가?widgets
특정 라우트에 대응하는 화면인가?pages

작은 프로젝트에서는 모든 레이어가 필요하지 않습니다. app, pages, features, shared 정도로 시작해서 프로젝트가 커지면 entities와 widgets를 추가하는 방식도 괜찮습니다.

같은 레이어 참조 문제 해결

entities 간에 서로 데이터가 필요한 경우가 흔합니다. post를 보여줄 때 작성자 정보도 필요합니다. 하지만 entities/post에서 entities/user를 직접 import하면 규칙 위반입니다. 이럴 때 Slot 패턴을 사용합니다.

엔티티 컴포넌트가 특정 영역을 props로 받게 만드는 방식입니다.

TSX
// entities/post/ui/PostCard.tsx
interface PostCardProps {
  post: Post;
  authorSlot: React.ReactNode; // 외부에서 주입
}
 
export const PostCard = ({ post, authorSlot }) => (
  <article>
    <header>{authorSlot}</header>
    <PostPreview post={post} />
  </article>
);
TSX
// widgets/post-card/ui/FullPostCard.tsx
import { PostCard } from "@/entities/post";
import { UserAvatar } from "@/entities/user";
 
export const FullPostCard = ({ post, author }) => (
  <PostCard post={post} authorSlot={<UserAvatar user={author} />} />
);

PostCard는 user에 대해 아무것도 모릅니다. 그저 authorSlot이라는 구멍만 뚫어두고, 상위 레이어에서 그 구멍을 채웁니다. 이렇게 하면 규칙을 지키면서도 유연한 조합이 가능해집니다.

기존 프로젝트에 적용하기

한 번에 전체 구조를 바꾸기보다 점진적으로 마이그레이션하는 것이 현실적입니다.

1단계: shared 정리

가장 안전한 출발점입니다. 기존 components 폴더에서 비즈니스 로직 없는 공통 컴포넌트를 shared/ui로 옮깁니다. API 클라이언트는 shared/api로, 유틸리티는 shared/lib로 이동합니다.

2단계: entities 추출

핵심 도메인 타입과 기본 UI를 entities로 분리합니다. User, Post 같은 핵심 개념부터 시작하는 것이 좋습니다.

3단계: features 분리

로그인, 좋아요 같은 재사용되는 인터랙션을 features로 추출합니다.

4단계: pages와 widgets 정리

페이지 컴포넌트를 pages로 이동하고, 재사용되는 큰 UI 블록을 widgets로 분리합니다.

새 기능을 추가할 때부터 FSD 구조를 따르고, 기존 코드는 건드릴 때 함께 마이그레이션하는 방식이 부담이 적습니다.

도구 활용

ESLint 설정

@feature-sliced/eslint-config는 FSD 공식 린터입니다. 레이어 간 잘못된 import나 Public API 위반을 자동으로 검사해줍니다.

Bash
npm install -D @feature-sliced/eslint-config
JavaScript
// .eslintrc.js
module.exports = {
  extends: ["@feature-sliced"],
};

경로 별칭 설정

경로 별칭을 설정하면 import 경로가 깔끔해집니다.

JSON
// tsconfig.json
{
  "compilerOptions": {
    "paths": { "@/*": ["src/*"] }
  }
}

정리

FSD는 프론트엔드 프로젝트를 비즈니스 도메인 중심으로 조직하는 방법론입니다.

핵심 정리

  1. 6개 레이어로 코드의 영향 범위를 구분하고, 상위에서 하위로만 import합니다
  2. 같은 레이어의 슬라이스는 서로 격리됩니다. 조합이 필요하면 상위 레이어에서 처리합니다
  3. Public API(index.ts)를 통해서만 외부와 소통합니다. 내부 구현은 캡슐화됩니다

이 규칙들 덕분에 "이 코드 어디 있지?"라는 질문에 명확히 답할 수 있고, 변경의 영향 범위를 예측할 수 있으며, 새 팀원도 구조를 빠르게 파악할 수 있게 됩니다.

참고 자료

  • Feature-Sliced Design 공식 문서
  • FSD Layers 레퍼런스
  • FSD Public API
  • FSD 튜토리얼
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

Architecture

함수형 프로그래밍?

Data, Calculation, Action — 코드를 읽는 함수형 프레임워크

읽기
Architecture

Monorepo 전략: Turborepo와 Nx

대규모 프론트엔드 프로젝트를 위한 모노레포 전략과 빌드 오케스트레이션 도구 비교

읽기
다음 글Monorepo 전략: Turborepo와 Nx
목차
  • 들어가며
  • 핵심 구조: Layers, Slices, Segments
  • 6개의 레이어
    • shared: 비즈니스 로직 없는 인프라
    • entities: 비즈니스 개념의 표현
    • features: 사용자가 수행하는 동작
    • widgets: 조합된 독립 UI 블록
    • pages: 화면 단위
    • app: 애플리케이션 초기화
  • 의존성 규칙
  • Public API
  • 코드를 어디에 둘지 고민될 때
  • 같은 레이어 참조 문제 해결
  • 기존 프로젝트에 적용하기
  • 도구 활용
    • ESLint 설정
    • 경로 별칭 설정
  • 정리
  • 참고 자료