끄적이는 개발노트
Next.js 15 - Zustand 본문
Next.js 15에 Global state 관리에 대해 찾아보면 Context API, Redux, Recoil, Zustand, Jotai 등 굉장히 다양한 라이브러리가 많다. 본인은 업무에서 Recoil을 주로 사용했었고, 간단한 프로젝트에서는 Context API를 사용했었다. 그 중 최근 들어서 Next.js와 Zustand가 같이 많이 사용되고 추천한다는 글이 눈에 띄어서 한번 사용해보기로 했다.
Zustand?
작고 빠르며 확장 가능한 기본적인 상태 관리 툴
hook 기반의 편리한 api
명확한 Flux 패턴의 충분한 규칙 보유
우선, 공식문서를 살펴보면 Zustand는 정말 간단하게 스토어 생성 => 사용 의 두 단계로만 이루어져 굉장히 편리하다. 하지만, Next.js 환경에서는 Zustand를 제대로 사용하는데 몇가지 어려움이 있다고 설명하면서 추가적인 단계가 필요하다고 설명한다.
https://zustand.docs.pmnd.rs/guides/nextjs
Setup with Next.js - Zustand
zustand.docs.pmnd.rs
그 이유는 간단하게 Next.js는 SSR, 서버 사이드 렌더링 프레임워크이기 때문이다.
React와 같은 일반적인 SPA 환경은 클라이언트만 존재하기 때문에 사용자마다 하나의 전역 공간을 갖게 된다. 하지만, Next.js는 SSR 환경으로 서버를 가지고 있는데, 서버는 클라이언트와 일 대 다 관계로 구성되어 동시에 여러 요청을 처리할 수 있다.
이는 Zustand의 스토어는 전역 변수(모듈 상태) 기반인데 이를 Next.js에서 그대로 사용하게 되면 SSR 환경인 Next.js에서는 서버의 전역 공간에서 store를 공유하기 때문에 요청 간에 Zustand 스토어의 값이 공유되는 일이 발생할 수 있다. 또한, 스토어의 초기값을 설정할 때, 서버에서 생성된 값과 클라이언트에서 실행되는 상태가 달라 Hydration 에러가 발생할 수도 있다.
따라서, 공식문서에서는 스토어를 Context API 안에 독립적으로 생성하고 초기화하는 방법을 권장하고 있다.
(다만, 찾아보니 초기값 세팅에서 ssr을 사용하지 않는 state에는 기존 사용대로 Next에서도 provider 없이 사용가능하다고는 한다.)
결국, 기존 Zustand 구현과 다른 점을 살펴보면 아래와 같다.
- createStore를 통해 요청마다 독립적인 스토어 생성
- 생성한 스토어를 Context API를 통해 공유 범위 지정
- useRef를 통해 중복된 인스턴스 생성 방지 (useRef는 React 렌더링 주기와 상관없어, state의 hydration 방지)
다만, 이렇게 보니 기존에 내가 사용해보기로 마음을 먹게 해준 간결함이 사라진 느낌이었다. 기존 Context API와 다른 점이 크게 와닿지 않았고, recoil이 익숙해서 그런지는 몰라도 좀 더 편리하다는 생각이 들었다. 그래서 구글링을 하다보니 사용 이유를 알게 되었다.
우선, 내가 사용한 경험이 있는 Context API와 Recoil과 비교를 해보면 다음과 같다.
vs Context API
- Context API에 비해서 기존 적용 방법은 provider가 필요없기 때문에 단순함
- 보일러 플레이트 감소
- selector를 통해 특정 상태만 불러와 불필요한 렌더링 방지
- 중앙 집중, action 기반으로 좀 더 체계적이고 일관성 있는 흐름과 구조 생성
Recoil은...
이 부분에서는 구성이 다르기 때문에 비교를 하기 보다는 찾아본 recoil의 현 상태를 정리해보려고 한다. 기본적으로 atom 형태로 이루어져 구독과 업데이트가 가능하다. 이는 정말 간단한 방식으로 state 관리가 용이해지고 selectors를 통해 atom의 파생 상태를 생성할 수도 있다.
다만, 문제는 마지막 업데이트가 약 1-2년 전일만큼 관리가 되지 않고 있어, React와 Next의 최신 버전과 호환문제가 발생하고 있다. 또한, redux와 같이 오래된 라이브러리는 아니기 때문에 참고자료도 적다. 따라서, recoil과 같은 atom과 hook 형식을 원하는 사람들은 jotai로 넘어가고 있는 상황이라고 한다.
이러한 부분들이 겹치다보니 Zustand의 사용률이 올라가는게 아닐까하는 개인적인 추론이다.
1. 설치
npm install zustand
#or
yarn add zustand
2. 스토어 생성
// src/lib/stores/theme-stores.ts
import { createStore } from "zustand";
// state type
export interface IThemeState {
theme: "light" | "dark";
}
// actions type
export interface IThemeActions {
toggleTheme: () => void;
}
// store type (state & actions)
export type TThemeStore = IThemeState & IThemeActions;
// 초기 state 지정
export const defaultInitState: IThemeState = {
theme: "light",
};
// store 생성 (state, actions)
export const createThemeStore = (initState: IThemeState = defaultInitState) => {
return createStore<TThemeStore>()((set) => ({
...initState,
toggleTheme: () =>
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
}));
};
3. provider 생성
// src/lib/providers/theme-providers.tsx
import { createThemeStore, type TThemeStore } from "@/stores/theme-store";
import { createContext, ReactNode, useContext, useRef } from "react";
import { useStore } from "zustand";
// store 생성함수 type
export type TThemeStoreApi = ReturnType<typeof createThemeStore>;
// context 생성
export const ThemeStoreContext = createContext<TThemeStoreApi | undefined>(
undefined
);
// provider props type
export interface IThemeStoreProviderProps {
children: ReactNode;
}
// provider 생성
export const ThemeStoreProvider = ({ children }: IThemeStoreProviderProps) => {
// useRef를 통해 생성한 store 연결
const themeRef = useRef<TThemeStoreApi | null>(null);
if (themeRef.current === null) {
themeRef.current = createThemeStore();
}
// provider에 값 담기
return (
<ThemeStoreContext.Provider value={themeRef.current}>
{children}
</ThemeStoreContext.Provider>
);
};
// 스토어 hook 생성 (selector와 useContext 활용)
export const useThemeStore = <T,>(selector: (store: TThemeStore) => T): T => {
const themeStoreContext = useContext(ThemeStoreContext);
if (!themeStoreContext) {
throw new Error(`useThemeStore must be used within ThemeStoreProvider`);
}
return useStore(themeStoreContext, selector);
};
4. layout 적용
// layout.tsx
import { ThemeStoreProvider } from "@/lib/providers/theme-provider";
...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ThemeStoreProvider>
...
{children}
...
</ThemeStoreProvider>
</body>
</html>
);
}
5. 사용
사용은 아래와 같이 원하는 state 혹은 action을 꺼내 사용하면 불필요한 렌더링을 막을 수 있다.
물론, 아래 예제는 사실상 state와 action을 전부 사용하는데 이럴 때는 그냥 useThemeStore()를 사용해도 된다.
// app/test/page.tsx
"use client";
import { useThemeStore } from "@/lib/providers/theme-provider";
const TestPage = () => {
const toggleTheme = useThemeStore((state) => state.toggleTheme);
const theme = useThemeStore((state) => state.theme);
return (
<div className="w-full h-full flex flex-col justify-center items-center gap-4">
<p className="text-3xl text-black">현재 테마 : {theme}</p>
<button
type="button"
className="p-4 ring ring-gray-400 rounded-lg"
onClick={toggleTheme}
>
테마변경
</button>
</div>
);
};
export default TestPage;
6. 실행
사용하면서 느낀 점은 확실히 state와 action이 구분되어 구조적으로 명확한 개발이 가능할 것 같다. 또한, 막상 사용해보니 Context API를 경험해봐서 그런지 꽤나 간편하다는 생각도 들었다.
'JavaScript > Next.js' 카테고리의 다른 글
Next.js 15 - Tiptap Editor (0) | 2025.06.05 |
---|---|
Next.js 15 - SearchableDropdown (1) | 2025.05.28 |
Next.js 15 - Toast Message (with. Portal & Context API) (0) | 2025.05.14 |
Next.js 15 - Calendar 만들기 (with. dayjs) (0) | 2025.05.08 |
Next.js 15 - Modal 구현 (with. Parallel Routes & Intercepting Routes) (1) | 2025.04.26 |