React 상태관리 완벽 가이드
Context API, Redux, Zustand 비교와 실전
학습 항목
이미지 로딩 중...
Next.js Zustand 상태관리 완벽 가이드
Next.js 프로젝트에서 Zustand를 활용한 효율적인 상태관리 방법을 배웁니다. 간단한 API부터 실무에서 바로 사용할 수 있는 고급 패턴까지 단계별로 학습합니다.
목차
- Zustand 기본 설정 - Next.js에서 스토어 생성하기
- 컴포넌트에서 스토어 사용하기 - 선택적 구독으로 성능 최적화
- 비동기 액션 처리 - API 호출과 로딩 상태 관리
- 미들웨어 활용 - Persist로 로컬 스토리지 동기화
- 스토어 분리와 조합 - 관심사 분리로 확장성 높이기
- 액션과 상태 분리 - 코드 재사용성 극대화
- Immer 미들웨어 - 불변성 관리 간소화
- Devtools 통합 - 상태 변화 추적과 디버깅
- TypeScript 고급 패턴 - 타입 안전성 극대화
- SSR과 하이드레이션 - Next.js App Router 완벽 대응
1. Zustand 기본 설정 - Next.js에서 스토어 생성하기
시작하며
여러분이 Next.js 프로젝트에서 상태관리를 하려고 Redux나 Context API를 설정할 때, 보일러플레이트 코드가 너무 많아서 답답했던 경험 있으신가요? Provider로 컴포넌트를 감싸고, action과 reducer를 분리하고, 타입을 일일이 정의하는 과정이 번거로웠을 겁니다.
이런 복잡함은 개발 속도를 늦추고, 특히 빠르게 프로토타입을 만들어야 하는 스타트업 환경에서는 치명적입니다. 상태관리 라이브러리를 설정하는 데만 몇 시간을 쓰다 보면 정작 중요한 비즈니스 로직 개발에 집중하기 어렵습니다.
바로 이럴 때 필요한 것이 Zustand입니다. Zustand는 단 몇 줄의 코드로 전역 상태를 만들 수 있고, Provider 없이도 어디서든 상태를 사용할 수 있습니다.
지금부터 Next.js 환경에서 Zustand 스토어를 생성하는 방법을 알아보겠습니다.
개요
간단히 말해서, Zustand 스토어는 전역 상태와 그 상태를 변경하는 함수들을 담은 하나의 객체입니다. Redux처럼 복잡한 설정 없이도 강력한 상태관리를 할 수 있습니다.
예를 들어, 사용자 정보, 장바구니 데이터, 모달 상태 같은 여러 컴포넌트에서 공유해야 하는 데이터를 관리할 때 매우 유용합니다. 기존에는 Context API를 사용해 Provider로 앱 전체를 감싸고 useContext로 값을 가져왔다면, 이제는 create 함수 하나로 스토어를 만들고 어디서든 import해서 사용할 수 있습니다.
Zustand의 핵심 특징은 크게 세 가지입니다. 첫째, 보일러플레이트가 거의 없어 코드가 간결합니다.
둘째, TypeScript 지원이 뛰어나 타입 안정성을 확보할 수 있습니다. 셋째, React의 렌더링 최적화와 완벽하게 호환됩니다.
이러한 특징들이 현대 Next.js 개발에서 생산성을 크게 높여줍니다.
코드 예제
// store/userStore.ts
import { create } from 'zustand'
// 타입 정의
interface UserState {
user: { name: string; email: string } | null
isLoggedIn: boolean
// 상태를 변경하는 액션들
login: (name: string, email: string) => void
logout: () => void
}
// Zustand 스토어 생성
export const useUserStore = create<UserState>((set) => ({
user: null,
isLoggedIn: false,
// set 함수로 상태 업데이트
login: (name, email) => set({ user: { name, email }, isLoggedIn: true }),
logout: () => set({ user: null, isLoggedIn: false }),
}))
설명
이것이 하는 일: 위 코드는 사용자 로그인 정보를 관리하는 전역 스토어를 생성합니다. 앱 어디서든 이 스토어를 import하면 로그인 상태를 확인하고 변경할 수 있습니다.
첫 번째로, UserState 인터페이스를 정의합니다. 이는 스토어가 가질 상태의 구조와 타입을 명시합니다.
user 객체는 사용자 정보를 담고, isLoggedIn은 로그인 여부를 나타냅니다. login과 logout은 함수 타입으로, 상태를 변경하는 액션을 의미합니다.
이렇게 타입을 먼저 정의하면 TypeScript의 자동완성과 타입 체크를 활용할 수 있습니다. 그 다음으로, create 함수가 실행되면서 실제 스토어를 만듭니다.
create의 콜백 함수는 set이라는 매개변수를 받는데, 이것이 상태를 업데이트하는 핵심 함수입니다. 초기 상태로 user는 null, isLoggedIn은 false를 설정했습니다.
login 함수 내부에서 set을 호출하면 user와 isLoggedIn이 동시에 업데이트되고, 이 스토어를 구독하는 모든 컴포넌트가 자동으로 리렌더링됩니다. 세 번째 단계로, logout 함수는 상태를 초기 상태로 되돌립니다.
set 함수에 전달된 객체는 기존 상태와 병합되는 것이 아니라 해당 키의 값만 교체합니다. 최종적으로 useUserStore라는 커스텀 훅이 만들어지며, 이를 컴포넌트에서 호출하면 상태와 액션에 접근할 수 있습니다.
여러분이 이 코드를 사용하면 Provider 설정 없이 바로 전역 상태관리를 시작할 수 있습니다. Redux와 달리 액션 타입, 리듀서, 디스패치 등의 개념이 필요 없어 학습 곡선이 낮고, Next.js의 Server Components와도 잘 호환됩니다.
코드량이 줄어들어 유지보수가 쉬워지고, 새로운 팀원이 합류해도 빠르게 이해할 수 있습니다.
실전 팁
💡 스토어 파일은 store 또는 lib 폴더에 모아두고, 도메인별로 분리하세요. userStore, cartStore, uiStore처럼 관심사를 나누면 코드 관리가 훨씬 수월합니다.
💡 Next.js App Router를 사용한다면 'use client' 지시문이 필요합니다. Zustand는 클라이언트 상태관리 라이브러리이므로 서버 컴포넌트에서는 직접 사용할 수 없습니다.
💡 set 함수는 얕은 병합을 수행합니다. 중첩된 객체를 업데이트할 때는 스프레드 연산자를 활용해 명시적으로 병합하세요: set(state => ({ user: { ...state.user, name: 'New' } }))
💡 개발 중에는 devtools 미들웨어를 추가하면 Redux DevTools로 상태 변화를 추적할 수 있습니다. 디버깅이 훨씬 편리해집니다.
💡 초기 상태가 복잡하다면 함수로 분리하세요. const initialState = { user: null, isLoggedIn: false }처럼 상수로 빼두면 테스트와 리셋이 쉬워집니다.
2. 컴포넌트에서 스토어 사용하기 - 선택적 구독으로 성능 최적화
시작하며
여러분이 전역 스토어를 만들었는데, 사용자 이름만 필요한 컴포넌트가 이메일이 바뀔 때마다 리렌더링된다면 어떨까요? 불필요한 렌더링이 쌓이면 앱이 느려지고, 특히 리스트나 복잡한 UI에서는 성능 문제가 눈에 띄게 나타납니다.
이런 문제는 전역 상태의 모든 변경사항을 컴포넌트가 구독하기 때문에 발생합니다. Redux의 경우 reselect 같은 별도의 라이브러리로 메모이제이션을 해야 하지만, Zustand는 기본 기능만으로 선택적 구독이 가능합니다.
바로 이럴 때 필요한 것이 Zustand의 셀렉터 패턴입니다. 필요한 상태만 정확히 선택해서 구독하면, 그 값이 바뀔 때만 컴포넌트가 리렌더링됩니다.
지금부터 효율적인 스토어 사용법을 배워보겠습니다.
개요
간단히 말해서, 셀렉터는 스토어에서 필요한 부분만 골라내는 함수입니다. 컴포넌트마다 필요한 상태가 다르므로, 전체 스토어를 구독하는 대신 특정 값만 선택해서 가져오는 것이 효율적입니다.
예를 들어, 헤더 컴포넌트는 isLoggedIn만, 프로필 컴포넌트는 user.name만 필요할 수 있습니다. 이렇게 필요한 것만 구독하면 성능이 크게 개선됩니다.
기존에는 useContext로 전체 컨텍스트 값을 가져왔기 때문에 컨텍스트의 어떤 부분이 바뀌든 모든 소비자가 리렌더링되었다면, 이제는 useUserStore(state => state.isLoggedIn)처럼 특정 값만 선택할 수 있습니다. Zustand 셀렉터의 핵심 특징은 자동 최적화입니다.
선택한 값이 실제로 변경되지 않으면 컴포넌트가 리렌더링되지 않습니다. 내부적으로 Object.is로 이전 값과 비교하여 동일하면 업데이트를 건너뜁니다.
이는 수동으로 메모이제이션을 설정할 필요 없이 자동으로 성능을 보장해줍니다.
코드 예제
// components/Header.tsx
'use client'
import { useUserStore } from '@/store/userStore'
export default function Header() {
// 필요한 상태와 액션만 선택적으로 구독
const isLoggedIn = useUserStore((state) => state.isLoggedIn)
const userName = useUserStore((state) => state.user?.name)
const logout = useUserStore((state) => state.logout)
return (
<header>
{isLoggedIn ? (
<div>
<span>환영합니다, {userName}님!</span>
{/* 액션 함수를 직접 호출 */}
<button onClick={logout}>로그아웃</button>
</div>
) : (
<a href="/login">로그인</a>
)}
</header>
)
}
설명
이것이 하는 일: 위 코드는 헤더 컴포넌트에서 로그인 상태와 사용자 이름만 선택적으로 구독하여, 불필요한 리렌더링을 방지합니다. 첫 번째로, useUserStore 훅을 세 번 호출하는데, 각각 다른 셀렉터 함수를 전달합니다.
(state) => state.isLoggedIn은 스토어 전체에서 isLoggedIn 값만 추출합니다. 이렇게 하면 user 객체가 바뀌어도 isLoggedIn이 같다면 이 줄은 리렌더링을 발생시키지 않습니다.
셀렉터를 나누면 각 값이 독립적으로 추적됩니다. 그 다음으로, userName을 가져올 때 옵셔널 체이닝(?.)을 사용합니다.
user가 null일 수 있으므로 안전하게 접근해야 합니다. logout 액션도 동일한 방식으로 가져오는데, 함수는 참조가 변경되지 않으므로 이 부분은 리렌더링을 유발하지 않습니다.
Zustand는 액션 함수를 안정적으로 유지합니다. 세 번째로, JSX에서 isLoggedIn 값에 따라 조건부 렌더링을 합니다.
로그인 상태일 때만 사용자 이름과 로그아웃 버튼을 표시하고, 버튼 클릭 시 logout 함수를 직접 호출합니다. logout이 실행되면 스토어의 상태가 변경되고, 이를 구독하는 모든 컴포넌트(이 Header 포함)가 새로운 값으로 자동 업데이트됩니다.
여러분이 이 코드를 사용하면 Redux의 useSelector와 useDispatch를 합친 것과 같은 효과를 얻으면서도 코드가 훨씬 간결합니다. 셀렉터를 잘 활용하면 대규모 앱에서도 렌더링 성능을 유지할 수 있고, 각 컴포넌트가 정확히 필요한 데이터만 구독하므로 디버깅도 쉬워집니다.
또한 TypeScript의 타입 추론이 자동으로 작동하여 오타나 잘못된 접근을 컴파일 타임에 잡아줍니다.
실전 팁
💡 여러 값을 한 번에 가져와야 한다면 객체로 반환하세요: useUserStore(state => ({ name: state.user?.name, email: state.user?.email })). 단, 객체는 매번 새로 생성되므로 shallow 비교를 사용해야 합니다.
💡 shallow 비교를 위해 import { shallow } from 'zustand/shallow'를 사용하고, useUserStore(selector, shallow)처럼 두 번째 인자로 전달하면 객체의 속성을 개별적으로 비교합니다.
💡 액션 함수는 컴포넌트 외부로 빼낼 수 있습니다. const logout = useUserStore.getState().logout처럼 훅 없이 가져오면 이벤트 핸들러에서도 사용 가능합니다.
💡 계산된 값이 필요하다면 셀렉터 내부에서 처리하세요: useUserStore(state => state.user ? ${state.user.name} (${state.user.email}) : '게스트'). 매번 같은 입력이면 같은 출력이 보장됩니다.
💡 성능 최적화를 위해 셀렉터를 컴포넌트 외부에 상수로 선언할 수 있습니다: const selectIsLoggedIn = (state) => state.isLoggedIn. 하지만 대부분의 경우 인라인 화살표 함수로 충분합니다.
3. 비동기 액션 처리 - API 호출과 로딩 상태 관리
시작하며
여러분이 API에서 데이터를 가져올 때, 로딩 스피너를 보여주고 에러를 처리하는 로직을 매번 컴포넌트에 작성하고 있나요? 같은 패턴의 코드가 여러 컴포넌트에 중복되면 유지보수가 어렵고, 로딩 상태와 에러 상태를 일관되게 관리하기 힘듭니다.
이런 문제는 비동기 로직이 컴포넌트에 흩어져 있기 때문에 발생합니다. Redux에서는 redux-thunk나 redux-saga 같은 미들웨어를 추가로 설정해야 하지만, 이 역시 보일러플레이트를 늘립니다.
비동기 처리가 복잡해질수록 코드는 더 읽기 어려워집니다. 바로 이럴 때 필요한 것이 Zustand의 비동기 액션입니다.
스토어 내부에 async 함수를 정의하면, API 호출부터 로딩/에러 상태 관리까지 한 곳에서 처리할 수 있습니다. 컴포넌트는 단순히 액션을 호출하고 상태를 구독하기만 하면 됩니다.
개요
간단히 말해서, Zustand의 비동기 액션은 스토어 내부에 async/await를 사용하는 일반 함수를 추가하는 것입니다. 상태 업데이트가 필요한 시점마다 set을 호출하면 되므로, 로딩 시작, 데이터 로드 완료, 에러 발생 같은 각 단계를 명확하게 표현할 수 있습니다.
예를 들어, 사용자 목록을 가져오는 fetchUsers 함수를 스토어에 두면, 모든 컴포넌트가 일관된 방식으로 데이터를 로드할 수 있습니다. 기존에는 useEffect 내부에서 fetch를 호출하고 useState로 로딩/에러를 관리했다면, 이제는 스토어의 fetchUsers만 호출하면 모든 상태가 자동으로 업데이트됩니다.
비동기 액션의 핵심 특징은 로직의 중앙화입니다. API 엔드포인트, 에러 처리, 재시도 로직 등을 스토어에 캡슐화하면 컴포넌트는 UI 렌더링에만 집중할 수 있습니다.
또한 같은 액션을 여러 컴포넌트에서 호출해도 상태는 하나이므로 데이터 일관성이 보장됩니다. 테스트할 때도 스토어 액션만 단위 테스트하면 되어 편리합니다.
코드 예제
// store/productStore.ts
import { create } from 'zustand'
interface Product {
id: number
name: string
price: number
}
interface ProductState {
products: Product[]
isLoading: boolean
error: string | null
// 비동기 액션
fetchProducts: () => Promise<void>
}
export const useProductStore = create<ProductState>((set) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
// 로딩 시작
set({ isLoading: true, error: null })
try {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('상품 로드 실패')
const data = await response.json()
// 성공 시 데이터 업데이트
set({ products: data, isLoading: false })
} catch (error) {
// 에러 발생 시 에러 상태 업데이트
set({ error: (error as Error).message, isLoading: false })
}
},
}))
설명
이것이 하는 일: 위 코드는 상품 목록을 API에서 가져오는 비동기 액션을 스토어에 정의하여, 모든 컴포넌트가 일관되게 데이터를 로드하고 로딩 상태를 추적할 수 있게 합니다. 첫 번째로, ProductState 인터페이스에 데이터(products), 로딩 상태(isLoading), 에러 메시지(error)를 모두 포함시킵니다.
이 세 가지는 비동기 처리의 필수 요소입니다. fetchProducts는 Promise<void>를 반환하는 비동기 함수로 선언하여, 호출 측에서 await를 사용할 수 있게 합니다.
타입 정의를 명확히 하면 실수를 줄일 수 있습니다. 그 다음으로, fetchProducts 함수 내부에서 먼저 set({ isLoading: true, error: null })을 호출합니다.
이는 이전 에러를 초기화하고 로딩 상태를 활성화하여, UI에서 스피너를 보여줄 수 있게 합니다. try 블록에서 fetch API를 호출하고, response.ok를 확인하여 HTTP 에러를 감지합니다.
성공하면 data를 파싱하여 set({ products: data, isLoading: false })로 상태를 업데이트합니다. 이 시점에 products를 구독하는 컴포넌트들이 새 데이터로 리렌더링됩니다.
세 번째로, catch 블록에서 에러를 잡아 set({ error: error.message, isLoading: false })로 에러 상태를 설정합니다. isLoading을 false로 바꾸는 것을 잊지 않아야 합니다.
그렇지 않으면 UI가 영원히 로딩 중 상태로 남습니다. 최종적으로 컴포넌트는 const { products, isLoading, error, fetchProducts } = useProductStore()처럼 필요한 값을 가져와서, useEffect에서 fetchProducts()를 호출하거나 버튼 클릭 시 실행할 수 있습니다.
여러분이 이 코드를 사용하면 비동기 로직을 컴포넌트에서 완전히 분리할 수 있습니다. 여러 페이지에서 같은 상품 데이터가 필요해도 fetchProducts 한 번 호출로 모든 곳이 업데이트되고, 캐싱 로직을 추가하기도 쉽습니다.
에러 처리가 중앙화되어 사용자에게 일관된 에러 메시지를 보여줄 수 있고, 나중에 재시도 로직이나 낙관적 업데이트를 추가할 때도 스토어만 수정하면 됩니다.
실전 팁
💡 AbortController를 사용해 컴포넌트 언마운트 시 진행 중인 요청을 취소하세요. fetchProducts에 signal을 전달하고 cleanup에서 abort()를 호출하면 메모리 누수를 방지할 수 있습니다.
💡 낙관적 업데이트(Optimistic Update)를 구현하려면 set을 두 번 호출하세요. 먼저 예상 결과로 즉시 업데이트하고, API 응답 후 실제 데이터로 교체합니다. 에러 시 롤백도 잊지 마세요.
💡 재시도 로직을 추가하려면 재귀 호출이나 반복문을 사용하세요. let retries = 3; while (retries > 0) { try { ... break } catch { retries-- } } 같은 패턴이 유용합니다.
💡 디바운싱이 필요한 검색 같은 경우, lodash.debounce로 액션을 감싸거나 Zustand의 미들웨어를 활용하세요. 불필요한 API 호출을 줄여 성능과 비용을 절약할 수 있습니다.
💡 로딩 상태를 더 세밀하게 관리하려면 상태를 'idle' | 'loading' | 'success' | 'error' 같은 문자열 유니온 타입으로 바꾸세요. 각 상태를 명확히 구분할 수 있습니다.
4. 미들웨어 활용 - Persist로 로컬 스토리지 동기화
시작하며
여러분이 사용자가 장바구니에 상품을 담았는데, 페이지를 새로고침하면 모든 데이터가 사라지는 경험을 해본 적 있나요? 전역 상태는 메모리에만 존재하므로, 브라우저를 닫거나 새로고침하면 초기화됩니다.
이는 사용자 경험을 크게 해치는 문제입니다. 이런 문제를 해결하려면 상태를 로컬 스토리지나 세션 스토리지에 저장해야 하는데, 수동으로 구현하면 상태가 변경될 때마다 localStorage.setItem을 호출하고, 초기화 시 localStorage.getItem으로 복원하는 코드를 작성해야 합니다.
이는 번거롭고 실수하기 쉽습니다. 바로 이럴 때 필요한 것이 Zustand의 persist 미들웨어입니다.
단 몇 줄만 추가하면 스토어가 자동으로 로컬 스토리지와 동기화되고, 페이지를 새로고침해도 상태가 유지됩니다. 지금부터 영구 저장이 필요한 상태를 관리하는 방법을 배워보겠습니다.
개요
간단히 말해서, persist 미들웨어는 스토어의 상태를 자동으로 브라우저 저장소에 저장하고 복원하는 기능입니다. 상태가 변경될 때마다 자동으로 저장되고, 앱이 시작될 때 자동으로 불러와서 초기 상태로 설정됩니다.
예를 들어, 다크 모드 설정, 사용자 언어 선택, 장바구니 아이템 같이 세션을 넘어 유지되어야 하는 데이터에 이상적입니다. 기존에는 useEffect에서 localStorage를 읽고 쓰는 코드를 직접 작성했다면, 이제는 create를 persist로 감싸기만 하면 모든 것이 자동화됩니다.
persist 미들웨어의 핵심 특징은 투명성입니다. 스토어를 사용하는 코드는 전혀 바뀌지 않으며, 단지 저장과 복원이 자동으로 일어날 뿐입니다.
저장소 종류(localStorage, sessionStorage, AsyncStorage 등)를 선택할 수 있고, 일부 상태만 선택적으로 저장하거나 직렬화 방식을 커스터마이징할 수도 있습니다. SSR 환경에서도 안전하게 작동하도록 설계되어 Next.js와 완벽히 호환됩니다.
코드 예제
// store/cartStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface CartItem {
id: number
name: string
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: number) => void
clearCart: () => void
}
export const useCartStore = create<CartState>()(
// persist 미들웨어로 감싸기
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
})),
clearCart: () => set({ items: [] }),
}),
{
name: 'cart-storage', // 로컬 스토리지 키 이름
storage: createJSONStorage(() => localStorage), // 저장소 종류
}
)
)
설명
이것이 하는 일: 위 코드는 장바구니 상태를 로컬 스토리지에 자동으로 저장하여, 브라우저를 닫았다가 다시 열어도 장바구니 내용이 그대로 유지되게 합니다. 첫 번째로, zustand/middleware에서 persist와 createJSONStorage를 import합니다.
persist는 스토어를 감싸는 고차 함수이고, createJSONStorage는 저장소 어댑터를 생성합니다. create 다음에 빈 괄호 ()를 추가하는 것이 중요한데, 이는 TypeScript의 타입 추론을 위한 패턴입니다.
이렇게 하면 미들웨어를 사용하면서도 타입 안정성을 유지할 수 있습니다. 그 다음으로, persist 함수의 첫 번째 인자로 기존 스토어 정의를 전달합니다.
addItem, removeItem 같은 액션은 평소처럼 작성하면 됩니다. 두 번째 인자는 설정 객체인데, name은 localStorage에 저장될 키 이름을 지정합니다.
브라우저 개발자 도구의 Application 탭에서 'cart-storage' 키로 데이터를 확인할 수 있습니다. storage 옵션으로 localStorage를 명시적으로 지정하여, sessionStorage나 다른 저장소로 쉽게 바꿀 수 있습니다.
세 번째로, 상태가 변경될 때마다 persist는 자동으로 직렬화(JSON.stringify)하여 저장소에 씁니다. 앱이 로드될 때는 역직렬화(JSON.parse)하여 초기 상태로 복원합니다.
만약 저장된 데이터가 없으면 스토어의 기본 초기값(items: [])을 사용합니다. 최종적으로 사용자가 addItem을 호출하면 메모리 상태와 로컬 스토리지가 모두 업데이트되어, 새로고침 후에도 같은 데이터를 볼 수 있습니다.
여러분이 이 코드를 사용하면 사용자가 장바구니에 상품을 담고 실수로 브라우저를 닫아도 데이터가 보존됩니다. 전자상거래 사이트에서 이는 매우 중요한 기능이며, 전환율을 높이는 데 직접적으로 기여합니다.
또한 persist는 성능 최적화도 내장하고 있어, 상태 변경이 빈번해도 쓰로틀링을 통해 과도한 저장소 쓰기를 방지합니다. Next.js의 SSR에서도 서버에서는 저장소 접근을 건너뛰고 클라이언트에서만 hydration하므로 안전합니다.
실전 팁
💡 일부 상태만 저장하려면 partialize 옵션을 사용하세요: partialize: (state) => ({ items: state.items }). 함수나 큰 객체를 제외하여 저장소 용량을 절약할 수 있습니다.
💡 Next.js App Router에서는 'use client' 컴포넌트에서만 persist 스토어를 사용하세요. 서버 컴포넌트에서는 localStorage에 접근할 수 없습니다.
💡 마이그레이션이 필요하면 version과 migrate 옵션을 활용하세요. 스토어 구조가 바뀔 때 기존 사용자의 저장된 데이터를 새 형식으로 변환할 수 있습니다.
💡 민감한 데이터(토큰, 비밀번호 등)는 로컬 스토리지에 저장하지 마세요. XSS 공격에 취약합니다. 대신 httpOnly 쿠키나 메모리 상태를 사용하세요.
💡 저장소가 가득 찰 때를 대비해 try-catch로 에러를 처리하세요. onRehydrateStorage 콜백을 사용하면 복원 중 발생하는 에러를 로깅하거나 처리할 수 있습니다.
5. 스토어 분리와 조합 - 관심사 분리로 확장성 높이기
시작하며
여러분이 앱이 커지면서 하나의 스토어에 사용자 정보, 상품 목록, UI 상태, 설정 등 모든 것을 담고 있나요? 스토어가 수백 줄이 되면 어떤 상태가 어떤 액션과 관련 있는지 파악하기 어렵고, 여러 개발자가 동시에 수정할 때 충돌이 자주 발생합니다.
이런 문제는 단일 책임 원칙(Single Responsibility Principle)을 위배하기 때문에 생깁니다. 모든 상태를 한 곳에 모으면 결합도가 높아지고, 테스트와 유지보수가 점점 어려워집니다.
Redux에서는 리듀서를 combineReducers로 합치지만, 이 역시 설정이 복잡합니다. 바로 이럴 때 필요한 것이 스토어 분리 패턴입니다.
Zustand는 여러 개의 독립적인 스토어를 만들 수 있고, 필요하면 스토어끼리 참조하여 조합할 수도 있습니다. 각 스토어가 명확한 책임을 가지면 코드가 깔끔해지고 재사용성도 높아집니다.
개요
간단히 말해서, 스토어 분리는 도메인이나 기능별로 여러 개의 작은 스토어를 만드는 것입니다. 각 스토어는 하나의 관심사만 다루므로 이해하기 쉽고, 독립적으로 테스트할 수 있습니다.
예를 들어, useAuthStore는 인증만, useProductStore는 상품 데이터만, useUIStore는 모달이나 사이드바 같은 UI 상태만 관리합니다. 이렇게 분리하면 각 스토어의 크기가 적당하게 유지됩니다.
기존에는 거대한 하나의 스토어를 여러 개발자가 수정하면서 merge 충돌이 잦았다면, 이제는 각자 담당하는 스토어만 수정하면 되므로 협업이 훨씬 원활해집니다. 스토어 분리의 핵심 특징은 느슨한 결합(Loose Coupling)입니다.
각 스토어는 독립적으로 작동하지만, 필요할 때 다른 스토어를 import하여 상태를 읽거나 액션을 호출할 수 있습니다. 이는 모듈화의 이점을 그대로 가져오면서도 유연성을 유지합니다.
또한 특정 스토어만 persist 미들웨어를 적용하거나, 개발 환경에서만 devtools를 활성화하는 등 세밀한 제어가 가능합니다.
코드 예제
// store/authStore.ts
import { create } from 'zustand'
interface AuthState {
token: string | null
isAuthenticated: boolean
login: (token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
}))
// store/notificationStore.ts
import { create } from 'zustand'
import { useAuthStore } from './authStore' // 다른 스토어 참조
interface NotificationState {
notifications: string[]
addNotification: (message: string) => void
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
addNotification: (message) => {
// 다른 스토어의 상태를 읽기
const isAuthenticated = useAuthStore.getState().isAuthenticated
if (isAuthenticated) {
set((state) => ({ notifications: [...state.notifications, message] }))
}
},
}))
설명
이것이 하는 일: 위 코드는 인증 스토어와 알림 스토어를 분리하여 각각의 책임을 명확히 하고, 필요한 경우 스토어 간 통신을 통해 협력하게 합니다. 첫 번째로, authStore는 인증 관련 상태만 관리합니다.
token과 isAuthenticated는 사용자의 로그인 상태를 나타내고, login과 logout은 이를 변경하는 액션입니다. 이 스토어는 다른 것에 의존하지 않으며, 순수하게 인증 로직만 담당합니다.
파일을 분리하여 store/authStore.ts로 저장하면 나중에 찾기도 쉽고, 권한 관리 기능을 추가할 때 이 파일만 수정하면 됩니다. 그 다음으로, notificationStore는 알림 메시지를 관리하는데, addNotification 액션 내부에서 authStore를 참조합니다.
useAuthStore.getState()를 호출하면 현재 스토어의 상태 스냅샷을 얻을 수 있습니다. 이는 리액트 훅이 아닌 일반 함수 호출이므로, 스토어 액션 내부나 이벤트 핸들러에서도 사용할 수 있습니다.
isAuthenticated를 확인하여 로그인한 사용자에게만 알림을 추가하는 로직을 구현했습니다. 세 번째로, 이런 패턴을 사용하면 스토어 간 의존성을 명시적으로 관리할 수 있습니다.
notificationStore는 authStore에 의존하지만, authStore는 notificationStore를 몰라도 됩니다. 이는 단방향 의존성으로, 순환 참조를 방지하고 코드 흐름을 명확하게 합니다.
최종적으로 컴포넌트에서는 const auth = useAuthStore(), const { addNotification } = useNotificationStore()처럼 필요한 스토어만 선택적으로 사용할 수 있습니다. 여러분이 이 코드를 사용하면 대규모 앱에서도 각 스토어가 적절한 크기를 유지하여 가독성이 높아집니다.
새로운 기능을 추가할 때 어느 스토어에 넣어야 할지 명확하고, 특정 도메인을 리팩토링할 때 해당 스토어만 수정하면 되므로 위험도가 낮습니다. 또한 스토어별로 다른 미들웨어를 적용할 수 있어, 예를 들어 authStore는 persist를 사용하고 notificationStore는 메모리에만 두는 식의 세밀한 제어가 가능합니다.
실전 팁
💡 스토어가 서로 순환 참조하지 않도록 주의하세요. A가 B를 참조하고 B가 A를 참조하면 초기화 에러가 발생할 수 있습니다. 의존성 그래프를 단방향으로 유지하세요.
💡 공통 로직은 별도의 헬퍼 함수로 빼세요. 여러 스토어에서 같은 검증 로직을 쓴다면 utils 폴더에 함수를 만들고 import하세요.
💡 getState()는 현재 상태의 스냅샷만 반환하므로, 상태 변화를 구독하지 않습니다. 실시간으로 다른 스토어를 감시하려면 subscribe API를 사용하세요.
💡 스토어 개수가 너무 많아지면 관리가 어려워집니다. 보통 5-10개 정도가 적당하며, 그 이상은 폴더 구조로 그룹화하세요 (예: store/auth/, store/product/).
💡 타입을 공유해야 한다면 types 폴더에 공통 타입을 정의하고 여러 스토어에서 import하세요. 중복된 타입 정의를 줄일 수 있습니다.
6. 액션과 상태 분리 - 코드 재사용성 극대화
시작하며
여러분이 여러 스토어에서 비슷한 CRUD 패턴(생성, 읽기, 수정, 삭제)을 반복적으로 작성하고 있나요? 사용자 목록, 상품 목록, 게시글 목록 모두 비슷한 fetchAll, create, update, delete 로직을 가지는데, 매번 복사-붙여넣기하면 코드 중복이 심해지고 버그가 생길 여지가 많아집니다.
이런 문제는 액션 로직이 스토어에 강하게 결합되어 있기 때문입니다. 같은 패턴을 추상화하지 못하면 유지보수 비용이 기하급수적으로 증가하고, 나중에 에러 처리 방식을 바꾸려면 모든 스토어를 수정해야 합니다.
바로 이럴 때 필요한 것이 액션 팩토리 패턴입니다. 공통 로직을 함수로 추출하여 재사용하면, 스토어는 상태와 도메인별 로직만 가지고 나머지는 헬퍼 함수에 위임할 수 있습니다.
이렇게 하면 DRY(Don't Repeat Yourself) 원칙을 지킬 수 있습니다.
개요
간단히 말해서, 액션 팩토리는 공통적인 비동기 로직을 함수로 만들어 여러 스토어에서 재사용하는 패턴입니다. CRUD 작업은 대부분 비슷한 흐름을 따르므로, 이를 제네릭 함수로 추상화하면 엄청난 코드 절약이 가능합니다.
예를 들어, createCRUDActions<T>(endpoint)처럼 API 엔드포인트만 전달하면 모든 CRUD 액션을 자동으로 생성하는 함수를 만들 수 있습니다. 기존에는 각 스토어마다 거의 같은 fetch, create, update, delete 함수를 작성했다면, 이제는 헬퍼 함수를 호출하여 필요한 액션만 가져올 수 있습니다.
액션 팩토리의 핵심 특징은 추상화와 일관성입니다. 모든 비동기 작업이 같은 패턴으로 에러를 처리하고 로딩 상태를 관리하므로, 사용자 경험이 일관되고 버그가 줄어듭니다.
또한 TypeScript 제네릭을 활용하면 타입 안정성을 유지하면서도 유연하게 재사용할 수 있습니다. 나중에 인터셉터나 재시도 로직을 추가할 때도 한 곳만 수정하면 모든 스토어에 적용됩니다.
코드 예제
// lib/createApiActions.ts
interface ApiState<T> {
data: T[]
isLoading: boolean
error: string | null
}
// 제네릭 CRUD 액션 생성 팩토리
export function createApiActions<T>(endpoint: string) {
return {
fetchAll: async (set: any) => {
set({ isLoading: true, error: null })
try {
const res = await fetch(endpoint)
if (!res.ok) throw new Error('Fetch 실패')
const data = await res.json()
set({ data, isLoading: false })
} catch (error) {
set({ error: (error as Error).message, isLoading: false })
}
},
create: async (set: any, item: T) => {
set({ isLoading: true })
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
})
const newItem = await res.json()
set((state: ApiState<T>) => ({
data: [...state.data, newItem],
isLoading: false
}))
} catch (error) {
set({ error: (error as Error).message, isLoading: false })
}
},
}
}
// store/todoStore.ts - 헬퍼 사용
import { create } from 'zustand'
import { createApiActions } from '@/lib/createApiActions'
interface Todo {
id: number
title: string
completed: boolean
}
const apiActions = createApiActions<Todo>('/api/todos')
export const useTodoStore = create<ApiState<Todo> & typeof apiActions>((set) => ({
data: [],
isLoading: false,
error: null,
fetchAll: () => apiActions.fetchAll(set),
create: (item) => apiActions.create(set, item),
}))
설명
이것이 하는 일: 위 코드는 반복적인 API 호출 로직을 팩토리 함수로 추상화하여, 어떤 도메인에서든 재사용할 수 있게 만듭니다. 첫 번째로, createApiActions 함수는 제네릭 타입 T를 받아서 해당 타입의 배열을 관리하는 액션들을 반환합니다.
endpoint 매개변수로 API 경로를 받아 내부에서 사용하므로, 같은 함수로 /api/todos, /api/products, /api/users 등 어떤 리소스든 처리할 수 있습니다. fetchAll과 create 두 개의 액션을 예시로 보여주는데, 실제로는 update, delete도 같은 방식으로 추가할 수 있습니다.
그 다음으로, fetchAll 내부를 보면 일반적인 비동기 패턴을 따릅니다. set 함수를 매개변수로 받아 상태를 업데이트하고, try-catch로 에러를 처리합니다.
이 로직은 어떤 도메인이든 동일하므로 한 번만 작성하면 됩니다. create 액션도 마찬가지로 POST 요청을 보내고 응답을 받아 기존 배열에 추가합니다.
set((state) => ...)처럼 함수형 업데이트를 사용하여 이전 상태를 안전하게 참조합니다. 세 번째로, 실제 스토어에서는 createApiActions('/api/todos')를 호출하여 Todo 타입에 맞는 액션들을 가져옵니다.
스토어 내부에서는 이 액션들을 래핑하여 fetchAll: () => apiActions.fetchAll(set)처럼 set을 전달합니다. 이렇게 하면 스토어는 단순히 상태 정의와 액션 연결만 담당하고, 실제 로직은 헬퍼 함수에 위임됩니다.
최종적으로 타입을 ApiState<Todo> & typeof apiActions로 합성하여 TypeScript가 모든 프로퍼티를 인식하게 합니다. 여러분이 이 코드를 사용하면 새로운 리소스를 추가할 때 몇 줄만 작성하면 됩니다.
예를 들어 상품 스토어를 만든다면 createApiActions<Product>('/api/products')만 호출하면 모든 CRUD 액션이 자동으로 생성됩니다. 에러 처리나 로딩 로직을 개선할 때도 createApiActions 함수만 수정하면 모든 스토어에 즉시 반영되므로, 유지보수 비용이 크게 줄어듭니다.
또한 팀 전체가 같은 패턴을 사용하므로 코드 리뷰와 협업이 원활해집니다.
실전 팁
💡 더 많은 옵션을 받으려면 매개변수를 확장하세요: createApiActions(endpoint, { headers, retryCount }) 같은 식으로 인증 헤더나 재시도 설정을 전달할 수 있습니다.
💡 낙관적 업데이트를 헬퍼에 통합하려면 임시 ID를 생성하여 즉시 추가하고, 서버 응답 후 실제 ID로 교체하는 로직을 추가하세요.
💡 React Query나 SWR 같은 서버 상태 라이브러리와 Zustand를 함께 쓰는 것도 고려해보세요. Zustand는 클라이언트 상태, React Query는 서버 상태로 역할을 분담하면 더 효율적입니다.
💡 제네릭 타입을 더 세밀하게 제약하려면 extends 키워드를 사용하세요: <T extends { id: number }>처럼 하면 ID가 필수인 타입만 받을 수 있습니다.
💡 액션 팩토리를 여러 개 만들어 용도별로 나누세요. createApiActions, createAuthActions, createFormActions처럼 도메인별로 최적화된 헬퍼를 제공하면 더 유연합니다.
7. Immer 미들웨어 - 불변성 관리 간소화
시작하며
여러분이 중첩된 객체나 배열의 깊은 속성을 업데이트할 때, 스프레드 연산자를 여러 번 겹쳐 쓰느라 코드가 복잡해진 경험 있으신가요? set(state => ({ user: { ...state.user, profile: { ...state.user.profile, name: 'New' } } }))처럼 가독성이 떨어지고 실수하기 쉬운 코드가 만들어집니다.
이런 문제는 React와 Zustand가 불변성을 요구하기 때문입니다. 기존 객체를 직접 수정하면 상태 변화를 감지하지 못하므로, 항상 새로운 객체를 만들어야 합니다.
하지만 중첩이 깊어질수록 코드는 점점 읽기 어려워지고, 한 단계라도 빼먹으면 버그가 발생합니다. 바로 이럴 때 필요한 것이 Immer 미들웨어입니다.
Immer를 사용하면 마치 객체를 직접 수정하는 것처럼 코드를 작성해도, 내부적으로 불변 업데이트가 자동으로 처리됩니다. 코드가 직관적이고 짧아지면서도 성능과 안정성은 유지됩니다.
개요
간단히 말해서, Immer는 Draft 객체를 통해 가변적으로 코드를 작성하면 자동으로 불변 업데이트를 수행하는 라이브러리입니다. Zustand의 immer 미들웨어를 적용하면 set 함수 내부에서 state를 직접 수정할 수 있고, Immer가 이를 감지하여 새로운 불변 객체를 생성합니다.
예를 들어, state.user.profile.name = 'New'처럼 자바스크립트 기본 문법으로 쓸 수 있어 코드가 훨씬 읽기 쉽습니다. 기존에는 {...state, nested: {...state.nested, value: 1}}처럼 모든 레벨을 스프레드했다면, 이제는 state.nested.value = 1만 쓰면 됩니다.
Immer 미들웨어의 핵심 특징은 직관성과 안전성의 균형입니다. 코드는 가변적으로 작성하지만 실제 동작은 불변이므로, React의 렌더링 최적화를 해치지 않습니다.
또한 타입스크립트와 완벽히 호환되어 자동완성과 타입 체크가 그대로 작동합니다. 복잡한 상태 구조를 다룰 때 특히 빛을 발하며, 배열 메서드(push, splice 등)도 자유롭게 사용할 수 있습니다.
코드 예제
// 먼저 Immer 설치: pnpm add immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface User {
id: number
profile: {
name: string
address: {
city: string
zipCode: string
}
}
posts: string[]
}
interface UserState {
user: User | null
updateCity: (city: string) => void
addPost: (post: string) => void
}
export const useUserStore = create<UserState>()(
immer((set) => ({
user: null,
// Immer 덕분에 직접 수정하는 코드 작성 가능
updateCity: (city) => set((state) => {
if (state.user) {
state.user.profile.address.city = city // 직접 수정!
}
}),
// 배열도 push로 간단히 추가
addPost: (post) => set((state) => {
if (state.user) {
state.user.posts.push(post) // 직접 push!
}
}),
}))
)
설명
이것이 하는 일: 위 코드는 Immer를 적용하여 깊이 중첩된 사용자 프로필의 주소나 게시글 배열을 마치 가변 객체처럼 간단하게 업데이트합니다. 첫 번째로, immer 미들웨어로 스토어를 감싸는 방식은 persist와 동일합니다.
create<UserState>() 다음에 빈 괄호를 쓰고 immer를 호출합니다. User 인터페이스를 보면 profile 안에 address가 있고, 그 안에 city가 있는 3단계 중첩 구조입니다.
일반적인 불변 업데이트라면 세 번의 스프레드가 필요하지만, Immer를 쓰면 그럴 필요가 없습니다. 그 다음으로, updateCity 액션 내부에서 state.user.profile.address.city = city처럼 직접 할당합니다.
이 코드는 마치 객체를 변경하는 것처럼 보이지만, 실제로는 Immer의 Proxy 객체인 Draft를 수정하는 것입니다. set 함수가 종료되면 Immer는 Draft에 가해진 모든 변경을 감지하여, 변경된 경로만 새 객체로 만들고 나머지는 재사용하는 최적화된 불변 객체를 생성합니다.
따라서 성능도 수동 스프레드와 동일하거나 더 좋습니다. 세 번째로, addPost에서는 배열의 push 메서드를 사용합니다.
일반적으로 불변 업데이트에서는 [...state.user.posts, post]처럼 새 배열을 만들어야 하지만, Immer는 push, splice, unshift 같은 가변 메서드를 자동으로 불변 업데이트로 변환합니다. 최종적으로 이 스토어를 사용하는 컴포넌트는 Immer의 존재를 전혀 알 필요 없이, 평소처럼 상태를 구독하고 액션을 호출하면 됩니다.
여러분이 이 코드를 사용하면 복잡한 상태 구조를 다룰 때 실수를 크게 줄일 수 있습니다. 특히 폼 데이터나 다단계 설정처럼 중첩이 깊은 경우, Immer 없이는 코드가 금방 지저분해지는데, Immer를 쓰면 코드 리뷰 시 로직을 빠르게 이해할 수 있습니다.
또한 배열 조작이 많은 경우 filter, map, push를 자유롭게 조합할 수 있어 함수형 프로그래밍의 장점을 살릴 수 있습니다. 성능 오버헤드는 거의 없으며, 오히려 불필요한 객체 생성을 줄여주는 경우도 많습니다.
실전 팁
💡 Immer와 일반 불변 업데이트를 섞어 쓸 수 있습니다. 간단한 경우는 return { count: state.count + 1 }처럼 반환하고, 복잡한 경우만 state를 수정하세요.
💡 return 문을 사용하면 Immer가 비활성화됩니다. set((state) => { state.x = 1; return state })는 작동하지 않으니 주의하세요. 수정만 하고 아무것도 반환하지 않아야 합니다.
💡 성능이 중요한 부분에서는 Immer를 건너뛸 수 있습니다. set((state) => original(state).value)처럼 original 헬퍼를 쓰면 Draft를 벗어날 수 있습니다.
💡 복잡한 계산 로직은 Immer 외부에서 처리하세요. Draft 내부에서 무거운 연산을 하면 성능이 떨어질 수 있으므로, 결과만 Draft에 할당하는 것이 좋습니다.
💡 타입 안정성을 위해 strict mode를 활성화하세요. tsconfig.json에서 "strict": true를 켜면 state가 readonly가 되어, Immer 없이 직접 수정하려는 시도를 컴파일 타임에 잡아줍니다.
8. Devtools 통합 - 상태 변화 추적과 디버깅
시작하며
여러분이 버그를 추적하는데 상태가 언제, 왜 바뀌었는지 알 수 없어 console.log를 여기저기 찍고 있나요? 특히 비동기 액션이 여러 개 동시에 실행될 때, 어떤 순서로 상태가 변경되었는지 파악하기가 정말 어렵습니다.
이런 문제는 상태 변화의 히스토리를 추적할 도구가 없기 때문입니다. Redux를 쓸 때는 Redux DevTools로 모든 액션과 상태를 타임라인으로 볼 수 있었는데, Zustand에서도 같은 경험을 할 수 있을까요?
디버깅 도구 없이 상태관리를 한다면 개발 속도가 크게 느려집니다. 바로 이럴 때 필요한 것이 Zustand의 devtools 미들웨어입니다.
Redux DevTools 브라우저 확장과 연동하여, 모든 상태 변화를 기록하고 시간 여행 디버깅까지 가능하게 해줍니다. 어떤 액션이 어떤 상태를 바꿨는지 한눈에 보고, 원하는 시점으로 되돌릴 수도 있습니다.
개요
간단히 말해서, devtools 미들웨어는 Zustand 스토어를 Redux DevTools에 연결하여 상태 변화를 시각화하는 도구입니다. 브라우저에 Redux DevTools 확장을 설치하고 미들웨어를 적용하면, 액션 이름과 상태 스냅샷이 자동으로 기록됩니다.
예를 들어, login 액션을 실행하면 DevTools에 "login"이 표시되고, 변경 전후의 상태를 비교할 수 있습니다. 이는 복잡한 상태 흐름을 이해하는 데 필수적입니다.
기존에는 상태가 왜 바뀌었는지 추측하거나 로그를 일일이 추가했다면, 이제는 DevTools를 열어 전체 히스토리를 시각적으로 확인할 수 있습니다. devtools 미들웨어의 핵심 특징은 타임 트래블 디버깅입니다.
과거 상태로 되돌리거나 특정 액션을 다시 실행해볼 수 있어, 버그 재현이 훨씬 쉬워집니다. 또한 액션 이름을 커스터마이징하고, 특정 액션을 필터링하거나 익명 액션을 추적하는 등 고급 기능도 제공합니다.
개발 환경에서만 활성화하고 프로덕션에서는 자동으로 비활성화할 수 있어 성능 걱정도 없습니다.
코드 예제
// store/counterStore.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
// 두 번째 인자로 액션 이름 지정
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
reset: () => set({ count: 0 }, false, 'reset'),
}),
{
name: 'CounterStore', // DevTools에 표시될 스토어 이름
enabled: process.env.NODE_ENV === 'development', // 개발 환경에서만 활성화
}
)
)
설명
이것이 하는 일: 위 코드는 카운터 스토어를 Redux DevTools에 연결하여, increment, decrement, reset 같은 모든 액션을 시각적으로 추적할 수 있게 합니다. 첫 번째로, devtools 미들웨어로 스토어를 감쌉니다.
두 번째 설정 객체에서 name: 'CounterStore'를 지정하면 DevTools에서 여러 스토어를 구분할 수 있습니다. enabled 옵션으로 개발 환경에서만 활성화하여, 프로덕션 빌드에서는 오버헤드를 제거합니다.
process.env.NODE_ENV를 사용하면 Next.js가 자동으로 환경에 따라 코드를 최적화합니다. 그 다음으로, set 함수를 호출할 때 세 번째 인자로 액션 이름을 전달합니다.
set(updater, false, 'increment')에서 두 번째 false는 replace 옵션(보통 사용 안 함)이고, 세 번째 'increment'가 DevTools에 표시될 이름입니다. 이렇게 하면 DevTools의 액션 리스트에 "increment"라고 명확히 표시되어, 어떤 액션이 언제 호출되었는지 한눈에 알 수 있습니다.
액션 이름이 없으면 "anonymous"로 표시되어 디버깅이 어렵습니다. 세 번째로, 브라우저에서 Redux DevTools 확장을 열면 CounterStore가 나타나고, 각 액션을 클릭하면 변경 전후의 상태 diff를 볼 수 있습니다.
또한 좌측 패널에서 특정 액션을 선택하고 "Jump"를 누르면 그 시점의 상태로 되돌아가며, "Skip"을 누르면 해당 액션을 건너뛴 결과를 시뮬레이션할 수 있습니다. 최종적으로 이는 복잡한 상태 변화를 이해하고 버그를 재현하는 데 큰 도움이 됩니다.
여러분이 이 코드를 사용하면 디버깅 시간이 크게 단축됩니다. 특히 비동기 액션이 여러 개 얽혀 있을 때, 어떤 순서로 실행되었는지 타임라인으로 확인할 수 있어 경쟁 조건(race condition) 같은 미묘한 버그를 찾기 쉽습니다.
또한 QA 팀이나 다른 개발자에게 버그를 재현하는 과정을 DevTools 히스토리로 공유할 수 있어 협업도 원활해집니다. 상태 관리의 투명성이 높아져 코드 품질이 자연스럽게 향상됩니다.
실전 팁
💡 액션 이름을 상수로 관리하면 오타를 방지할 수 있습니다: const ACTIONS = { INCREMENT: 'increment' }처럼 enum이나 객체로 정의하세요.
💡 복잡한 상태 변화는 여러 단계로 나눠서 각각 다른 액션 이름을 붙이세요. "fetchStart", "fetchSuccess", "fetchError"처럼 세분화하면 흐름이 명확해집니다.
💡 DevTools에서 상태를 직접 수정할 수도 있습니다. Dispatch 탭에서 커스텀 액션을 보내거나, State 탭에서 JSON을 직접 편집하여 엣지 케이스를 테스트하세요.
💡 serialize 옵션으로 직렬화할 수 없는 값(함수, Date 등)을 처리하세요. 기본적으로 DevTools는 JSON 직렬화를 시도하므로, 복잡한 객체는 replacer를 제공해야 할 수 있습니다.
💡 프로덕션에서 실수로 devtools가 포함되지 않도록 번들 크기를 확인하세요. Next.js 프로덕션 빌드 후 .next 폴더를 분석하여 devtools 코드가 tree-shaking되었는지 확인하는 것이 좋습니다.
9. TypeScript 고급 패턴 - 타입 안전성 극대화
시작하며
여러분이 Zustand 스토어를 사용하면서 오타로 잘못된 프로퍼티에 접근하거나, 액션에 잘못된 인자를 전달해본 적 있나요? 런타임에 에러가 발생하면 이미 늦었고, 특히 프로덕션에서 발견되면 사용자에게 직접 영향을 미칩니다.
이런 문제는 타입 정의가 불완전하거나 제네릭을 잘못 사용했기 때문입니다. TypeScript는 강력한 도구이지만, Zustand처럼 함수형 API를 가진 라이브러리에서는 타입 추론이 복잡해질 수 있습니다.
타입을 제대로 설정하지 않으면 any로 추론되어 타입스크립트의 장점을 잃게 됩니다. 바로 이럴 때 필요한 것이 고급 TypeScript 패턴입니다.
정확한 타입 정의와 제네릭 활용으로 컴파일 타임에 모든 에러를 잡고, IDE의 자동완성을 최대한 활용할 수 있습니다. 타입 안전성을 극대화하면 리팩토링도 안심하고 할 수 있습니다.
개요
간단히 말해서, TypeScript 고급 패턴은 Zustand 스토어의 모든 상태와 액션에 정확한 타입을 부여하는 기법입니다. 인터페이스 정의, 제네릭 제약, 유틸리티 타입 활용 등을 통해 컴파일러가 최대한 많은 에러를 사전에 찾아내게 만듭니다.
예를 들어, 셀렉터 함수의 반환 타입이 자동으로 추론되고, 액션 함수의 매개변수 타입이 정확히 체크되어야 합니다. 기존에는 state: any처럼 느슨한 타입을 사용했다면, 이제는 엄격한 타입 정의로 실수를 원천 차단할 수 있습니다.
고급 타입 패턴의 핵심 특징은 추론과 검증입니다. 타입을 한 곳에 정의하면 나머지는 TypeScript가 자동으로 추론하므로, 중복된 타입 선언을 줄일 수 있습니다.
또한 branded type, discriminated union 같은 고급 기법을 활용하면 런타임 에러를 컴파일 타임으로 끌어올릴 수 있습니다. 대규모 프로젝트에서 이런 엄격함이 버그 발생률을 크게 낮춰줍니다.
코드 예제
// types/store.ts - 공통 타입 정의
export interface AsyncState<T> {
data: T | null
isLoading: boolean
error: string | null
}
// 액션을 별도 타입으로 분리
export interface AsyncActions<T> {
fetch: () => Promise<void>
setData: (data: T) => void
setError: (error: string) => void
reset: () => void
}
// 전체 스토어 타입 = 상태 + 액션
export type AsyncStore<T> = AsyncState<T> & AsyncActions<T>
// store/userStore.ts - 타입 활용
import { create } from 'zustand'
import type { AsyncStore } from '@/types/store'
interface User {
id: number
name: string
email: string
}
// 제네릭으로 User 타입 주입
export const useUserStore = create<AsyncStore<User>>((set) => ({
data: null,
isLoading: false,
error: null,
fetch: async () => {
set({ isLoading: true, error: null })
try {
const res = await fetch('/api/user')
const data: User = await res.json() // 명시적 타입 지정
set({ data, isLoading: false })
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
},
setData: (data) => set({ data }), // 타입 자동 추론
setError: (error) => set({ error }),
reset: () => set({ data: null, isLoading: false, error: null }),
}))
// 사용 시 타입 안전성 보장
const user = useUserStore((state) => state.data) // User | null로 추론
const fetch = useUserStore((state) => state.fetch) // () => Promise<void>로 추론
설명
이것이 하는 일: 위 코드는 재사용 가능한 타입 정의를 만들어 모든 스토어에 일관된 타입 안정성을 제공하고, 개발자 경험을 향상시킵니다. 첫 번째로, AsyncState<T> 인터페이스는 비동기 작업의 세 가지 상태를 제네릭으로 정의합니다.
T는 실제 데이터 타입을 나타내며, User든 Product든 어떤 타입이든 주입할 수 있습니다. AsyncActions<T>는 이 상태를 조작하는 액션들의 시그니처를 정의합니다.
fetch는 반환값이 없는 비동기 함수, setData는 T 타입을 받는 동기 함수로 명확히 타이핑되었습니다. 이렇게 상태와 액션을 분리하면 나중에 조합하기 쉽습니다.
그 다음으로, AsyncStore<T> 타입은 인터섹션(&)으로 상태와 액션을 합칩니다. 이는 최종적으로 스토어가 가져야 할 전체 타입 구조를 나타냅니다.
useUserStore를 만들 때 create<AsyncStore<User>>처럼 제네릭에 User를 전달하면, 모든 프로퍼티가 User 기준으로 타입이 결정됩니다. 예를 들어 data는 User | null이 되고, setData의 매개변수는 User가 됩니다.
TypeScript가 자동으로 이를 추론하여, 잘못된 타입을 전달하면 컴파일 에러가 발생합니다. 세 번째로, 스토어 구현 내부에서 const data: User = await res.json()처럼 명시적으로 타입을 지정하면, API 응답이 예상한 형태가 아닐 때 런타임 에러 대신 타입 가드를 추가할 수 있습니다.
setData, setError 같은 액션은 매개변수 타입이 자동으로 추론되므로, 구현할 때 타입을 다시 쓸 필요가 없습니다. 최종적으로 컴포넌트에서 useUserStore를 사용할 때, 셀렉터의 반환 타입도 정확히 추론되어 user?.name처럼 안전하게 접근할 수 있습니다.
여러분이 이 코드를 사용하면 리팩토링 시 자신감이 생깁니다. 예를 들어 User 인터페이스에서 email 필드를 제거하면, 그 필드를 사용하는 모든 곳에서 컴파일 에러가 발생하여 누락을 방지할 수 있습니다.
또한 새로운 개발자가 팀에 합류해도 타입 정의만 보면 스토어 구조를 빠르게 이해할 수 있고, IDE의 자동완성 덕분에 API를 외울 필요가 없습니다. 타입 안전성은 장기적으로 유지보수 비용을 크게 줄여주는 투자입니다.
실전 팁
💡 zod나 yup 같은 런타임 스키마 검증 라이브러리를 함께 사용하세요. API 응답을 검증하여 타입과 실제 데이터가 일치하는지 확인하면 더 안전합니다.
💡 Partial, Required, Pick, Omit 같은 유틸리티 타입을 활용하세요. 예를 들어 폼 상태에서 Partial<User>를 쓰면 모든 필드가 선택적이 됩니다.
💡 branded type을 사용해 primitive 타입을 구분하세요: type UserId = number & { __brand: 'UserId' }처럼 하면 일반 number와 UserId를 섞어 쓰는 실수를 방지할 수 있습니다.
💡 discriminated union으로 상태를 표현하면 타입 좁히기가 쉬워집니다: type Status = { status: 'loading' } | { status: 'success', data: User } | { status: 'error', error: string }
💡 strict 모드와 strictNullChecks를 반드시 활성화하세요. tsconfig.json에서 이 옵션들을 켜지 않으면 타입 안정성의 절반을 놓치는 것입니다.
10. SSR과 하이드레이션 - Next.js App Router 완벽 대응
시작하며
여러분이 Next.js App Router를 사용하면서 서버 컴포넌트에서 Zustand를 쓰려다 "window is not defined" 에러를 만난 적 있나요? 또는 클라이언트에서 persist로 복원한 상태가 서버 렌더링 결과와 달라서 하이드레이션 에러가 발생했나요?
이런 문제는 Zustand가 기본적으로 클라이언트 전용 라이브러리이기 때문입니다. 서버에는 브라우저 API(localStorage, window 등)가 없으므로, SSR 환경에서는 특별한 처리가 필요합니다.
Next.js 13 이상의 App Router는 서버/클라이언트 컴포넌트를 구분하므로, 이를 제대로 이해하지 못하면 에러가 속출합니다. 바로 이럴 때 필요한 것이 SSR-safe 패턴입니다.
'use client' 지시문과 적절한 초기화 로직으로 서버와 클라이언트를 분리하고, 하이드레이션 불일치를 방지하는 기법을 배워야 합니다. 이렇게 하면 Next.js의 SSR 이점을 유지하면서 Zustand를 안전하게 사용할 수 있습니다.
개요
간단히 말해서, SSR-safe 패턴은 Zustand 스토어를 클라이언트 컴포넌트에서만 사용하고, 서버 데이터는 props로 전달하여 초기화하는 방법입니다. Next.js App Router에서 서버 컴포넌트는 기본이므로, Zustand를 사용하는 컴포넌트는 명시적으로 'use client'를 선언해야 합니다.
또한 persist 미들웨어를 쓸 때는 서버에서 렌더링된 초기 HTML과 클라이언트에서 복원된 상태가 달라 하이드레이션 에러가 날 수 있으므로, skipHydration 옵션이나 useEffect 내부에서 초기화하는 패턴을 사용합니다. 기존에는 모든 페이지가 클라이언트 렌더링이었다면, 이제는 서버와 클라이언트의 경계를 명확히 이해하고 적절히 분리해야 합니다.
SSR-safe 패턴의 핵심 특징은 점진적 향상(Progressive Enhancement)입니다. 서버에서 초기 HTML을 빠르게 제공하고, 클라이언트에서 인터랙티브하게 만드는 Next.js의 철학을 그대로 살릴 수 있습니다.
또한 서버에서 데이터를 fetch하여 props로 내려주고, 클라이언트 스토어를 초기화하는 패턴을 사용하면 SEO와 성능을 모두 잡을 수 있습니다. 하이드레이션 에러를 방지하는 것은 사용자 경험을 해치지 않는 필수 요소입니다.
코드 예제
// app/products/page.tsx - 서버 컴포넌트
import ProductList from './ProductList'
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store' // SSR 시 항상 최신 데이터
})
return res.json()
}
export default async function ProductsPage() {
const initialProducts = await getProducts() // 서버에서 데이터 fetch
return (
<div>
<h1>상품 목록</h1>
{/* 클라이언트 컴포넌트에 props로 전달 */}
<ProductList initialData={initialProducts} />
</div>
)
}
// app/products/ProductList.tsx - 클라이언트 컴포넌트
'use client' // 필수!
import { useEffect } from 'react'
import { useProductStore } from '@/store/productStore'
export default function ProductList({ initialData }: { initialData: Product[] }) {
const products = useProductStore((state) => state.products)
const setProducts = useProductStore((state) => state.setProducts)
// 클라이언트에서 초기 데이터로 스토어 초기화
useEffect(() => {
if (initialData) {
setProducts(initialData)
}
}, [initialData, setProducts])
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
// store/productStore.ts - skipHydration 옵션
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useProductStore = create(
persist(
(set) => ({
products: [],
setProducts: (products) => set({ products }),
}),
{
name: 'product-storage',
skipHydration: true, // SSR 환경에서 자동 복원 건너뛰기
}
)
)
설명
이것이 하는 일: 위 코드는 Next.js의 SSR을 활용하여 초기 데이터를 서버에서 가져오고, 클라이언트에서 Zustand 스토어로 상태를 관리하는 안전한 패턴을 구현합니다. 첫 번째로, ProductsPage는 서버 컴포넌트로, async/await를 사용하여 서버에서 직접 데이터를 fetch합니다.
이는 클라이언트가 JavaScript를 다운로드하기 전에 HTML에 데이터가 포함되어 SEO에 유리하고 초기 로딩이 빠릅니다. getProducts에서 cache: 'no-store'를 설정하면 매 요청마다 새 데이터를 가져오며, 'force-cache'를 쓰면 정적 생성처럼 작동합니다.
initialProducts를 ProductList 컴포넌트에 props로 전달합니다. 그 다음으로, ProductList는 'use client'로 선언되어 클라이언트 컴포넌트가 됩니다.
여기서만 Zustand 훅을 사용할 수 있습니다. useEffect 내부에서 initialData를 받아 setProducts로 스토어를 초기화합니다.
이 패턴은 서버에서 렌더링된 초기 상태와 클라이언트 스토어가 동기화되도록 보장합니다. useEffect는 클라이언트에서만 실행되므로 안전합니다.
세 번째로, productStore에서 skipHydration: true를 설정하면 persist가 자동으로 localStorage에서 복원하는 것을 막습니다. 대신 우리가 useEffect에서 명시적으로 초기화하므로, 서버 HTML과 클라이언트 렌더링 결과가 일치하여 하이드레이션 에러가 발생하지 않습니다.
만약 skipHydration을 쓰지 않으면, 서버는 빈 배열을 렌더하고 클라이언트는 localStorage의 이전 데이터를 렌더하여 불일치가 생깁니다. 여러분이 이 코드를 사용하면 Next.js의 SSR 장점을 잃지 않으면서 Zustand의 편리함을 누릴 수 있습니다.
서버에서 초기 데이터를 빠르게 제공하여 사용자가 즉시 콘텐츠를 보고, 클라이언트에서 인터랙티브한 기능(필터링, 정렬 등)을 Zustand로 관리할 수 있습니다. 이는 성능과 개발자 경험을 모두 만족시키는 최적의 패턴입니다.
또한 검색 엔진이 서버에서 렌더링된 데이터를 크롤링할 수 있어 SEO도 보장됩니다.
실전 팁
💡 서버 컴포넌트에서는 절대 Zustand 훅을 호출하지 마세요. 'use client' 없이 사용하면 빌드 에러나 런타임 에러가 발생합니다.
💡 복잡한 초기화 로직은 별도의 useHydration 커스텀 훅으로 빼세요. 여러 스토어를 초기화해야 할 때 재사용할 수 있습니다.
💡 persist를 사용할 때는 onRehydrateStorage 콜백으로 복원 완료를 감지하세요. 복원이 끝나기 전에 UI를 렌더하면 깜빡임이 발생할 수 있습니다.
💡 서버 데이터와 클라이언트 상태를 명확히 구분하세요. 서버에서만 필요한 데이터(메타데이터, SEO 정보)는 스토어에 넣지 말고 props로만 전달하세요.
💡 Next.js 15+에서는 Server Actions를 활용하여 서버에서 스토어를 변경하는 패턴도 가능합니다. revalidatePath와 조합하면 강력한 데이터 동기화를 구현할 수 있습니다.