Next.js RSC에서 fetch 에러 처리 구현하기

해진·2025년 12월 12일
post-thumbnail

Next.js App Router 환경에서 RSC(React Server Components)의 fetch 실패 시 전체 페이지가 깨지는 문제를 해결한 경험을 공유해요.

TL;DR

HOC 패턴을 활용해 모든 RSC에서 활용할 범용적인 에러 처리 래퍼를 구현했어요.

범용적인 컴포넌트를 위해 보일러플레이트를 줄이고 간편하게 그러나 명확하게 사용하기 좋은 인터페이스를 제공하기 위해 노력했어요.

핵심 포인트:

  • ✅ RSC에서 섹션별 에러 격리로 한 섹션에서 필요한 API 실패가 전체 페이지에 영향 주지 않음
  • ✅ 일관된 Fallback UI 제공으로 사용자 경험 개선
  • ✅ 중앙화된 에러 처리로 코드 복잡도 감소
  • ✅ 자동 로깅으로 디버깅 효율성 향상

핵심 코드:

export function withErrorHandling<TProps>(
  Component: (props: TProps) => Promise<ReactNode>,
  options: {
    sectionName: string;
    errorFallback: ReactNode | ((error: Error) => ReactNode);
  }
)

🤔 혹시 RSC를 사용하시나요?

최근 RSC에 대한 관심이 높아지며 실제 프로덕트에서도 사용하는 경우가 늘어나고 있고, 학습을 위해 토이 프로젝트 등에서 많이 사용해보실 것 같아요. RSC가 가지는 다양한 특징 중에서도 저는 Suspense태그와 함께 사용 시 서버에서 스트리밍하게 데이터를 페치할 수 있다는 점이 큰 장점으로 느껴졌어요.

RSC를 사용할 수 있는 프레임워크 중 가장 많이 쓰이는 Next.js의 App Router를 사용해보면 우리는 RSC(React Server Component)와 RCC(React Client Component)를 구분하는 기준을 다음과 같이 구분할 수 있어요.

  • 데이터를 다루는 컴포넌트 - RSC
  • 상태를 다루는 컴포넌트 - RCC

너무나 좋은 분리라고 생각했어요. 데이터의 흐름이 잘 보이며, 각자가 잘 하는 것에 집중할 수 있는 아키텍처라고 생각했거든요. 그러나 데이터를 다루는 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 호출 중 정상적으로 가져온 데이터도 있는데 단 하나의 호출 실패로 모든 페이지를 볼 수 없죠.

신뢰도 있는 서비스 제공을 위해서 위와 같은 페이지의 에러는 개선해야 한다고 생각했어요. 따라서 에러 범위를 최소화할 수 있는 방법을 찾아보았어요.


🔍 해결 방법 탐색

1. Error.tsx활용

<ErrorBoundary fallback={<ErrorPage />}>
  <ClassDetailPage />
</ErrorBoundary>

문제점:

  • 여전히 전체가 깨짐
  • 섹션별 세밀한 처리 불가

Next.js에서 제공하는 error.tsx는 라우트 세그먼트 단위로 작동하기 때문에, 한 페이지 내에서 섹션별로 다른 에러 처리를 하려면 각 섹션을 별도 라우트로 분리해야 해요. 하지만 그렇게 하면 페이지 구조가 불필요하게 복잡해지고, 원래 의도한 단일 페이지 경험을 제공하기 어려워지죠.


2. 호출마다 try-catch 추가

let instructor;
try {
  instructor = await getInstructor({ instructorId: ... });
} catch {
  instructor = null;
}

문제점:

  • 코드 중복 심함
  • 일관된 에러 UI 어려움
  • 유지보수성 저하

그러면 각 호출마다 try-catch로 감싸 에러를 처리할 수 있을 것 같았어요. 그러나 이렇게 되면 모든 호출에 try-catch가 들어가게 되고 이는 너무 많은 코드로 컴포넌트가 어지러워졌어요.

3. HOC 패턴으로 에러 처리 컴포넌트 감싸기 (선택) ✅

각 섹션을 독립적인 컴포넌트로 분리하고, HOC로 감싸서 에러를 격리하는 방식이에요.

장점:

  • 섹션별 독립적 에러 처리
  • 일관된 패턴 적용
  • 코드 중복 최소화
  • 재사용 가능한 구조

try-catch로 원하는 처리를 할 수 있었지만 너무 반복되는 코드 구조로 보일러플레이트가 심한 문제를 보고 저는 HOC 패턴을 활용해보고자 했어요. 필요한 try-catch 구조를 담고 있는 컴포넌트를 만들고 isLoading 상태에 쓰일 컴포넌트, error가 나왔을 때의 컴포넌트를 props로 넘겨주는 구조를 생각했어요.

이렇게 되면 try-catch를 활용했을 때와 동일한 독립적인 에러 처리가 가능해지고, 이전에 반복되던 구조를 한 컴포넌트로 관리할 수 있게 되죠. 이런 구조로 개발할 때 컴포넌트 감싸기 하나로 우리는 코드 중복을 줄이고 재사용한 구조를 쓸 수 있게 돼요.


🛠️ 구현 과정

Step 1: 기본 HOC 구조

먼저 에러를 잡는 기본 래퍼부터 만들었어요.

// 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: 정적 컴포넌트 또는 동적 함수 모두 지원
  • 자동 로깅: 에러 발생 시 자동으로 섹션명, 에러 정보 기록
  • 개발 환경 디버깅: props를 로그에 포함하여 재현 용이

Step 2: 일관된 에러 UI 컴포넌트

모든 섹션에서 재사용 가능한 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>
  );
}

디자인 원칙:

  • 커스터마이징 가능한 높이, 배경색
  • 선택적 메시지, 상태 코드 표시
  • 일관된 타이포그래피 및 스타일

Step 3: 실제 적용

// ✅ 개선된 코드
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>
  );
}

🎬 시나리오: Instructor API 실패 시

Before

┌─────────────────────────
│   전체 페이지 에러 💥        
│                          
│  모든 데이터 사라짐          
└─────────────────────────

After

┌──────────────────────────
│ ProductHeader ✅           ← 정상 표시
├──────────────────────────
│ Instructor 에러 ⚠️          ← Fallback UI
├──────────────────────────
│ Recommend ✅               ← 정상 표시
└──────────────────────────

핵심 원칙:

  • 각 섹션은 독립적으로 동작
  • 한 섹션의 실패가 다른 섹션에 영향 없음
  • 사용자는 정상 작동하는 섹션 계속 사용 가능

📊 개선 효과

✅ 사용자 경험

  • 한 API 실패해도 다른 섹션은 정상 표시
  • 일관된 에러 메시지로 혼란 감소
  • 부분적 정보라도 제공

✅ 개발자 경험

  • 중복 코드 95% 감소
  • 일관된 에러 처리 패턴
  • 자동 로깅으로 디버깅 시간 단축

✅ 코드 품질

  • 재사용 가능한 HOC
  • 타입 안전성 보장
  • 단일 책임 원칙 준수

n개의 api호출 중 에러가 발생하는 호출이 있다면 그 api와 연관이 있는 섹션에만 에러 컴포넌트가 렌더링 되게 개선되었어요. 에러와 상관이 없는 곳은 에러에 구애받지 않고 정보를 전달할 수 있게 되었죠.

또한 에러 핸들링 방식이 간단하여 ctrl c + v 만으로도 에러 핸들링 로직을 사용할 수 있게 되었어요.

또한 “에러 발생!”이라는 사건에 있어 공통으로 처리해야할 로직을 한 컴포넌트에서 처리할 수 있다는 점 또한 개발자의 입장에서 유지보수의 이점이 되었어요.


마무리

"한 페이지에서 n개의 API를 호출하는데, 하나만 실패해도 전체가 깨진다"는 문제를 HOC 패턴으로 해결했어요.

핵심 배운 점:

  1. 에러 격리의 중요성: 한 실패가 전체를 무너뜨리면 안 됨
  2. 일관성의 가치: 통일된 에러 UI가 사용자 신뢰도 향상
  3. 패턴의 힘: HOC로 반복 제거, 유지보수성 향상

RSC 환경에서 안정적인 에러 처리는 선택이 아닌 필수예요. 비슷한 고민을 하고 계신 분들께 도움이 되었으면 좋겠어요.

참고 자료

profile
안녕하세요, Frontend 개발자 윤해진입니다.

0개의 댓글