Ian's Archive 🏃🏻

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접미사로 명명해 각 컴포넌트에서 사용
copyButtonText
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 })),
}));

컴포넌트에서 사용

copyButtonText
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>
    </>
  );
}

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

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

copyButtonText
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 })),
  },
}));

컴포넌트에서 사용

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

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

상태 초기화

copyButtonText
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] });
      });
    },
  },
}));
copyButtonText
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>
    </>
  );
}

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

copyButtonText
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 }),
}));
copyButtonText
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) 예시

copyButtonText
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를 관리하는 기준과 방법

Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.