
현재 작년에 개발했었던 프로젝트를 아예 새로 리워크하는 중입니다.
스택도 마음에 들지 않았지만 컴포넌트 아키텍처가 매우 엉망이었습니다ㅋㅋ
메인 목표는 업비트 API 기반 실시간 코인 차트 제공, 유튜브 영상과 코인 관련 기사 등을 제공하는 서비스였습니다. 이건 그대로 두고 좀 더 사용할 만한 가치가 있는 서비스로 기능부터 재설계했습니다.
이외에도 얼마 전 종료된 프로젝트에서 적용했던 비동기 흐름 제어 패턴을 일부 차용하여 Suspense, ErrorBoundary, useSuspenseQuery와 같은 모던 프론트엔드의 선언적 로딩 및 에러 처리를 실제 적용하는 등의 새로운 코드 패턴과 웹소켓 풀스택 구현을 이번 프로젝트의 주 목표로 삼고 달리는 중입니다.
그러던 와중 이전에는 보지 못했던 새로운 에러를 만나게 되었습니다.
저는 여태까지 프로젝트에서 useQuery, useInfiniteQuery만 사용해서 서버 상태 관리를 했습니다. Tanstack Query v5에서 useSuspenseQuery가 공식적으로 등장했지만, 제가 경험과 지식이 부족했던 탓에 매번 사용하던 훅만 사용했었죠. 그래서 useQuery와 useSuspenseQuery의 차이부터 알아보았습니다.
이전 프로젝트에서 구현했던 커스텀 훅 패턴
export const useFetchGatheringDetailReview = (
gatheringId: number,
limit: number,
offset: number,
enabled: boolean
) => {
const fetchGatheringDetailReviews = async () => {
try {
const response = await internalClient.get(INTERNAL_PATHS.REVIEWS, { params: { gatheringId, limit, offset } });
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const serverError = error?.response?.data?.error;
console.error(serverError?.message);
}
}
}
const { data, isLoading, isError } = useQuery({
enabled: !!gatheringId && enabled,
queryKey: gatheringDetailQuery.reviews(gatheringId),
queryFn: fetchGatheringDetailReviews,
})
return { data, isLoading, isError }
}
이 훅을 사용하는 컴포넌트에서는 isLoading과 isError를 사용하여 조건부 UI를 렌더링 하는 것으로 대기 상태 처리와 에러 처리를 했었습니다. 컴포넌트의 UI 코드가 길어지고, 책임이 복잡해지는 구조입니다. 이제 아래는 useSuspenseQuery 기반의 이번 프로젝트에서 만든 커스텀 훅입니다.
export function useFetchRisedCoins() {
const { data, isError, error } = useSuspenseQuery({
queryKey: risedCoinsQuery.all(),
queryFn: () => getRisedCoins(),
});
return { topRisedCoins: data, isError, error };
}
두 훅의 사용방법은 큰 차이가 없습니다. 하지만 이번에 구현한 훅에서 대기 상태 처리는 Suspense, 에러 상태 처리는 ErrorBoundary가 useSuspenseQuery를 사용하는 컴포넌트 부모 레벨에서 해당 컴포넌트로부터 위임받습니다.
이게 가능한 이유는, useSuspenseQuery로 요청한 결과는 언제나 성공한 결과만을 반환하기 때문입니다. 그래서 부모 컴포넌트에서 Suspense로 반드시 감싸야합니다.
훅을 실행하는 컴포넌트에서 조건부 UI 코드를 작성하지 않고
Suspense로 로딩 상태의 일관적인 UI 핸들링ErrorBoundary로 일관적인 에러 핸들링할 수 있는 것이 선언적 프로그래밍의 핵심 이점이죠.
제 프로젝트에서는 트렌드 정보 제공 페이지에서 각 컴포넌트에서 페칭하는 데이터가 다르기 때문에 각기 다른 useSuspenseQuery 기반 훅을 실행하고, 컨테이너 역할인 페이지 컴포넌트에서(SSR) ErrorBoundary와 Suspense로 감싸주었습니다.
구현을 한 뒤 문제가 터졌습니다. Hydration Erorr였습니다.
‘트렌드’ 페이지에서 시황 뉴스를 담당하는 Situation.tsx 컴포넌트useSituationArticles 를 실행합니다.
useSituationArticles
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { situationArticlesQuery } from '@/queries/trends/situationArticles.query';
import { apiClient } from '@/lib/api/apiClient';
import { INTERNAL_PATHS } from '@/lib/api/paths';
import { ISituationArticles } from '@/types/trends/situationArticles';
const TWO_HOURS = 2 * 60 * 60 * 1000;
/** 시황 조회 훅
* @returns {ISituationArticles[]} 시황 목록
*/
export function useSituationArticles() {
const { data, isError, error } = useSuspenseQuery({
queryKey: situationArticlesQuery.all(),
queryFn: () => apiClient<ISituationArticles[]>(INTERNAL_PATHS.situationArticles),
staleTime: TWO_HOURS,
gcTime: TWO_HOURS * 2,
});
return { situationArticles: data, isError, error };
}
Situation
'use client';
import { useEffect, useState } from 'react';
import { useSituationArticles } from '@/hooks/trends/useSituationArticles';
import { ISituationArticles } from '@/types/trends/situationArticles';
import { Card } from '@/components/shadcn-ui/card';
const formatDate = (pubDate: string) => {
const date = new Date(pubDate);
return `${date.getMonth() + 1}월 ${date.getDate()}일 ${date.getHours()}:${date.getMinutes()} `;
};
export default function Situation({ today }: { today: string }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [currentNews, setCurrentNews] = useState<ISituationArticles | null>(null);
const { situationArticles } = useSituationArticles();
const currentDate = formatDate(today);
useEffect(() => {
if (!situationArticles) return;
const interval = setInterval(() => setCurrentIndex(prevIndex => (prevIndex + 1) % situationArticles!.length), 5000);
return () => clearInterval(interval);
}, [situationArticles]);
useEffect(() => {
setCurrentNews(situationArticles?.[currentIndex] || null);
}, [situationArticles, currentIndex]);
return (
<Card
aria-label='시황 뉴스'
className="p-4 flex flex-col gap-4">
<h2 className="text-main text-xl sm:text-2xl font-bold">시황</h2>
<time className="text-description">{currentDate}</time>
<article className="relative w-full h-10 p-8 rounded-lg flex items-center">
<button
key={currentIndex}
type="button"
aria-label="기사 링크로 이동하기 버튼"
onClick={() => window.open(currentNews?.url, '_blank')}
className="absolute overflow-hidden sm:whitespace-nowrap text-ellipsis inset-0 p-2 flex items-center hover-button"
>
<span className="font-semibold text-base sm:text-lg">
{currentNews?.title}
</span>
</button>
</article>
</Card>
);
}
TrendsPage
import { Metadata } from 'next';
import { Suspense } from 'react';
import { getExchangeRate } from '@/actions/trends/getExchangeRate';
import { ErrorBoundaryWrapper } from '@/components/shared/ErrorBoundaryWrapper';
import { SkeletonForTrends } from '@/components/trends/SkeletonForTrends';
import ExchangeRate from '@/components/trends/ExchangeRate';
import Situation from '@/components/trends/Situation';
import Topics from '@/components/trends/Topics';
import TodayTopRisedCoins from '@/components/trends/TodayTopRisedCoins';
import TodayTopTradingVolumeCoins from '@/components/trends/TodayTopTradingVolumeCoins';
import YoutubeVideos from '@/components/trends/YoutubeVideos';
import PrefetchedYoutubeVideos from '@/components/trends/prefetched/PrefetchedYoutubeVideos';
import PrefetchedTopics from '@/components/trends/prefetched/PrefetchedTopics';
import PrefetchedSituation from '@/components/trends/prefetched/PrefetchedSituation';
export const metadata: Metadata = {
title: '트렌드 : EZBIT',
description: '최신 코인 관련 트렌드 정보를 확인하세요',
keywords: ['코인', '트렌드', '영상', '상승 코인'],
};
export const dynamic = 'force-dynamic';
const TODAY = new Date().toISOString();
export default async function TrendsPage() {
const exchangeRates = await getExchangeRate();
return (
<main className="contents-container py-4 sm:py-6 px-4 lg:px-0 flex flex-col items-center gap-2">
<section className="w-full flex flex-col md:flex-row gap-2 items-stretch">
{/* 환율, 시황, 토픽 */}
<section className="w-full md:w-4/7 flex flex-col gap-2">
<ErrorBoundaryWrapper
featureName='환율'
message='환율 로딩 중 문제가 발생했습니다.'
>
<Suspense fallback={<SkeletonForTrends height='h-[120px]' type='exchange-rate' />}>
<ExchangeRate exchangeRates={exchangeRates} />
</Suspense>
</ErrorBoundaryWrapper>
<ErrorBoundaryWrapper
featureName='시황 뉴스'
message='시황 뉴스 로딩 중 문제가 발생했습니다.'
>
<Suspense fallback={<SkeletonForTrends height='h-[150px]' type='news-list' />}>
<PrefetchedSituation>
<Situation today={TODAY} />
</PrefetchedSituation>
</Suspense>
</ErrorBoundaryWrapper>
<ErrorBoundaryWrapper
featureName='토픽 뉴스'
message='토픽 뉴스 로딩 중 문제가 발생했습니다.'
>
<div className="flex-1">
<Suspense fallback={<SkeletonForTrends height='h-[500px]' type='news-list' />}>
<PrefetchedTopics>
<Topics />
</PrefetchedTopics>
</Suspense>
</div>
</ErrorBoundaryWrapper>
</section>
{/* 실시간 상승률 TOP 10, 24시간 거래대금 TOP 5 */}
<section className="w-full md:w-3/7 flex flex-col gap-2">
<ErrorBoundaryWrapper
featureName='실시간 상승률 TOP 10'
message='실시간 상승률 TOP 10 로딩 중 문제가 발생했습니다.'
>
<TodayTopRisedCoins />
</ErrorBoundaryWrapper>
<ErrorBoundaryWrapper
featureName='24시간 거래대금 TOP 5'
message='24시간 거래대금 TOP 5 로딩 중 문제가 발생했습니다.'
>
<TodayTopTradingVolumeCoins />
</ErrorBoundaryWrapper>
</section>
</section>
{/* 유튜브 영상 */}
<section className="w-full flex items-center gap-2">
<ErrorBoundaryWrapper
featureName='유튜브 영상'
message='유튜브 영상 로딩 중 문제가 발생했습니다.'
>
<Suspense fallback={<SkeletonForTrends height='h-[200px]' type='youtube-grid' />}>
<PrefetchedYoutubeVideos>
<YoutubeVideos />
</PrefetchedYoutubeVideos>
</Suspense>
</ErrorBoundaryWrapper>
</section>
</main>
);
}
원인을 헤매다가 겨우 알아냈습니다.
1. 서버 렌더링 (SSR)
서버가 페이지를 렌더링합니다. useSuspenseQuery를 사용하는 클라이언트 컴포넌트를 만납니다. 이때 서버에는 해당 쿼리에 대한 데이터가 없으므로, Suspense의 fallback UI를 렌더링하여 HTML을 생성하고 클라이언트로 보냅니다.
2. 클라이언트 초기 렌더링
클라이언트는 서버로부터 fallback UI가 포함된 HTML을 받아 화면에 먼저 그립니다. 그 후 자바스크립트 번들 파일을 다운로드하고 실행하여 Hydration을 시작합니다.
3. Hydration 과정에서의 문제 (Race Condition)
React가 Hydration을 위해 클라이언트에서 컴포넌트를 다시 렌더링하는 순간, useSuspenseQuery가 실행됩니다. useSuspenseQuery는 즉시 데이터 페칭을 시작합니다. 만약 네트워크가 매우 빠르거나 데이터가 브라우저 캐시에 남아있어서 데이터 페칭이 Hydration이 끝나기 전에 순식간에 완료되면, useSuspenseQuery는 더 이상 pending 상태가 아니라 success 상태가 됩니다.
결과적으로 클라이언트는 fallback UI가 아닌, 데이터가 채워진 실제 UI를 렌더링합니다.
4. 불일치 및 에러 발생
React는 Hydration을 하면서 서버가 보내준 HTML 구조와 클라이언트가 방금 렌더링한 구조를 비교합니다.
fallback UI내부 children 컴포넌트두 구조가 다르므로 React는 "서버와 클라이언트의 내용이 일치하지 않습니다"라는 Hydration Error를 발생시킵니다.
이걸 해결하는 방법은 바로 initialData를 활용하는 것입니다.
서버 사이드에서 fallback을 생성하여 넘겨주는 것이 불일치의 원인이 되는 것이므로,
서버에서 프리페칭을 하여 빌드하는 HTML의 구조를 자식 컴포넌트와 일치시켜주는 것입니다.
props로 넘기는 방식이 일반적이지만, Tanstack Query에서는 useSuspenseQuery를 사용하는 상황에서 데이터 프리페칭을 하기 위한 prefetchQuery와 dehydrate, HydrationBoundary를 제공합니다. 이 메서드와 컴포넌트를 사용해서 Prefetch 컴포넌트를 만들어 실제 클라이언트 컴포넌트를 감싸겠습니다.
QueryClient에 prefetchQuery를 사용하여 데이터 캐싱dehydrate()로 캐시 상태를 직렬화HydrationBoundary에 감싸진 클라이언트로 전송하여 Hydration 진행PrefetchedSituation
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { situationArticlesQuery } from '@/queries/trends/situationArticles.query';
import { fetchSituationArticles } from '@/lib/data/fetchSituationArticles';
import React from 'react';
const TWO_HOURS = 2 * 60 * 60 * 1000;
export default async function PrefetchedSituation({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient();
try {
await queryClient.prefetchQuery({
queryKey: situationArticlesQuery.all(),
queryFn: fetchSituationArticles,
staleTime: TWO_HOURS,
gcTime: TWO_HOURS * 2,
});
} catch (error) {
console.error('❌ 시황 뉴스 프리페치 실패:', error);
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}
Next.js에서 useSuspenseQuery를 사용할 때는 항상 서버 컴포넌트에서 Prefetching Component 패턴을 적용하는 것을 권장합니다. 이는 단순히 Hydration Error를 해결하는 것을 넘어서, SSR의 장점을 최대한 활용하면서 선언적 프로그래밍의 이점과 데이터 캐싱을 보장하는 최적의 방법이라고 생각합니다. SSR과 useSuspenseQuery를 완벽하게 조화시킬 수 있으며, 사용자에게 더 나은 경험을 제공할 수 있습니다.