프론트엔드/Next.js

(Next.js) 서버 컴포넌트 + 클라이언트 상태 관리 전략

그린티_ 2025. 11. 12. 18:18
반응형

서론

Next.js 13 이후로 서버 컴포넌트가 도입되면서 기존의 전역 상태 관리 패턴이 그대로 통하지 않게 됐습니다.
클라이언트 전용 상태 관리 라이브러리(Zustand, Jotai 등)를 서버 컴포넌트와 어떻게 조합해야 할까라는 생각에 찾아봤습니다.

프로젝트를 진행하던 중 이런 상황이 마침 발생해서 직접 적용해봤습니다.
우선 페이지를 이동할 때마다 큰 변화가 없는 데이터에 대한 API 호출은 비효율적이라고 생각했고 한번 불러서 저장해서 사용해보도록 했습니다.
그 중 가장 간단하고 쉽게 할 수 있는 Zustand를 활용하게 됐습니다.

서버 컴포넌트 vs 클라이언트 컴포넌트의 역할

구분 서버 컴포넌트 클라이언트 컴포넌트
실행 위치 서버 브라우저
데이터 요청 가능 (fetch 직접 사용) 불가능 (보통 React Query 사용)
상태 관리 불가능 가능 (useState, Zustand 등)
"use client" 필요 여부

Zustand Store 정의

import { create } from 'zustand'

export interface LottoResult {
  round: number
  date: string
  numbers: number[]
  bonus: number
}

export interface Stat {
  number: number
  freq: number
}

interface LottoStore {
  latestResult: LottoResult | null
  recentStats: Stat[]              
  last50Rounds: LottoResult[]      

  setLatestResult: (result: LottoResult) => void
  setRecentStats: (stats: Stat[]) => void
  setLast50Rounds: (results: LottoResult[]) => void
}

export const useLottoStore = create<LottoStore>((set) => ({
  latestResult: null,
  recentStats: [],          
  last50Rounds: [],

  setLatestResult: (result) => set({ latestResult: result }),
  setRecentStats: (stats) => set({ recentStats: stats }),
  setLast50Rounds: (results) => set({ last50Rounds: results }),
}))

여기서 핵심은
• 이 store는 클라이언트에서만 작동해야 하므로 서버 컴포넌트에서는 import하지 않음
• 로또 결과, 통계 등 전역으로 재사용할 데이터를 저장
• 이후 다른 컴포넌트에서 setLatestResult() 형태로 상태 업데이트 가능

서버 데이터 → 클라이언트 동기화 (React Query + Zustand)

"use client"

import { useQuery } from "@tanstack/react-query"
import { LottoResult, useLottoStore } from "../stores/useLottoStore"

export function useLatestLotto() {
  const latestResult = useLottoStore((state) => state.latestResult)
  const setLatestResult = useLottoStore((state) => state.setLatestResult)

  const query = useQuery<LottoResult>({
    queryKey: ["lotto", "latest"],
    queryFn: async () => {
      const res = await fetch("/api/lotto/latest")
      if (!res.ok) throw new Error("Failed")
      const data = await res.json()

      setLatestResult(data)
      return data
    },
    enabled: !latestResult,
    refetchInterval: 1000 * 60 * 30, 
    staleTime: 1000 * 60 * 29,
  })

  return {
    data: latestResult || query.data, 
    isLoading: !latestResult && query.isLoading,
    isError: query.isError,
  }
}

여기서 핵심은
• React Query: 서버에서 데이터 가져옴
• Zustand: 클라이언트 상태로 보관 (리렌더링 간 유지)
• enabled 조건: Zustand에 데이터가 이미 있으면 불필요한 refetch 방지

전역으로 유지할 값만 Zustand에 넣는다

실제 클라이언트 UI 컴포넌트

"use client"

import { useLatestLotto } from "@/hooks/queries/useLatestLotto"
import { useLottoStore } from "@/hooks/stores/useLottoStore"
import { Card } from "@/components/ui/card"
import { LottoBall } from "./LottoBall"

export function LatestResults() {
  const { latestResult } = useLottoStore()
  const { isLoading, isError } = useLatestLotto()

  if (isLoading) return <Card>불러오는 중...</Card>
  if (isError || !latestResult) return null

  return (
    <Card className="p-4 space-y-3">
      <h2 className="text-sm font-bold">최신 당첨번호</h2>
      <div className="flex gap-2 justify-center items-center">
        {latestResult.numbers.map((num, i) => (
          <LottoBall key={i} number={num} />
        ))}
        <span>+</span>
        <LottoBall number={latestResult.bonus} />
      </div>
    </Card>
  )
}

이 부분에서는 “Zustand가 저장한 상태를 그대로 UI에 반영”
→ React Query의 데이터 흐름과도 완전히 분리됨

후기

상태 관리가 필요한 간단한 데이터를 저장할 때 Zustand는 정말 가볍고 직관적인 해결책이었습니다.
설정도 간단하고, 어디서든 불러서 쓰기 쉬워서 앞으로도 이런 패턴은 Next.js + React Query 프로젝트에서 자주 활용할 것 같습니다.

반응형

'프론트엔드 > Next.js' 카테고리의 다른 글

(Next.js) CSR vs SSR  (1) 2025.11.26
(Next.js) 주요 특징 SSR vs SSG  (0) 2025.05.12
(Next.js) npm run dev와 npm start 차이  (4) 2025.01.09
(Next.js) PWA 구현  (4) 2025.01.07