Zustand

Zustand Store 타입 지정: TypeScript로 state와 action 안전하게 관리하기

2026.05.13·약 14분

이 글에서 정리하는 내용

Zustand Store에 TypeScript 타입을 붙일 때는 상태 값만 보는 것으로 끝나지 않습니다. Store가 어떤 값을 가지고, 그 값을 어떤 action으로만 바꿀 수 있는지 하나의 계약으로 잡아야 합니다. 작은 카운터 Store에서는 차이가 늦게 보이지만, 유저 정보·모달·필터·장바구니처럼 상태가 늘어나면 이 기준이 코드 안정성에 바로 영향을 줍니다.

타입 없는 Store가 처음에는 괜찮아 보이는 이유

Zustand TypeScript Store 타입 누락 문제: state와 action 사용 범위가 불명확해지는 흐름

Zustand Store를 처음 만들 때는 타입을 붙이지 않아도 코드가 잘 동작하는 것처럼 보입니다. 특히 카운터처럼 값 하나와 함수 하나만 있는 구조라면 JavaScript만으로도 크게 불편하지 않습니다. 버튼을 누르면 숫자가 올라가고, 화면도 바로 바뀝니다.

하지만 실제 화면의 Store는 보통 그렇게 작게 끝나지 않습니다. 관리자 화면에서 선택한 필터를 저장하거나로그인한 유저 정보를 보관하거나, 모달의 열림 상태와 종류를 관리하기 시작하면 값의 모양이 금방 늘어납니다. 이때 타입이 없으면 Store가 어떤 값을 받을 수 있는지 컴포넌트 작성 단계에서 바로 확인하기 어렵습니다.

예를 들어 이름 변경 action에는 문자열이 들어가야 하는데 숫자를 넘기거나, 유저 객체를 넣어야 하는 action에 일부 필드가 빠진 객체를 넘길 수 있습니다. 작은 예제에서는 이런 실수가 잘 보이지 않습니다. 실제로는 화면이 이상하게 렌더링되거나, 특정 조건에서만 에러가 난 뒤에야 원인을 찾게 됩니다.

import { create } from "zustand"; export const useCounterStore = create((set) => ({ count: 0, increase: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 })}));

이 코드는 짧아서 한눈에 읽힙니다. 그래도 Store 바깥에서 보면 가 어떤 구조인지가 인자를 받는지이 어떤 상태를 바꾸는지 명확하게 잠겨 있지는 않습니다. Store가 커진 뒤에는 이런 빈틈이 컴포넌트 사용부까지 번집니다.

TypeScript를 붙이는 이유는 코드가 짧을 때부터 복잡하게 만들기 위해서가 아닙니다. Store가 제공하는 상태와 변경 함수를 미리 정해두고, 잘못된 사용을 컴포넌트 작성 중에 걸러내기 위해서입니다.

Store 타입은 state와 action을 같이 잡는다

Zustand에서 Store 타입을 잡을 때 상태 값만 따로 보면 기준이 흐려집니다. Store는 값을 보관하는 곳이면서 동시에 그 값을 바꾸는 action을 함께 가지고 있습니다. 그래서 만 타입으로 보는 것이 아니라같은 함수까지 하나의 타입 안에 넣어야 합니다.

이 기준을 잡아두면 Store를 사용하는 컴포넌트는 내부 구현을 몰라도 됩니다. 어떤 상태를 읽을 수 있는지, 어떤 action을 어떤 인자로 호출해야 하는지만 알면 됩니다. Store 타입은 내부 구현 설명서라기보다 외부에서 사용할 수 있는 범위를 정한 계약에 가깝습니다.

type CounterStore = { count: number; increase: () => void; reset: () => void;
};

여기서 는 가 숫자이고는 인자 없이 호출되며도 값을 받지 않는다는 약속을 담고 있습니다. 함수 타입까지 적는 순간 Store의 사용 방식이 더 분명해집니다.

처음에는 action 타입을 따로 적는 것이 번거롭게 느껴질 수 있습니다. 하지만 action에 인자가 생기면 차이가 바로 보입니다. 문자열을 받아야 하는 action, 특정 객체를 받아야 하는 action, 숫자 id만 받아야 하는 action을 타입으로 구분할 수 있기 때문입니다.

create<Store>() 형태로 타입 연결하기

Zustand에서 TypeScript를 사용할 때 자주 보이는 형태가 입니다. 괄호가 두 번 나오기 때문에 처음에는 어색하지만, 흐름을 나누면 단순합니다. 먼저 Store 전체 타입을 넘기고, 그다음 실제 Store 구현을 작성하는 구조입니다.

import { create } from "zustand"; type CounterStore = { count: number; increase: () => void; reset: () => void;
}; export const useCounterStore = create<CounterStore>()((set) => ({ count: 0, increase: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 })}));

이렇게 작성하면 안의 도 기준으로 추론됩니다. 는 숫자로 다뤄지고, 존재하지 않는 상태명을 잘못 적으면 TypeScript가 바로 잡아줍니다.

컴포넌트에서 사용할 때도 타입은 이어집니다. selector로 를 가져오면 이 함수가 인자를 받지 않는다는 정보가 유지됩니다. Store를 사용하는 쪽에서 타입을 다시 작성하지 않아도 되는 이유가 여기에 있습니다.

function CounterButton() { const count = useCounterStore((state) => state.count); const increase = useCounterStore((state) => state.increase); return ( <button type="button" onClick={increase}> 현재 값: {count} </button> );
}

컴포넌트는 Store의 내부 구현을 알 필요가 없습니다. 는 숫자이고 는 클릭 이벤트에 바로 연결할 수 있는 함수라는 정도만 알면 됩니다. 이 정보는 Store를 만들 때 지정한 타입에서 따라옵니다.

action 인자가 있는 Store

타입 지정의 효과는 action에 인자가 생기는 순간 더 선명해집니다. 이름을 변경하는 Store를 예로 들면 은 반드시 문자열을 받아야 합니다. 이 규칙을 Store 타입에 넣어두면 컴포넌트에서 실수로 숫자나 객체를 넘기는 흐름을 작성 단계에서 막을 수 있습니다.

import { create } from "zustand"; type ProfileStore = { name: string; setName: (name: string) => void; clearName: () => void;
}; export const useProfileStore = create<ProfileStore>()((set) => ({ name: "", setName: (name) => set({ name }), clearName: () => set({ name: "" })}));

구현부의 에서 매개변수 에 타입을 다시 적지 않아도 됩니다. 이미 에서 라고 정의했기 때문에 TypeScript가 구현부의 을 문자열로 추론합니다.

이 방식은 action이 많아질수록 더 가치가 있습니다. 예를 들어처럼 action 이름이 비슷해질 때도 각 함수가 받는 값의 범위가 타입으로 구분됩니다.

객체, 배열, null 상태 타입 지정하기

실제 화면에서는 숫자와 문자열만 저장하지 않습니다. 로그인한 유저 정보, 선택된 상품, 열려 있는 모달 정보처럼 객체 상태가 자주 등장합니다. 이때 초기값이 없어서 로 시작하는 경우도 많습니다.

초기값이 인 상태는 타입을 더 분명하게 잡아야 합니다. 현재는 값이 없지만, 나중에는 객체가 들어올 수 있다는 범위를 Store 타입에 적어야 합니다. 그렇지 않으면 컴포넌트에서 을 바로 접근해도 되는지, 먼저 체크가 필요한지 기준이 흐려집니다.

import { create } from "zustand"; type User = { id: number; name: string; email: string;
}; type UserStore = { user: User | null; setUser: (user: User) => void; clearUser: () => void;
}; export const useUserStore = create<UserStore>()((set) => ({ user: null, setUser: (user) => set({ user }), clearUser: () => set({ user: null })}));

여기서 는 입니다. 그래서 컴포넌트에서 을 바로 쓰려고 하면 TypeScript가 가능성을 확인하라고 요구합니다. 이 경고는 귀찮은 절차가 아니라, 아직 로그인 정보가 없거나 초기 로딩 상태일 수 있다는 화면 조건을 코드에 반영하는 과정입니다.

function UserName() { const user = useUserStore((state) => state.user); if (!user) { return <p>로그인 정보가 없습니다.</p>; } return <p>{user.name}님</p>;
}

이 조건문이 들어가면 아래에서는 가 로 좁혀집니다. TypeScript가 가능성을 제거한 뒤에 접근을 허용하는 흐름입니다. Store 타입이 화면의 분기 조건까지 자연스럽게 이어지는 예입니다.

배열 상태도 비슷합니다. 장바구니나 선택된 항목 목록처럼 배열을 Store에 둘 때는 배열 안에 들어갈 항목 타입을 먼저 정합니다. 처럼 적어두면 action에서 새 항목을 추가하거나 제거할 때도 같은 기준을 유지할 수 있습니다.

import { create } from "zustand"; type CartItem = { id: number; title: string; price: number;
}; type CartStore = { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: number) => void; clearItems: () => void;
}; export const useCartStore = create<CartStore>()((set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item]})), removeItem: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id)})), clearItems: () => set({ items: [] })}));

이 구조에서는 에 들어오는 값이 반드시 이어야 하고은 숫자 를 받습니다. Store 타입만 봐도 장바구니 상태가 어떤 방식으로 변경되는지 확인할 수 있습니다.

Store 타입을 분리해야 하는 시점

Zustand TypeScript Store 타입 설계: slice 단위 state와 action 계약을 분리하는 구조

처음부터 타입 파일을 과하게 나눌 필요는 없습니다. 작은 Store라면 Store 파일 안에 타입과 구현을 같이 두는 편이 읽기 쉽습니다. 파일을 열었을 때 상태 구조와 action 구현을 한 번에 확인할 수 있기 때문입니다.

분리를 고민해야 하는 시점은 Store가 커졌을 때입니다.같은 타입이 여러 컴포넌트나 다른 Store에서도 필요해지면 별도 파일로 빼는 것이 낫습니다. 반대로 해당 Store 내부에서만 쓰는 타입이라면 같은 파일에 두어도 충분합니다.

// stores/user/types.ts
export type User = { id: number; name: string; email: string;
}; export type UserStore = { user: User | null; setUser: (user: User) => void; clearUser: () => void;
};
// stores/user/useUserStore.ts
import { create } from "zustand";
import type { UserStore } from "./types"; export const useUserStore = create<UserStore>()((set) => ({ user: null, setUser: (user) => set({ user }), clearUser: () => set({ user: null })}));

이 정도 분리는 상태가 많아진 뒤에 적용해도 늦지 않습니다. 처음부터 구조를 복잡하게 만들면 Zustand의 장점인 단순함이 줄어듭니다. 기준은 단순합니다. 타입이 Store 내부에서만 쓰이면 같은 파일에 두고, 여러 곳에서 공유되면 분리합니다.

타입 이름도 같이 점검해야 합니다. 처럼 너무 넓은 이름보다는 처럼 Store의 책임이 드러나는 이름이 낫습니다. 파일이 늘어나도 어떤 Store 타입인지 바로 구분할 수 있습니다.

Store가 많아지는 프로젝트에서는 폴더 기준도 함께 정해두면 좋습니다. 예를 들어처럼 기능 단위로 묶으면 Store 구현과 타입을 같이 찾기 쉽습니다. 다만 이 구조는 Store가 커졌을 때 선택할 수 있는 방식이지, 처음부터 반드시 적용해야 하는 규칙은 아닙니다.

정리

Zustand Store에 TypeScript를 붙인다는 것은 상태 값에 타입만 적는 작업이 아닙니다. Store가 외부에 제공하는 상태와 action을 하나의 계약으로 묶는 작업입니다.

작은 카운터 Store에서는 타입의 필요성이 늦게 보입니다. 하지만 action 인자가 생기고, 객체 상태가 들어오고, 초기값이나 배열 상태를 다루기 시작하면 타입이 Store 사용 방식을 정리해줍니다.

처음 기준은 간단합니다. Store 타입 안에 state와 action을 함께 작성하고, 형태로 연결합니다. 그다음 객체나 배열처럼 실제 화면에서 쓰는 상태를 다룰 때는 가능한 값의 범위를 타입으로 먼저 정합니다.

다음에 Store를 만들 때는 구현부터 작성하기보다이 Store가 어떤 값을 가지고 어떤 action으로만 바뀌어야 하는지 먼저 적어보는 것이 좋습니다. 그 타입이 정리되면 컴포넌트에서 Store를 사용할 때도 실수할 수 있는 범위가 줄어듭니다.

참고 자료

Zustand 공식 TypeScript 가이드와 Advanced TypeScript 가이드의 사용 방식을 기준으로 TypeScript 적용 형태를 확인했습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기