PUBLISHED
Feature-Sliced Design
작성일: 2024.12.22

프론트엔드 애플리케이션이 커질수록 문제는 코드의 복잡성이 아니라 구조의 일관성에서 발생한다. 컴포넌트와 상태, 비즈니스 로직이 뒤섞이기 시작하면, 기능 추가는 점점 느려지고 변경에 대한 두려움은 커진다. FSD(Feature-Sliced Design)는 이러한 문제를 “어떻게 구현할 것인가”가 아니라, “어디에 둘 것인가”의 문제로 재정의한다. 구조를 먼저 설계함으로써, 코드가 커지는 속도를 통제하려는 시도다.
FSD는 레이어와 슬라이스를 통해 책임과 의존 방향을 명확히 나눈다. 이 접근 방식은 단기적인 개발 속도를 약간 희생하는 대신, 장기적인 유지보수성과 확장성을 선택한다. 다만 그만큼 규칙은 명확하고 엄격하다. FSD는 자유로운 패턴 모음이 아니라, 팀이 합의한 구조적 계약에 가깝다. 이 글에서는 FSD가 무엇을 해결하려는 아키텍처인지, 그리고 어떤 맥락에서 의미를 가지는지부터 차근히 살펴보려 한다.

Layers
FSD는 애플리케이션을 수평으로 나누지 않는다. 대신 의존 방향 기준으로 수직 레이어를 쌓는다. 각 레이어는 명확한 역할을 가지며, 하위 레이어는 상위 레이어를 알지 못한다. 이 단방향성 덕분에 구조가 커져도 결합도가 통제되고, 변경의 영향 범위가 자연스럽게 제한된다. 레이어는 단순한 폴더 구분이 아니라, 코드가 어디까지 책임져야 하는지를 정하는 경계선이다.
이 지점에서 중요한 것은 레이어의 이름이 아니다. 예를 들어 Next.js에서는 라우터에 따라 pages나 app이라는 이름의 폴더가 자연스럽게 등장한다. 하지만 이 이름들이 곧 FSD의 레이어 의미를 대체하지는 않는다. 프레임워크가 요구하는 폴더 구조와, 아키텍처가 의도하는 책임 구분은 다른 문제다. 이름이 같다고 해서 같은 역할을 가지는 것도 아니고, 이름이 다르다고 해서 다른 역할을 가져야 하는 것도 아니다.
FSD에서 레이어의 핵심은 고유한 책임을 가진다는 점이다. 어떤 레이어는 애플리케이션을 초기화하고, 어떤 레이어는 화면을 구성하며, 어떤 레이어는 도메인 규칙을 소유한다. 이 책임이 흐려지기 시작하면, 레이어는 단순한 디렉터리 분류로 전락한다. FSD는 “이 코드를 어디에 둘 것인가”를 통해 “이 코드는 무엇에 책임을 지는가”를 명확히 하려는 아키텍처다. 레이어를 이해할 때 가장 먼저 고민해야 할 지점은 구조의 형태가 아니라, 이 책임의 경계다.
shared
shared는 특정 도메인에도, 특정 기능에도 속하지 않는 공통 자산의 영역이다. UI 컴포넌트, 유틸 함수, 디자인 토큰, API 클라이언트처럼 “어디서나 쓰이지만 아무도 소유하지 않는 것들”이 여기에 위치한다.
이 레이어의 경계는 모호함이다. 그래서 가장 관리가 어렵고, 가장 쉽게 비대해진다. shared에 무엇을 둘 것인지는 항상 보수적으로 판단해야 한다. 지금은 공통처럼 보여도, 사실은 특정 도메인에만 필요한 코드인 경우가 대부분이다.
entities
entities는 비즈니스의 중심이다. 도메인 모델, 핵심 상태, 도메인 규칙이 이 레이어에 위치한다. 사용자, 상품, 주문처럼 애플리케이션이 다루는 “개념 그 자체”가 여기에 속한다.
이 레이어의 가장 중요한 경계는 안정성이다. entities는 위 레이어가 어떻게 바뀌든, 최대한 영향을 받지 않아야 한다. UI가 바뀌고, 페이지 구조가 바뀌어도, 도메인 규칙은 그대로 남아야 한다. 이 안정성이 확보되지 않으면, 전체 구조는 빠르게 흔들린다.
features
features는 FSD에서 가장 오해가 많은 레이어다. 이 레이어의 책임은 사용자 행동 하나다. 로그인, 좋아요, 결제, 검색처럼 명확한 액션 단위의 기능이 여기에 속한다.
중요한 경계는 “기능은 도메인을 소유하지 않는다”는 점이다. features는 entity를 사용해 행동을 구현하지만, entity의 규칙을 바꾸지는 않는다. 만약 하나의 feature가 다른 feature를 직접 참조하기 시작한다면, 그건 기능 간 결합이 아니라 책임 침범이다.
widgets
widgets는 여러 feature와 entity를 묶어 의미 있는 화면 블록을 만든다. 헤더, 사이드바, 상품 상세 영역처럼 단독으로도 의미가 있지만, 페이지의 일부로 사용되는 단위가 여기에 해당한다.
widgets의 핵심 경계는 “재사용 가능한 조합”이다. 이 레이어는 레이아웃과 UI 흐름을 책임지지만, 비즈니스 규칙의 소유권은 가지지 않는다. 로직은 아래에서 끌어오고, 자신은 조합에만 집중한다. widgets가 복잡해질수록, 오히려 로직은 줄어드는 것이 정상이다.
pages
pages 레이어는 사용자 관점의 진입점이다. URL 하나에 대응되는 화면 단위이며, 여러 위젯과 기능을 조합해 하나의 페이지를 만든다. 이 레이어의 책임은 흐름과 배치다.
여기서 중요한 경계는 “소유하지 않는다”는 점이다. pages는 상태와 로직을 사용할 뿐, 그것을 정의하지 않는다. 로그인 페이지가 로그인 로직을 직접 구현하기 시작하면, 책임은 곧바로 흐려진다. pages는 항상 얇게 유지되어야 하며, 아래 레이어의 기능을 조합하는 역할에 머물러야 한다.
app
app 레이어의 책임은 단 하나다. 애플리케이션을 시작시키는 것.
전역 스타일, 전역 상태 프로바이더, 라우터 초기화, 환경 설정처럼 “한 번만 존재해야 하는 구성 요소들”이 이 레이어에 위치한다. 이 레이어는 애플리케이션 전체를 알고 있지만, 그 내부에서 어떤 비즈니스가 돌아가는지는 알 필요가 없다.
중요한 경계는 여기다. app은 절대 도메인 규칙을 소유하지 않는다. 상태 계산, 조건 분기, 유스케이스가 들어오기 시작하면 이 레이어는 즉시 비대해진다. app은 조립자이지, 실행자가 아니다.
Slices
레이어가 “어디까지 책임지는가”에 대한 수직적 경계라면, 슬라이스는 그 책임을 수평으로 분해하는 방식이다. FSD에서 레이어는 몇 개 되지 않지만, 슬라이스는 애플리케이션이 커질수록 계속 늘어난다. 이 둘의 역할을 혼동하면 구조는 금방 흐트러진다.
중요한 점은 슬라이스가 단순한 기능 묶음이 아니라는 것이다. 슬라이스는 하나의 의미 있는 개념 또는 유스케이스를 온전히 소유하는 단위다. 예를 들어 entities/user, features/auth, widgets/header 같은 구조에서, 슬라이스는 해당 개념에 필요한 모든 파일을 내부에 포함한다. 이 내부 구현은 외부에서 직접 접근되지 않고, 오직 정해진 진입점만을 통해 사용된다.
슬라이스의 핵심 가치는 독립성이다. 하나의 슬라이스는 다른 슬라이스의 내부를 알지 못하며, 알 필요도 없다. 이 원칙이 지켜질 때, 슬라이스는 자연스럽게 교체 가능해지고 테스트 가능해진다. 반대로 슬라이스 간 경계가 흐려지면, 구조는 레이어보다 먼저 무너지기 시작한다.
슬라이스를 나눌 때 가장 흔히 하는 실수는 “파일 종류” 기준으로 나누는 것이다. 컴포넌트 슬라이스, 훅 슬라이스, API 슬라이스처럼 나누기 시작하면, 슬라이스는 책임 단위가 아니라 기술 단위가 된다. FSD에서 슬라이스는 항상 의미와 책임을 기준으로 나뉜다.
좋은 질문은 이것이다.
“이 코드는 무엇을 위해 존재하는가?”
이 질문에 대한 답이 명확하다면, 그 자체로 하나의 슬라이스가 될 수 있다. 반대로 답이 모호하다면, 아직 슬라이스로 분리할 준비가 되지 않은 코드일 가능성이 크다.
슬라이스와 레이어의 관계
슬라이스는 항상 특정 레이어에 속한다. features/auth는 features 레이어의 슬라이스이고, entities/user는 entities 레이어의 슬라이스다. 같은 이름의 슬라이스가 여러 레이어에 존재할 수는 있지만, 그 책임은 레이어에 따라 완전히 달라진다.
이 점이 중요한 이유는, 슬라이스 간 의존 관계 역시 레이어 규칙을 그대로 따른다는 것이다. 슬라이스는 같은 레이어 안에서도 무분별하게 서로를 참조하지 않는다. 필요하다면 책임을 아래 레이어로 내리거나, shared로 추상화해야 한다.
public API
슬라이스는 내부 구현을 숨기고, 외부와의 접점을 명확히 가져야 한다. 이 접점이 바로 public API다. 외부에서는 슬라이스 내부 파일을 직접 import하지 않고, 오직 이 진입점을 통해서만 접근한다. 이 규칙은 귀찮아 보이지만, 슬라이스가 독립적인 단위로 유지되기 위한 최소 조건이다. public API가 없는 슬라이스는 사실상 내부 구현이 외부로 노출된 상태와 다르지 않다.
슬라이스가 무너지는 전형적인 패턴
슬라이스는 FSD에서 구조의 균형을 잡아주는 핵심 단위다. 레이어가 아무리 잘 나뉘어 있어도, 슬라이스의 경계가 흐려지면 구조는 빠르게 무너진다. 문제는 이 붕괴가 한 번에 일어나지 않는다는 점이다. 대부분은 “조금 편해서” 선택한 타협이 쌓인 결과다. 그리고 그 타협은 거의 항상 슬라이스 경계에서 시작된다.
가장 흔한 패턴은 슬라이스 간 직접 참조가 늘어나는 경우다. 처음에는 단순한 재사용처럼 보인다. 이미 비슷한 기능이 있으니 가져다 쓰는 것이 합리적으로 느껴진다.
import { useAuth } from '@/features/auth'이 코드는 겉보기에는 문제 없어 보인다. 하지만 이 순간부터 profile 슬라이스는 auth 슬라이스의 존재와 역할을 직접 인지하게 된다. 슬라이스는 더 이상 독립적인 단위가 아니라, 서로를 알고 있는 기능 묶음이 된다. 이 참조가 반복되면 특정 슬라이스를 수정할 때 영향을 받는 범위는 급격히 커진다.
이 패턴의 본질은 “재사용”이 아니라 책임 침범이다. 두 슬라이스 모두가 필요로 하는 로직이라면, 그 책임은 features가 아니라 entities나 shared로 내려가야 한다. features 간 의존은 구조적으로 가장 먼저 경계해야 할 신호다.
또 다른 전형적인 패턴은 public API를 우회하는 import다. 슬라이스 내부 파일을 직접 import하기 시작하면, 슬라이스는 더 이상 경계를 가지지 않는다.
import { validateUser } from '@/features/auth/model/validator'이 코드는 LoginPage가 auth 슬라이스의 내부 구조를 정확히 알고 있다는 뜻이다.
features/
└─ auth/
├─ index.ts
└─ model/
├─ validator.ts
├─ useAuth.ts
└─ session.ts이 상태에서는 validator.ts를 합치거나 위치를 바꾸는 순간, 외부 코드까지 함께 수정해야 한다. 내부 구현이 외부에 노출된 것이다. 반면 public API를 통한 접근은 다르다.
import { useAuth } from '@/features/auth'여기서 LoginPage는 auth 기능을 사용할 뿐, 어떻게 구현되어 있는지는 알지 못한다. public API는 불필요한 형식이 아니라, 변경 비용을 통제하기 위한 방어선이다. 이 선이 무너지면 슬라이스는 이름만 남고 캡슐화는 사라진다.
슬라이스를 나눌 때 기술을 기준으로 삼는 경우도 구조를 빠르게 무너뜨린다. 아래와 같은 구조는 익숙하지만, FSD의 슬라이스 개념과는 정반대다. 여기서 슬라이스는 의미 단위가 아니라 파일 종류 묶음이 된다. 하나의 기능을 이해하려면 여러 폴더를 동시에 열어야 하고, 변경은 항상 여러 곳에 흩어진다.
features/
├─ hooks/
├─ api/
└─ components/FSD에서 슬라이스는 “이 기능에 필요한 모든 것”을 내부에 포함해야 한다. 기술은 슬라이스를 나누는 기준이 아니라, 슬라이스 내부를 정리하는 기준이다. hooks, api, ui는 슬라이스 안에 있어야 할 세그먼트이지, 슬라이스 그 자체가 아니다.
가장 심각한 상태는 슬라이스가 단순한 폴더로 전락하는 경우다. 슬라이스라는 이름은 남아 있지만, 그 안에는 명확한 규칙도, 진입점도 없다. 내부 파일은 외부에서 자유롭게 참조되고, 구조를 지키는 장치도 없다. 상태에서는 슬라이스를 없애도 구조적으로 달라지는 것이 없다. 이미 슬라이스라는 개념은 붕괴된 상태다. 그리고 이런 붕괴는 대부분 “이 정도는 괜찮겠지”라는 작은 선택에서 시작된다.
Segments
슬라이스의 목적은 파일을 예쁘게 나누는 것이 아니다. 책임을 고립시키고, 변경의 영향 범위를 통제하는 것이다. 그리고 이 경계가 무너지기 시작하는 지점을 인식하는 것만으로도, FSD는 훨씬 오래 유지될 수 있다. 다만 여기에는 한 가지 현실적인 문제가 남는다. 하나의 슬라이스 안에는 결국 여러 종류의 코드가 섞이게 된다는 점이다.
이 문제를 해결하기 위해 FSD는 세그먼트(Segments) 라는 개념을 도입한다. 세그먼트는 슬라이스 내부를 다시 나누기 위한 장치지만, 또 하나의 책임 단위는 아니다. 세그먼트는 “무엇을 하는가”가 아니라, “어떤 성격의 코드인가”를 기준으로 슬라이스 내부를 정리한다. 이를 통해 슬라이스는 하나의 책임을 유지하면서도, 내부 구조는 명확하게 정돈된다.
즉, 슬라이스가 책임의 경계라면, 세그먼트는 그 경계를 무너지지 않게 유지하기 위한 내부 질서다. 세그먼트는 슬라이스를 더 쪼개기 위한 도구가 아니라, 슬라이스가 하나의 단위로 남아 있을 수 있도록 돕는 보조 장치다. 이 관점을 놓치지 않으면, 세그먼트는 구조를 복잡하게 만드는 개념이 아니라 오히려 단순하게 만드는 개념이 된다.

이 표에서 가장 중요한 점은, 세그먼트가 책임의 단위가 아니라는 것이다. 책임은 여전히 슬라이스에 있고, 세그먼트는 그 책임을 수행하기 위한 역할 분담에 가깝다. 다시 말해 세그먼트는 “무엇을 담당하는가”를 나누는 개념이 아니라, 하나의 책임을 어떤 성격의 코드로 구현할지를 정리하는 기준이다.
이 관점에서 보면 model 세그먼트에 대한 오해가 가장 먼저 드러난다. model은 흔히 말하는 “비즈니스 로직” 폴더가 아니다. 정확히는 슬라이스가 책임지는 개념을 상태와 로직의 형태로 표현하는 영역이다. 그래서 model은 UI를 알 필요가 없고, API의 세부 구현에도 과도하게 의존해서는 안 된다. model이 화면이나 통신 방식에 끌려다니기 시작하면, 슬라이스의 핵심 책임은 쉽게 흐려진다.
세그먼트를 잘못 이해했을 때 나타나는 패턴들도 비교적 일정하다. ui에 로직이 조금 섞이는 것은 당장은 편해 보이지만, 그 “조금”이 쌓이면 슬라이스의 경계는 점점 흐려진다. api 세그먼트에서 상태를 들고 있는 경우도 마찬가지다. api의 책임은 통신에 한정되어야 하며, 상태는 반드시 model로 올라와야 한다. 또한 lib와 shared를 혼동하는 경우도 잦은데, lib는 슬라이스 내부 전용 보조 코드이고, shared는 슬라이스 간에 공용으로 쓰이는 자산이라는 점에서 역할이 다르다.
정리하면 세그먼트는 슬라이스를 더 잘게 쪼개기 위한 장치가 아니다. 슬라이스가 하나의 책임 단위로 유지되도록, 내부를 질서 있게 정리하기 위한 도구다. 슬라이스를 “무엇을 담당하는가”로 이해했다면, 세그먼트는 “그 책임을 어떤 성격의 코드로 나누어 구현하는가”로 이해하는 것이 가장 정확하다.
실전 리팩터링 예시
아래는 FSD를 도입했지만, 슬라이스와 세그먼트 경계가 점점 흐려진 전형적인 코드다. 기능은 잘 동작하지만, 구조적으로는 이미 부담이 쌓이기 시작한 상태다.
features/
└─ auth/
├─ LoginForm.tsx
├─ useAuth.ts
├─ validateUser.ts
├─ authApi.ts
└─ index.tsimport { useAuth } from './useAuth'
import { validateUser } from './validateUser'
export function LoginForm() {
const { login } = useAuth()
const onSubmit = (data) => {
if (validateUser(data)) {
login(data)
}
}
// ...
}겉보기에는 문제가 없어 보인다. 하지만 이 슬라이스에는 몇 가지 신호가 보인다.
UI 컴포넌트가 검증 로직을 직접 호출한다
API, 상태, 검증 로직이 한 폴더에 섞여 있다
파일 수가 늘어날수록 역할 구분이 흐려진다
이 상태에서는 새로운 인증 방식이 추가되거나, 검증 규칙이 바뀔 때마다 어디를 고쳐야 하는지 판단하기 어려워진다.
이 슬라이스의 가장 큰 문제는 책임은 하나인데, 코드의 성격이 뒤섞여 있다는 점이다. auth 슬라이스는 “인증”이라는 하나의 책임을 가지지만, 화면 표현 상태 관리 검증 로직 API 통신 이 모두가 동일한 레벨에서 공존하고 있다. 슬라이스 자체는 맞지만, 세그먼트가 없는 상태이다.
같은 책임을 유지한 채, 세그먼트 기준으로 구조를 정리하면 아래와 같아진다.
features/
└─ auth/
├─ ui/
│ └─ LoginForm.tsx
├─ model/
│ ├─ useAuth.ts
│ └─ validateCredentials.ts
├─ api/
│ └─ authApi.ts
└─ index.tsimport { useAuth } from '../model/useAuth'
export function LoginForm() {
const { submit } = useAuth()
// UI는 로직을 호출만 한다
}import { validateCredentials } from './validateCredentials'
import { loginRequest } from '../api/authApi'
export function useAuth() {
const submit = (data) => {
if (!validateCredentials(data)) return
loginRequest(data)
}
return { submit }
}여기서 중요한 변화는 코드의 양이 아니라 의존 방향이다.
UI는 model만 안다
model은 api와 내부 로직만 안다
API는 오직 통신만 담당한다
슬라이스의 책임은 그대로지만, 내부 질서는 명확해졌다.
결론
FSD는 구조를 설계하는 아키텍처다. 하지만 구조는 설계만으로 유지되지 않는다. 슬라이스와 세그먼트로 책임의 경계를 명확히 하더라도, 실무에서는 작은 편의 선택들이 반복되며 그 경계가 서서히 흐려진다. 이 글에서 살펴본 구조 붕괴 패턴들 역시 대부분 “규칙을 몰라서”가 아니라, “지키기 어려워서” 발생한다는 공통점을 가진다.
결국 FSD의 핵심은 책임의 경계를 명확히 하고, 그 경계를 무너지지 않도록 지키는 것이다. 슬라이스와 세그먼트를 통해 이러한 구조적 경계를 설계할 수 있지만, 실무에서는 작은 타협들이 서서히 구조를 무너뜨리기 마련이다.
이런 문제를 자동으로 감시하고, 구조적 위반을 초기에 발견하는 데 도움을 주는 도구가 바로 Steiger다. Steiger는 FSD의 규칙을 코드 수준에서 검사하는 아키텍처 전용 린터로, 슬라이스 간 의존, public API 우회, 세그먼트 구조 붕괴 같은 문제를 초기에 드러낸다. Steiger에 대한 내용은 별도의 포스트에서 다루고 있으므로, 관심이 있다면 Steiger를 통해 FSD 구조를 어떻게 자동으로 검증할 수 있는지를 이어서 살펴보길 권한다.