나의 포트폴리오 웹에서 그동안 해온 프로젝트 배포 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",
}
암튼 이렇게 리액트쿼리를 적용했는데, 왜 여전히 네트워크 요청이 발생하는거니?!
그래서 일단 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;
리렌더링이 과도한거같아 이것도 수정해봐야겠다
진짜끝!
'개발 공부 일지 > React' 카테고리의 다른 글
MVC 패턴과 Flux 패턴 이해하기 (2) | 2025.01.24 |
---|---|
전역 모달이 필요한 이유 (0) | 2025.01.15 |
제어 컴포넌트와 비제어 컴포넌트 (Controlled vs. Uncontrolled) (0) | 2025.01.10 |
리액트 - useReducer (1) | 2024.09.05 |
리액트 - 모달 구현 방법 참조 (0) | 2024.09.05 |