안녕하세요! 프론트엔드 개발자 여러분. 오늘은 Zustand를 실무에서 더 강력하고 깔끔하게 쓸 수 있도록 만들어주는 Slices Pattern(슬라이스 패턴)에 대해 함께 공부해볼 거예요. 공식 문서를 바탕으로 차근차근, 그리고 제 실무 경험을 듬뿍 담아 부연 설명도 해드릴 테니 잘 따라와 주세요!
앱에 기능이 하나둘씩 추가되다 보면, 하나의 스토어가 감당할 수 없을 만큼 점점 커지고 유지보수하기 너무 힘들어지는 순간이 오죠. 파일 하나에 코드가 천 줄, 이천 줄 넘어간다고 상상해 보세요. 끔찍하죠? 😅
이럴 때 Zustand에서는 메인 스토어를 더 작고 독립적인 개별 스토어들로 나누어서 모듈화(modularity)를 달성할 수 있어요. 게다가 이 과정이 아주 간단하답니다!
Redux를 써보신 분들이라면 Redux Toolkit의 createSlice 기능이 떠오르실 텐데요, 개념적으로 아주 비슷해요. 관심사별로(예: 유저 정보, 장바구니 상태 등) 상태와 액션을 분리하는 거죠.
먼저, 첫 번째 개별 스토어(슬라이스)를 만들어볼게요. 여기서는 '물고기' 상태를 관리합니다:
export const createFishSlice = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
그다음, 또 다른 개별 스토어를 만들어볼게요. 이번엔 '곰' 상태를 관리합니다:
export const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})
자, 이제 이렇게 따로따로 만든 스토어들을 하나의 바운드(결합된) 스토어(bounded store)로 합쳐줄 수 있어요.
💡 강사의 부연 설명:
여기서create함수 안에 들어가는(...a)는 Zustand의set,get,api메서드들을 배열 형태로 받는 Rest 파라미터예요. 전개 연산자(...)를 써서 이 메서드들을 각 슬라이스 함수(createBearSlice,createFishSlice)에 고스란히 전달해 주는 원리랍니다. 정말 우아한 패턴이죠?
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
합쳐진 스토어를 React 컴포넌트에서 사용하는 방법은 기존과 완전히 똑같아요!
import { useBoundStore } from './stores/useBoundStore'
function App() {
const bears = useBoundStore((state) => state.bears)
const fishes = useBoundStore((state) => state.fishes)
const addBear = useBoundStore((state) => state.addBear)
return (
<div>
<h2>Number of bears: {bears}</h2>
<h2>Number of fishes: {fishes}</h2>
<button onClick={() => addBear()}>Add a bear</button>
</div>
)
}
export default App
🔥 강사의 실무 꿀팁:
컴포넌트에서 상태를 꺼내올 때,const { bears, fishes } = useBoundStore()처럼 구조 분해 할당을 쓰고 싶어질 수 있어요. 하지만 위 코드처럼 상태를 하나하나 개별적인 선택자(selector)로 꺼내오는 것을 강력히 권장합니다! 구조 분해 할당을 사용하면 스토어 내의 어떤 상태가 변하든 컴포넌트가 리렌더링 될 위험이 크거든요. 개별적으로 가져오면 딱 내가 구독한 상태(bears나fishes)가 변할 때만 리렌더링 되기 때문에 성능 최적화에 훨씬 유리합니다.
개발을 하다 보면, 한 번의 액션으로 여러 슬라이스의 상태를 동시에 바꿔야 할 때가 있어요. Zustand에서는 단일 함수 안에서 동시에 여러 스토어를 업데이트하는 것도 가능하답니다.
예를 들어, '곰'과 '물고기'를 동시에 추가하는 액션을 만들어 볼게요. 인자로 get을 받아오면 다른 슬라이스의 액션에 쉽게 접근할 수 있어요!
export const createBearFishSlice = (set, get) => ({
addBearAndFish: () => {
get().addBear()
get().addFish()
},
})
이 새로운 슬라이스도 앞서 했던 방식 그대로 결합된 스토어에 합쳐주기만 하면 됩니다.
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
import { createBearFishSlice } from './createBearFishSlice'
export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createBearFishSlice(...a),
}))
Zustand의 꽃 중 하나가 바로 미들웨어죠! 결합된 스토어에 미들웨어를 추가하는 방법도 일반적인 단일 스토어에 추가할 때와 동일해요.
우리가 만든 useBoundStore에 데이터를 로컬 스토리지에 저장해주는 persist 미들웨어를 추가해 볼까요?
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
import { persist } from 'zustand/middleware'
export const useBoundStore = create(
persist(
(...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}),
{ name: 'bound-store' },
),
)
🚨 강사의 강력한 주의사항:
공식 문서에서도 강조하고 있지만, 미들웨어는 반드시 최종적으로 합쳐진 스토어(combined store)에만 적용하셔야 해요. 개별 슬라이스(createBearSlice 등) 내부에 미들웨어를 씌우려고 하면 타입이 꼬이거나 상태 동기화가 깨지는 등 전혀 예상치 못한 버그가 발생할 수 있습니다. 꼭 명심해 주세요!
실무에서는 당연히 TypeScript를 쓰시겠죠? Zustand에서 TypeScript와 함께 슬라이스 패턴을 사용하는 방법에 대한 상세한 가이드는 여기에서 확인하실 수 있어요.
💡 강사의 TypeScript 꿀팁:
각 슬라이스가 반환하는 상태와 액션의 타입을interface나type으로 명확히 지정해 두고, 스토어를 결합할 때 교차 타입(Intersection Type,&)을 사용해BearSlice & FishSlice처럼 타입을 합쳐주면 자동 완성과 타입 추론을 완벽하게 누릴 수 있습니다. 위 링크된 공식 문서를 꼭! 한 번 정독해 보시길 추천합니다.
이전 글: 스토어 액션 없이 연습하기 (Practice with no store actions)
다음 글: 불변 상태와 병합 (Immutable state and merging)