본문 바로가기
개발 공부 일지/React

[포트폴리오] react-query 캐싱이 안됨 -> re-render시 초기화 문제

by yelimu 2025. 2. 22.

나의 포트폴리오 웹에서 그동안 해온 프로젝트 배포 url 의 og image를 가져오는 기능을 구현해두었다.

 

프로젝트 배포 주소를 url로 받아서 open graph 이미지를 받아오는 비동기 함수인데,

목록과 세부 페이지(모달)에서 반복되는 로직이기에 커스텀 훅(▼)으로 분리했다. 

 

import { useEffect, useState } from "react";

const useGetOpenGraphImage = (url: string) => {
  const [imageUrl, setImageUrl] = useState<string>();
  const [loading, setLoading] = useState(false);

  const getOpenGraphImage = async () => {
    try {
      setLoading(true);
      const res = await fetch(`/api/url?url=${encodeURIComponent(url)}`);
      const data = await res.json();
      setImageUrl(data.imageUrl);
    } catch (err) {
      console.log("오픈그래프 이미지 fetching 에러", err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    getOpenGraphImage();
  }, []);
  return { imageUrl, loading, getOpenGraphImage };
};

export default useGetOpenGraphImage;

 

그런데 동일한 네트워크 요청이 반복되는게 신경쓰여서, react query (tanstack query) ▼를 적용했다.

import { useQuery } from "@tanstack/react-query";

const useGetOpenGraphImage = (url: string) => {
  const {
    data: imageUrl,
    isLoading: loading,
    error,
  } = useQuery({
    queryKey: ["thumbnails", url],
    queryFn: async () => {
      const res = await fetch(`/api/url?url=${encodeURIComponent(url)}`);
      const data = await res.json();
      return data.imageUrl;
    },
    enabled: !!url,
    staleTime: Infinity,
    gcTime: Infinity
  });
  if (error) {
    console.log("오픈그래프 이미지 fetching 에러", error);
  }
  return { imageUrl, loading };
};
export default useGetOpenGraphImage;

참고로 cacheTime 이 gcTime 으로 변경되었다고 한다.

공식 문서

 

받아온 url을 zustand나 jotai 를 사용해서 전역 상태로 관리할지, 리액트 쿼리를 사용할지 좀 고민이 되었다.

중복된 API 요청이 발생하지 않도록 캐싱하는것이 목적이긴 하나, 

썸네일 url을 받아온 이후에는 다른 수정이 발생하지 않을거기때문에 전역 상태로 관리해도 문제는 없을 것 같았다.

또한 번들 크기 면에서도 zustand가 1KB,  리액트쿼리가 12KB로 꽤 차이가 나긴 한다.

그런데 여러개의 url 의 썸네일 get 요청을 보내고, 응답으로 받은 데이터를 받아오고, 각각의 아이템에 다시 전달하는게 번거로울 것 같기에 리액트 쿼리가 더 나은 선택같다.

즉 키-값 구조로 데이터를 관리해야하고, 프로젝트 개수가 추가될 때마다 계속 업데이트해줘야하는 번거로움이 있다.

{
  "https://example.com/1": "썸네일1.jpg",
  "https://example.com/2": "썸네일2.jpg",
  "https://example.com/3": "썸네일3.jpg",
}

암튼 이렇게 리액트쿼리를 적용했는데, 왜 여전히 네트워크 요청이 발생하는거니?!

페이지 로딩 시 image 4개를 받아와놓고 모달 띄우면 또 리퀘스트를 보내고있다ㅠ

 

그래서 일단 dev tool 을 설치해보았다.

npm install @tanstack/react-query-devtools --save-dev

import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

...
<ReactQueryDevtools />
...

 

fresh 4개 -> fetching 1개

새로 요청한거 맞지 지금. !! 

    enabled: !!url, // url이 있을 때만 실행
    staleTime: Infinity, // 캐시된 데이터가 무효화되지 않도록 함
    refetchOnWindowFocus: false, // 창 포커스 시 refetch 방지
    refetchOnReconnect: false, // 네트워크 재연결 시 refetch 방지

이런 저런 옵션을 다 추가해봐도 소용이 없다 ㅜㅜ

 

다만 이렇게 요청 속도는 차이가 많이 나긴 하는데 캐싱되었다면 네트워크 요청 자체가 발생하면 안되는거 아닌가요?? 

 

혹시나 싶어 쿼리 키에서 url을 빼보았는데 

    queryKey: ["thumbnails"],

응 아니야

 


해결했다 ~! 

캐싱이 문제가 아니라, 모달이 열릴때마다 queryClient 가 초기화되는것이 문제였다.

원인은 다름아닌.. 리액트쿼리 프로바이더 위치에 있었다.

 

-app
-layout
-page
	-clientLayout
            -about section
            -project section
            -contact section

포트폴리오는 next.js app router 프로젝트이고, 대략 이러한 구조로 되어있다.

 

보통은 프로바이더를 최상위 layout (또는 _app)에 위치하는데, Project 섹션에서만 리액트쿼리 쓰인다는 생각에 Project 컴포넌트 내부에 Provider 추가해버렸다.

"use client";
// import 생략

export default function ProjectSection() {
  const queryClient = new QueryClient();
  const [openProjectModal, setOpenProjectModal] = useState(false);
  
  const handleClickDetail = (name: string) => {
    setOpenProjectModal(true);
    setProjectName(name);
  };
  const handleCloseModal = () => {
    setOpenProjectModal(false);
  };
  return (
    <QueryClientProvider client={queryClient}>
      <section className={cn("flex flex-col items-center", spacingClass)}>
      //...
        {openProjectModal && <ProjectDetailModal onClick={handleCloseModal} projectName={projectName} />}
      </section>
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
}

그 결과 ▶ 모달이 열릴때 즉 openProjectModal 상태값이 변할때 -> 컴포넌트 리렌더링 

-> 내부의 코드가 다시 실행됨 -> new QueryClicent가 다시 실행되어 초기화됨 

-> 새 queryClient 는 api를 다시 호출함 

 

이런 결과를 초래하게 된것이다.. !!!

QueryClientProvider는 컴포넌트 리렌더링의 영향을 받지 않돌록 컴포넌트 외부에 위치해야하는데, 나는 provider를 외부에 위치하는이유를 정확히 몰랐기때문에 이런 실수를 한것이다. 

 

애초에 왜 컴포넌트 안에 프로바이더를 위치했냐면,, clientLayout이 별도로 있다는걸 잊은채; app/layout 에 프로바이더를 추가하자 이런 에러가 발생했기 때문인데

    <html lang="ko">
      <QueryClientProvider>
        <body>{children}</body>
      </QueryClientProvider>
    </html>
⨯ Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. <... client={{}} children={[...]}> ^^^^ at stringify (<anonymous>) at stringify (<anonymous>) at stringify (<anonymous>) { digest: '3238641649' }

서버 컴포넌트에 클라이언트 컴포넌트로 전달되는 client prop이 순수 객체가 아니다라는 에러

서버 컴포넌트에서 클라이언트 컴포넌트로 전달할 수 있는 데이터는 일반 객체만 가능하다. 

 

app/layout은 서버 컴포넌트, React Query의 QueryClientProvider는 클라이언트 컴포넌트이다.

서버 컴포넌트는 서버에서만 실행되고 클라이언트에서 실행되지 않음

 

그렇기때문에 QueryClientProvider는 별도의 ClientLayout 에 위치해야 한다. (나의 경우에는 app/page/clientLayout )


import { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const Provider = ({ children }: { children: ReactNode }) => {
  const queryClient = new QueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
};

export default Provider;

여러 종류의 provider, devtool 이 사용될 수 있기때문에 별도의 Provider 컴포넌트에 이들을 래핑하고 

"use client";

export default function ClientLayout({ children }: { children: React.ReactNode }) {

	return (
        <Provider>
          <Header />
          <main >
            {children}
            <ToTopButton/>
          </main>
          <Toast />
        </Provider>
  	);
}

Provider 컴포넌트로 clientLayout을 감싸주었다. 

 

이 외의 해결책은 아래와 같다.

1. (낮은 추천) query client를 component 밖으로 뺄 것

 : component 외부는 한번만 실행되는 코드로 re-render 영향이 없음

2. (중간 추천) query client를 state에 담아서 사용할 것

 : re-render가 일어나도 state 값은 유지되므로 영향이 없음

 

적용한 결과는!

이미지 url 4개를 받아온 후 => 모달 열었을때 추가적인 네트워크 요청이 발생하지 않는다.

모달 내에서 이미지 로딩도 발생하지 않음 👍따봉 


이 모든 해결과정은 나으 선배님이 알려준 방법이다. 이것이 사수의 중요성인가 하는 생각도 드는 한편, 문제를 정의하고 해결하는 능력을 키워야겠다는 생각이 들었다. 

단순히 캐싱이 안돼! 가 아니라 리액트쿼리가 초기화되는 문제점을 알아채고 원인을 찾아야 하며, 

리렌더링 시 초기화된다는 문제를 해결하기 위해 여러 단계(상태로 관리한다, 컴포넌트 외부로 분리한다, 최상위에서 래핑하여 사용한다 등)의 해결방법이 있을 수 있다는걸 배울 수 있었다.


라고 작성하고 다시 테스트해보니 또다시 계속 초기화가 발생하는걸 발견했다.

 

ClientLayout과 page에서 리렌더링 될때마다 콘솔 출력해보니 꽤 여러번 리렌더링 하고있음을 알수있었다

그리고 이러한 리렌더링에 의해 쿼리 클라이언트가 초기화된다.

이를 해결하기 위해 로컬 상태를 추가하여 리렌더링이 발생해도 초기화되지 않도록 조치했다.

import { ReactNode, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const Provider = ({ children }: { children: ReactNode }) => {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
};

export default Provider;

 

리렌더링이 과도한거같아 이것도 수정해봐야겠다

진짜끝!

참조 블로그 - devtools에 쿼리상태 없는 오류