본문 바로가기

트러블슈팅

React Query와 Next.js에서 헤더 데이터 캐싱 문제 트러블슈팅

이번 글에서는 실제 프로젝트에서 겪었던 헤더 데이터 로딩 이슈와 이를 React Query의 다양한 옵션을 활용해 해결한 경험을 공유합니다.

문제 상황

저희 웹사이트에서는 헤더가 공통 컴포넌트로 모든 페이지에 표시되고 있는 구조였습니다. 헤더에 표시될 배너 데이터를 효율적으로 관리하기 위해 처음에는 다음과 같은 접근 방식을 시도했습니다.

  1. Zustand를 사용하여 배너 데이터를 전역 상태로 관리
  2. React Query로 API 데이터를 가져와 Zustand 스토어에 저장
  3. 컴포넌트에서는 Zustand 스토어의 데이터를 사용하여 렌더링

그러나 이 방식에는 문제가 있었습니다. 콘솔에는 데이터가 정상적으로 로그되었지만, 이미지가 로드되지 않았고 페이지 이동 시 데이터가 유지되지 않았습니다.

원인 분석

  1. 불안정한 쿼리 키 구조: 함수를 사용한 쿼리 키(BANNER_QUERY_KEYS.listSet())가 매번 새로운 배열을 생성하여 캐시 매칭에 실패
  2. React Query 설정 문제: staleTime과 cacheTime 설정이 있었지만, Next.js의 페이지 라우팅에서 제대로 작동하지 않음
  3. QueryClient 인스턴스 관리: 각 페이지 전환마다 새로운 QueryClient 인스턴스가 생성되어 캐시가 유지되지 않음
  4. 데이터 변환 로직의 비효율성: useEffect를 사용한 데이터 변환이 불필요한 렌더링을 발생시킴

해결 방법

1. 안정적인 쿼리 키 구조 도입

함수 형태의 쿼리 키 대신 상수 배열을 사용하여 안정성을 확보했습니다.

export const BANNER_QUERY_KEYS = {
  all: ["banners"],
  list: ["banners", "list"],
  listSet: ["banners", "list", "set"],
};

2. QueryClient 설정 개선

_app.js에서 QueryClient 인스턴스를 생성하고 contextSharing 옵션을 활성화했습니다.

const [queryClient] = useState(() => new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분
      gcTime: 1000 * 60 * 30, // 30분
      refetchOnWindowFocus: false,
    },
  },
}));

return (
  <QueryClientProvider client={queryClient} contextSharing={true}>
    {/* ... */}
  </QueryClientProvider>
);

3. select 옵션을 활용한 데이터 변환

useEffect 대신 React Query의 select 옵션을 사용하여 데이터 변환 로직을 간소화했습니다.

select: (rawData) => {
  if (!rawData) return { /* 기본값 */ };

  return {
    bannerList: rawData,
    headerMenu: rawData.header_menu || [],
    mainAward: rawData.main_award || [],
    // 더 많은 변환...
  };
}

4. suspense 옵션 비활성화

SSR과 CSR 간의 hydration 불일치 문제를 방지하기 위해 suspense: false로 설정했습니다.

const { data, refetch, isFetching, isError, error } = useQuery({
  queryKey: BANNER_QUERY_KEYS.listSet,
  queryFn: fetchBanners,
  suspense: false, // hydration 문제 방지
  select: (rawData) => { /* ... */ }
});

핵심 개선 요소 설명

contextSharing 옵션

contextSharing={true}는 React Query가 여러 Provider 인스턴스 간에 동일한 React Context를 공유하도록 합니다. 이는 Next.js에서 특히 중요한데, 페이지 전환 시 새로운 React 트리가 생성되어도 쿼리 컨텍스트가 일관되게 유지되기 때문입니다.

이 설정이 없으면 페이지 이동 시마다 쿼리 캐시가 초기화되어 동일한 데이터에 대해 중복 요청이 발생합니다.

select 옵션

select 옵션은 쿼리 함수가 반환한 데이터를 변환하는 기능을 제공합니다.

  1. 데이터 변환: API 응답을 UI에 적합한 형태로 재구성
  2. 렌더링 최적화: 변환 결과가 변경될 때만 컴포넌트를 리렌더링
  3. 메모이제이션 효과: 동일한 입력에 대한 불필요한 계산 방지
  4. 데이터 정규화: 기본값 처리, 누락된 필드 추가 등

이 옵션을 사용함으로써 useEffect를 통한 상태 업데이트보다 더 효율적이고 선언적인 방식으로 데이터를 처리할 수 있습니다.

suspense 옵션

suspense 옵션은 React의 Suspense 기능과 React Query를 통합합니다. suspense: true로 설정하면, 데이터 로딩 중에 컴포넌트를 일시 중단시키고 가장 가까운 Suspense 경계에 로딩 상태를 위임합니다.

그러나 SSR 환경에서는 이 옵션이 hydration 불일치 문제를 일으킬 수 있습니다. 서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 컴포넌트 트리가 일치하지 않아 "This Suspense boundary received an update before it finished hydrating" 같은 오류가 발생합니다.

따라서 Next.js와 같은 SSR 프레임워크를 사용할 때는 suspense: false로 설정하는 것이 안전합니다.

 

결론

이번 트러블슈팅을 통해 React Query와 Next.js를 함께 사용할 때 발생할 수 있는 캐싱 문제를 해결했습니다.

핵심 개선 사항은 안정적인 쿼리 키 구조 사용, 전역 QueryClient 인스턴스와 contextSharing 설정, select 옵션을 활용한 효율적인 데이터 변환, suspense 옵션 비활성화로 hydration 문제 방지

 

이러한 변경 후 헤더 데이터가 페이지 이동 간에도 안정적으로 유지되고, 이미지가 정상적으로 로드되는 것을 확인했습니다. 또한 불필요한 API 호출이 줄어들어 성능도 향상되었습니다.

 

React Query는 강력한 도구이지만, 특히 SSR 환경에서는 세심한 설정이 필요한 것 같습니다. 이 글이 비슷한 문제를 겪고 있는 개발자들에게 도움이 되길 바랍니다 :)