Ian's Archive 🏃🏻

Profile

Ian

Ian's Archive

Developer / React, SpringBoot ...

📍 Korea
Github Profile →
Categories
All PostsAlgorithm19Book1C1CI/CD2Cloud3DB1DesignPattern9ELK4Engineering1Front3Gatsby2Git2IDE1JAVA7JPA5Java1Linux8Nginx1PHP2Python1React9Security4SpatialData1Spring26
thumbnail

Zustand + React Query 클라이언트, 서버 상태 분리

React
2025.06.18.

프론트엔드 개발에서 상태관리(State Management)는 애플리케이션의 복잡성을 결정하는 핵심 요소이다. 특히 사용자와의 상호작용으로 계속 변하는 데이터와 서버로부터 받아오는 데이터는 그 성격이 매우 다르다.

이러한 상황에서 Zustand와 React Query(TanStack Query)의 조합이 빛을 발한다. 이 두 라이브러리를 함께 사용하면, 각자의 역할에 맞춰 상태를 명확하게 분리하고 코드의 가독성과 유지보수성을 크게 향상시킨다.

1. Zustand

스토어(Store)를 사용해, 컴포넌트 간 공유할 데이터를 중앙에서 관리

클라이언트(브라우저) 내에서만 의미를 가지는 상태를 관리 ex - 다크 모드 설정, 모달의 열림/닫힘 상태, 사용자가 입력 중인 폼 데이터 등

  • 사용 방법
    • create 함수로 스토어 생성: create 함수에 상태(state)와 상태를 변경하는 액션(action)을 정의한 콜백 함수를 전달하여 스토어를 생성한다.
    • 콜백 함수의 매개변수 set, get:
      • set: 상태를 업데이트하는 함수다. set 함수에 객체를 전달하면 기존 상태와 병합(merge)되어 업데이트된다.
      • get: 스토어의 현재 상태를 가져오는 함수다. 다른 액션 내에서 현재 상태 값을 참조할 때 유용하다.
    • 컴포넌트에서 사용: create 함수가 반환하는 훅(Hook)을 컴포넌트에서 직접 호출하여 상태나 액션을 가져온다. 이때, 필요한 데이터만 선택(select)하여 가져오면 불필요한 리렌더링을 방지할 수 있다.
    • create함수 호출에서 반환하는 스토어 훅은 useCountStore와 같이 use접두사와 Store접미사로 명명해 각 컴포넌트에서 사용
복사
import { create } from 'zustand'

// 스토어의 타입 정의
interface CountState {
  count: number
  increase: () => void
  decrease: () => void
}

// create 함수의 콜백에서 get을 사용해 현재 상태를 가져와 업데이트
export const useCountStore = create<CountState>((set, get) => ({
  count: 1,
  increase: () => {
    const { count } = get() // 현재 상태 값 조회
    set({ count: count + 1 })
  },
  decrease: () => {
    const { count } = get() // 현재 상태 값 조회
    set({ count: count - 1 })
  },
}))

// set 함수의 콜백을 사용해 이전 상태(previous state)를 기반으로 업데이트
export const useCountStoreWithPrevState = create<CountState>(set => ({
  count: 1,
  increase: () => set(state => ({ count: state.count + 1 })),
  decrease: () => set(state => ({ count: state.count - 1 })),
}))

컴포넌트에서 사용

복사
import { useCountStore } from './store/count'

export default function App() {
  // 1. 필요한 상태(count)만 선택하여 가져오기
  const count = useCountStore(state => state.count)

  // 2. 필요한 액션(increase, decrease)만 선택하여 가져오기
  const increase = useCountStore(state => state.increase)
  const decrease = useCountStore(state => state.decrease)

  return (
    <>
      <h2>{count}</h2>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
    </>
  )
}

심화: 액션 분리와 상태 초기화

스토어가 복잡해지면 상태와 액션을 분리하여 관리하는 것이 좋다. 이렇게 하면 코드의 구조가 명확해지고, 컴포넌트에서 액션만 필요할 때 불필요한 상태 변화로 인한 리렌더링을 방지할 수 있다.

복사
import { create } from 'zustand'

interface CountState {
  count: number
  actions: {
    // actions 객체로 액션들을 그룹화
    increase: () => void
    decrease: () => void
  }
}

export const useCountStore = create<CountState>(set => ({
  count: 1,
  actions: {
    increase: () => set(state => ({ count: state.count + 1 })),
    decrease: () => set(state => ({ count: state.count - 1 })),
  },
}))

컴포넌트에서 사용

복사
function CounterActions() {
  const { increase, decrease } = useCountStore(state => state.actions)

  return (
    <div>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
    </div>
  )
}

상태 초기화

복사
import { create } from 'zustand'

interface State {
  count: number
  double: number
  min: number
  max: number
}

interface Actions {
  actions: {
    increase: () => void
    decrease: () => void
    resetState: (keys?: Array<keyof State>) => void
  }
}

const initialState: State = {
  count: 1,
  double: 2,
  min: 0,
  max: 99,
}
export const useCountStore = create<State & Actions>(set => ({
  ...initialState,
  actions: {
    increase: () => set(state => ({ count: state.count + 1 })),
    decrease: () => set(state => ({ count: state.count - 1 })),
    resetState: keys => {
      // 전체 상태 초기화
      if (!keys) {
        set(initialState)
        return
      }
      // 일부 상태 초기화
      keys.forEach(key => {
        set({ [key]: initialState[key] })
      })
    },
  },
}))
복사
import { useCountStore } from './store/count'

export default function resetState() {
  const { resetState } = useCountStore(state => state.actions)
  return (
    <>
      <button onClick={() => resetState()}>Reset All!</button>
      <button onClick={() => resetState(['double', 'min'])}>
        Reset Double, Min!
      </button>
    </>
  )
}

실제 운영 환경에서의 코드 예시

복사
interface DomainState {
  selectedItem: DomnItem | null
  setSelectedItem: (item: DomnItem) => void
  clearSelectedItem: () => void
  updateSelectedField: (field: keyof DomnItem, value: string) => void
  resetSelectedItem: () => void
}

export const useDomainStore = create<DomainState>(set => ({
  selectedItem: null,
  setSelectedItem: item => set({ selectedItem: item }),
  clearSelectedItem: () => set({ selectedItem: null }),
  updateSelectedField: (field, value) =>
    set(state =>
      state.selectedItem
        ? {
            selectedItem: {
              ...state.selectedItem,
              [field]: value,
            },
          }
        : state,
    ),
  resetSelectedItem: () => set({ selectedItem: null }),
}))
복사
const {
  data: result,
  isFetching,
  refetch,
} = useListQuery({
  page,
  size,
  keyword,
  condition,
  initialData: useInitialData ? initialData : undefined,
})

2. React Query

API 요청을 통해 가져오는 서버 데이터를 관리한다. 이 데이터는 비동기적으로 로드되고, 클라이언트가 직접 소유하지 않으며, 언제든 최신 데이터가 아닐 수(stale) 있다

React Query의 대표 기능

  • 데이터 가져오기 및 캐싱: GET 요청 결과를 메모리에 캐싱하여 동일한 요청이 다시 발생했을 때 API를 재호출하지 않고 캐시된 데이터를 즉시 반환
  • 동일 요청 중복 제거(Deduplication): 짧은 시간 내에 여러 컴포넌트에서 동일한 API를 요청하면, 실제로는 단 한 번의 네트워크 요청만 보낸다.
  • 자동 데이터 갱신: 사용자가 브라우저 창에 다시 포커스하거나, 네트워크가 재연결될 때 자동으로 데이터를 다시 가져와(refetch) 항상 최신 상태를 유지하려고 시도한다.
  • 성능 최적화 기능: 무한 스크롤(useInfiniteQuery), 페이지네이션 등 복잡한 UI를 쉽게 구현할 수 있도록 지원한다.

3. Zustand + ReactQuery

그렇다면 이 둘을 왜 함께 사용할까? 역할이 명확하게 분리되기 때문입니다.

  • 클라이언트 상태 (UI State) → Zustand
    • 서버와 무관한 모든 상태 (테마, 언어 설정, UI 요소의 표시 여부 등)
    • 여러 컴포넌트에서 공유되어야 하지만 서버 데이터는 아닌 값들
  • 서버 상태 (Server State) → React Query:
    • API를 통해 CRUD(Create, Read, Update, Delete)하는 모든 데이터
    • 로딩(loading), 에러(error), 데이터(data) 등 비동기 요청과 관련된 모든 상태

이러한 분리는 “관심사의 분리(Separation of Concerns)” 원칙을 따르며, 프로젝트가 커져도 예측 가능하고 관리하기 쉬운 코드를 유지하게 해준다. 서버 데이터를 Zustand에 넣으려고 시도하는 순간, 로딩/에러 상태, 캐싱, 데이터 동기화 등 React Query가 자동으로 처리해주던 모든 것을 직접 구현해야 하는 함정에 빠지게 된다.

커스텀 훅: useQuery와 useMutation

  • useQuery: 서버에서 데이터를 가져올 때(GET) 사용
    • queryKey: 각 쿼리 데이터를 식별하는 고유한 키. 이 키를 기준으로 캐싱
    • queryFn: 실제 API 요청을 수행하는 비동기 함수
    • staleTime: 데이터가 ‘신선함(fresh)‘에서 ‘오래됨(stale)’ 상태로 전환되는 데 걸리는 시간. 이 시간 내에는 데이터가 있어도 API를 재요청하지 않는다.
  • useMutation: 서버 데이터를 변경할 때(POST, PUT, DELETE) 사용
    • mutationFn: 서버에 변경 요청을 보내는 비동기 함수입
    • onSuccess: 뮤테이션(요청)이 성공했을 때 실행되는 콜백 함수. 데이터 생성/수정 후 관련 목록을 다시 불러오는 등의 후속 처리를 할 때 유용
    • onError: 뮤테이션이 실패했을 때 실행되는 콜백 함수

Zustand와 React Query 연동 예시 코드

아래 코드는 React Query를 사용하여 서버 데이터(Datalink)를 조회하고, 새로운 데이터(Schedul)를 생성하는 커스텀 훅(Custom Hook) 예시

복사
export function useQueryWithResult<T>(
  queryKey: readonly (string | number)[],
  url: string,
  queryParams?: Record<string, any>,
  options?: Omit<UseQueryOptions<FetchResult<T>>, 'queryKey' | 'queryFn'>,
) {
  return useQuery<FetchResult<T>>({
    queryKey,
    queryFn: () => fetchGet<T>(url, queryParams),
    staleTime: 60 * 1000,
    ...options,
  })
}

export function usePostMutation<T, U>(
  url: string,
  options?: Omit<UseMutationOptions<FetchResult<T>, Error, U>, 'mutationFn'>,
) {
  return useMutation<FetchResult<T>, Error, U>({
    mutationFn: (payload: U) => serverPostJson<T>(url, payload),
    ...options,
  })
}

export function useDatalinkListQuery(enabled: boolean = true) {
  return useQueryWithResult<ApiResponse<Datalink[]>>(
    ['datalinks'],
    `${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct/datalinks`,
    undefined,
    {
      enabled,
      staleTime: 10 * 60 * 1000,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
    },
  )
}

export function useSaveSchedulMutation() {
  const queryClient = useQueryClient()

  return usePostMutation<ApiResponse<any>, Omit<SchedulSaveDto, 'schedulId'>>(
    `${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct`,
    {
      onSuccess: async () => {
        toast.success('연계 작업이 등록되었습니다.')
        await queryClient.invalidateQueries({ queryKey: ['schedulList'] })
        window.location.reload()
      },
      onError: err => {
        toast.error('등록 실패: ' + err.message)
      },
    },
  )
}

Tip: 낙관적 업데이트 (Optimistic Updates)

  • 사용자 경험을 극대화하기 위한 고급 기법
  • 서버의 응답을 기다리지 않고, 요청이 성공할 것이라고 가정하고 UI를 먼저 업데이트하는 방식입
    • 예를 들어, 사용자가 ‘좋아요’ 버튼을 누르면, 서버 응답을 받기 전에 즉시 ‘좋아요’ 카운트를 1 올리고 버튼 색을 바꾼다.
    • 만약 나중에 서버에서 에러가 발생하면, 그때 UI를 원래 상태로 되돌린다.
    • 이렇게 하면 사용자는 자신의 행동에 대한 즉각적인 피드백을 받아 훨씬 쾌적한 경험을 할 수 있다.

=> React Query는 이러한 낙관적 업데이트를 구현하기 위한 강력한 기능(onMutate, onError, onSettled)을 제공한다.

Reference

Zustand 핵심 정리
TanStack Query(React Query) 핵심 정리
Next.Js에서 fetch말고 React-Query 사용하기
How to Setup React Query in Next.js 13 App Directory
(번역) React Query를 사용하여 서버 상태를 관리하는 방법
서버에서 React Query prefetching 한 데이터 사용하기
Next.js app router에서 React Query 사용하면서 고민했던 것들
Query Keys를 관리하는 기준과 방법

Previous Post
Next.js 폴더 구조, React Query, Zustand 조합
Next Post
Spring Boot와 Uppy로 구현하는 대용량 파일 업로드
Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.