Feature-Sliced Design(FSD) 도입기 - 1
이전 글에서 Container 중심 구조의 한계와 Feature-Sliced Design(FSD)으로의 전환 이유를 정리했습니다. 이번 글에서는 실제로 FSD를 프로젝트에 도입하면서 고민했던 내용들을 공유합니다.
1. 도입 배경
FSD는 프론트엔드 프로젝트를 레이어(layer)와 슬라이스(slice) 단위로 구조화하는 아키텍처 방법론입니다. 사내 개발자들에게 새 프로젝트에서 FSD 사용을 제안했고, 현재 도입하여 코드를 작성하고 있습니다.
이번 프로젝트에서 FSD를 사용하기로 한 가장 큰 이유는 다음과 같습니다.
- 프로젝트 페이지 수가 450페이지 이상으로 방대한 서비스이며, 기존 아키텍처처럼 페이지 단위 관리가 어려움
- 개발 담당자와 유지보수 담당자가 다르고, 유지보수 담당자는 코드 히스토리를 충분히 알지 못한 상태에서 유지보수에 참여해야 함
- 페이지 개수는 많지만 도메인 단위로 묶으면 10개 내외의 도메인 + 하위 기능으로 정의할 수 있음
도메인 + 하위 기능 단위로 아키텍처를 구성하는 것이 핵심이었고, 그 결과 폴더 구조만으로 유지보수가 가능하도록 만드는 것이 주요 목적이었습니다.
2. ESLint 룰 설정하기
FSD는 레이어 간 의존성 방향, Public API의 존재처럼 import 체계를 꽤 엄격하게 관리하는 아키텍처입니다. 이 부분이 지켜지지 않으면 코드리뷰에서 불필요한 논의가 많아지고, 아키텍처가 정상적으로 관리되지 않을 가능성이 큽니다. 그래서 import 관계를 관리하기 위한 ESLint 설정이 필요하다고 판단했습니다.
eslint-plugin-boundaries 선택 이유
처음에는 FSD 전용 ESLint 플러그인을 도입할까 했습니다. 하지만 FSD는 단일화된 아키텍처가 아니라 지침에 가깝고, 그 지침의 variant가 다양합니다. 뒤에서 다룰 도메인 그루핑이나 widget 우선 전략처럼 프로젝트 상황에 맞게 변형이 필요한데, FSD 전용 플러그인은 이런 변형에 유연하게 대응하기 어렵다고 느꼈습니다.
eslint-plugin-boundaries는 레이어 정의, 의존성 방향, 진입점 규칙을 모두 직접 설정할 수 있어서 저희 프로젝트 특성에 맞게 커스터마이징하기 좋았습니다.
강제하는 규칙 3가지
eslint-plugin-boundaries를 통해 강제하고 있는 규칙은 크게 세 가지입니다.
1. 레이어 간 의존성 방향 (boundaries/element-types)
FSD의 핵심 규칙입니다. 상위 레이어는 하위 레이어만 import할 수 있고, 그 반대는 불가능합니다.
app → _app → pages → widgets → features → entities → shared
예를 들어 entities에서 widgets를 import하거나, features에서 pages를 import하면 ESLint 에러가 발생합니다. 명시적으로 허용하지 않은 모든 import를 차단하는 화이트리스트 방식으로 설정했기 때문에, 새 레이어를 추가해도 기본적으로 차단됩니다.
2. Cross-slice import 금지 (boundaries/element-types)
같은 레이어 내에서 다른 슬라이스를 직접 import하는 것을 금지합니다. features/auth에서 features/inventory-list를 직접 import할 수 없습니다. feature 간 조합이 필요하다면 상위 레이어인 widgets에서 해야 합니다. 이 규칙 덕분에 각 슬라이스의 독립성이 보장됩니다.
3. Public API 강제 (boundaries/entry-point)
슬라이스 외부에서는 반드시 index.ts를 통해서만 접근해야 합니다. 내부 구현 파일을 직접 import하면 캡슐화가 깨지기 때문입니다.
import { UserCard } from "@/entities/user"는 허용되지만, import { UserCardView } from "@/entities/user/ui/UserCardView"는 에러가 발생합니다.
Next.js App Router와의 공존
Next.js App Router는 src/app/ 디렉토리를 라우팅에 사용합니다. FSD의 app 레이어와 이름이 충돌하므로, FSD의 app 레이어를 src/_app/으로 분리했습니다.
src/
├── app/ ← Next.js App Router (라우팅 전용)
├── _app/ ← FSD app 레이어 (providers, styles)
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/
ESLint 설정에서 이 둘을 별도 타입으로 등록하여, app(라우터)은 모든 FSD 레이어를 import할 수 있고, _app(FSD app)은 pages 이하만 import할 수 있도록 했습니다. 이렇게 하면 Next.js의 라우팅 구조와 FSD의 아키텍처 규칙이 충돌 없이 공존합니다.
3. Slice 단위 정하기
FSD의 각 Layer는 Slice로 분리됩니다. FSD 공식 문서에서는 슬라이스를 다음과 같이 정의하고 있습니다.
Slices are the conceptual boundaries for distinct features, ensuring each set of functionalities or modules stands on its own
슬라이스는 개별 기능을 명확히 구분하는 개념적 경계로서, 각 기능 또는 모듈이 서로 독립적으로 동작하도록 한다.
즉, 기능 단위로 분리된 폴더입니다. 하지만 여기서 기능은 일반적으로 페이지 개수보다 훨씬 많습니다.
저희 프로젝트를 예로 들면, rec(신재생에너지 인증서, REC) 도메인에만 목록 조회, 상세 보기, 등록, 필터링, 엑셀 다운로드 등의 기능이 있고, user(사용자) 도메인에도 프로필, 권한 관리, 설정 등의 기능이 있습니다. 450개 이상의 페이지를 기능 단위로 슬라이스를 나누면 수백 개의 폴더가 평탄하게 나열되는 구조가 됩니다.
src/widgets/
rec-list/
rec-detail/
rec-create/
rec-filter/
rec-export/
user-profile/
user-permission/
user-settings/
... (수백 개)
유지보수 담당자가 이 구조에서 원하는 기능을 찾으려면 모든 폴더를 훑어야 합니다. 이는 폴더 구조만으로 유지보수가 원활하도록이라는 목적에 부합하지 않았습니다.
도메인 그루핑
고민 끝에 FSD에서 지원하는 Grouping slices 패턴을 활용하여 도메인 단위 그룹 폴더를 추가하기로 결정했습니다.
src/widgets/
@rec/ ← 도메인 그룹 (슬라이스가 아닌 그룹 폴더)
rec-list/ ← 슬라이스
ui/
model/
index.ts
rec-detail/
rec-create/
@user/
user-profile/
user-permission/
user-settings/
@ 접두사는 그룹 폴더와 실제 슬라이스를 시각적으로 구분하기 위한 컨벤션입니다. 그룹 폴더 자체는 슬라이스가 아니므로 index.ts 같은 Public API를 갖지 않습니다.
이 구조를 적용하면 ESLint 설정의 capture 패턴도 2단계로 조정이 필요합니다.
// 기존: src/widgets/* → 1단계 매칭
// 변경: src/widgets/@*/* → 도메인 그룹 하위의 슬라이스 매칭
{
type: "widgets",
pattern: "src/widgets/@*/*",
mode: "folder",
capture: ["domain", "slice"],
}
이렇게 하면 유지보수 시점에 REC 관련 목록 기능을 수정해야 한다라고 했을 때, widgets → @rec → rec-list 순서로 자연스럽게 탐색할 수 있습니다. 도메인의 기능을 찾는 사고 흐름이 폴더 구조에 그대로 반영되는 것입니다.
4. Widget, Feature, Entity 단위 정하기
가장 어려운 주제였습니다. feature와 entity를 처음에는 단순히 동사적 개념(~하다) → feature, 명사적 개념(~데이터) → entity 정도로 설정하고 설계를 시작했습니다. 하지만 실제 코드를 시뮬레이션해보니 경계가 모호한 경우가 많았습니다.
시뮬레이션: REC 목록 화면
REC 목록 화면을 설계한다고 가정해봅니다. 이 화면에는 REC 테이블, 필터, 페이지네이션이 있습니다. 처음에는 이렇게 분리하려 했습니다.
entities/rec: REC 데이터 모델, 타입 정의features/rec-filter: 필터 적용 기능features/rec-list: REC 목록 조회 기능widgets/rec-table: 위 요소를 조합한 테이블 UI
깔끔해 보이지만, 실제로는 필터 상태가 목록 조회 API의 파라미터와 강하게 결합되어 있어서 features/rec-filter와 features/rec-list를 독립적으로 분리하기 어려웠습니다. 억지로 분리하면 두 feature 사이에 공유 상태를 위한 우회 로직이 필요해졌습니다.
또, rec-list는 언뜻 보면 명사적 개념인지 동사적 개념인지 모호해서 entities에 둬야 하는 게 아닌가? 하는 논의가 발생했습니다. 이런 식으로 분리 고민에 시간을 많이 쏟게 되는 상황이 반복되었습니다.
Widget 우선 전략
그래서 결정한 것은 무조건 큰 단위부터 만들자입니다.
독립적인 widget 단위에 대해서는 팀 내에서도 이견이 없었습니다. 화면에 보이는 큰 기능 단위가 보통 widget이 되는 경우가 많았습니다. 위 REC 목록 예시라면 필터, 테이블, 페이지네이션을 모두 하나의 widgets/rec-table로 만드는 것입니다. 이는 widget만으로도 모든 페이지를 개발할 수 있다는 의미이기도 했습니다.
중복이 발생할 때 분리한다
그러면 feature와 entity는 언제 사용할까요? 중복이 발생할 때 분리하기로 결정했습니다.
- 동사적 개념의 중복 →
feature로 분리: 예를 들어 엑셀 다운로드 기능이 REC 목록, 사용자 목록, 통계 화면 등 여러 widget에서 필요해졌다면features/excel-download로 추출합니다. - 명사적 개념의 중복 →
entity로 분리: 예를 들어 사용자 프로필 카드 UI와 사용자 데이터 모델이 여러 widget에서 동일하게 사용된다면entities/user로 추출합니다.
중복이 발생했다는 것은 독립적인 재사용 단위가 자연스럽게 드러났다는 뜻입니다. 복잡한 비즈니스 로직을 여러 컴포넌트에서 동일하게 중복하기는 쉽지 않으니까요. 재사용 단위가 아직 드러나지 않았는데 feature와 entity를 미리 분리해두는 것은 설계에 시간을 잡아먹히는 길이라고 보았습니다.
5. 마무리
이렇게 몇 가지 논의를 거쳐 합의점을 찾고 FSD를 실제 프로젝트에 적용할 수 있게 되었습니다. 정리하면 핵심 결정은 세 가지입니다.
eslint-plugin-boundaries로 레이어 간 의존성과 Public API를 자동 검증한다- 슬라이스 앞에 도메인 그룹 폴더를 두어 450개 이상 페이지의 탐색성을 확보한다
- Widget을 기본 단위로 시작하고, 중복이 발생할 때 feature/entity로 분리한다
아직 개발 초기 단계라 이 구조가 유지보수 시점에 어떤 효과를 발휘할지는 지켜봐야 합니다. 후속 글에서는 실제 코드를 작성하면서 만난 문제와 해결 과정을 공유하겠습니다.