
프론트엔드 개발에서 상태관리(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를 관리하는 기준과 방법