FSD 슬라이스별 Zustand 스토어 분리와 widgets model 클라이언트 상태 격리 패턴
프론트엔드 프로젝트가 어느 정도 규모를 넘어서면, 처음엔 깔끔해 보였던 전역 Zustand 스토어 하나가 어느 순간 수십 개의 키를 품게 됩니다. 저도 실무에서 그 상황을 겪은 적이 있는데, 위젯 하나가 전역 스토어의 20개 키를 구독하고 있어서 탭 전환할 때마다 전혀 관계없는 컴포넌트가 리렌더링되던 게 기억납니다. 그때 PR 충돌도 잦았고, 새로 합류한 팀원이 "이 상태가 어디서 바뀌는 건가요?"라고 물을 때마다 설명하는 데 시간이 꽤 걸렸습니다.
이 글에서는 Feature-Sliced Design(FSD)의 슬라이스별 스토어 분리와 widgets 레이어의 model 세그먼트를 활용해 클라이언트 UI 상태를 격리하는 패턴을 구체적인 코드와 함께 살펴봅니다. 슬라이스마다 자신만의 스토어를 소유하고 Public API를 통해서만 외부에 노출하는 이 원칙을 적용하고 나면, PR 충돌이 눈에 띄게 줄고 새 팀원이 코드 흐름을 파악하는 속도도 달라집니다.
대상 독자는 Zustand를 이미 쓰고 있고 FSD에 관심이 생겼거나 막 도입하려는 프론트엔드 개발자를 중심으로 설명 밀도를 맞췄습니다. FSD를 처음 접하더라도 핵심 개념 섹션에서 필요한 배경 지식을 짚고 넘어갑니다.
핵심 개념
FSD의 3단 계층 구조
FSD는 프론트엔드 코드를 레이어 → 슬라이스 → 세그먼트의 세 단계로 나눕니다.
app/
pages/
widgets/
features/
entities/
shared/레이어는 위에서 아래 방향으로만 의존할 수 있습니다. widgets는 features와 entities를 참조할 수 있지만, 반대 방향은 규칙 위반입니다. 같은 레이어 안의 슬라이스끼리도 원칙적으로 직접 참조가 금지되어 있습니다.
FSD v2.1 cross-import: 슬라이스 간 교차 참조가 불가피한 경우를 위해
@x표기법이 공식 표준으로 편입됐습니다(entities/user/@x/product같은 형태). 단, 이는 예외적 허용이지 기본 패턴이 아닙니다. 같은 레이어 슬라이스 간 일상적인 직접 import는 여전히 피하는 것이 권장됩니다.
슬라이스(Slice) 란 같은 레이어 안에서 도메인 단위로 나눈 폴더입니다.
user,product,checkout처럼 비즈니스 개념 하나가 슬라이스 하나가 됩니다.
각 슬라이스 안에서 코드는 목적별 세그먼트로 나뉩니다.
| 세그먼트 | 역할 |
|---|---|
ui |
렌더링 담당 React 컴포넌트 |
model |
상태(Zustand store), 셀렉터, 커스텀 훅, 타입 |
api |
서버 통신 함수 |
lib |
유틸리티, 헬퍼 |
model 세그먼트가 하는 일
model 세그먼트의 핵심 역할은 상태 로직을 UI에서 분리하는 것입니다. ui 세그먼트의 컴포넌트는 렌더링에만 집중하고, 상태를 어디서 가져오는지, 어떻게 업데이트하는지는 model이 책임집니다.
widgets/dashboard-panel/
model/
store.ts ← Zustand 스토어 정의
selectors.ts ← 파생 상태 계산
hooks.ts ← UI 연결용 커스텀 훅
ui/
DashboardPanel.tsx
index.ts ← Public API (외부 노출 인터페이스)Public API 는 슬라이스의
index.ts를 통해 외부에 공개하는 인터페이스입니다. 내부 구현은 자유롭게 바꾸되, 이 파일에서 export하는 것만 안정적으로 유지하면 됩니다.
슬라이스별 스토어 소유 원칙
솔직히 처음엔 "그냥 스토어 하나에 다 넣으면 안 되나?"라는 생각이 들 수 있습니다. 작은 프로젝트라면 그게 더 간단하기도 하고요. 하지만 슬라이스마다 스토어를 분리하면 중요한 특성이 생깁니다.
- 스토어의 액션과 셀렉터는 해당 슬라이스의
index.ts를 통해서만 외부에 노출됩니다 - 내부 구현을 바꿔도 Public API만 유지하면 다른 슬라이스에 영향이 없습니다
- Zustand의 셀렉터 기반 구독 덕분에, 관련 없는 상태 변경이 리렌더링을 유발하지 않습니다
스토어가 늘어나도 괜찮습니다. 오히려 환영할 일입니다.
실전 적용
예시 1: widgets 레이어에 대시보드 패널 스토어 만들기
대시보드 패널 위젯이 있다고 가정해봅니다. 어떤 탭이 활성화되어 있는지, 패널이 접혀있는지 — 이런 UI 상태는 서버와 무관하고 이 위젯 안에서만 필요합니다. 전역 스토어에 섞여 있으면 탭 전환할 때마다 완전히 관계없는 컴포넌트까지 리렌더링되는 문제가 생기는데, 위젯 전용 스토어로 분리하면 이 범위가 명확히 좁혀집니다.
// widgets/dashboard-panel/model/store.ts
import { create } from 'zustand'
interface DashboardPanelState {
activeTab: string
isCollapsed: boolean
setActiveTab: (tab: string) => void
toggleCollapse: () => void
}
export const useDashboardPanelStore = create<DashboardPanelState>((set) => ({
activeTab: 'overview',
isCollapsed: false,
setActiveTab: (tab) => set({ activeTab: tab }),
toggleCollapse: () => set((s) => ({ isCollapsed: !s.isCollapsed })),
}))셀렉터는 별도 파일로 분리해두면 컴포넌트마다 인라인으로 선택자를 작성하는 것보다 훨씬 재사용하기 좋습니다. 여기서 셀렉터 파일에는 상태를 읽는 파생 값만 넣고, 액션(toggleCollapse 같은 업데이트 함수)은 스토어에서 직접 가져오는 것을 기준으로 삼으면 역할 구분이 명확해집니다.
// widgets/dashboard-panel/model/selectors.ts
import { useDashboardPanelStore } from './store'
export const useActiveTab = () =>
useDashboardPanelStore((s) => s.activeTab)
export const useIsCollapsed = () =>
useDashboardPanelStore((s) => s.isCollapsed)컴포넌트는 model 세그먼트의 훅을 가져다 쓰기만 하면 됩니다.
// widgets/dashboard-panel/ui/DashboardPanel.tsx
import { useActiveTab, useIsCollapsed } from '../model/selectors'
import { useDashboardPanelStore } from '../model/store'
export const DashboardPanel = () => {
const activeTab = useActiveTab()
const isCollapsed = useIsCollapsed()
// 액션은 스토어에서 직접 — 읽기(selectors)와 쓰기(store actions)를 구분하는 기준
const toggleCollapse = useDashboardPanelStore((s) => s.toggleCollapse)
return (
<div>
<button onClick={toggleCollapse}>
{isCollapsed ? '펼치기' : '접기'}
</button>
{!isCollapsed && <TabContent activeTab={activeTab} />}
</div>
)
}마지막으로 index.ts에서 외부에 노출할 것만 선별합니다. 스토어 자체는 감추는 게 핵심입니다.
// widgets/dashboard-panel/index.ts
export { DashboardPanel } from './ui/DashboardPanel'
// store와 selectors는 여기서 노출하지 않습니다
// 이 위젯의 UI 상태는 위젯 내부에서만 소비됩니다| 파일 | 역할 | 외부 노출 |
|---|---|---|
model/store.ts |
Zustand 스토어 정의 | ❌ |
model/selectors.ts |
파생 상태 셀렉터 | ❌ |
ui/DashboardPanel.tsx |
렌더링 컴포넌트 | ✅ (index.ts 경유) |
예시 2: 같은 위젯을 여러 인스턴스로 사용할 때 — 스토어 팩토리 패턴
대시보드에 동일한 패널 위젯을 여러 개 배치하되 각자 독립적인 상태를 가져야 하는 경우가 있습니다. create()로 만든 단일 스토어는 모든 인스턴스가 공유하므로 패널 A의 탭 전환이 패널 B에도 그대로 반영되는 문제가 생깁니다.
이때 Zustand의 createStore() — React hook 없이 store 인스턴스를 직접 다루는 vanilla API — 와 React Context를 결합하면 깔끔하게 해결됩니다. Context Provider가 하위 트리마다 독립된 스토어 인스턴스를 주입하기 때문에, 각 패널은 완전히 별개의 상태를 가집니다.
// widgets/dashboard-panel/model/store.ts
import { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'
import { createContext, useContext } from 'react'
interface DashboardPanelState {
activeTab: string
setActiveTab: (tab: string) => void
}
// createStore()는 React hook 없이 store 인스턴스를 직접 다루는 API입니다
// Context에 인스턴스를 주입할 수 있어서 다중 인스턴스 격리에 적합합니다
export const createPanelStore = (initialTab = 'overview') =>
createStore<DashboardPanelState>((set) => ({
activeTab: initialTab,
setActiveTab: (tab) => set({ activeTab: tab }),
}))
export type PanelStore = ReturnType<typeof createPanelStore>
export const PanelStoreContext = createContext<PanelStore | null>(null)
export const usePanelStore = <T>(selector: (s: DashboardPanelState) => T): T => {
const store = useContext(PanelStoreContext)
if (!store) throw new Error('PanelStoreContext.Provider가 필요합니다')
return useStore(store, selector)
}팩토리 패턴에서는 createPanelStore와 PanelStoreContext를 외부에서 사용해야 하므로, index.ts의 Public API도 그에 맞게 달라집니다. 예시 1에서는 스토어를 완전히 감췄다면, 여기서는 인스턴스 생성과 주입에 필요한 요소만 선택적으로 공개합니다.
// widgets/dashboard-panel/index.ts (팩토리 패턴 적용 후)
export { DashboardPanel } from './ui/DashboardPanel'
// 다중 인스턴스를 위해 팩토리와 Context를 공개
export { createPanelStore, PanelStoreContext } from './model/store'
export type { PanelStore } from './model/store'
// usePanelStore는 위젯 내부 훅이므로 여전히 노출하지 않습니다사용하는 쪽에서는 이렇게 됩니다.
// pages/dashboard/ui/DashboardPage.tsx
import { createPanelStore, PanelStoreContext, DashboardPanel } from 'widgets/dashboard-panel'
import type { PanelStore } from 'widgets/dashboard-panel'
// 타입이 명시되므로 Provider value와 스토어 인스턴스의 타입이 자동으로 보장됩니다
const panelA: PanelStore = createPanelStore('analytics')
const panelB: PanelStore = createPanelStore('reports')
export const DashboardPage = () => (
<div>
<PanelStoreContext.Provider value={panelA}>
<DashboardPanel />
</PanelStoreContext.Provider>
<PanelStoreContext.Provider value={panelB}>
<DashboardPanel />
</PanelStoreContext.Provider>
</div>
)각 Provider 하위 트리가 독립된 스토어 인스턴스를 참조하기 때문에, panelA의 탭을 바꿔도 panelB에는 전혀 영향이 없습니다.
예시 3: 상태 종류별 위치 결정하기
"이 상태를 어디 두어야 하지?" — 실무에서 가장 자주 맞닥뜨리는 질문입니다. 아래 기준으로 결정해볼 수 있습니다.
| 상태 종류 | 위치 | 예시 |
|---|---|---|
| 서버에서 가져오는 도메인 데이터 | entities/*/model/ 또는 TanStack Query |
유저 프로필, 상품 목록 |
| 특정 기능의 사용자 입력 상태 | features/*/model/ |
필터 선택값, 검색어 |
| 위젯 내부 UI 상태 | widgets/*/model/ |
탭 활성화, 펼침/접힘, 드래프트 |
| 앱 전역 설정 | app/model/ |
테마, 인증 토큰 |
잘못 배치한 전형적인 사례를 보면 판단 기준이 더 명확해집니다. "대시보드 패널의 탭 활성화 상태"를 아래처럼 entities 레이어에 두는 경우가 있습니다.
// ❌ 잘못된 위치 — entities는 도메인 데이터를 담는 레이어
// entities/panel/model/store.ts
export const usePanelUIStore = create<{ activeTab: string }>()((set) => ({
activeTab: 'overview',
setActiveTab: (tab) => set({ activeTab: tab }),
}))이 상태는 서버와 무관한 순수 UI 상태이므로 widgets/dashboard-panel/model/store.ts에 두는 것이 맞습니다. entities는 유저 프로필이나 상품 목록처럼 도메인 데이터를 다루는 레이어입니다.
TanStack Query 분리 권장: 서버에서 패칭해 오는 데이터는 Zustand 대신 TanStack Query로 관리하는 것이 FSD에서 권장되는 패턴입니다. Zustand는 순수 클라이언트 상태만 담당하게 되어 역할이 명확해집니다.
장단점 분석
장점
실제로 이 패턴을 도입했을 때 가장 먼저 체감한 건 PR 충돌이 줄었다는 점입니다. 슬라이스가 독립적이니 두 팀원이 각자 다른 위젯을 작업해도 스토어 파일이 겹칠 일이 없습니다.
| 항목 | 내용 |
|---|---|
| 명확한 경계 | UI 컴포넌트가 렌더링에만 집중하고, 상태 로직은 model이 전담합니다 |
| 병렬 개발 | 슬라이스가 독립적이라 여러 팀원이 충돌 없이 동시 작업할 수 있습니다 |
| 예측 가능한 의존성 | 상위→하위 단방향 의존으로 예기치 않은 사이드 이펙트가 줄어듭니다 |
| 테스트 용이성 | Public API만 import하면 되므로 내부 리팩토링이 테스트에 영향을 주지 않습니다 |
| 리렌더링 최적화 | 셀렉터 기반 구독으로 관련 없는 상태 변경이 리렌더링을 유발하지 않습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 학습 곡선 | FSD + 슬라이스 스토어 패턴을 팀 전체가 익히는 데 시간이 필요합니다 | @feature-sliced/eslint-plugin으로 규칙 위반을 자동 감지하면 온보딩이 수월해집니다 |
| 스토어 간 데이터 공유 | 두 슬라이스의 상태를 함께 사용해야 할 때 조율 로직이 복잡해집니다 | 상위 레이어(page)에서 합성하거나 공유 상태를 shared 또는 entities로 끌어올립니다 |
| 과도한 분리 위험 | 소규모 프로젝트에 적용하면 오히려 불필요한 복잡성이 생길 수 있습니다 | 팀 규모와 도메인 복잡도를 먼저 판단하고 도입 여부를 결정하는 것을 권장합니다 |
| Persistence 주의 | persist 미들웨어를 외부에서 주입하면 숨겨진 결합이 생깁니다 |
영속화 설정은 반드시 해당 슬라이스의 model/store.ts 안에서만 처리하는 것이 좋습니다 |
hidden coupling(숨겨진 결합) 이란 코드 상으로 명시되지 않은 의존 관계가 생기는 것입니다.
persist미들웨어를 외부에서 감싸면 스토어가 어디서 어떻게 저장되는지 추적하기 어려워집니다.
실무에서 가장 흔한 실수
-
index.ts에서 스토어 전체를 그대로 re-export하기 —export * from './model/store'처럼 내부 구현을 모두 노출하면 Public API의 의미가 사라집니다. 외부에서 실제로 필요한 것만 선별해서 export하는 것이 중요합니다. 예시 1에서 스토어를 감추고 컴포넌트만 노출한 것처럼요. -
같은 레이어 슬라이스끼리 직접 참조하기 —
widgets/header에서widgets/sidebar의 스토어를 직접 import하는 패턴은 FSD 규칙 위반입니다. 공유가 필요한 상태는 반드시 하위 레이어(entities또는shared)로 내려야 합니다.@feature-sliced/eslint-plugin이 이 위반을 자동으로 잡아줍니다. -
서버 데이터를 Zustand 스토어에 저장하기 — 패칭한 데이터를 Zustand에 담으면 로딩·에러·캐싱 로직까지 직접 관리해야 합니다. TanStack Query와 역할을 나누어 Zustand는 순수 클라이언트 상태만 담당하도록 구성하면 코드가 훨씬 단순해집니다. 저도 초반에 이 경계를 흐릿하게 두다가 스토어가 비대해지는 걸 경험했습니다.
마치며
FSD에서 슬라이스별 Zustand 스토어 분리의 핵심은, 각 슬라이스가 자신의 상태를 소유하고 Public API를 통해서만 외부와 소통하는 경계를 만드는 것입니다.
지금 바로 시작해볼 수 있는 3단계가 있습니다.
-
기존 전역 스토어에서 위젯 UI 상태 하나를 골라서 분리해볼 수 있습니다 — 탭 활성화나 모달 열림 상태처럼 서버와 무관한 것부터 시작하면 리스크가 낮습니다. 해당 위젯 폴더 안에
model/store.ts를 만들고create()로 스토어를 정의해볼 수 있습니다. -
슬라이스의
index.ts를 Public API로 정비해볼 수 있습니다 — 현재index.ts가export * from으로 모든 것을 노출하고 있다면, 외부에서 실제로 필요한 것만 남기고 나머지는 제거해볼 수 있습니다. 이 과정에서 불필요하게 공개된 내부 구현이 눈에 들어옵니다. -
@feature-sliced/eslint-plugin을 프로젝트에 추가해볼 수 있습니다 — 설치 후 아래처럼 설정하면 레이어 의존 방향 위반을 자동으로 감지해줍니다.
pnpm add -D @feature-sliced/eslint-plugin// eslint.config.js
import fsd from '@feature-sliced/eslint-plugin'
export default [
...fsd.configs.recommended,
]규칙 위반이 CI에서 잡히기 시작하면, 팀 전체가 FSD 원칙을 자연스럽게 내면화하게 됩니다.
참고 자료
- Zustand: The Minimalist State Architecture | Feature-Sliced Design 공식 블로그
- Layers | Feature-Sliced Design 공식 문서
- FSD v2.1 "Pages come first!" 릴리즈 노트
- Migration from v2.0 to v2.1 | Feature-Sliced Design
- Slices Pattern | Zustand 공식 문서
- zustand-slices | GitHub
- When should we use multiple stores instead of a single store with separate slices? | Zustand GitHub Discussion
- Secrets of a Scalable Component Architecture | Feature-Sliced Design