며칠 째 과제를 끝내기 위해 그리고 이쁘게 만들기 위해 몰두하고 있다. 개발하면서 정말 여러가지의 트러블을 경험했지만 그 중에서 제일 이해가 안되는 문제를 다뤄보려고 한다. 바로 suspense를 어떻게 써야하는지에 대한 것이다.
1. Suspense란 무엇인가?
먼저 Suspense란 무엇인지 알아볼 필요가 있다. Suspense란 비동기 데이터를 로딩하거나, 컴포넌트를 동적으로 로드할 때 로딩 상태를 처리하기 위한 React의 내장 기능이다. React 16.6v 에서 처음 도입되었으며, 주로 동적 컴포넌트 로드와 서버 컴포넌트의 데이터 로딩을 처리하는 데 사용된다. Suspense는 UI가 렌더링되기 전에 필요한 데이터를 로드하거나 작업이 완료되기를 기다리는 동안 특정 UI(fallback)를 표시해준다. React는 이 과정에서 UI의 중단없는 렌더링을 지원한다.
1) 주요 특징
- 로딩 상태 관리 : 데이터가 로드되거나 컴포넌트가 준비되기 전에 fallback으로 대체 UI를 렌더링한다.
- 비동기 처리 : 비동기 로직( React.lazy, use, 또는 외부 라이브러리)과 함께 사용된다.
- 서버-클라이언트 통합 : React 서버 컴포넌트와 클라이언트 컴포넌트 간의 데이터 흐름을 원활하게 만들어준다.
2. 사용 예시
1) 동적 컴포넌트 로드
import React, { Suspense } from "react";
const LazyComponent = React.lazy(() => import("./MyComponent"));
const App = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
};
export default App;
- MyComponent가 로드될 때까지 fallback에 지정된 <div>Loading...</div>이 렌더링된다.
- 로딩이 완료 후 LazyComponent가 렌더링된다.
2) 서버-클라이언트 데이터 로드
const fetchData = async () => {
const response = await fetch("/api/data");
return response.json();
};
const MyComponent = () => {
const data = use(fetchData());
return <div>Data: {JSON.stringify(data)}</div>;
};
const App = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
);
};
export default App;
- use 훅이 데이터를 가져오는 동안 fallback UI를 표시한다.
- 데이터 로딩 완료 후 MyComponent를 렌더링한다.
3) React Query와 함께 사용
import { useQuery } from "@tanstack/react-query";
const fetchData = async () => {
const response = await fetch("/api/data");
return response.json();
};
const DataComponent = () => {
const { data } = useQuery({
queryKey: ["data"],
queryFn: fetchData,
suspense: true, // Suspense 활성화
});
return <div>Data: {JSON.stringify(data)}</div>;
};
const App = () => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<DataComponent />
</React.Suspense>
);
};
export default App;
4) 서버 컴포넌트와 함께 사용
// 서버 컴포넌트
export default async function Page() {
const data = await fetch("https://api.example.com/data").then((res) =>
res.json()
);
return (
<Suspense fallback={<div>Loading...</div>}>
<ClientComponent data={data} />
</Suspense>
);
}
// 클라이언트 컴포넌트
"use client";
export function ClientComponent({ data }: { data: any }) {
return <div>Data: {JSON.stringify(data)}</div>;
}
3. 제약
1) 단독 사용이 불가능하다.
- Suspense는 비동기 데이터를 직접 처리하지 못한다.
- 따라서 React.lazy, use, 또는 React Query와 같은 외부 라이브러리와 함께 사용해야 한다.
2) 에러 처리가 필요하다.
- 비동기 작업 중 발생한 에러는 ErrorBoundary를 통해 처리해주어야 한다.
import { ErrorBoundary } from "react-error-boundary";
const ErrorFallback = ({ error }: { error: Error }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
);
const App = () => (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
);
4. 그럼 왜 사용하려고 했는가?
이번 과제의 도전과제에서 로딩 및 에러 핸들링의 고도화기능이 있었다. 이전 팀프로젝트에서 Loading 페이지를 구현했던 것이 생각나 도전하고자 하였다. 발제 노션에서 제시 된 방법은 4가지가 있었는데 나는 이 중 2가지를 적용시켜보고자 하였다.
- React Suspense와 loading.tsx를 사용하여 서버 컴포넌트의 로딩 상태를 관리
- 로딩 시간 시뮬레이션 : 의도적으로 API 호출에 지연을 추가하여 로딩 시간을 시뮬레이션
일단 Suspense는 정의에서 서버 컴포넌트에서 사용할 수 있다는 것을 알았다. 즉 , 클라이언트 컴포넌트에서는 Suspense를 사용하지 못하므로 useEffect를 사용하여 로딩페이지를 구현할 수 있을 것이다. 처음에 시도 했던 방법은 이렇다.
useEffect(() => {
// 데이터를 로딩 후 로딩 상태 해제
const timeout = setTimeout(() => setIsLoading(false), 1000); // 예시로 1초 대기
return () => clearTimeout(timeout); // 클린업 함수
}, []);
if (isLoading) {
return <Loading />; // 로딩 중일 때 로딩 컴포넌트 렌더링
}
그런데 가만히 생각해보니 굳이 클라이언트 컴포넌트에서 로딩 처리를 해주어야 할까 의문이 들었다.
로딩패이지의 가장 큰 의미는 사용자에게 중단없는 UI 렌더링을 제공하는 것이라고 생각한다.
즉, 다시 생각하면 클라이언트 컴포넌트에서 서버 컴포넌트로부터 로드된 데이터 이외의 추가적인 데이터를 비동기로 가져오거나, 사용자 상호작용에 따라 데이터를 로드해야 하는 경우에는 로딩페이지를 보여주는 것이 사용자에게 좋은 UX를 제공해 줄 수 있을 것이다.
5. Suspense 활용
그럼 다시 생각해보자. 서버 컴포넌트에서는 무조건 Suspense를 사용하여 로딩처리해주는 것이 적절할까? 아래의 코드는 챔피언 디테일 페이지를 구현하기 위해 작성한 서버 컴포넌트이다.
import { Metadata } from "next";
import { ChampionDetails } from "@/types/champions";
import { fetchChampionDetails, fetchLatestVersion } from "@/utils/serverApi";
import Detail from "./Details";
type Props = {
params: {
id: string;
};
};
export const generateMetadata = async ({
params,
}: Props): Promise<Metadata> => {
const champion: ChampionDetails = await fetchChampionDetails(params.id);
return {
title: `League Of Legends : ${champion.name}`,
description: `${champion.lore}`,
openGraph: {
title: `League Of Legends : ${champion.name}`,
description: `${champion.lore}`,
url: `https://lol-app-psi.vercel.app/champions/${params.id}`,
},
};
};
// 서버에서 데이터를 가져오는 비동기 함수
const fetchData = async (id: string) => {
const [version, champion] = await Promise.all([
fetchLatestVersion(),
fetchChampionDetails(id),
]);
return { version, champion };
};
const DetailPage = async ({ params }: Props) => {
// 서버에서 데이터를 가져옴
const { version, champion } = await fetchData(params.id);
return <Detail champion={champion} version={version} />;
};
export default DetailPage;
이 로직에서 서버 컴포넌트를 통해 데이터를 가져오고 이를 렌더링하기 전에 준비를 마친다. 따라서 이 로직에서는 Suspense는 불필요하다고 할 수 있다. 이런 이유로 Suspense를 어디서 사용하는 것이 적절할지 많은 고민을 하게 되었다. 그리고 나는 tanstackQuery를 활용하여 동적인 데이터를 로드하고 캐싱하고 있는 클라이언트 컴포넌트가 생각났다. 바로 Rotation 페이지였다. 나는 이 컴포넌트에 Suspense를 적용시켜보고자 결정하고 고민하였다.
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchChampionRotationData } from "@/utils/rotateApi";
import { Champion } from "@/types/champions";
import ChampionCardList from "@/components/champion/ChampionList";
import { RotationSEO } from "@/components/rotation/RotationSEO";
import { sortChampionsByName } from "@/utils/sortChampionByName";
import Loading from "../loading";
type RotationProps = {
allPlayers: Champion[];
newPlayers: Champion[];
};
const RotationPage = () => {
const { data, isPending, error } = useQuery<RotationProps>({
queryKey: ["championRotation"],
queryFn: fetchChampionRotationData,
retry: false,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
});
if (isPending) {
return <Loading/>
}
if (error) return "error"
const sortedAllPlayers = data?.allPlayers
? sortChampionsByName(data.allPlayers)
: [];
const sortedNewPlayers = data?.newPlayers
? sortChampionsByName(data.newPlayers)
: [];
return (
<>
<RotationSEO />
<article className="flex flex-col gap-10 min-h-screen pb-[200px] m-auto max-w-custom container">
<ChampionCardList
title="금주 로테이션 챔피언"
champions={sortedAllPlayers}
/>
<hr className="border border-[#ddd] dark:border-[#a1a1a1]" />
<ChampionCardList
title="로테이션 챔피언 (신규)"
champions={sortedNewPlayers}
/>
</article>
</>
);
};
export default RotationPage;
이 컴포넌트에서는 React Query 를 통해 데이터를 가져오고 있지만 현재는 isPending과 error를 조건부로 처리하여 로딩 및 에러상태를 관리하고 있다. 이를 Suspense를 활용하여 리펙토링을 하게 되면 로딩 상태는 fallback에서 처리할 수 있으며, React Query의 errorBoundary와 결합하여 에러 상태를 효과적으로 처리할 수 있을 것이라고 생각하였다.
"use client";
import React, { Suspense } from "react";
import { useQuery } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { fetchChampionRotationData } from "@/utils/rotateApi";
import { sortChampionsByName } from "@/utils/sortChampionByName";
import { Champion } from "@/types/champions";
import ChampionCardList from "@/components/champion/ChampionList";
import { RotationSEO } from "@/components/rotation/RotationSEO";
import Loading from "../loading";
import ErrorHandler from "../ErrorHandler";
type RotationProps = {
allPlayers: Champion[];
newPlayers: Champion[];
};
const RotationContent = () => {
const { data } = useQuery<RotationProps>({
queryKey: ["championRotation"],
queryFn: fetchChampionRotationData,
retry: false,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
});
const sortedAllPlayers = data?.allPlayers
? sortChampionsByName(data.allPlayers)
: [];
const sortedNewPlayers = data?.newPlayers
? sortChampionsByName(data.newPlayers)
: [];
return (
<>
<RotationSEO />
<article className="flex flex-col gap-10 min-h-screen pb-[200px] m-auto max-w-custom container">
<ChampionCardList
title="금주 로테이션 챔피언"
champions={sortedAllPlayers}
/>
<hr className="border border-[#ddd] dark:border-[#a1a1a1]" />
<ChampionCardList
title="로테이션 챔피언 (신규)"
champions={sortedNewPlayers}
/>
</article>
</>
);
};
const RotationPage = () => {
return (
<ErrorBoundary
FallbackComponent={ErrorHandler} // 에러 핸들링 컴포넌트
onReset={() => {
console.log("Error boundary reset triggered."); // 에러 초기화 후 실행할 로직
}}
>
<Suspense fallback={<Loading />}>
<RotationContent />
</Suspense>
</ErrorBoundary>
);
};
export default RotationPage;
이렇게 리펙토링을 하였다. 그리고 잘 작동할지에 대해 GPT에게 물어보았다. 하지만 이로 인해 나는 GPT로부터 가장 큰 골칫거리를 떠맡고 말았다. 이제부터는 트러블슈팅 시작이다..
6. 트러블슈팅
내가 작성한 코드에는 어떤 문제가 있을까? GPT 말로는 이 Suspense를 React Query와 결합하기 위해서는 React Query 속성에 Suspense 옵션을 활성화 해주어야 한다고 한다. 그래, 활성화만 하면 되는거아니야? 라 생각하고 suspense: true로 활성화한 순간부터 머리가 말랑해지기 시작했다. 지긋지긋한 타입 오류이다... 지금 부터 타입 추론 문제 해결 시작이다...
처음 발생한 타입 오류 발생 원인은 data의 undefined 상태와 관련있다. React Query에서 Suspense가 활성화된 경우에는 데이터가 로드되기 전 컴포넌트가 렌더링되지 않으므로 data는 항상 정의된 상태로 간주되어야 할 것이다. 하지만 TypeScript는 이를 자동으로 추론해주지 않기 때문에 타입 오류가 발생하였다고 생각하였다.
1) 첫 번째 해결 방법 : data를 명시적으로 지정
명시적으로 지정해주었는데도 같은 문제가 발생하고 있다. 그렇다면 단순히 타입 오류의 문제가 아닐 수도 있겠다라는 생각이 들었다. suspense에 발생한 오류를 확인해보았다.
아니나 다를까.. 이게 도대체 뭐라는거지.. 첫 줄에 보아하니 QueryClient를 언급하고 있고, suspense라는 것이 기본값으로 정의된 데이터 타입 옵션에 존재하지 않는다라고 하는 것 같다.또한 기본값으로 정의되지 않은 데이터 타입 옵션에도 존재하지 않고 있으며, UseQuery의 타입으로도 지정되어 있지 않았다라고 말하는 것 같다...
와 이건 GPT의 힘을 빌려야겠다. 그리고 GPT는 나에게 이렇게 대답해주었다.
" 이 오류는 React Query의 useQuery에서 suspense 옵션이 기본적으로 지원되지 않는 경우 발생합니다... "
" React Query의 최신 버전에서는 suspense를 활성화하려면 QueryClient에 별도로 설정을 추가해야 합니다..."
한마디로 GPT는 나한테 이렇게 말한 것이다.
" 님.. 이거 suspense 옵션 쓴다고 QueryClient에 언질도 안해놓고서는 왜 혼자 이상한거로 붙들고 있는거임? "
4시간동안 고민했다. 아니 그 이상했다 이놈아 .. 진작 알려주지 왜 그랬어!!
2) 두 번째 해결 방법 : QueryClient에 suspense 옵션을 활성화
자... 이건 양파다... 마트료시카다... 이번엔 무슨 문제냐
모르겠다.. 그러니까 결국에는 처음에 했던 대로하는 게 .. 잘 작동한다는 소리 아닐까... 내일 튜터님들께 여쭤보고 마저 기재해야겠다. 내일의 til은 이 문제의 해결과 함께 아래의 해결 방법을 조사해서 올릴 것 같다.
+ 지금까지 작업한 사이트의 전체적인 모습 또한 업로드할 예정이다.
'TIL' 카테고리의 다른 글
12. 23. 39일차 TIL 효율적인 파일 구조와 렌더링 방식 (2) | 2024.12.24 |
---|---|
12. 19. 38일차 TIL Next.js 과제_03 마무리, 커리어코칭 정리 (0) | 2024.12.19 |
12. 13. 36일차 TIL Next.js 과제_02 (0) | 2024.12.14 |
12. 12. 35일차 TIL next과제 시작 (0) | 2024.12.12 |
12. 11. 34일차 TIL Next.js 실습 (0) | 2024.12.12 |