Next.js App Router 환경에서 RSC(React Server Components)의 fetch 실패 시 전체 페이지가 깨지는 문제를 해결한 경험을 공유해요.
HOC 패턴을 활용해 모든 RSC에서 활용할 범용적인 에러 처리 래퍼를 구현했어요.
범용적인 컴포넌트를 위해 보일러플레이트를 줄이고 간편하게 그러나 명확하게 사용하기 좋은 인터페이스를 제공하기 위해 노력했어요.
핵심 포인트:
핵심 코드:
export function withErrorHandling<TProps>(
Component: (props: TProps) => Promise<ReactNode>,
options: {
sectionName: string;
errorFallback: ReactNode | ((error: Error) => ReactNode);
}
)
최근 RSC에 대한 관심이 높아지며 실제 프로덕트에서도 사용하는 경우가 늘어나고 있고, 학습을 위해 토이 프로젝트 등에서 많이 사용해보실 것 같아요. RSC가 가지는 다양한 특징 중에서도 저는 Suspense태그와 함께 사용 시 서버에서 스트리밍하게 데이터를 페치할 수 있다는 점이 큰 장점으로 느껴졌어요.
RSC를 사용할 수 있는 프레임워크 중 가장 많이 쓰이는 Next.js의 App Router를 사용해보면 우리는 RSC(React Server Component)와 RCC(React Client Component)를 구분하는 기준을 다음과 같이 구분할 수 있어요.
너무나 좋은 분리라고 생각했어요. 데이터의 흐름이 잘 보이며, 각자가 잘 하는 것에 집중할 수 있는 아키텍처라고 생각했거든요. 그러나 데이터를 다루는 RSC에서 어떠한 문제를 발견했어요.
만약 n개의 API를 RSC에서 호출하는데 여기서 1개의 API 호출이 실패하여 에러가 난다면 어떻게 될까요?
언급한 문제는 사실 제가 마주했던 상황이었어요.
클래스 상세 페이지에서는 다양한 정보를 제공하기 위해 4개의 API를 호출하고 있었어요. 그러나 단 하나의 API 호출 실패가 전체 페이지의 서버 에러로 처리되어버렸죠.
// ❌ 문제가 있던 코드
async function ClassDetailPage({ slug }: { slug: string }) {
const 상품 = await get상품({ productSlug: slug }); // API 1
const 커리큘럼 = await get커리큘럼({ courseId: ... }); // API 2 ← 실패 시?
const 추천상품 = await find추천상품(); // API 3
const 강사정보 = await get강사정보({ tutorId: ... }); // API 4
return (
<div>
<suspense fallback={<skeleton/>}>
<ProductHeader 상품={상품} />
</suspense>
<suspense fallback={<skeleton/>}>
<InstructorInfo 강사정보={강사정보} />
</suspense>
<suspense fallback={<skeleton/>}>
<RecommendSection 추천상품={추천상품} />
</suspense>
<suspense fallback={<skeleton/>}>
<CurriculumList 커리큘럼={커리큘럼} />
</suspense>
</div>
);
}
위 코드의 결과는 다음과 같아요.
여러 API 호출 중 정상적으로 가져온 데이터도 있는데 단 하나의 호출 실패로 모든 페이지를 볼 수 없죠.
신뢰도 있는 서비스 제공을 위해서 위와 같은 페이지의 에러는 개선해야 한다고 생각했어요. 따라서 에러 범위를 최소화할 수 있는 방법을 찾아보았어요.
<ErrorBoundary fallback={<ErrorPage />}>
<ClassDetailPage />
</ErrorBoundary>
문제점:
Next.js에서 제공하는 error.tsx는 라우트 세그먼트 단위로 작동하기 때문에, 한 페이지 내에서 섹션별로 다른 에러 처리를 하려면 각 섹션을 별도 라우트로 분리해야 해요. 하지만 그렇게 하면 페이지 구조가 불필요하게 복잡해지고, 원래 의도한 단일 페이지 경험을 제공하기 어려워지죠.
let instructor;
try {
instructor = await getInstructor({ instructorId: ... });
} catch {
instructor = null;
}
문제점:
그러면 각 호출마다 try-catch로 감싸 에러를 처리할 수 있을 것 같았어요. 그러나 이렇게 되면 모든 호출에 try-catch가 들어가게 되고 이는 너무 많은 코드로 컴포넌트가 어지러워졌어요.
각 섹션을 독립적인 컴포넌트로 분리하고, HOC로 감싸서 에러를 격리하는 방식이에요.
장점:
try-catch로 원하는 처리를 할 수 있었지만 너무 반복되는 코드 구조로 보일러플레이트가 심한 문제를 보고 저는 HOC 패턴을 활용해보고자 했어요. 필요한 try-catch 구조를 담고 있는 컴포넌트를 만들고 isLoading 상태에 쓰일 컴포넌트, error가 나왔을 때의 컴포넌트를 props로 넘겨주는 구조를 생각했어요.
이렇게 되면 try-catch를 활용했을 때와 동일한 독립적인 에러 처리가 가능해지고, 이전에 반복되던 구조를 한 컴포넌트로 관리할 수 있게 되죠. 이런 구조로 개발할 때 컴포넌트 감싸기 하나로 우리는 코드 중복을 줄이고 재사용한 구조를 쓸 수 있게 돼요.
먼저 에러를 잡는 기본 래퍼부터 만들었어요.
// shared/lib/utils/with-error-handling.tsx
export function withErrorHandling<TProps extends Record<string, unknown>>(
Component: (props: TProps) => Promise<ReactNode>,
options: {
sectionName: string;
errorFallback: ReactNode | ((error: Error) => ReactNode);
},
): (props: TProps) => Promise<ReactNode> {
return async (props: TProps) => {
try {
return await Component(props);
} catch (error) {
// 로깅
logger.error({
sectionName: options.sectionName,
error: error instanceof Error ? error.message : String(error),
...(process.env.NODE_ENV === 'development' && { props }),
});
// Fallback UI 반환
if (typeof options.errorFallback === 'function') {
return options.errorFallback(error as Error);
}
return options.errorFallback;
}
};
}
핵심 설계:
모든 섹션에서 재사용 가능한 Fallback 컴포넌트를 만들었어요.
// shared/ui/SectionErrorFallback.tsx
export function SectionErrorFallback({
title,
message,
statusCode,
height = 'h-[200px]',
bgColor = 'bg-lightGrey',
}: {
title: string;
message?: string;
statusCode?: HTTP_STATUS;
height?: string;
bgColor?: string;
}) {
return (
<div className={`flex ${height} w-full items-center justify-center rounded-lg ${bgColor}`}>
<div className="text-center">
<p className="mb-2 text-lg font-bold text-black">{title}</p>
{message && <p className="text-sm text-darkGrey">{message}</p>}
{statusCode && <p className="text-sm text-darkGrey">에러 상태 코드: {statusCode}</p>}
</div>
</div>
);
}
디자인 원칙:
// ✅ 개선된 코드
const ProductHeaderSection = withErrorHandling(
async ({ slug }: { slug: string }) => {
const product = await getProduct({ productSlug: slug });
return <SingleClassHeader product={product} />;
},
{
sectionName: 'ProductHeader',
errorFallback: (error) => (
<SectionErrorFallback
title="클래스 정보를 불러올 수 없습니다"
height="h-[346px]"
bgColor="bg-black"
message={error.message}
statusCode={error instanceof HttpError ? error.statusCode : undefined}
/>
),
},
);
const InstructorSection = withErrorHandling(
async ({ slug }: { slug: string }) => {
const product = await getProduct({ productSlug: slug });
const instructor = await getInstructor({ instructorId: product.instructorId });
return <InstructorInfo instructor={instructor} />;
},
{
sectionName: 'Instructor',
errorFallback: (error) => (
<SectionErrorFallback
title="강사 정보를 불러올 수 없습니다"
height="h-[300px]"
message={error.message}
/>
),
},
);
*// 페이지 컴포넌트*
async function ClassDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return (
<div>
<ProductHeaderSection slug={slug} />
<InstructorSection slug={slug} />
<RecommendSection />
</div>
);
}
┌─────────────────────────
│ 전체 페이지 에러 💥
│
│ 모든 데이터 사라짐
└─────────────────────────
┌──────────────────────────
│ ProductHeader ✅ ← 정상 표시
├──────────────────────────
│ Instructor 에러 ⚠️ ← Fallback UI
├──────────────────────────
│ Recommend ✅ ← 정상 표시
└──────────────────────────
핵심 원칙:
n개의 api호출 중 에러가 발생하는 호출이 있다면 그 api와 연관이 있는 섹션에만 에러 컴포넌트가 렌더링 되게 개선되었어요. 에러와 상관이 없는 곳은 에러에 구애받지 않고 정보를 전달할 수 있게 되었죠.
또한 에러 핸들링 방식이 간단하여 ctrl c + v 만으로도 에러 핸들링 로직을 사용할 수 있게 되었어요.
또한 “에러 발생!”이라는 사건에 있어 공통으로 처리해야할 로직을 한 컴포넌트에서 처리할 수 있다는 점 또한 개발자의 입장에서 유지보수의 이점이 되었어요.
"한 페이지에서 n개의 API를 호출하는데, 하나만 실패해도 전체가 깨진다"는 문제를 HOC 패턴으로 해결했어요.
핵심 배운 점:
RSC 환경에서 안정적인 에러 처리는 선택이 아닌 필수예요. 비슷한 고민을 하고 계신 분들께 도움이 되었으면 좋겠어요.