반갑습니다, 예비 프론트엔드 개발자 여러분! 오늘 우리는 정말 핫한 상태 관리 라이브러리인 Zustand를 타입스크립트(TypeScript)와 함께 사용하는 방법을 공식 문서를 통해 배워볼 거예요.
Zustand는 굉장히 가벼운 상태 관리 라이브러리로, 특히 React와 찰떡궁합을 자랑합니다. Zustand를 사용하면 복잡한 Reducer나 Context, 그리고 길고 지루한 보일러플레이트 코드들을 피할 수 있어요.
여기에 TypeScript를 곁들이면 어떻게 될까요? 스토어의 상태, 액션, 셀렉터들에 강력한 타입이 지정되면서 에디터의 자동 완성 기능은 물론이고 컴파일 타임에 안전성까지 챙길 수 있게 된답니다.
💡 강사의 팁: 최근 현업에서 Redux의 무거운 보일러플레이트에 지친 프론트엔드 팀들이 Zustand로 정말 많이 넘어오고 있습니다. 가볍고 직관적이면서도 TS 지원이 강력하기 때문이죠! 이번 기회에 확실히 마스터해 두시면 실무에서 큰 무기가 될 겁니다.
이번 기초 가이드에서 다룰 내용은 다음과 같습니다:
combine, devtools, persist)createWithEqualityFn (기존 create 함수를 확장한 스토어 함수) 다루기여기서는 Typescript의 interface를 사용해서 상태와 액션의 형태를 먼저 정의할 거예요. <BearState>라는 제네릭(Generic)을 사용하면 스토어가 반드시 이 형태를 따르도록 강제할 수 있습니다.
이게 무슨 뜻이냐면, 만약 여러분이 실수로 필드 하나를 빼먹거나 잘못된 타입을 사용하면 TypeScript가 즉시 에러를 뿜어내며 경고해 준다는 뜻이죠. 순수 JavaScript(Plain JS)를 쓸 때와 달리, 타입 안전성이 보장된 상태 관리가 가능해집니다.
create 함수는 커링(curried) 형태를 사용하는데, 그 결과로 UseBoundStore<StoreApi<BearState>> 타입의 스토어가 만들어집니다.
👨🏫 강사의 보충 설명: > 코드를 보면
create<BearState>()((set) => ...)처럼 괄호()가 두 번 연속으로 들어간 걸 볼 수 있죠? 처음 Zustand에 입문하시는 분들이 가장 많이 헷갈려하시는 부분입니다.
TypeScript에서 함수에 제네릭과 인자를 동시에 전달할 때 타입 추론이 제대로 안 되는 문제를 해결하기 위해 Zustand가 고안한 '커링(Currying)' 패턴입니다. 첫 번째()에 제네릭 타입을 넘겨주고, 두 번째()에 실제 상태 업데이트 로직을 넣어준다고 이해하시면 편합니다!
// store.ts
import { create } from 'zustand'
// Define types for state & actions
interface BearState {
bears: number
food: string
feed: (food: string) => void
}
// Create store using the curried form of `create`
export const useBearStore = create<BearState>()((set) => ({
bears: 2,
food: 'honey',
feed: (food) => set(() => ({ food })),
}))
컴포넌트 내부에서 우리는 상태를 읽어오고 액션을 호출할 수 있어요. (s) => s.bears와 같은 셀렉터(Selectors)를 사용하면 정확히 내가 필요한 상태에만 구독(subscribe)할 수 있습니다.
이렇게 하면 불필요한 리렌더링(re-renders)을 줄이고 앱의 성능을 크게 향상시킬 수 있죠. 물론 JavaScript로도 할 수 있는 일이지만, TypeScript를 쓰면 IDE(VSCode 등)에서 상태 필드들에 대한 자동 완성 기능을 완벽하게 지원해 줍니다. 타이핑 실수를 원천 차단해주죠!
💡 강사의 팁: 현업에서 코드를 리뷰하다 보면
const { bears } = useBearStore()처럼 스토어 전체를 통째로 가져와서 구조분해할당을 하시는 분들이 종종 있습니다. 이러면food가 바뀌어도 이 컴포넌트가 다시 렌더링됩니다! 항상 아래 코드처럼 필요한 상태만 콕 집어서 가져오는 습관을 들이세요.
import { useBearStore } from './store'
function BearCounter() {
// Select only 'bears' to avoid unnecessary re-renders
const bears = useBearStore((s) => s.bears)
return <h1>{bears} bears around</h1>
}
상태를 초기화(Reset)하는 기능은 사용자가 로그아웃하거나 "세션 지우기" 같은 동작을 할 때 아주 유용합니다. 여기서 우리는 속성 타입들을 두 번씩 반복해서 적지 않기 위해 typeof initialState를 활용합니다.
이렇게 하면 initialState의 내용이 바뀌더라도 TypeScript가 알아서 타입을 자동으로 업데이트해 주죠. 순수 JS를 쓸 때보다 훨씬 안전하고 깔끔한 코드를 작성할 수 있습니다.
👨🏫 강사의 보충 설명:
실무에서는 폼(Form) 데이터를 관리하거나, 복잡한 필터 상태를 초기화할 때 이 패턴을 정말 많이 씁니다. 상태 객체가 커질수록 일일이 타입을 적어주는 건 고역이거든요.typeof키워드를 활용해 타입 추론을 똑똑하게 이용하는 아주 좋은 예시입니다.
import { create } from 'zustand'
const initialState = { bears: 0, food: 'honey' }
// Reuse state type dynamically
type BearState = typeof initialState & {
increase: (by: number) => void
reset: () => void
}
const useBearStore = create<BearState>()((set) => ({
...initialState,
increase: (by) => set((s) => ({ bears: s.bears + by })),
reset: () => set(initialState),
}))
function ResetZoo() {
const { bears, increase, reset } = useBearStore()
return (
<div>
<div>{bears}</div>
<button onClick={() => increase(5)}>Increase by 5</button>
<button onClick={reset}>Reset</button>
</div>
)
}
Zustand는 ExtractState라는 내장 헬퍼(helper) 타입을 제공합니다. 이 기능은 테스트 코드 작성, 유틸리티 함수 생성, 또는 컴포넌트의 props 타입을 정의할 때 아주 유용해요.
스토어의 상태나 액션 타입을 일일이 수동으로 다시 정의할 필요 없이, 이 헬퍼를 쓰면 스토어의 전체 상태 타입을 한 번에 반환받을 수 있습니다.
스토어 타입을 추출하는 방법은 다음과 같습니다:
// store.ts
import { create, type ExtractState } from 'zustand'
export const useBearStore = create((set) => ({
bears: 3,
food: 'honey',
increase: (by: number) => set((s) => ({ bears: s.bears + by })),
}))
// Extract the type of the whole store state
export type BearState = ExtractState<typeof useBearStore>
추출한 타입을 테스트 코드에서 사용하는 모습입니다:
// test.cy.ts
import { BearState } from './store.ts'
test('should reset store', () => {
const snapshot: BearState = useBearStore.getState()
expect(snapshot.bears).toBeGreaterThanOrEqual(0)
})
유틸리티 함수에서 사용할 때도 마찬가지입니다:
// util.ts
import { BearState } from './store.ts'
function logBearState(state: BearState) {
console.log(`We have ${state.bears} bears eating ${state.food}`)
}
logBearState(useBearStore.getState())
💡 강사의 팁: 프로젝트 규모가 커지면 상태를 조작하는 순수 비즈니스 로직을 리액트 컴포넌트 밖의 일반 유틸 함수로 분리하는 경우가 생깁니다. 이때
ExtractState를 모르면 타입을 어떻게 주입해야 할지 막막할 수 있어요. 꼭 기억해두세요!
때로는 한 번에 여러 개의 상태 속성이 필요할 때가 있죠. 셀렉터에서 객체를 반환하게 만들면 여러 필드에 동시에 접근할 수 있습니다.
하지만 주의할 점이 있어요! 셀렉터가 반환한 객체에서 속성들을 바로 구조분해할당 해버리면 불필요한 리렌더링이 발생할 수 있습니다. (반환할 때마다 새로운 객체가 생성되기 때문이죠)
이런 문제를 방지하기 위해, 셀렉터를 useShallow로 감싸주는 것을 강력히 권장합니다. 이렇게 하면 선택된 값들이 얕은 비교(shallowly equal)를 통해 이전과 같다면 리렌더링을 막아줍니다.
이 방식은 스토어 전체를 구독하는 것보다 훨씬 효율적입니다. 게다가 TypeScript 덕분에 bears나 food의 스펠링을 실수로 잘못 적는 일도 막아줍니다.
useShallow에 대한 더 자세한 내용은 API 공식 문서를 참고해 보세요.
👨🏫 강사의 보충 설명:
Zustand는 기본적으로 상태가 이전 상태와 엄격한 일치(===)를 할 때만 렌더링을 건너뜁니다. 만약 셀렉터가(state) => ({ a: state.a, b: state.b })처럼 매번 새로운 객체를 만들어서 반환하면 참조값이 매번 달라져서 React는 상태가 계속 바뀌었다고 착각하게 됩니다! 이게 프론트엔드 성능 저하의 주범 중 하나입니다. 여러 개를 가져올 땐 무조건useShallow를 씌워주세요!
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
// Bear store with explicit types
interface BearState {
bears: number
food: number
}
const useBearStore = create<BearState>()(() => ({
bears: 2,
food: 10,
}))
// In components, you can use both stores safely
function MultipleSelectors() {
const { bears, food } = useBearStore(
useShallow((state) => ({ bears: state.bears, food: state.food })),
)
return (
<div>
We have {food} units of food for {bears} bears
</div>
)
}
모든 값을 스토어에 직접 저장할 필요는 없습니다. 기존 상태들을 조합해서 계산해 낼 수 있는 값들이 있죠. 우리는 셀렉터를 통해 이런 값들을 파생(derive)시킬 수 있습니다.
이렇게 하면 데이터의 중복을 피할 수 있고 스토어를 아주 가볍고 최소한의 상태로만 유지할 수 있어요. TypeScript가 bears가 숫자(number)라는 걸 보장해주기 때문에 안심하고 수학 연산을 할 수 있습니다.
💡 강사의 팁: "원본 데이터 하나로 계산할 수 있는 건 굳이 따로 상태로 만들지 마라"는 프론트엔드 상태관리의 핵심 원칙 중 하나입니다. (Redux의 reselect를 생각하시면 됩니다!)
import { create } from 'zustand'
interface BearState {
bears: number
foodPerBear: number
}
const useBearStore = create<BearState>()(() => ({
bears: 3,
foodPerBear: 2,
}))
function TotalFood() {
// Derived value: required amount food for all bears
const totalFood = useBearStore((s) => s.bears * s.foodPerBear) // don't need to have extra property `{ totalFood: 6 }` in your Store
return <div>We need ${totalFood} jars of honey</div>
}
combine 미들웨어이 미들웨어는 초기 상태(initial state)와 액션(actions)을 분리해서 코드를 훨씬 깔끔하게 만들어 줍니다.
가장 큰 장점은 TS가 상태와 액션으로부터 타입을 자동으로 추론해준다는 점이에요. 즉, 앞서 했던 것처럼 interface를 따로 만들 필요가 없습니다!
타입 안전성이 부족했던 일반 JS와는 확실히 차별화되는 부분이죠. 이 패턴은 TypeScript 프로젝트에서 매우 인기 있는 스타일입니다.
더 자세한 내용은 API 공식 문서를 참고하세요.
👨🏫 강사의 보충 설명:
강사인 저도 개인적으로 제일 좋아하는 미들웨어입니다. 앞서<BearState>타입 정의하고 커링하는 과정조차 귀찮다면combine이 최고의 선택입니다. 코드 양이 확 줄어들어요!
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
// State + actions are separated
export const useBearStore = create<BearState>()(
combine({ bears: 0 }, (set) => ({
increase: () => set((s) => ({ bears: s.bears + 1 })),
})),
)
devtools 미들웨어이 미들웨어를 사용하면 Zustand를 Redux DevTools 확장 프로그램과 연결할 수 있어요. 상태가 어떻게 변하는지(inspect changes) 확인하고, 타임 트래블(과거 상태로 되돌리기)도 할 수 있어서 디버깅에 최고입니다.
개발 단계에서 정말정말 유용한 도구예요. 여기에 미들웨어를 입혀도 TS는 여러분의 액션과 상태 타입을 아주 철저하게 검사해 줍니다.
더 자세한 내용은 API 공식 문서를 참고하세요.
💡 강사의 팁: 상태 추적이 어려워질 때 브라우저에 Redux DevTools 깔고 이 미들웨어만 슥 붙여보세요. 상태가 어떻게 흐르는지 시각적으로 완벽하게 파악할 수 있습니다.
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
export const useBearStore = create<BearState>()(
devtools((set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
})),
)
persist 미들웨어이 미들웨어는 여러분의 스토어를 브라우저의 localStorage(또는 다른 스토리지)에 저장(유지)해 줍니다. 이 말은 즉, 사용자가 새로고침을 해도 우리의 곰(bears) 데이터가 날아가지 않고 살아남는다는 뜻이죠!
데이터 유지가 필수적인 앱을 만들 때 정말 좋습니다. TypeScript를 사용하면 스토리지에서 가져온 상태 타입도 항상 일관성을 유지하기 때문에 런타임에서 갑자기 예상치 못한 에러가 터질 일이 없습니다.
더 자세한 내용은 API 공식 문서를 참고하세요.
💡 강사의 팁: 다크모드/라이트모드 설정, 장바구니, 유저 로그인 토큰 관리 등을 할 때
persist미들웨어를 쓰면 단 몇 줄만으로 로컬스토리지 연동이 끝납니다. 정말 사기적인(?) 기능이죠. 단, 저장할 수 없는 타입(예: 함수, Map, Set)에는 주의하세요!
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface BearState {
bears: number
increase: () => void
}
export const useBearStore = create<BearState>()(
persist(
(set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
}),
{ name: 'bear-storage' }, // localStorage key
),
)
외부 API에서 원격 데이터를 가져와야 할 때 액션을 비동기(async)로 만들 수 있습니다. 여기서는 곰의 마리 수를 서버에서 가져와서 상태를 업데이트해 볼게요.
TS를 활용하면 API 응답 타입(BearData)이 올바른지 엄격하게 강제할 수 있습니다. JS를 썼다면 count의 철자를 틀릴 수도 있겠지만, TS는 그런 실수를 미리 막아줍니다.
👨🏫 강사의 보충 설명:
Zustand는 Thunk나 Saga 같은 별도의 미들웨어를 깔지 않아도 비동기 처리가 너무나 자연스럽게 됩니다. 그냥async/await를 액션 안에 쓰기만 하면 돼요. 다만, 로딩 상태(isLoading)나 에러 상태(error) 처리는 React Query처럼 자동으로 해주진 않으니, 스토어 안에 상태를 추가해서 직접 관리해 주셔야 합니다.
import { create } from 'zustand'
interface BearData {
count: number
}
interface BearState {
bears: number
fetchBears: () => Promise<void>
}
export const useBearStore = create<BearState>()((set) => ({
bears: 0,
fetchBears: async () => {
const res = await fetch('/api/bears')
const data: BearData = await res.json()
set({ bears: data.count })
},
}))
createWithEqualityFn이건 일치 여부 검사(equality) 기능이 내장된 create의 또 다른 변형 함수입니다. 여러분이 상태를 비교할 때 항상 사용자 정의 비교 로직(custom equality checks)을 사용하고 싶을 때 유용해요.
자주 쓰이는 패턴은 아니지만, Zustand가 얼마나 유연한 라이브러리인지를 잘 보여주는 예시입니다. 당연히 TS의 강력한 타입 추론 기능은 여기서도 그대로 유지됩니다.
더 자세한 내용은 API 공식 문서를 참고하세요.
💡 강사의 팁: 기본적으로 Zustand는 Object.is 로 이전 상태를 비교하지만, 좀 더 딥(deep)한 비교가 필요하거나 커스텀 얕은 비교가 필요할 때 이 함수를 사용하면 좋습니다.
import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'
const useBearStore = createWithEqualityFn(() => ({
bears: 0,
}))
const bears = useBearStore((s) => s.bears, Object.is)
// or
const bears = useBearStore((s) => ({ bears: s.bears }), shallow)
도메인(분야)이 다르면 각각 다른 스토어를 여러 개 생성할 수 있어요. 예를 들면, 곰을 관리하는 BearStore와 물고기를 관리하는 FishStore를 따로 두는 거죠.
규모가 큰 앱에서는 이렇게 상태를 격리시키는 것이 유지보수하기 훨씬 수월합니다. TypeScript와 함께 쓰면 각 스토어가 자신만의 엄격한 타입을 가지기 때문에, 실수로 곰 스토어에 물고기를 넣거나 하는 대참사를 막을 수 있답니다.
👨🏫 강사의 보충 설명:
스토어를 무조건 하나에 다 때려넣는(Mono-store) 방식보다, 성격에 맞게 여러 개(Multi-store)로 쪼개는 것을 추천합니다. 유지보수도 좋고, 관련된 코드끼리 응집도가 높아집니다!
import { create } from 'zustand'
// Bear store with explicit types
interface BearState {
bears: number
addBear: () => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 2,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
}))
// Fish store with explicit types
interface FishState {
fish: number
addFish: () => void
}
const useFishStore = create<FishState>()((set) => ({
fish: 5,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
}))
// In components, you can use both stores safely
function Zoo() {
const { bears, addBear } = useBearStore()
const { fish, addFish } = useFishStore()
return (
<div>
<div>
{bears} bears and {fish} fish
</div>
<button onClick={addBear}>Add bear</button>
<button onClick={addFish}>Add fish</button>
</div>
)
}
Zustand와 TypeScript의 조합은 정말 완벽한 밸런스를 제공합니다. 작고 미니멀한 스토어라는 Zustand 특유의 단순함을 그대로 가져가면서도, 강력한 타입 시스템이 주는 안정성을 모두 누릴 수 있죠.
복잡한 보일러플레이트 코드나 복잡한 패턴을 익힐 필요가 없습니다. 상태와 액션이 나란히 한 곳에 존재하고, 모든 타입이 완벽하게 정의되어 있어 그저 가져다 쓰기만 하면 됩니다.
일단 패턴을 익히기 위해 기본 스토어부터 시작해 보세요. 그 후 점진적으로 확장해 나가는 걸 추천합니다: 타입 추론을 더 깔끔하게 하려면 combine을 도입하고, 데이터를 저장하려면 persist를, 버그를 잡을 땐 devtools를 붙여보세요!
이전 글: React 18 이전 버전에서 React 이벤트 핸들러 외부에서 액션 호출하기
다음 글: 고급 TypeScript 가이드 (Advanced TypeScript Guide)