software engineering/frontend

Feature-Sliced Design(FSD): 프론트엔드 아키텍처의 새로운 접근법

Hyeong Nim 2025. 1. 20. 22:49

Frontend

프론트엔드 구조를 설계할 때 2024년 가장 화두가 되었던 구조였다고 생각합니다. Clean Architecture, Port & Adapter(Hexagonal) Architecture 그리고 Modular Monolith Architecture까지 Backend 서비스를 구상할 때 어떤 아키텍처를 갖는게 좋을지 고민했지만 Microfrontend를 제외하고 기존 클래식한 아키텍처 이후 어플리케이션 모듈 사이에 components, hooks, api 등에 나눠서 분산되어 낮은 응집도를 해결하고 싶었고, 폴더 구조 간 의존성 방향이 존재하지 않아 순환 참조가 발생하는 높은 결합를 해결하고 싶었던 프론트엔드 개발자의 고민을 타겟하여 관심을 많이 갖는거 같습니다.

Feature-Sliced Design(FSD)

프론트엔드 개발에서 종종 겪는 문제 중 하나는 코드의 응집도(cohesion)가 낮고, 결합도(coupling)가 높은 상태로 프로젝트가 진행된다는 점입니다. 이는 코드가 복잡해지고 유지보수가 어려워지는 주된 이유가 됩니다. 이런 문제를 해결하기 위해 등장한 다양한 접근 방식 중 하나가 Feature-Sliced Design(FSD)입니다.

FSD는 모듈화된 코드 구조를 만들어 코드 재사용성을 높이고 유지보수를 용이하게 하는 것을 목표로 합니다. 이를 통해 프로젝트의 복잡도를 관리하고, 팀 전체가 더 효율적으로 작업할 수 있는 환경을 제공합니다. 이번 글에서는 FSD의 기본 개념부터 제가 실제로 경험한 적용 사례까지 자세히 살펴보겠습니다.

FSD란?

FSD는 프론트엔드 애플리케이션을 기능(feature)을 중심으로 나누어 설계하는 방법론입니다. 이를 통해 관련된 코드들이 자연스럽게 한 곳에 모이게 되어 응집도가 높아지고, 다른 기능들과의 결합도를 낮출 수 있습니다.

FSD는 크게 아래와 같은 세 가지 원칙에 따라 구조를 나눕니다: Layers, Slices, Segments.

Layers

레이어는 어플리케이션의 가장 초기 디렉토리 진입점입니다. 각 레이어는 비지니스 요구를 갖고 책임을 갖고 있습니다. 모든 레이어가 필수적으로 필요하지 않지만 컨셉은 명확이 갖고 가야합니다.

  • App*: routing, entrypoints, global style, provider와 같은 앱을 실행할 때 필요한 모든것을 관리합니다.
  • Processes(더 이상 사용되지 않음): 복잡한 페이지 간 시나리오.
  • Pages: 전체 한 페이지 또는 중첩된 라우팅 부분의 페이지의 큰 부분을 나타냅니다.
  • Widgets: 기능이나 UI의 큰 덩어리로, 일반적으로 use case를 뜻합니다.
  • Features: 재사용 가능한 구현체로, 일반적으로 사용자에게 비지니스 가치를 전달해주는 행위를 말합니다.
  • Entities: user 또는 product 와 같이 핵심 비지니스 단위를 말합니다.
  • Shared*: 프로젝트나 비지니스와 분리될 수 있는재사용 가능한 기능들을 말합니다.

AppShared는 다른 레이어와 달리 slices를 갖지 않고 직접 segments를 갖습니다. 가장 중요한 점은, 한 레이어의 모듈은 반드시 아래 모듈만 import할 수 있습니다.

Slice

슬라이스는 비즈니스 도메인별로 코드를 분할하는 레이어로 논리적으로 관련된 모듈을 서로 가깝게 유지하여 코드베이스를 탐색하기 쉽게 만듭니다. 슬라이스는 동일 레이어에 있는 다른 슬라이스를 사용할 수 없으므로 높은 응집력과 낮은 결합도를 달성하는 데 도움이 됩니다.

슬라이스와 앱 및 공유 레이어는 세그먼트로 구성되며 세그먼트는 코드를 목적에 따라 그룹화합니다. 세그먼트 이름은 표준에 의해 제한되지 않지만 가장 일반적인 목적에 대한 몇 가지 기존 이름이 있습니다.

  • ui— UI 표시와 관련된 모든 것: UI 구성 요소, 날짜 포맷터, 스타일 등
  • api— 백엔드 상호작용: 요청 함수, 데이터 유형, 매퍼 등
  • model— 데이터 모델: 스키마, 인터페이스, 저장소 및 비즈니스 로직.
  • lib— 이 슬라이스의 다른 모듈에 필요한 라이브러리 코드입니다.
  • config— 구성 파일 및 기능 플래그.

슬라이스를 갖게 된다면,

  • 균일성: 구조가 표준화되어 있으므로 프로젝트가 더 균일해지고, 팀에서 새로운 멤버를 더 쉽게 적응할 수 있습니다.
  • 변경 및 리팩토링에 대한 안정성: 한 계층의 모듈은 같은 계층이나 그 위의 계층에 있는 다른 모듈을 사용할 수 없습니다. 이를 통해 앱의 나머지 부분에 예상치 못한 결과 없이 격리된 수정을 할 수 있습니다.
  • 논리의 제어된 재사용: 계층에 따라 코드를 매우 재사용 가능하게 만들거나 매우 지역적으로 만들 수 있습니다. 이렇게 하면 DRY 원칙을 따르는 것과 실용성 사이의 균형을 유지할 수 있습니다 .
  • 비즈니스 및 사용자 요구 사항에 대한 지향성
  • 앱은 비즈니스 도메인으로 분할되었으며, 명명 시 비즈니스 언어를 사용하도록 권장됩니다. 이를 통해 프로젝트의 다른 모든 관련 없는 부분을 완전히 이해하지 않고도 유용한 제품 작업을 수행할 수 있습니다.

Layer와 Slice가 뭔지 살펴 봤다면, 어떻게 정의할 수 있을지 자세하게 보겠습니다.

Layer에 대한 정의

Shared

이 레이어는 App 레이어와 마찬가지로 슬라이스를 포함하지 않습니다 . 슬라이스는 레이어를 비즈니스 도메인으로 나누기 위한 것이지만, 비즈니스 도메인은 Shared에 존재하지 않습니다. 즉, Shared의 모든 파일은 서로를 참조하고 가져올 수 있습니다.

이 레이어에서 일반적으로 찾을 수 있는 세그먼트는 다음과 같습니다.

  • 📁 api— API 클라이언트이며 잠재적으로 특정 백엔드 엔드포인트에 요청을 하는 기능도 있습니다.
  • 📁 ui— 애플리케이션의 UI 키트.
  • 이 계층의 구성 요소는 비즈니스 로직을 포함해서는 안 되지만 비즈니스 테마가 되어도 괜찮습니다. 예를 들어, 여기에 회사 로고와 페이지 레이아웃을 넣을 수 있습니다. UI 로직이 있는 구성 요소도 허용됩니다(예: 자동 완성 또는 검색 창).
  • 📁 lib— 내부 라이브러리 모음.
  • 이 폴더는 도우미나 유틸리티로 취급해서는 안 됩니다. 대신, 이 폴더의 모든 라이브러리는 날짜, 색상, 텍스트 조작 등과 같이 초점이 맞춰진 영역이 하나씩 있어야 합니다. 초점이 맞춰진 영역은 README 파일에 문서화되어야 합니다. 팀의 개발자는 이러한 라이브러리에 무엇을 추가할 수 있고 무엇을 추가할 수 없는지 알아야 합니다. (@lib/datetime)
  • 📁 config— 앱에 대한 환경 변수, 글로벌 기능 플래그 및 기타 글로벌 구성.
  • 📁 routes— 경로 일치를 위한 경로 상수 또는 패턴.
  • 📁 i18n— 번역을 위한 설정 코드, 글로벌 번역 문자열.

세그먼트를 더 추가할 수는 있지만, 이러한 세그먼트의 이름이 콘텐츠의 본질이 아닌 목적을 설명하는지 확인하십시오. 예를 들어, components, hooks, 는 types코드를 찾을 때 그다지 도움이 되지 않기 때문에 나쁜 세그먼트 이름입니다.

Entity

이 레이어의 슬라이스는 프로젝트가 작업하는 실제 세계의 개념을 나타냅니다. 일반적으로 이는 비즈니스에서 제품을 설명하는 데 사용하는 용어입니다. 예를 들어, 소셜 네트워크는 User, Post, Group과 같은 비즈니스 엔터티와 함께 작업할 수 있습니다.

  • model — 데이터 저장소, validation schema
  • api — entity 관련 API Request 함수들
  • ui — 비주얼적으로 표현할 인터페이스 (단, props와 slots를 통해 여러 비지니스 로직을 연결할 수 있도록 구성하되 복잡하지 않도록)

Feature

이 계층은 앱에서의 주요 상호작용, 즉 사용자가 하고 싶어하는 일을 위한 것입니다. 핵심은 모든게 feature가 되지 않아도 된다는점에 주목해주면 좋겠습니다. 재사용을 위해서 모든게 feature가 된다면 좋지만 너무 많은 feature가 있다면 중요한 feature가 묻힐 수 있습니다.

FSD 구조에서 Read와 CUD의 분리 전략

Feature-Sliced Design(FSD)은 기능 단위로 코드를 구조화해 유지보수성과 확장성을 높이는 설계 방식입니다. 이때 데이터 흐름을 읽기(Read)와 변경(CUD: Create, Update, Delete)로 나누게 되어 다음과 같은 이점이 있습니다:

  1. 관심사의 분리 (Separation of Concerns)
    • Read는 대부분 UI 중심이며 서버 상태 캐싱 및 최적화가 핵심입니다.
    • CUD는 사용자 입력, 인증, 에러 처리 등 사이드 이펙트가 많아 구조적으로 명확히 분리하는 것이 관리에 좋습니다.
  2. 기술 스택 분리 가능
    • 예: Read는 react-query 중심, CUD는 redux-toolkit 또는 zustand와 같은 상태 관리로 분리 가능.
  3. 테스트 용이성 증가
    • Read 로직은 mock API로 쉽게 테스트 가능하고,
    • CUD 로직은 유저 인터랙션 중심의 시나리오 테스트에 집중할 수 있습니다.
  4. 비동기 흐름 명확화
    • CUD는 대부분 async mutation을 포함하므로, 로딩/성공/실패 처리를 명확히 할 수 있음.

Widgets

위젯 계층은 대규모 자립형 UI 블록을 위한 것입니다. 위젯은 여러 페이지에서 재사용되거나 위젯이 속한 페이지에 여러 개의 대규모 독립 블록이 있는 경우 가장 유용하며, 이것이 그 중 하나입니다. UI 블록이 페이지에서 콘텐츠의 대부분을 차지하고, 결코 재사용되지 않는 경우, 해당 블록은 위젯이 아니어야 하며 , 대신 해당 페이지 바로 안에 배치해야 합니다.

중첩된 라우팅 시스템(Remix 라우터와 같은)을 사용하는 경우에는 일반적인 플랫 라우팅 시스템의 페이지 레이어를 대신해서 위젯 레이어로서 data fetching, 로딩 상태, 오류 경계와 연관된 전체 라우터 블록을 위젯 레이어로 사용해도 좋습니다. 마찬가지로 페이지 레이아웃 또한 위젯 레이어에 사용해도 좋습니다.

Pages

한 페이지는 일반적으로 한 슬라이스에 해당하지만, 매우 유사한 페이지가 여러 개 있는 경우 등록 및 로그인 양식과 같이 하나의 슬라이스로 그룹화할 수 있습니다. 일반적으로 페이지 슬라이스에서 model이 있는건 일반적이지 않으며 로딩이나 에러 바운더리를 포함한 UI는 ui 로, data fetching과 mutation은 api 에 저장하면 됩니다.

Process - feature와 app으로 대체하면 됩니다. 더 이상 사용하지 않습니다

App

기술적 관점(예: 컨텍스트 제공자)과 비즈니스 관점(예: 분석) 모두에서 앱 전체에 관련된 모든 문제입니다.

이 레이어는 일반적으로 슬라이스를 포함하지 않으며, 공유 레이어와 달리 세그먼트를 직접 포함합니다. 이 레이어에서 일반적으로 찾을 수 있는 세그먼트는 다음과 같습니다.

  • 📁 routes— 라우터 구성
  • 📁 store— 글로벌 스토어 구성
  • 📁 styles— 글로벌 스타일
  • 📁 entrypoint— 프레임워크별 애플리케이션 코드 진입점

 

Slice에 대한 정의

슬라이스의 주요 목적은 제품, 비즈니스 또는 애플리케이션에 대한 의미에 따라 코드를 그룹화하는 것입니다. Shared는 비즈니스 로직을 전혀 포함하지 않아야 하므로 제품에 의미가 없고, App은 전체 애플리케이션과 관련된 코드만 포함해야 하므로 분할이 필요하지 않습니다.

낮은 결합도와 높은 응집도

슬라이스는 독립적이고 매우 응집력 있는 코드 파일 그룹이 되도록 의도되었습니다. 이상적인 슬라이스는 해당 레이어의 다른 슬라이스와 독립적(결합 없음)이며 주요 목표(높은 응집력)와 관련된 대부분의 코드를 포함합니다.

슬라이스 간의 종속성은 레이어에 대한 가져오기 규칙 에 의해 규제됩니다 .

*슬라이스의 모듈(파일)은 아래 레이어에 위치한 경우에만 다른 슬라이스를 가져올 수 있습니다.*

예를 들어, ~/features/aaa 라는 슬라이스가 있습니다. 만약 이 파일안에 ~/feature/aaa/api/request.ts가 있다면 ~/features/bbb 에서는 import 할 수 없습니다. 대신shared 또는 entities 에 있는 항목들은 import할 수 있고, features/aaa/lib/cache.ts 와 같은 sibling code는 import 할 수 있습니다.

대신 App과 Shared 레이어는 논외입니다.

 

퍼블릭 API 정의

각 슬라이스와 세그먼트에는 퍼블릭 API가 있습니다. 퍼블릭 API는 index.js 또는 index.ts 파일로 표현되며, 이를 통해 슬라이스나 세그먼트에서 필요한 기능만 외부로 추출하고 불필요한 기능은 격리할 수 있습니다. 인덱스 파일은 진입점 역할을 합니다.

공개 API 규칙:

  • 애플리케이션 슬라이스와 세그먼트는 공개 API 인덱스 파일에 정의된 슬라이스의 기능과 구성 요소만 사용합니다.
  • 공개 API에 정의되지 않은 슬라이스나 세그먼트의 내부 부분은 격리된 것으로 간주되며 슬라이스나 세그먼트 자체 내에서만 액세스가 가능합니다.

공개 API는 가져오기 및 내보내기 작업을 간소화하므로 애플리케이션을 변경할 때 코드의 모든 곳에서 가져오기를 변경할 필요가 없습니다.

좋은 공개 API는 다른 코드를 사용하고 통합하는 것을 편리하고 신뢰할 수 있는 슬라이스로 만듭니다. 이는 다음 세 가지 목표를 설정하여 달성할 수 있습니다.

  1. 나머지 애플리케이션은 리팩토링과 같은 슬라이스의 구조적 변경으로부터 보호되어야 합니다.
  2. 슬라이스 동작의 이전 기대치를 깨는 상당한 변경 사항은 공개 API의 변경을 야기해야 합니다.
  3. 슬라이스의 필요한 부분만 노출되어야 합니다.

💡Cross Import

일반적으로 이는 레이어의 가져오기 규칙 에 의해 금지되지만 , 종종 cross import에 대한 합법적인 이유가 있습니다. 이러한 목적을 위해 @x-notation이라고도 알려진 특별한 종류의 공개 API가 있습니다 . 엔티티 A와 B가 있고 엔티티 B가 엔티티 A에서 가져와야 하는 경우 엔티티 A는 엔티티 B에 대한 별도의 공개 API를 선언할 수 있습니다.

import type { EntityA } from "entities/A/@x/B";

내부 코드만을 위한 특별한 공개 API를 entities/A/@x에 만들어야합니다. cross import를 최소한으로 유지하고 이 표기법은 엔터티 계층에서만 사용하세요 . 이 경우 교차 수입을 제거하는 것이 비합리적인 경우가 많습니다.

💡 인덱스 파일(Barrel 파일)을 사용하면 생기는 문제

  1. 순환 참조

세 개의 파일 , fileA.js, fileB.js, 이 fileC.js서로를 원형으로 가져오고 있습니다. 이러한 상황은 번들러가 처리하기 어려운 경우가 많으며, 어떤 경우에는 디버깅하기 어려운 런타임 오류가 발생할 수도 있습니다. 순환 임포트는 인덱스 파일 없이도 발생할 수 있지만, 인덱스 파일이 있으면 실수로 순환 임포트를 생성할 수 있는 명확한 기회가 있습니다.

// HomePage.jsx
import { loadUserStatistics } from "../"; // importing from pages/home/index.js
// index.js
export { HomePage } from "./ui/HomePage";
export { loadUserStatistics } from "./api/loadUserStatistics";

위와 같은 경우 index.js는 HomePage.jsx를 import하지만 HomePage.js는 index.js를 import하기 때문에 순환참조가 발생합니다.

이 문제를 방지하려면 다음 두 가지 원칙을 고려하세요. 두 개의 파일이 있고, 한 파일이 다른 파일에서 가져오는 경우:

  • 동일한 슬라이스에 있다면, 반드시 전체경로를 갖는 상대경로를 사용하여 import합니다. (index.js에서 가져오기 금지)
  • 서로 다른 슬라이스에 있는 경우 항상 alias와 같은 절대 경로를 사용하여 import 합니다*.* </aside>

거대 재사용 UI 블록 만들기

우리는 이미 코드 재사용을 용이하게 하기 위해 Shared를 가지고 있지만, Shared에 큰 UI 블록을 넣는 데는 단서가 있습니다. Shared 계층은 위의 계층에 대해 알 수 없습니다. Shared와 Pages 사이에는 Entities, Features, Widgets라는 세 가지 다른 레이어가 있습니다. 일부 프로젝트는 재사용 가능한 큰 블록에서 필요한 것을 이러한 레이어에 포함할 수 있으며, 이는 재사용 가능한 블록을 Shared에 넣을 수 없다는 것을 의미하며, 그렇지 않으면 상위 레이어에서 가져오는 것이 되는데, 이는 금지되어 있습니다. 여기서 Widgets 레이어가 등장합니다. 이는 Shared, Entities, Features 위에 위치하므로 모두 사용할 수 있습니다.

FAQ

  • 페이지의 레이아웃/템플릿을 어디에 저장하나요

일반 마크업 레이아웃이 필요한 경우shared/ui에 보관할 수 있습니다. 내부에서 더 높은 레이어를 사용해야 하는 경우 몇 가지 옵션이 있습니다.

  • 아마도 레이아웃이 전혀 필요하지 않을 수도 있습니다. 레이아웃이 몇 줄에 불과하다면, 추상화하려고 하기보다는 각 페이지에 코드를 복제하는 것이 합리적일 수 있습니다.
  • 레이아웃이 필요한 경우 별도의 위젯이나 페이지로 구성하고 앱의 라우터 구성에서 구성할 수 있습니다. 중첩 라우팅은 또 다른 옵션입니다.

내가 정의한 FSD Guidelines

slice/
  ├── api/     // axios, graphql, fetch
  ├── config/  // const, configurations
  ├── model/   // 데이터 저장소, validation schema, 비지니스 로직, 스키마, 인터페이스
  ├── lib/     // 내부 라이브러리(이 폴더는 도우미나 유틸리티로 취급해서는 안 됩니다. 대신, 이 폴더의 모든 라이브러리는 날짜, 색상, 텍스트 조작 등과 같이 초점이 맞춰진 영역이 하나씩 있어야 합니다.)
  └── ui/      // 비주얼적으로 표현할 인터페이스

Entity Layer (재사용성이 많은 도메인에 특화된 레이어)

규칙:

  1. 순수함수로 작성
  2. entities/${domain} 으로 폴더 만들기
  3. 도메인이 포함된 Layer중 가장 하위로 전파되지 않도록 외부 의존성 모듈 금지(GraphQL, Axios 포함)
  4. Model에 정의된 인터페이스에 맞게 데이터가 변환되어 사용할 수 있도록 하기
  5. 가장 많은 재사용을 목적으로 다른 모듈과 결합도를 최대한 없애기
  6. Feature나 Widget에서 결합하여 사용할 수 있도록 작성하기
  7. props와 slots를 통해 여러 비지니스 로직을 연결할 수 있도록 구성하되 복잡하지 않도록 구성하기
  8. 데이터는 기본적으로 props로 받기

Features Layer (모든 이벤트가 일어나는 행위를 포함하는 레이어)

명령과 조회 책임 분리 원칙(CQRS)은 소프트웨어 설계에서 메서드나 함수는 시스템 상태를 수정하는 명령이거나 시스템 상태에 대한 정보를 조회하여 반환하는 쿼리 둘 중에 하나여야 하며, 2가지가 동시에 수행되지 않아야 한다는 원칙입니다. 명령 메서드는 액션 또는 객체 상태 변경을 수행하며 값을 반환하지 않습니다.

반면 조회 메서드는 객체의 상태 변경 없이 읽습니다. 명령과 조회를 분리하면 컴포넌트 사이에 결합을 분리하여 테스트와 유지보수 및 코드 변경을 쉽게 만듭니다. 또한 동작에 대한 추론이 쉬워져서 전반적인 시스템 설계를 개선할 수 있습니다.

규칙:

  1. 하나의 Feature는 하나의 기능만 포함하기
  2. features/addToCart 처럼 동사로 만들기
  3. 비지니스 로직은 훅으로 관리하돼 하나의 기능만 포함하는 훅으로 구성하기
  4. 모든 UI를 포함한 폼을 Feature로 구성하기

Widgets Layer (entities와 features를 결합하여 페이지에 사용되는 레이어)

규칙:

  • 중첩된 라우팅 시스템(Remix 라우터와 같은)을 사용하는 경우에는 일반적인 플랫 라우팅 시스템의 페이지 레이어를 대신해서 위젯 레이어로서 data fetching, 로딩 상태, 오류 경계와 연관된 전체 라우터 블록을 위젯 레이어로 사용하기
  • 페이지 레이아웃
  • entities와 features를 결합하여 페이지에 사용되는 독립적인 UI 컴포넌트
  • 중복되지 않다면 반드시 Page 레이어로 이동하기
  • entity에 데이터 넘겨주기 위해 GraphQL이나 axios fetching을 호출하여 prop으로 전달하기

Public API (Barrel Files)

TL;DR; shared, entities가 많은 파일들을 노출하고 있어서 성능저하가 있었습니다. 장점도 있지만 심각한 퍼포먼스를 야기하는 경우가 있었어서 결론은 사용하지 않기로 결정했습니다.

  1. 사용하기 편해짐
    • 외부에서 import할 때 경로를 짧고 깔끔하게 쓸 수 있다. 예) import { Button } from '@/components' 처럼
  2. 캡슐화
    • 어떤 내부 파일을 외부에 노출할지 선택할 수 있다.
    • 폴더 내부 구조가 바뀌어도 public API만 지키면 외부 코드를 고칠 필요가 없다.
  3. 유지보수 편리
    • 코드 리팩터링할 때 내부 파일 이름이나 구조를 바꿔도, index.js만 수정하면 됨.
  4. 의도 명확화
    • 이 폴더를 사용하는 사람이 "무엇을 쓸 수 있는지"를 명확하게 알 수 있다.
    • (ex: Button 컴포넌트만 제공하고, 내부의 Button.styles.js 같은 건 숨길 수 있음.)

단점

  1. 중복 관리 가능성
    • index.js 파일을 일일이 관리해야 해서 추가/삭제할 때 실수할 수도 있다.
  2. 자동 완성 문제
    • IDE나 에디터에서 디테일한 자동 완성 도움을 덜 받을 수 있다.
    • (특히 잘못 export하면 타입 추론이 떨어질 수도 있음.)
  3. Tree-shaking이 불완전해질 수 있음
    • 여러 걸 모아 export할 때, 번들러가 사용하지 않는 코드를 제대로 제거 못할 수도 있다.
    • (요즘은 Rollup이나 Webpack이 꽤 잘 해주긴 하지만, 케이스에 따라 문제가 생길 수 있음.)
  4. 추가 추상화 레이어
    • 프로젝트가 엄청 작으면 굳이 index로 관리하는 게 오히려 복잡하게 느껴질 수 있음.

Next.js

App Router를 사용하더라도 절대로 pages 폴더를 삭제하면 빌드 에러가 발생할 수 있습니다.

참조

Overview | Feature-Sliced Design

FSD Linter

feature-sliced/cli

vscode-generator