Zustand

Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수

2026.05.07·수정 2026.05.12·약 18분

이 글에서 정리하는 내용

Zustand는 문법이 짧아서 프로젝트에 빠르게 붙일 수 있지만, 실무에서는 어떤 상태를 전역 store에 넣을지 먼저 정하지 않으면 구조가 쉽게 흐려집니다. 이 글은 Zustand를 사용할 때 상태 범위, 서버 상태 분리, selector, persist, action 구조를 어떤 기준으로 점검해야 하는지 정리합니다.

Zustand가 쉬워서 더 쉽게 무너지는 지점

Zustand store가 UI 상태와 서버 상태를 함께 담으며 복잡해지는 흐름을 보여주는 구조도

Zustand는 처음 사용할 때 부담이 적습니다. 를 크게 감싸지 않아도 되고, action을 만들기 위해 별도의 reducer 파일을 계속 늘리지 않아도 됩니다. 그래서 모달, 메뉴, 테마처럼 작은 UI 상태를 분리할 때는 빠르게 효과가 납니다.

문제는 편해진 뒤에 생깁니다. 모달 열림 여부를 넣고, 메뉴 상태를 넣고로그인 사용자 정보를 넣고, API로 받은 목록 데이터까지 넣기 시작하면 store가 상태 관리 도구라기보다 임시 보관함처럼 변합니다. 처음에는 전역에서 바로 꺼낼 수 있어서 편하지만, 시간이 지나면 “이 값은 어디에서 바뀌는지”, “새로고침 후에도 남아야 하는지”, “서버에서 다시 받아와야 하는 값인지”가 흐려집니다.

Zustand를 실무에서 쓸 때 먼저 잡아야 할 기준은 문법이 아니라 경계입니다. 모든 컴포넌트가 접근할 수 있다는 이유만으로 모든 상태를 넣으면 안 됩니다. 전역 store는 값을 공유하는 장소이기도 하지만, 동시에 변경 책임이 모이는 장소입니다.

store가 임시 보관함처럼 커지는 코드

import { create } from "zustand"; type Product = { id: string; name: string; price: number;
}; type AppStore = { isMenuOpen: boolean; selectedAdminTab: "dashboard" | "orders" | "settings"; products: Product[]; currentProductId: string | null; setMenuOpen: (open: boolean) => void; setSelectedAdminTab: (tab: AppStore["selectedAdminTab"]) => void; setProducts: (products: Product[]) => void; setCurrentProductId: (id: string | null) => void;
}; export const useAppStore = create<AppStore>((set) => ({ isMenuOpen: false, selectedAdminTab: "dashboard", products: [], currentProductId: null, setMenuOpen: (open) => set({ isMenuOpen: open }), setSelectedAdminTab: (tab) => set({ selectedAdminTab: tab }), setProducts: (products) => set({ products }), setCurrentProductId: (id) => set({ currentProductId: id })}));

이 코드는 문법만 보면 크게 어색하지 않습니다. 하지만 UI 상태, 관리자 탭 상태, 서버에서 받은 상품 목록, 선택된 상품 id가 한 store에 섞여 있습니다. 기능이 작을 때는 괜찮아 보여도 상품 목록 갱신, 탭 초기화, 메뉴 닫기, 상세 화면 이동이 함께 들어오면 store의 역할이 흐려집니다.

처음에는 하나의 store로 시작할 수 있습니다. 다만 상태의 성격이 달라지는 순간에는 나누는 기준을 다시 확인해야 합니다. 메뉴 열림 상태와 상품 목록 데이터는 둘 다 전역에서 쓰일 수 있지만, 실제 책임은 다릅니다.

Zustand에 넣을 상태와 넣지 않을 상태

Zustand에 잘 맞는 상태는 주로 클라이언트 UI 흐름에 속합니다. 예를 들어 모바일 메뉴가 열려 있는지, 설정 패널이 펼쳐져 있는지, 관리자 화면에서 어떤 탭을 보고 있는지, 테마 선택값이 무엇인지 같은 상태입니다. 이런 값들은 서버에서 내려주는 데이터라기보다 사용자의 현재 조작 흐름을 표현합니다.

반대로 컴포넌트 안에서만 쓰는 값까지 전부 Zustand로 올릴 필요는 없습니다. 입력창 하나의 포커스 여부, 단일 컴포넌트 내부에서만 쓰는 임시 토글, 한 번 렌더링되고 사라지는 로컬 계산값은 로 충분한 경우가 많습니다. 전역 store에 넣는 순간 다른 파일에서도 접근할 수 있게 되지만, 그만큼 변경 경로도 넓어집니다.

판단이 애매할 때는 “이 상태가 사라지면 다른 컴포넌트도 같이 깨지는가”를 보면 됩니다. 특정 컴포넌트 안에서만 의미가 있으면 지역 상태로 두고, 여러 컴포넌트가 같은 값을 기준으로 움직여야 한다면 Zustand 후보로 볼 수 있습니다.

Next.js 환경에서는 한 가지를 더 봐야 합니다. Zustand hook을 직접 사용하는 컴포넌트는 클라이언트에서 동작하는 컴포넌트여야 합니다. 서버 컴포넌트에서 전역 UI 상태를 바로 읽으려고 하기보다, 상태를 사용하는 영역을 클라이언트 컴포넌트로 분리하는 흐름이 자연스럽습니다.

상태 유형Zustand 사용 판단
모달 열림 여부여러 위치에서 열고 닫는다면 적합
관리자 탭 선택값화면 전환 기준으로 공유된다면 적합
API 목록 데이터대부분 TanStack Query 같은 서버 상태 도구가 더 적합
단일 입력창 상태컴포넌트 내부 가 더 단순
테마, 언어 설정사용자 설정으로 유지해야 한다면 적합

서버 상태를 Zustand에 넣을 때 생기는 문제

실무에서 가장 자주 섞이는 부분이 서버 상태입니다. 상품 목록, 사용자 목록, 게시글 상세, 주문 내역처럼 API에서 받아오는 데이터는 여러 화면에서 쓰일 수 있습니다. 그래서 Zustand에 넣고 싶은 유혹이 생깁니다. 하지만 전역에서 쓰인다는 사실과 Zustand에 넣어야 한다는 판단은 다릅니다.

서버 상태는 단순히 값을 보관하는 문제가 아닙니다. 언제 다시 가져올지, 오래된 데이터인지로딩 중인지, 실패했는지, 수정 후 어떤 목록을 다시 갱신할지까지 함께 따라옵니다. 이 책임을 Zustand store 안에서 직접 관리하기 시작하면 상태 관리 코드가 금방 두꺼워집니다.

서버 데이터를 Zustand에 복사해 넣는 흐름

const useProductStore = create<{ products: Product[]; setProducts: (products: Product[]) => void;
}>((set) => ({ products: [], setProducts: (products) => set({ products })})); function ProductPage() { const products = useProductStore((state) => state.products); const setProducts = useProductStore((state) => state.setProducts); useEffect(() => { fetch("/api/products") .then((response) => response.json()) .then((data) => setProducts(data)); }, [setProducts]); return <ProductList products={products} />;
}

이 방식은 처음에는 단순합니다. 하지만 상품이 수정된 뒤 목록을 다시 불러와야 하는 순간부터 직접 처리해야 할 일이 늘어납니다. 로딩 상태도 필요하고에러 상태도 필요하고같은 API를 다른 화면에서 호출했을 때 캐시를 어떻게 맞출지도 결정해야 합니다.

이런 데이터는 보통 TanStack Query 같은 서버 상태 관리 도구와 역할을 나누는 쪽이 낫습니다. Zustand는 “현재 어떤 필터 패널이 열렸는지”, “사용자가 어떤 보기 모드를 선택했는지” 같은 클라이언트 상태를 맡고, API 응답 데이터의 캐싱과 갱신은 서버 상태 도구가 맡는 구조가 더 명확합니다.

역할을 나눈 코드 흐름

const useProductUiStore = create<{ viewMode: "grid" | "list"; setViewMode: (mode: "grid" | "list") => void;
}>((set) => ({ viewMode: "grid", setViewMode: (mode) => set({ viewMode: mode })})); function ProductPage() { const viewMode = useProductUiStore((state) => state.viewMode); const { data: products = [], isLoading } = useQuery({ queryKey: ["products"], queryFn: fetchProducts}); if (isLoading) return <p>상품을 불러오는 중입니다.</p>; return <ProductList products={products} viewMode={viewMode} />;
}

여기서는 상품 목록 자체는 서버 상태로 두고, 보기 방식만 Zustand에 둡니다. 이렇게 나누면 Zustand store는 UI 선택값에 집중하고, 서버 데이터의 새로고침과 캐시 관리는 별도 도구가 처리합니다. 전역 상태가 필요한 상황에서도 상태의 출처가 다르면 관리 책임도 분리해야 합니다.

store 구조를 나눌 때 확인할 기준

store를 무조건 잘게 나누는 것도 답은 아닙니다. 처음부터처럼 너무 많이 쪼개면 오히려 파일 이동이 잦아지고 흐름을 찾기 어려워집니다. 작은 프로젝트에서는 하나의 store로 시작하는 편이 더 빠를 수 있습니다.

다만 action 이름이 모호해지는 순간은 분리 신호로 볼 수 있습니다.같은 이름이 여러 의미로 쓰이기 시작하면 store가 너무 많은 책임을 갖고 있을 가능성이 높습니다. 상태 이름보다 action 이름을 보면 구조의 어색함이 더 빨리 드러납니다.

예를 들어 관리자 화면의 탭 상태와 결제 모달 상태가 같은 store에 들어갈 수는 있습니다. 하지만 탭 초기화와 결제 모달 닫기가 같은 action 안에 묶이면 이후 수정이 어려워집니다. 어떤 화면을 나갈 때 무엇을 초기화해야 하는지 매번 store 내부를 확인해야 하기 때문입니다.

도메인별로 나눈 store 예시

type AdminUiStore = { selectedTab: "dashboard" | "orders" | "settings"; setSelectedTab: (tab: AdminUiStore["selectedTab"]) => void;
}; export const useAdminUiStore = create<AdminUiStore>((set) => ({ selectedTab: "dashboard", setSelectedTab: (tab) => set({ selectedTab: tab })})); type ModalStore = { activeModal: "payment" | "preview" | null; openModal: (modal: Exclude<ModalStore["activeModal"], null>) => void; closeModal: () => void;
}; export const useModalStore = create<ModalStore>((set) => ({ activeModal: null, openModal: (modal) => set({ activeModal: modal }), closeModal: () => set({ activeModal: null })}));

이렇게 나누면 파일은 늘어나지만 책임은 분명해집니다. 관리자 탭은 관리자 UI 흐름 안에서만 보고, 모달은 모달 흐름 안에서만 봅니다. store를 나누는 기준은 파일 개수를 줄이는 것이 아니라, 상태 변경 이유가 같은지 확인하는 데 있습니다.

selector와 persist를 사용할 때의 주의점

Zustand를 쓸 때 컴포넌트에서 store 전체를 가져오는 코드는 피해야 합니다. 컴포넌트가 실제로 쓰는 값은 하나인데 store 전체를 구독하면, 관련 없는 상태 변경에도 렌더링 영향을 받을 수 있습니다. 작은 예제에서는 눈에 잘 띄지 않지만, 관리자 화면처럼 카드, 리스트, 필터가 함께 있는 화면에서는 차이가 커집니다.

function Header() { const isMenuOpen = useLayoutStore((state) => state.isMenuOpen); const closeMenu = useLayoutStore((state) => state.closeMenu); return ( <header> {isMenuOpen && <button onClick={closeMenu}>메뉴 닫기</button>} </header> );
}

이 코드는 가 필요한 값만 가져옵니다. store 전체를 가져온 뒤 구조 분해하는 방식보다 변경 범위를 좁게 잡을 수 있습니다. 특히 여러 상태가 들어 있는 store라면 selector를 습관처럼 쓰는 것이 좋습니다.

여러 값을 한 번에 가져오려고 객체를 반환하는 selector도 조심해야 합니다. 매번 새 객체를 반환하면 비교 기준 때문에 예상보다 자주 렌더링될 수 있습니다. 여러 값을 묶어서 가져와야 한다면 같은 얕은 비교 도구가 필요한지 확인해야 합니다. 이 부분은 단순 성능 최적화가 아니라, 컴포넌트가 어떤 상태에 반응해야 하는지 명확히 하는 작업입니다.

persist는 더 조심해서 써야 합니다. 테마, 언어, 사이드바 접힘 여부처럼 사용자가 다시 방문했을 때 유지되어도 자연스러운 값에는 잘 맞습니다. 하지만 모달 열림 여부에러 메시지로딩 상태, 임시 선택값까지 저장하면 새로고침 후 이상한 화면이 남을 수 있습니다.

import { create } from "zustand";
import { persist } from "zustand/middleware"; type PreferenceStore = { theme: "light" | "dark"; sidebarCollapsed: boolean; setTheme: (theme: PreferenceStore["theme"]) => void; setSidebarCollapsed: (collapsed: boolean) => void;
}; export const usePreferenceStore = create<PreferenceStore>()( persist( (set) => ({ theme: "light", sidebarCollapsed: false, setTheme: (theme) => set({ theme }), setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed })}), { name: "preference-storage", partialize: (state) => ({ theme: state.theme, sidebarCollapsed: state.sidebarCollapsed})} )
);

를 사용하면 저장할 상태를 제한할 수 있습니다. persist를 붙이는 목적은 모든 값을 남기는 것이 아니라, 남아도 되는 값만 고르는 것입니다. 저장 대상이 명확하지 않다면 persist를 붙이기 전에 새로고침 후에도 자연스러운 상태인지 먼저 확인해야 합니다.

실무 적용 전 확인할 체크리스트

Zustand 적용 전 상태 수명과 출처, persist 대상을 점검하는 체크리스트 인포그래픽

Zustand를 적용하기 전에 먼저 상태의 수명과 출처를 확인해야 합니다. 컴포넌트가 사라질 때 같이 사라져도 되는 값인지, 페이지를 이동해도 남아야 하는 값인지, 서버에서 다시 받아올 수 있는 값인지에 따라 관리 방식이 달라집니다.

다음 질문에 대부분 “예”라고 답할 수 있으면 Zustand store에 넣을 후보로 볼 수 있습니다.

  • 여러 컴포넌트가 같은 값을 기준으로 동작하는가?
  • props로 계속 전달하면 중간 컴포넌트의 역할이 흐려지는가?
  • 상태 변경 로직을 action 이름으로 분리했을 때 의미가 명확한가?
  • 서버에서 다시 받아와야 하는 데이터가 아니라 클라이언트 UI 상태인가?
  • 새로고침 후에도 유지해야 한다면 persist 대상이 분명한가?

반대로 “API 응답을 전역에서 쓰고 싶다”가 주된 이유라면 Zustand보다 서버 상태 관리 도구를 먼저 봐야 합니다. “여러 페이지에서 접근해야 한다”는 이유만으로 Zustand에 넣으면 캐싱, 재요청에러 처리 책임이 뒤따라옵니다.

또 하나 확인할 부분은 URL입니다. 필터, 검색어, 페이지 번호처럼 사용자가 링크로 공유하거나 뒤로 가기로 복원해야 하는 값은 Zustand보다 URL query가 더 자연스러울 수 있습니다. 상태 관리 도구를 쓰기 전에 그 값이 화면 내부 상태인지, 주소로 표현해야 하는 상태인지 구분해야 합니다.

정리

Zustand는 복잡한 설정 없이 상태를 분리할 수 있다는 점에서 실무에 빠르게 적용하기 좋습니다. 하지만 간단하다는 이유로 모든 상태를 넣기 시작하면 store가 프로젝트의 중심 구조가 아니라 불분명한 전역 저장소가 됩니다.

실무에서 먼저 볼 기준은 세 가지입니다. 이 상태가 클라이언트 UI 상태인지, 여러 컴포넌트가 공유해야 하는지, 새로고침 후에도 남아야 하는지입니다. 여기에 서버 상태인지 URL 상태인지까지 함께 확인하면 Zustand가 맡을 부분과 다른 도구가 맡을 부분이 더 분명해집니다.

Zustand를 잘 쓰는 코드는 store를 크게 만드는 코드가 아니라, 상태의 책임을 좁게 유지하는 코드입니다. 처음에는 작은 store 하나로 시작해도 괜찮지만, action 이름이 흐려지고 persist 대상이 늘어나고 서버 데이터가 섞이기 시작하면 구조를 다시 나누는 시점으로 봐야 합니다.

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기