Frontend/Today I Learned

[Next.js] Zustand 사용하기

joycie416 2024. 11. 28. 20:23

Zustand 공식 문서를 확인하면 Next.js와 같이 사용하는 방법에 대해 별도로 안내하고 있다. 이를 확인하면 React의 Context API와 함께 사용하라고 하는데, 그 이유가 무엇일까? 이에 대해 정리한 후, 실제 사용방법에 대해 정리하려고 한다.

 

1. Next.js의 SSR과 zustand store

Next.js는 서버와 클라이언트 측에서 모두 실행되므로, 전역 변수의 동작이 zustand의 가정과 다르다. 즉 zustand는 하나의 store에서 모든 상태를 관리하지만, Next.js는 서버와 클라이언트 상태가 분리될 수 있다는 것이다. 그래서 Next.js 서버의 상태를 클라이언트와 공유하기 위해서 별도의 과정이 필요하다. 이때 아래 상황들을 고려해야 한다.

 

1. Per-request store : 요청당 store

Next.js는 여러 요청을 동시에 처리할 수 있다. 따라서 서버에서 상태를 공유하게 되면 여러 사용자의 요청이 꼬일 수 있기 때문에, 하나의 요청 당 하나의 store를 가져야한다.

 

2. SSR 친화적

앞서 말했듯이 Next.js 애플리케이션은 서버에서 렌더링된 후 클라이언트에서 또 한 번 렌더링된다. 따라서 서버와 클라이언트에서 다르게 렌더링되면 hydration error가 발생할 수 있다. 따라서 서버에서 렌더링된 후 클라이언트에 서버의 데이터를 통해 초기화해야 한다.

 

3. SPA 라우팅 친화적

Next.js는 SSR과 CSR, 즉 서버와 클라이언트 사이드 렌더링을 모두 지원하는 하이브리드 모델이다. 따라서 store를 유지해야할 때와 초기화해야 할 때가 명확하지 않을 수 있다. 이 때 Context API를 사용하면 특정 페이지나 컴포넌트 범위로 store를 한정 지을 수 있다. 페이지를 이동할 때 그 페이지에만 필요했던 상태가 초기화될 수 있다.

 

4. 서버 캐싱 친화적

Next.js는 페이지와 컴포넌트를 서버에서 최대한 캐싱해 페이지 로딩 속도를 극대화하고 성능을 향상시키고자 한다. Zustand store도 이와 유사하게 기본적으로 singleton 객체로 관리되며, 한 번 store가 로드되면 동일한 애플리케이션 내에서 동일한 상태를 유지한다. 따라서 zustand store 역시도 한 번 생성된 후 서버에 캐싱되어 저장된다.

 

그래서 왜 Context API를 같이 사용해야 한다는 것일까?

내가 생각한 이유는 다음과 같다.

 

먼저 4번 내용을 보면, zustand store도 한번 생성된 후 서버에 캐싱된다. 이 경우 하나의 스토어를 여러 사람들이 공유하게 되어 혼란이 발생할 수 있다. 따라서 context API와 엮어서 사용하면 클라이언트 마다 개별 스토어를 가지게 되어 해당 문제를 해결할 수 있게 된다.

 

3번처럼 store 값이 페이지 이동에 따라 초기화를 해야할 수도 있다. 하지만 서버에서 전역적으로 관리하게 되면 별도의 복잡한 로직을 추가해야 한다. 이때 페이지나 컴포넌트 별로 context API를 통해 관리하게 되면 초기화 과정을 보다 편리하게 구현할 수 있다.

 

 

나도 3번처럼 초기화에 대해 고민했었다. 처음에는 zustand를 context API와 엮어 사용하는 것이 불편해 zustand만 사용했다. 검색어를 쿼리스트링으로 저장해 관리하는 로직상, 페이지를 이동하거나 새로고침하는 경우 적절히 초기화해주어야했다. 하지만 zustand만 사용하는 경우 로직이 조금 번거로워졌고, zustand에서 안내하는 것처럼 context API를 사용해 초기화해야겠다고 생각했다. 이후 페이지 로드 시 searchParams를 초기값으로 넣어 적절히 초기화할 수 있었다.

 

2. Zustand 적용하기

아래 코드는 zustand에서 제공한 코드이다. 따라해보자. (feat. TypeScript)

 

Step 1. counter-store.ts 생성하기

// src/stores/counter-store.ts
import { createStore } from 'zustand/vanilla'

export type CounterState = {
  count: number
}

export type CounterActions = {
  decrementCount: () => void
  incrementCount: () => void
}

export type CounterStore = CounterState & CounterActions

export const defaultInitState: CounterState = {
  count: 0,
}

export const createCounterStore = (
  initState: CounterState = defaultInitState,
) => {
  return createStore<CounterStore>()((set) => ({
    ...initState,
    decrementCount: () => set((state) => ({ count: state.count - 1 })),
    incrementCount: () => set((state) => ({ count: state.count + 1 })),
  }))
}

 

Step 2. Context API Provider 생성하기

// src/providers/counter-store-provider.tsx
'use client'

import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'

import { type CounterStore, createCounterStore } from '@/stores/counter-store'

export type CounterStoreApi = ReturnType<typeof createCounterStore>

export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
  undefined,
)

export interface CounterStoreProviderProps {
  children: ReactNode
}

export const CounterStoreProvider = ({
  children,
}: CounterStoreProviderProps) => {
  const storeRef = useRef<CounterStoreApi>()
  if (!storeRef.current) {
    storeRef.current = createCounterStore()
  }

  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

export const useCounterStore = <T,>(
  selector: (store: CounterStore) => T,
): T => {
  const counterStoreContext = useContext(CounterStoreContext)

  if (!counterStoreContext) {
    throw new Error(`useCounterStore must be used within CounterStoreProvider`)
  }

  return useStore(counterStoreContext, selector)
}

 

Zustand 공식 문서에서는 export로 Provider를 내보냈지만, 나는 구분하기 위해 `export default CounterStoreProvider`로 작성했다.

Step 3. Provider로 원하는 곳을 감싸주기

// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

import { CounterStoreProvider } from '@/providers/counter-store-provider'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <CounterStoreProvider>{children}</CounterStoreProvider>
      </body>
    </html>
  )
}

 

현재는 모든 라우터에서 CounterStore를 사용할 수 있게 되어있다. 하지만 특정한 페이지에서만 사용하길 원하는 경우 아래처럼 page 컴포넌트에 작성할 수 있다. 이렇게 작성하면 페이지에 접근할 때마다 store를 초기화할 수도 있다.

// src/app/page.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider'
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return (
    <CounterStoreProvider>
      <HomePage />
    </CounterStoreProvider>
  )
}

 

Step 4. 하위 컴포넌트에서 사용하기

// src/app/page.tsx
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return <HomePage />
}
// src/components/pages/home-page.tsx
'use client'

import { useCounterStore } from '@/providers/counter-store-provider'

export const HomePage = () => {
  const { count, incrementCount, decrementCount } = useCounterStore(
    (state) => state,
  )

  return (
    <div>
      Count: {count}
      <hr />
      <button type="button" onClick={() => void incrementCount()}>
        Increment Count
      </button>
      <button type="button" onClick={() => void decrementCount()}>
        Decrement Count
      </button>
    </div>
  )
}