쇼핑 카트 (Zustand · 현업 패턴)

튜토리얼 — 쇼핑 카트 (Zustand · 현업 패턴)
카운트 +1은 Zustand 맛만 본 거였죠. 진짜 카트는 다릅니다.
- 어떤 상품이 몇 개 들어 있고
- 수량을 늘리거나 줄이거나 통째로 비우고
- 새로고침해도 살아남고
- 화면 곳곳(헤더의 배지·상세 패널)이 같은 데이터로 늘 일치합니다
이걸 컴포넌트가 복잡해지지 않게 다루는 게 Zustand가 잘하는 일이에요. 이 튜토리얼은 현업에서 가장 자주 만나는 네 가지 조합을 한 번에 보여 줍니다.
- state + actions 를 한 store 안에 — 추가/삭제/수량 변경/비우기
- selector 로 파생값(총 개수·총액) 처리
- persist 미들웨어 로 새로고침 살아남기
- Next.js SSR + persist 의 hydration 다루기 — 입문자가 가장 자주 막히는 지점
개념
store 하나에 상태와 액션이 같이 산다. 컴포넌트에 useState가 흩어지지 않고, 상태를 바꾸는 방법(액션)이 한 곳에 모입니다.
selector 로 필요한 조각만 꺼낸다. useCartStore((s) => s.items) 처럼. 그 조각이 바뀔 때만 그 컴포넌트가 다시 렌더링됩니다. 헤더의 배지(총 개수만 필요)는 어느 상품의 수량이 바뀌든 다시 그려야 하지만, 다른 상품을 추가했다고 결제 버튼 컴포넌트가 다시 그려질 필요는 없죠.
미들웨어로 부가 기능을 입힌다. persist로 localStorage 동기화, devtools로 Redux DevTools 연동. 우리가 짠 store 코드는 그대로 두고 한 번 감싸기만 합니다.
함께 해보기
1단계 — Zustand 설치
npm install zustand
2단계 — Store 설계
stores/useCartStore.ts 파일을 만듭니다. 타입을 먼저 정한 다음 state · actions · selectors · 미들웨어 순서로 쌓아 갑니다.
// stores/useCartStore.ts import { create } from "zustand"; import { persist, devtools, createJSONStorage } from "zustand/middleware"; // ─── 도메인 타입 ────────────────────────────────────────── export type Product = { id: string; name: string; price: number; }; export type CartItem = Product & { quantity: number; }; // ─── store 모양 ────────────────────────────────────────── type CartState = { items: CartItem[]; }; type CartActions = { addItem: (product: Product) => void; removeItem: (id: string) => void; updateQuantity: (id: string, quantity: number) => void; clear: () => void; }; // ─── store 본체 (state + actions + 미들웨어) ───────────── export const useCartStore = create<CartState & CartActions>()( devtools( persist( (set) => ({ items: [], addItem: (product) => set((state) => { const existing = state.items.find((i) => i.id === product.id); // 이미 있으면 수량만 +1, 없으면 새 항목 추가 if (existing) { return { items: state.items.map((i) => i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i, ), }; } return { items: [...state.items, { ...product, quantity: 1 }] }; }), removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id), })), updateQuantity: (id, quantity) => set((state) => { // 0 이하면 자동 제거 if (quantity <= 0) { return { items: state.items.filter((i) => i.id !== id) }; } return { items: state.items.map((i) => i.id === id ? { ...i, quantity } : i, ), }; }), clear: () => set({ items: [] }), }), { name: "shop-cart", // localStorage 의 key storage: createJSONStorage(() => localStorage), }, ), { name: "CartStore" }, // Redux DevTools 에 보일 이름 ), ); // ─── selector (파생값) ────────────────────────────────── // 컴포넌트 밖에 둬서 여러 컴포넌트가 같은 함수 참조를 쓰게 한다. export const selectTotalCount = (state: CartState) => state.items.reduce((sum, i) => sum + i.quantity, 0); export const selectTotalPrice = (state: CartState) => state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
핵심 결정 4가지.
- state 와 actions 를 한 객체로 —
create<CartState & CartActions>()처럼 타입을 합치고, 한 객체 안에서items(데이터)와addItem/removeItem/… (조작)이 함께 산다. - 불변 업데이트 —
set안에서 항상[...state.items, ...]·state.items.map(...)·state.items.filter(...). 기존 배열을 직접 건드리지 않는다. (Reducer 절에서 배운 규칙 그대로) - selector 함수를 컴포넌트 밖으로 — 매 렌더링마다 새로 만들어지는 게 아니라 한 번만 만들어, Zustand 가 "같은 selector"임을 알아챌 수 있게.
- 미들웨어를 양파처럼 감싸기 —
devtools(persist(impl, options), options). 가장 안쪽이 실제 store, 그 위에 persist, 가장 바깥이 devtools. 순서는 관례적으로 devtools 가 가장 바깥.
3단계 — SSR hydration 안전 훅
hooks/useHydrated.ts 파일을 만듭니다. Next.js + persist의 짝입니다.
// hooks/useHydrated.ts import { useEffect, useState } from "react"; /** * 서버 렌더 → 클라이언트 hydration 직후의 짧은 시간을 안전하게 다룬다. * 이 훅이 false 인 동안은 컴포넌트가 "서버가 보낸 그 모습 그대로" 그려지고, * true 가 되는 순간 store 의 진짜(persist 된) 값으로 자연스럽게 전환된다. */ export function useHydrated(): boolean { const [hydrated, setHydrated] = useState(false); useEffect(() => { setHydrated(true); }, []); return hydrated; }
왜 필요한가? 서버에서는 localStorage가 없습니다. 그래서 서버 렌더는 항상 빈 카트로 그려집니다. 클라이언트가 받아 hydration 한 직후 Zustand persist가 localStorage에서 카트를 복원하면, 화면이 갑자기 "빈 카트 → 채워진 카트"로 점프하면서 React가 "서버랑 다르네?" 경고를 띄울 수 있어요. useHydrated는 그 짧은 시간 동안 컴포넌트가 서버와 같은 모습(빈 카트)을 보여 주게 합니다.
4단계 — 상품 목록 컴포넌트
components/shop/ProductList.tsx 파일을 만듭니다.
"use client"; import { useCartStore, type Product } from "@/stores/useCartStore"; const PRODUCTS: Product[] = [ { id: "p1", name: "노트북", price: 1_500_000 }, { id: "p2", name: "마우스", price: 35_000 }, { id: "p3", name: "키보드", price: 120_000 }, { id: "p4", name: "모니터", price: 450_000 }, ]; export default function ProductList() { // 액션만 꺼낸다 — 액션은 안 바뀌니까 ProductList 는 절대 다시 안 렌더링된다. const addItem = useCartStore((s) => s.addItem); return ( <ul className="grid grid-cols-2 gap-3"> {PRODUCTS.map((product) => ( <li key={product.id} className="rounded border p-3"> <h3 className="font-bold">{product.name}</h3> <p className="text-sm">{product.price.toLocaleString()}원</p> <button data-testid={`add-${product.id}`} className="mt-2 rounded bg-blue-500 px-3 py-1 text-white" onClick={() => addItem(product)} > 장바구니 담기 </button> </li> ))} </ul> ); }
useCartStore((s) => s.addItem) 만 구독 — 액션 함수의 참조는 안 바뀌므로 이 컴포넌트는 카트 내용이 바뀌든 말든 다시 그려지지 않습니다. selector 의 보상.
5단계 — 헤더 배지
components/shop/CartBadge.tsx 파일을 만듭니다.
"use client"; import { useCartStore, selectTotalCount } from "@/stores/useCartStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function CartBadge() { const hydrated = useHydrated(); const totalCount = useCartStore(selectTotalCount); return ( <div data-testid="cart-badge" className="rounded-full bg-red-500 px-3 py-1 text-white" > 🛒 {hydrated ? totalCount : 0} </div> ); }
selectTotalCount는 items 배열을 합산해 숫자 하나를 돌려줍니다. 이 숫자가 바뀔 때만 CartBadge가 다시 그려져요. 어느 상품의 수량이 바뀌든 무관 — 합산 결과가 같으면 안 그립니다.
6단계 — 카트 상세 패널
components/shop/CartPanel.tsx 파일을 만듭니다.
"use client"; import { useCartStore, selectTotalCount, selectTotalPrice, } from "@/stores/useCartStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function CartPanel() { const hydrated = useHydrated(); // 필요한 조각만 각각 구독 — 정확히 바뀐 부분만 다시 렌더 const items = useCartStore((s) => s.items); const totalCount = useCartStore(selectTotalCount); const totalPrice = useCartStore(selectTotalPrice); const updateQuantity = useCartStore((s) => s.updateQuantity); const removeItem = useCartStore((s) => s.removeItem); const clear = useCartStore((s) => s.clear); // hydration 이전엔 서버와 같은 모습(빈 카트) if (!hydrated || items.length === 0) { return ( <section className="rounded border p-4"> <h2 className="font-bold">장바구니</h2> <p data-testid="cart-empty" className="text-gray-500"> 비어 있습니다 </p> </section> ); } return ( <section className="space-y-2 rounded border p-4"> <h2 className="font-bold">장바구니 ({totalCount}개)</h2> <ul className="space-y-2"> {items.map((item) => ( <li key={item.id} data-testid={`cart-item-${item.id}`} className="flex items-center justify-between gap-2" > <span className="flex-1"> {item.name} · {item.price.toLocaleString()}원 </span> <button data-testid={`dec-${item.id}`} className="rounded bg-gray-200 px-2" onClick={() => updateQuantity(item.id, item.quantity - 1)} > − </button> <span data-testid={`qty-${item.id}`}>{item.quantity}</span> <button data-testid={`inc-${item.id}`} className="rounded bg-gray-200 px-2" onClick={() => updateQuantity(item.id, item.quantity + 1)} > + </button> <button data-testid={`remove-${item.id}`} className="rounded bg-red-100 px-2 text-red-700" onClick={() => removeItem(item.id)} > 삭제 </button> </li> ))} </ul> <div className="flex justify-between border-t pt-2"> <strong>총액</strong> <strong data-testid="total-price"> {totalPrice.toLocaleString()}원 </strong> </div> <button data-testid="clear-cart" className="rounded bg-gray-600 px-3 py-1 text-white" onClick={clear} > 장바구니 비우기 </button> </section> ); }
7단계 — 페이지 + 확인
app/shop/page.tsx 파일을 만듭니다.
import ProductList from "@/components/shop/ProductList"; import CartBadge from "@/components/shop/CartBadge"; import CartPanel from "@/components/shop/CartPanel"; export default function ShopPage() { return ( <main className="space-y-4 p-6"> <header className="flex items-center justify-between"> <h1 className="text-2xl font-bold">🛍 쇼핑몰</h1> <CartBadge /> </header> <ProductList /> <CartPanel /> </main> ); }
/shop을 엽니다. 다음을 확인하세요.
- 상품 4개 + 빈 카트 + 배지
🛒 0. ✅ - "장바구니 담기" 클릭 → 배지 갱신, 카트에 항목 추가, 총액 계산. ✅
- 같은 상품 또 담기 → 새 항목이 아니라 수량만 +1. ✅
+/−로 수량 조절, 0 이 되면 자동 제거. ✅- "삭제" → 그 항목만 사라짐. ✅
- "장바구니 비우기" → 전부 사라짐. ✅
- 새로고침 (Cmd+R) → 카트가 그대로 있음. ← persist 미들웨어의 보상 ✅
더 깊이 들어가기
Q1. 왜 useState가 아니라 Zustand 인가?
장바구니는 여러 컴포넌트가 같은 데이터를 봐야 합니다 — 헤더 배지, 카트 패널, 결제 버튼, 사이드 알림. 이 모든 곳에 useState로는 못 합니다(상태가 한 컴포넌트에 갇히니까). Context 로도 가능하지만, Context 는 값이 바뀌면 그 통로를 듣는 모든 컴포넌트가 다 다시 렌더링 됩니다. Zustand는 selector 단위로 구독해서, 수량 합계 selector를 듣는 컴포넌트는 합계가 바뀔 때만 다시 그려져요. 큰 화면에서는 이게 곧 성능입니다.
Q2. selector를 왜 컴포넌트 밖에 두나요?
// 좋음 — 한 번만 만들어진다 export const selectTotalCount = (s: CartState) => ...; // 안 좋음 — 매 렌더링마다 새 함수 참조 const totalCount = useCartStore((s) => s.items.reduce(...));
후자도 동작하긴 합니다. 다만 selector 함수가 매번 새로 만들어져 Zustand 가 "같은 selector 인지" 비교를 못 하므로, 동일 컴포넌트가 가벼운 추가 작업을 매번 합니다. 그리고 같은 selector 를 두 컴포넌트가 쓰면 한 곳에서 정의해 재사용 하는 게 깔끔합니다.
Q3. useHydrated 없이 그냥 쓰면 안 되나요?
써도 동작합니다. 다만 새로고침 직후 화면이 "빈 카트 → 채워진 카트"로 점프 하는 시각적 깜빡임이 나고, 콘솔에 hydration mismatch 경고가 뜰 수 있어요. useHydrated 한 줄로 깜빡임도 경고도 깔끔히 사라집니다. Next.js + persist 의 표준 짝이에요.
Q4. Redux DevTools — 굳이 미들웨어를 끼는 이유?
크롬에 Redux DevTools 확장을 깔면, store 의 상태 변경이 시간 순서로 보입니다. "어느 액션이 부르는 set 으로 상태가 어떻게 바뀌었는가" 를 클릭으로 되감을 수 있어요. 확장이 없어도 코드는 그대로 돌아갑니다 — 미들웨어는 개발자 도구가 있을 때만 활성화돼요.
Q5. store 가 더 커지면?
도메인이 늘면 (cart, user, ui, notifications…) 한 파일이 무거워집니다. 그때는 slices 패턴으로 나눕니다.
const createCartSlice = (set) => ({ items: [], addItem: ..., ... }); const createUserSlice = (set) => ({ user: null, login: ..., ... }); export const useStore = create<CartSlice & UserSlice>()( persist( (...a) => ({ ...createCartSlice(...a), ...createUserSlice(...a) }), { name: "app-state" }, ), );
도메인별 파일로 분리하되 store 인스턴스는 하나만. Zustand 공식 예제에 자세히 나옵니다.
정리
- store 하나에 state + actions 묶기. 컴포넌트는 selector 로 필요한 조각만 꺼내 쓴다.
- 불변 업데이트 (
[...arr]·map·filter) 는 reducer 절의 규칙 그대로. - selector 함수는 모듈 스코프에 — 재사용 + 안정적 참조.
- persist 미들웨어 한 줄로 localStorage 동기화. Next.js 에서는
useHydrated와 짝. - selector 단위 구독 덕분에 큰 화면에서도 불필요한 렌더링이 적다.
연습 거리
- 할인 쿠폰 미들웨어처럼 store 에
discountstate 와applyCoupon(code)액션 추가.selectTotalPrice가 할인을 반영하도록 수정. - 즐겨찾기 별도 슬라이스(
favorites: string[],toggleFavorite(id))를 같은 store 에 추가. slices 패턴 연습. - 수량 상한 —
addItem/updateQuantity가 10 개를 넘지 못하게 제한. 상한 도달 시 액션이 무시되고 콘솔 경고. - 결제 버튼 컴포넌트를 추가해
selectTotalPrice만 구독. 카트 내용을 바꿔도total === 0일 때만 비활성화되도록.

댓글
댓글을 작성하려면 이 필요합니다.