
프론트엔드 개발을 하다 보면 서버 상태와 클라이언트 상태를 동시에 관리해야 하는 상황이 자주 발생합니다.
이번 포스트에서는 React Query(TanStack Query)와 React Context를 함께 활용하여 복합적인 상태를 효율적으로 관리한 경험을 공유하고자 합니다.
프로젝트에서 다음과 같은 요구사항이 있었습니다:
처음에는 모든 상태를 Context에 넣으려고 했습니다:
// ❌ 문제가 있는 접근
const AppContext = createContext({
items: [], // 서버 상태인데 Context에?
userSettings: new Map(), // 클라이언트 상태
// ...
});
이 방식의 문제점:
React Query: 서버 상태 관리
Context: 클라이언트 상태 관리
┌─────────────────────────────────────────┐
│ MainLayout Component │
│ (최상위 컴포넌트, 최종 요청 처리) │
└─────────────────────────────────────────┘
│
│ Context 제공
▼
┌─────────────────────────────────────────┐
│ AppContext │
│ - userSettings: Map<string, number> │
│ - selectedItemId: number │
└─────────────────────────────────────────┘
│
│ Context 사용
▼
┌─────────────────────────────────────────┐
│ DetailLayout Component │
│ (상세 페이지 레이아웃) │
└─────────────────────────────────────────┘
│
│ Hook 사용
▼
┌─────────────────────────────────────────┐
│ useDetailData Hook │
│ - React Query로 서버 상태 관리 │
│ - useState로 클라이언트 상태 관리 │
│ - Context에 클라이언트 상태 동기화 │
└─────────────────────────────────────────┘
// AppContext.tsx
'use client';
import { ReactNode, createContext, useContext, useState } from 'react';
interface AppContextValue {
// 선택된 항목 ID
selectedItemId: number | null;
setSelectedItemId: (itemId: number | null) => void;
// 사용자별 설정값 (itemId를 키로 사용)
userSettings: Map<string, number>;
setUserSettings: (settings: Map<string, number>) => void;
}
const AppContext = createContext<AppContextValue | undefined>(undefined);
export const AppProvider = ({ children }: AppProviderProps) => {
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [userSettings, setUserSettings] = useState<Map<string, number>>(
new Map()
);
return (
<AppContext.Provider
value={{
selectedItemId,
setSelectedItemId,
userSettings,
setUserSettings,
}}
>
{children}
</AppContext.Provider>
);
};
// useDetailData.ts
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useState, useMemo } from 'react';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
export const useDetailData = (
onSettingChange?: (itemId: string, value: number) => void
) => {
const queryClient = useQueryClient();
// ✅ React Query로 서버 상태 관리
const {
data: items,
isLoading: isItemsLoading,
// ... 기타 React Query 기능들
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 1 }) => fetchItems(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
// ✅ 클라이언트 상태 관리
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [userSettings, setUserSettings] = useState<Map<string, number>>(
new Map()
);
// 선택된 항목의 실제 ID (number)
const selectedItemNumberId = useMemo(() => {
if (!selectedItemId) return null;
const item = items?.pages
.flatMap((page) => page.data)
.find((item) => item.code === selectedItemId);
return item?.id || null;
}, [selectedItemId, items]);
// ✅ React Query로 선택된 항목의 상세 데이터 조회
const { data: detailData, isLoading: isDetailLoading } = useInfiniteQuery({
queryKey: ['detail', selectedItemNumberId],
queryFn: ({ pageParam = 1 }) =>
fetchDetailData(selectedItemNumberId, pageParam),
enabled: !!selectedItemNumberId,
});
// 설정값 변경 핸들러
const handleSettingChange = useCallback(
(value: number) => {
if (!selectedItemId) return;
// ✅ 클라이언트 상태 즉시 업데이트 (UI 반응성)
setUserSettings((prev) => {
const newMap = new Map(prev);
if (value === 0) {
newMap.delete(selectedItemId);
} else {
newMap.set(selectedItemId, value);
}
return newMap;
});
onSettingChange?.(selectedItemId, value);
// ✅ React Query Mutation으로 서버에 저장
if (value > 0) {
updateSettingMutation.mutate({
itemId: selectedItemNumberId,
value,
});
}
},
[selectedItemId, selectedItemNumberId, onSettingChange]
);
return {
// 서버 상태 (React Query)
items,
isItemsLoading,
detailData,
isDetailLoading,
// 클라이언트 상태
selectedItemId,
userSettings,
// 핸들러
handleSettingChange,
setSelectedItemId,
// ...
};
};
// DetailLayout.tsx
import { useEffect, useMemo } from 'react';
export const DetailLayout = ({
onSettingChange,
}: DetailLayoutProps = {}) => {
// ✅ Context에서 setter 가져오기
const { setUserSettings } = useAppContext();
// ✅ Hook에서 상태 가져오기
const {
userSettings, // Hook 내부의 클라이언트 상태
// ... 기타 서버 상태들
} = useDetailData(onSettingChange);
// ✅ Hook의 클라이언트 상태를 Context에 동기화
const userSettingsString = useMemo(
() => JSON.stringify(Array.from(userSettings.entries())),
[userSettings]
);
useEffect(() => {
setUserSettings(new Map(userSettings));
}, [userSettingsString, setUserSettings]);
// ...
};
// MainLayout.tsx
const handleSubmitRequest = useCallback(async () => {
// ✅ Context에서 사용자가 설정한 값 가져오기
const { userSettings } = useAppContext();
// ✅ React Query로 가져온 목록 데이터
const { data: items } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
});
const itemsWithSettings = items?.filter(
(item) => item.id && item.isActive
) || [];
const requestBody = {
items: itemsWithSettings.map((item) => {
// ✅ Context에서 사용자 설정값 가져오기
const userSettingValue = item.code
? userSettings.get(String(item.code))
: undefined;
// 사용자가 설정한 값이 있으면 그것을 사용, 없으면 기본값 사용
const finalValue =
userSettingValue !== undefined && userSettingValue > 0
? userSettingValue
: Math.min(item.defaultValue || DEFAULT_VALUE, DEFAULT_VALUE);
return {
itemId: item.id,
value: finalValue,
};
}),
};
// API 호출
await submitData(requestBody);
}, [userSettings, /* ... */]);
서버 상태 (React Query):
클라이언트 상태 (Context):
이렇게 분리함으로써 각각의 장점을 최대한 활용할 수 있습니다.
// ✅ 무한 스크롤 자동 처리
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 1 }) => fetchItems(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
// ✅ 자동 캐싱 및 리패칭
// ✅ 에러 처리 자동화
// ✅ 로딩 상태 자동 관리
React Query를 사용하면 이런 기능들을 거의 무료로 얻을 수 있습니다.
Context는 정말 필요한 클라이언트 상태만 관리합니다:
// ✅ Context에 들어가는 것: 클라이언트 상태만
const AppContext = {
userSettings: Map<string, number>, // 사용자 설정값
selectedItemId: number, // UI 상태
};
// ❌ Context에 들어가지 않는 것: 서버 상태
// items: [] // React Query로 관리
// detailData: [] // React Query로 관리
이렇게 하면 Context가 가벼워지고, 불필요한 리렌더링을 방지할 수 있습니다.
서버 데이터: React Query → Hook → Component
클라이언트 상태: Hook → Context → 다른 Component
데이터가 어디서 오는지, 어디로 가는지가 명확합니다.
문제: Map 객체를 Context에 저장할 때, Map의 내용이 변경되어도 React가 변경을 감지하지 못함
// ❌ 문제가 있는 코드
useEffect(() => {
setUserSettings(new Map(userSettings));
}, [userSettings]); // Map 객체는 참조가 같으면 변경 감지 안 됨
해결: Map의 entries를 문자열로 변환하여 비교
// ✅ 해결 방법
const userSettingsString = useMemo(
() => JSON.stringify(Array.from(userSettings.entries())),
[userSettings]
);
useEffect(() => {
setUserSettings(new Map(userSettings));
}, [userSettingsString, setUserSettings]);
문제: 키가 문자열인지 숫자인지 불일치
// ✅ 해결: 명시적으로 문자열로 변환
const itemKey = item.code ? String(item.code) : null;
const userSettingValue = itemKey
? userSettings.get(itemKey)
: undefined;
주의: 같은 데이터를 여러 곳에서 관리하지 않도록 주의
// ❌ 나쁜 예: 같은 데이터를 Hook과 Context 둘 다에서 관리
const [userSettings, setUserSettings] = useState(new Map()); // Hook
const { userSettings } = useAppContext(); // Context
// ✅ 좋은 예: Hook에서 관리하고 Context에 동기화
const { userSettings } = useDetailData(); // 원본
const { userSettings: contextSettings } = useAppContext(); // 동기화된 복사본
useMemo와 useCallback 적절히 사용
// ✅ 계산 비용이 큰 값은 useMemo로 메모이제이션
const selectedItemNumberId = useMemo(() => {
if (!selectedItemId) return null;
const item = items?.find((item) => item.code === selectedItemId);
return item?.id || null;
}, [selectedItemId, items]);
// ✅ 함수는 useCallback으로 메모이제이션
const handleSettingChange = useCallback(
(value: number) => {
// ...
},
[selectedItemId, itemId, /* dependencies */]
);
React Query와 Context를 함께 사용하면:
이 패턴은 복잡한 상태 관리가 필요한 프로젝트에서 매우 유용합니다. 특히 서버 데이터와 사용자 입력을 모두 다뤄야 하는 경우에 강력합니다.
질문이나 피드백이 있으시면 댓글로 남겨주세요! 🚀