React Query와 Context를 활용한 복합 상태 관리

IT쿠키·2026년 1월 26일
post-thumbnail

📋 목차

  1. 들어가며
  2. 문제 상황
  3. 해결 방법: React Query + Context
  4. 실제 구현 코드
  5. 왜 이렇게 했는가?
  6. 주의사항과 트러블슈팅
  7. 마무리

들어가며

프론트엔드 개발을 하다 보면 서버 상태클라이언트 상태를 동시에 관리해야 하는 상황이 자주 발생합니다.

이번 포스트에서는 React Query(TanStack Query)React Context를 함께 활용하여 복합적인 상태를 효율적으로 관리한 경험을 공유하고자 합니다.


문제 상황

프로젝트에서 다음과 같은 요구사항이 있었습니다:

  1. 목록 데이터와 상세 데이터는 서버에서 가져와야 함 (서버 상태)
  2. 사용자별 설정값은 사용자가 UI에서 직접 설정하는 값 (클라이언트 상태)
  3. 최종 요청 시 사용자가 설정한 값을 서버로 전송해야 함
  4. 여러 컴포넌트에서 이 상태들을 공유해야 함

초기 접근 방식의 문제점

처음에는 모든 상태를 Context에 넣으려고 했습니다:

// ❌ 문제가 있는 접근
const AppContext = createContext({
  items: [], // 서버 상태인데 Context에?
  userSettings: new Map(), // 클라이언트 상태
  // ...
});

이 방식의 문제점:

  • 서버 상태를 Context에 넣으면 캐싱, 리패칭, 에러 처리 등의 이점을 잃게 됨
  • 서버 데이터가 변경되어도 Context는 자동으로 업데이트되지 않음
  • React Query의 강력한 기능들(무한 스크롤, 자동 리패칭 등)을 활용할 수 없음

해결 방법: React Query + Context

역할 분리

React Query: 서버 상태 관리

  • 목록 데이터 조회
  • 상세 데이터 조회
  • 무한 스크롤 처리
  • 자동 캐싱 및 리패칭

Context: 클라이언트 상태 관리

  • 사용자가 설정한 값들
  • UI 상태 (선택된 항목, 모달 열림/닫힘 등)
  • 여러 컴포넌트 간 공유가 필요한 클라이언트 상태

아키텍처 다이어그램

┌─────────────────────────────────────────┐
│         MainLayout Component              │
│  (최상위 컴포넌트, 최종 요청 처리)        │
└─────────────────────────────────────────┘
                    │
                    │ Context 제공
                    ▼
┌─────────────────────────────────────────┐
│         AppContext                        │
│  - userSettings: Map<string, number>     │
│  - selectedItemId: number                │
└─────────────────────────────────────────┘
                    │
                    │ Context 사용
                    ▼
┌─────────────────────────────────────────┐
│         DetailLayout Component            │
│  (상세 페이지 레이아웃)                   │
└─────────────────────────────────────────┘
                    │
                    │ Hook 사용
                    ▼
┌─────────────────────────────────────────┐
│         useDetailData Hook                │
│  - React Query로 서버 상태 관리           │
│  - useState로 클라이언트 상태 관리        │
│  - Context에 클라이언트 상태 동기화       │
└─────────────────────────────────────────┘

실제 구현 코드

1. 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>
  );
};

2. Custom Hook (React Query + 클라이언트 상태)

// 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,
    // ...
  };
};

3. Context와 Hook 연결

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

  // ...
};

4. 최종 요청에서 Context 사용

// 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, /* ... */]);

왜 이렇게 했는가?

1. 서버 상태와 클라이언트 상태의 명확한 분리

서버 상태 (React Query):

  • 서버에서 가져오는 데이터
  • 캐싱이 필요함
  • 자동 리패칭이 필요함
  • 무한 스크롤 같은 복잡한 기능이 필요함

클라이언트 상태 (Context):

  • 사용자가 UI에서 설정하는 값
  • 서버와 동기화되지 않아도 되는 임시 상태
  • 여러 컴포넌트에서 공유가 필요함

이렇게 분리함으로써 각각의 장점을 최대한 활용할 수 있습니다.

2. React Query의 강력한 기능 활용

// ✅ 무한 스크롤 자동 처리
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['items'],
  queryFn: ({ pageParam = 1 }) => fetchItems(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage,
});

// ✅ 자동 캐싱 및 리패칭
// ✅ 에러 처리 자동화
// ✅ 로딩 상태 자동 관리

React Query를 사용하면 이런 기능들을 거의 무료로 얻을 수 있습니다.

3. Context는 최소한의 클라이언트 상태만 관리

Context는 정말 필요한 클라이언트 상태만 관리합니다:

// ✅ Context에 들어가는 것: 클라이언트 상태만
const AppContext = {
  userSettings: Map<string, number>, // 사용자 설정값
  selectedItemId: number, // UI 상태
};

// ❌ Context에 들어가지 않는 것: 서버 상태
// items: [] // React Query로 관리
// detailData: [] // React Query로 관리

이렇게 하면 Context가 가벼워지고, 불필요한 리렌더링을 방지할 수 있습니다.

4. 컴포넌트 간 데이터 흐름이 명확함

서버 데이터: React Query → Hook → Component
클라이언트 상태: Hook → Context → 다른 Component

데이터가 어디서 오는지, 어디로 가는지가 명확합니다.


주의사항과 트러블슈팅

1. Map 객체의 변경 감지 문제

문제: 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]);

2. 타입 일관성 유지

문제: 키가 문자열인지 숫자인지 불일치

// ✅ 해결: 명시적으로 문자열로 변환
const itemKey = item.code ? String(item.code) : null;
const userSettingValue = itemKey
  ? userSettings.get(itemKey)
  : undefined;

3. 중복 상태 관리 방지

주의: 같은 데이터를 여러 곳에서 관리하지 않도록 주의

// ❌ 나쁜 예: 같은 데이터를 Hook과 Context 둘 다에서 관리
const [userSettings, setUserSettings] = useState(new Map()); // Hook
const { userSettings } = useAppContext(); // Context

// ✅ 좋은 예: Hook에서 관리하고 Context에 동기화
const { userSettings } = useDetailData(); // 원본
const { userSettings: contextSettings } = useAppContext(); // 동기화된 복사본

4. 성능 최적화

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를 함께 사용하면:

  1. 서버 상태는 React Query가 효율적으로 관리
  2. 클라이언트 상태는 Context로 여러 컴포넌트에서 공유
  3. ✅ 각각의 장점을 최대한 활용
  4. ✅ 코드가 명확하고 유지보수하기 쉬움

이 패턴은 복잡한 상태 관리가 필요한 프로젝트에서 매우 유용합니다. 특히 서버 데이터와 사용자 입력을 모두 다뤄야 하는 경우에 강력합니다.

참고 자료


질문이나 피드백이 있으시면 댓글로 남겨주세요! 🚀

profile
IT 삶을 사는 쿠키

0개의 댓글