2025.08.07

HOC와 미들웨어로 구축하는 중앙집중식 오류 처리

Summary

Summary

Next.js SSR 환경에서 HOC 패턴과 미들웨어 헤더 전파를 활용하여 분산된 오류 처리를 중앙집중화하고 일관된 사용자 경험을 구현한 과정을 다룹니다.

커버 이미지
커버 이미지

Next.js SSR 환경에서는 페이지 내부의 try-catch만으로 모든 오류를 일관되게 처리하기 어렵습니다. 특히 결제 서비스처럼 사용자 경험이 중요한 애플리케이션에서는, 페이지 데이터 페칭 오류뿐 아니라 미들웨어에서 발생하는 인증 오류까지 같은 flow에서 다뤄야 했습니다.

문제의식: 분산된 오류 처리와 미들웨어의 한계

저희 서비스는 Next.js 기반의 SSR 애플리케이션으로, 사용자가 페이지에 진입하는 과정에서 다양한 서버 측 오류가 발생할 수 있습니다. 예를 들어, 데이터 페칭 실패, 권한 없음 등 여러 시나리오가 있습니다. 기존에는 이러한 오류들을 각 페이지 컴포넌트 내의 try-catch 블록에서 개별적으로 처리하고 있었습니다.

이 방식은 몇 가지 문제점을 야기했습니다.

  1. 코드 중복: 각 페이지마다 거의 동일한 try-catch 로직이 반복되어 코드 중복이 심했습니다.
  2. 유지보수 어려움: 오류 처리 로직을 수정하려면 모든 페이지를 수정해야 하는 번거로움이 있었습니다.
  3. 일관성 부족: 페이지마다 오류 메시지나 처리 방식이 미묘하게 달라질 위험이 있었습니다.

또한, 미들웨어에서 사용자 인증 여부를 확인하는 로직이 있었는데, 인증 쿠키가 없을 경우 단순히 로그인 페이지로 리다이렉트하는 방식이었습니다. 이 경우 사용자는 보고 있던 페이지를 잃게 되고, 왜 로그인 페이지로 이동했는지 명확한 안내를 받기 어려워 사용자 경험이 저해될 수 있었습니다.

이러한 문제들을 해결하기 위해 Next.js의 기능을 활용해 중앙집중식 오류 처리 시스템을 구축하고, 미들웨어에서 발생한 오류까지 SSR 페이지 컴포넌트로 안전하게 전파하는 전략을 세우게 되었습니다.

개념 설명: SSR, 미들웨어, HOC 그리고 헤더

이번 개선 작업의 핵심은 Next.js SSR의 특성을 이해하고, 미들웨어, HOC(Higher-Order Component), HTTP 헤더와 같은 기술 요소들을 유기적으로 연결하는 것이었습니다.

text
[사용자 요청]
    │
    ▼
[Middleware] ─── 세션 쿠키 확인
    │
    ├─ 인증 실패 → X-Error-Status / X-Error-Message 헤더에 오류 정보 삽입
    │              → rewrite()로 원래 URL 유지한 채 요청 전달
    │
    ▼
[SSR (getServerSideProps)] ─── 헤더에서 오류 정보 읽기 + API 데이터 페칭
    │
    ▼
[HOC (withPageErrorHandling)] ─── 오류 존재 시 GlobalErrorDisplay 렌더링
    │                              오류 없으면 정상 페이지 렌더링
    ▼
[사용자에게 응답]

다이어그램의 각 단계를 간략히 설명하면 다음과 같습니다.

  • SSR (getServerSideProps): 요청마다 서버에서 HTML을 생성합니다. 결제 관련 서비스 특성상 SSR 단계에서 발생하는 오류를 빠짐없이 처리하는 것이 핵심이었습니다.
  • Middleware: 요청이 라우트에 도달하기 전에 실행됩니다. 세션 쿠키를 확인하고, 인증 실패 시 redirect 대신 rewrite를 선택했습니다. URL을 유지한 채 오류를 표시하는 것이 사용자 경험에 더 낫다고 판단했기 때문입니다.
  • HOC (withPageErrorHandling): 적용된 페이지를 감싸는 래퍼입니다. 페이지 실행 중 HOC까지 전파된 오류가 있으면 공통 에러 화면을 렌더링하고, 없으면 정상 페이지를 그대로 보여줍니다. 개별 페이지에서는 반복되는 공통 오류 처리를 덜어낼 수 있습니다.
  • 커스텀 HTTP 헤더 (X-Error-Status, X-Error-Message): 미들웨어와 SSR 사이의 통신 채널입니다. 미들웨어에서 오류를 감지하면 이 헤더에 정보를 담아 보내고, SSR에서 읽어 HOC로 전달합니다.

구현 과정

1. 커스텀 오류 클래스 정의

먼저, 백엔드 API 오류를 표준화된 형태로 다루기 위한 커스텀 오류 클래스를 정의했습니다.

typescript
// src/libs/api/serverApis.ts
export class BackendApiError extends Error {
  statusCode: number;
  errorMessage: string;
 
  constructor(statusCode: number, errorMessage: string) {
    super(errorMessage);
    this.name = "BackendApiError";
    this.statusCode = statusCode;
    this.errorMessage = errorMessage;
  }
}

모든 API 오류를 statusCode + errorMessage 형태로 통일하여, 이후 HOC에서 일관되게 처리할 수 있도록 했습니다.

2. withPageErrorHandling HOC 구현

가장 먼저, 모든 페이지 컴포넌트에서 재사용할 수 있는 오류 처리 HOC를 구현했습니다. 이 HOC는 페이지 컴포넌트를 래핑하여, 페이지 실행 과정에서 자신까지 전파된 오류를 중앙집중식으로 처리합니다.

초기 구현 (src/libs/ssrErrorHandlingUtils.tsx)

typescript
import React from "react";
import { BackendApiError } from "@/libs/api/serverApis";
import GlobalErrorDisplay from "@/components/globalErrorDisplay";
 
export function withPageErrorHandling<P>(
  WrappedPageComponent: (props: P) => Promise<React.ReactElement>
) {
  return async function RenderWrapper(props: P) {
    try {
      return await WrappedPageComponent(props);
    } catch (err: any) {
      console.log("Error occurred:", err, err instanceof BackendApiError);
 
      if (err instanceof BackendApiError) {
        return (
          <GlobalErrorDisplay
            error={{
              statusCode: err.statusCode,
              errorMessage: err.errorMessage,
            }}
          />
        );
      }
      return (
        <GlobalErrorDisplay
          error={{
            statusCode: 500,
            errorMessage: JSON.stringify({
              errorCode: 500000,
              errorMessage: err.message ?? "",
            }),
          }}
        />
      );
    }
  };
}

페이지 컴포넌트를 try-catch로 감싸서, BackendApiError면 해당 상태 코드와 메시지를, 그 외에는 500 에러를 GlobalErrorDisplay로 보여줍니다. 이 HOC 하나로 각 페이지에서 상위로 전파된 오류의 처리 방식이 통일됩니다.

try-catch가 상위에서 오류를 받는 방식

이 구조를 이해하려면 먼저 동기 함수에서 오류가 전달되는 과정을 살펴볼 필요가 있습니다. 하위 함수에서 throw된 오류를 그 함수가 처리하지 않으면, 오류는 해당 함수를 호출한 상위 함수로 전달됩니다.

typescript
function c() {
  throw new Error("에러");
}
 
function b() {
  c();
}
 
function a() {
  try {
    b();
  } catch (error) {
    console.log("에러 처리");
  }
}

a()가 b()를 호출하고, b()가 다시 c()를 호출합니다. c()에서 던진 오류를 c()와 b()가 처리하지 않았기 때문에 호출 스택을 거슬러 올라가 a()의 catch에 도달합니다. a()의 try-catch가 b()와 c()를 특별히 감시하는 것은 아닙니다. 처리되지 않은 예외가 호출 스택을 따라 상위 호출자에게 전달되는 것입니다.

async/await에서의 Promise rejection 전파

비동기 함수에서도 원리는 비슷하지만 Promise가 중간에 놓입니다. async 함수 안에서 오류를 throw하면, 해당 함수가 반환하는 Promise는 reject됩니다. 그리고 await한 Promise가 reject되면 await 지점에서 오류가 다시 throw된 것처럼 동작합니다.

typescript
async function getPayment() {
  throw new Error("API 요청 실패");
}
 
async function PaymentPage() {
  const payment = await getPayment();
  return payment;
}
 
async function RenderWrapper() {
  try {
    return await PaymentPage();
  } catch (error) {
    console.log("공통 에러 처리");
  }
}

이 코드에서는 오류가 다음 순서로 전달됩니다.

text
getPayment()
  ↓
Promise reject
  ↓
PaymentPage의 await에서 throw
  ↓
PaymentPage의 Promise reject
  ↓
RenderWrapper의 await에서 throw
  ↓
catch

getPayment()의 Promise가 reject되면 PaymentPage의 await 지점에서 오류가 발생합니다. PaymentPage도 이 오류를 처리하지 않으므로 자신이 반환하는 Promise가 다시 reject됩니다. RenderWrapper는 그 Promise를 await하고 있으므로 같은 오류를 자신의 catch에서 받을 수 있습니다. 이처럼 여러 async 함수가 await로 연결되어 있으면 하위 함수의 오류가 상위 함수까지 자연스럽게 전달됩니다.

HOC가 오류 처리를 한곳에 모으는 이유

withPageErrorHandling이 특별한 방식으로 페이지의 모든 오류를 수집하는 것은 아닙니다. 핵심은 다음 호출을 try-catch 안에서 await하는 데 있습니다.

tsx
try {
  return await WrappedPageComponent(props);
} catch (error) {
  return <GlobalErrorDisplay />;
}

WrappedPageComponent 내부에서 처리되지 않은 오류는 해당 컴포넌트의 Promise를 reject시키고, await WrappedPageComponent(props) 지점까지 전달됩니다. HOC의 catch는 이렇게 자신까지 전파된 오류를 받아 공통 화면으로 바꿉니다.

따라서 에러 처리 중앙화의 핵심은 HOC 자체의 특별한 기능보다, 각 페이지 안에 반복되던 try-catch를 페이지 바깥의 공통 wrapper로 끌어올린 데 있습니다. 기존에는 PaymentPage마다 데이터 페칭을 try-catch로 감싸고 GlobalErrorDisplay를 반환해야 했습니다. 이제 페이지 내부에서는 오류를 별도로 처리하지 않고, export default withPageErrorHandling(PaymentPage) 한 줄로 공통 wrapper에 처리를 맡길 수 있습니다. 바로 다음 변경 전후 코드가 이 차이를 보여줍니다.

다만 비동기 작업을 시작하기만 하고 await하지 않으면 이 연결이 끊길 수 있습니다.

tsx
async function PaymentPage() {
  getPayment();
 
  return <Payment />;
}

이 경우 PaymentPage()는 <Payment />를 반환하며 먼저 정상적으로 끝날 수 있습니다. 이후 getPayment()가 reject되더라도 그 실패는 PaymentPage가 반환한 Promise의 실패로 이어지지 않으므로, RenderWrapper의 await PaymentPage()까지 전파되지 않습니다.

반대로 해당 Promise를 await하면 비동기 작업의 실패가 페이지의 실패로 이어집니다.

tsx
async function PaymentPage() {
  await getPayment();
 
  return <Payment />;
}

이때는 getPayment()의 rejection이 PaymentPage의 await 지점에서 다시 던져지고, PaymentPage의 Promise도 reject되므로 RenderWrapper의 catch까지 전달됩니다.

HOC가 잡을 수 있는 범위

이 HOC가 애플리케이션의 모든 오류를 잡는 것은 아닙니다. 다음 오류는 await WrappedPageComponent(props)의 호출 흐름 밖에서 발생할 수 있습니다.

  • 사용자의 클릭 이벤트 핸들러에서 나중에 발생한 오류
  • useEffect에서 발생한 오류
  • WrappedPageComponent가 React Element를 반환한 뒤, React가 하위 컴포넌트를 실제로 렌더링하는 과정에서 발생한 오류
  • await하지 않은 비동기 작업에서 발생한 오류

즉, 이 HOC는 모든 React 에러를 처리하는 Error Boundary가 아니라 페이지 실행 과정에서 자신까지 전파된 오류를 공통 처리하는 구조입니다. 범위를 한 문장으로 정리하면 다음과 같습니다.

try-catch는 하위 함수의 모든 에러를 자동으로 감시하는 것이 아니라, 동기 호출 또는 await를 통해 현재 try 블록까지 전파된 에러를 잡습니다.

이 원리 때문에 페이지와 API 함수 사이에 다음과 같은 오류 처리 flow를 만들 수 있습니다.

text
RenderWrapper
  ↓
PaymentPage
  ↓
API 함수
  ↓
throw
  ↑
PaymentPage로 전파
  ↑
RenderWrapper로 전파
  ↑
catch에서 공통 처리

3. 페이지 컴포넌트에 HOC 적용

구현한 HOC를 실제 페이지 컴포넌트에 적용해 분산된 try-catch 블록을 제거합니다.

변경 전 (src/app/checkout/page.tsx)

typescript
import { BackendApiError } from "@/libs/api/serverApis";
import { fetchPaymentRequest } from "@/app/api/payment-requests/route";
import CheckoutPageComponent from "./components/PaymentCheckoutPage";
import GlobalErrorDisplay from "@/components/globalErrorDisplay";
 
type PageProps = {
  searchParams: Promise<{ requestIdentifier?: string }>;
};
 
export default async function PaymentPage({ searchParams }: PageProps) {
  const resolvedSearchParams = await searchParams;
  const requestIdentifier = resolvedSearchParams.requestIdentifier;
 
  try {
    const { paymentRequest } = await fetchPaymentRequest(
      requestIdentifier ?? ""
    );
 
    return (
      <CheckoutPageComponent
        paymentRequest={paymentRequest}
        requestIdentifier={requestIdentifier}
      />
    );
  } catch (err) {
    if (err instanceof BackendApiError) {
      console.error("Unexpected error:", err);
      return (
        <GlobalErrorDisplay
          error={{
            statusCode: err.statusCode,
            errorMessage: err.errorMessage,
          }}
        />
      );
    }
    return (
      <GlobalErrorDisplay
        error={{
          statusCode: 500,
          errorMessage: "알 수 없는 에러가 발생했어요.",
        }}
      />
    );
  }
}

변경 후 (src/app/checkout/page.tsx)

typescript
import { withPageErrorHandling } from "@/libs/ssrErrorHandlingUtils";
import { fetchPaymentRequest } from "@/app/api/payment-requests/route";
import CheckoutPageComponent from "./components/PaymentCheckoutPage";
 
type PageProps = {
  searchParams: Promise<{ requestIdentifier?: string }>;
};
 
async function PaymentPage({ searchParams }: PageProps) {
  const resolvedSearchParams = await searchParams;
  const requestIdentifier = resolvedSearchParams.requestIdentifier;
 
  const { paymentRequest } = await fetchPaymentRequest(requestIdentifier ?? "");
 
  return (
    <CheckoutPageComponent
      paymentRequest={paymentRequest}
      requestIdentifier={requestIdentifier}
    />
  );
}
 
export default withPageErrorHandling(PaymentPage);

try-catch가 사라지고, 페이지는 데이터 페칭과 렌더링에만 집중합니다. 마지막 줄의 withPageErrorHandling(PaymentPage)가 페이지 실행 중 상위로 전파된 오류의 공통 처리를 위임합니다.

4. 미들웨어에서 발생한 오류를 SSR 페이지로 전파

다음 단계는 미들웨어에서 인증 오류를 감지했을 때, 사용자를 강제로 리다이렉트하는 대신, 현재 URL을 유지하면서 SSR 페이지로 오류 정보를 전달하는 것이었습니다. NextResponse.rewrite와 커스텀 HTTP 헤더를 활용했습니다.

변경 전 (src/middleware.ts의 인증 처리 로직)

typescript
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
// function hasSessionCookies({ cookies }: NextRequest) {
//   return cookies.has("auth_token") && cookies.has("session_id") && cookies.has("user_id");
// }
 
function isPaymentCallback(path: string) {
  return path.match(/\/payment\/[^/]+\/(success|fail)/);
}
 
export function authenticationMiddleware(request: NextRequest) {
  const path = request.nextUrl.pathname;
 
  if (
    path.startsWith("/_next") ||
    path.startsWith("/api") ||
    isPaymentCallback(path)
  ) {
    return NextResponse.next();
  }
 
  // if (!hasSessionCookies(request)) {
  //   return NextResponse.redirect(new URL("/login"));
  // }
 
  return NextResponse.next();
}

주석 처리된 부분이 기존 방식입니다. 세션 쿠키가 없으면 로그인 페이지로 redirect했는데, 이 경우 사용자가 보던 URL과 컨텍스트를 잃게 됩니다.

변경 후 (src/middleware.ts의 인증 처리 로직)

typescript
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
function hasSessionCookies({ cookies }: NextRequest) {
  return (
    cookies.has("auth_token") &&
    cookies.has("session_id") &&
    cookies.has("user_id")
  );
}
 
function isPaymentCallback(path: string) {
  return path.match(/\/payment\/[^/]+\/(success|fail)/);
}
 
export function authenticationMiddleware(request: NextRequest) {
  const path = request.nextUrl.pathname;
 
  if (
    path.startsWith("/_next") ||
    path.startsWith("/api") ||
    isPaymentCallback(path)
  ) {
    return NextResponse.next();
  }
 
  if (!hasSessionCookies(request)) {
    // 브라우저의 URL은 유지한 채, 루트 경로로 리라이트 (내부적으로 처리)
    const res = NextResponse.rewrite(new URL("/", request.url));
    res.headers.set("X-Error-Status", "401");
    res.headers.set(
      "X-Error-Message",
      encodeURIComponent("로그인 정보가 없어 로그인 사이트로 이동합니다."),
    );
    return res;
  }
 
  return NextResponse.next();
}

redirect 대신 rewrite로 URL을 유지하면서, 커스텀 헤더(X-Error-Status, X-Error-Message)에 오류 정보를 담아 SSR로 넘깁니다. 다이어그램에서 본 flow 그대로입니다.

5. HOC에 미들웨어 오류 처리 로직 추가

withPageErrorHandling HOC에 미들웨어가 전달한 커스텀 헤더를 감지하는 로직을 추가했습니다. 이를 통해 미들웨어 오류도 페이지 내부 오류와 동일한 방식으로 처리할 수 있게 됩니다.

개선된 HOC (src/libs/ssrErrorHandlingUtils.tsx)

typescript
import React from "react";
import { BackendApiError } from "@/libs/api/serverApis";
import GlobalErrorDisplay from "@/components/globalErrorDisplay/GlobalErrorDisplay";
import { headers as serverRequestHeaders } from "next/headers";
 
export function withPageErrorHandling<P>(
  WrappedPageComponent: (props: P) => Promise<React.ReactElement>
) {
  return async function RenderWrapper(props: P) {
    try {
      // authenticationMiddleware에서 쿠키가 없으면 에러 발생시킨 것을 handling
      const currentRequestHeaders = serverRequestHeaders();
      if (currentRequestHeaders.get("X-Error-Status") === "401") {
        throw new BackendApiError(
          401,
          JSON.stringify({
            errorCode: 401001,
            errorMessage: decodeURIComponent(
              currentRequestHeaders.get("X-Error-Message") || ""
            ),
          })
        );
      }
 
      return await WrappedPageComponent(props);
    } catch (err: any) {
      console.log("Error occurred:", err, err instanceof BackendApiError);
 
      if (err instanceof BackendApiError) {
        return (
          <GlobalErrorDisplay
            error={{
              statusCode: err.statusCode,
              errorMessage: err.errorMessage,
            }}
          />
        );
      }
      return (
        <GlobalErrorDisplay
          error={{
            statusCode: 500,
            errorMessage: JSON.stringify({
              errorCode: 500000,
              errorMessage: err.message ?? "",
            }),
          }}
        />
      );
    }
  };
}

초기 구현과의 차이는 try 블록 첫 부분입니다. 페이지 컴포넌트를 실행하기 전에 X-Error-Status 헤더를 먼저 확인하고, 401이면 BackendApiError를 던져 기존 catch flow에 합류시킵니다. 미들웨어 오류든 페이지 오류든 동일한 경로로 처리되는 구조입니다.

결과 및 효과, 그리고 남은 과제

이러한 개선 작업들을 통해 저희 서비스는 다음과 같은 효과를 확인했습니다.

코드 중복 제거 효과

HOC 도입 전후를 비교하면 다음과 같습니다.

항목BeforeAfter변화
페이지당 오류 처리 코드15~20줄1줄 (HOC 래핑)90% 감소
총 코드량 (10개 페이지)150~200줄약 40줄75~80% 감소
수정 시 변경 파일 수10개1개단일 파일로 통합

에러 처리 일관성

기존에는 페이지마다 오류 메시지 포맷이 미묘하게 달랐지만, HOC 적용 후에는 공통 처리 대상으로 정한 서버 실행 오류가 단일 경로로 수렴합니다. **API 오류(4xx/5xx)**는 BackendApiError로 포맷이 통일되었고, **인증 오류(401)**는 로그인 페이지로 강제 리다이렉트하던 방식에서 현재 URL을 유지한 채 오류 모달을 표시하는 방식으로 바뀌었습니다. 이전에는 사용자에게 빈 화면만 보여주던 미들웨어 오류도 커스텀 헤더를 통해 HOC까지 전파되어 동일한 UI로 처리됩니다. 500 에러 역시 페이지별 제각각이던 처리가 공통 에러 코드와 일관된 안내 문구로 통합되었습니다.

이 공통 경로에 도달한 오류는 GlobalErrorDisplay 하나로 안내하게 되었고, 오류 코드(401001, 404001 등)에 따라 적절한 메시지와 액션(로그인 이동, 홈 이동, 창 닫기)을 제공합니다.

  • 중앙집중식 오류 처리: 각 페이지에 분산되어 있던 try-catch 로직을 withPageErrorHandling HOC로 통합하여 코드 중복을 제거하고 유지보수성을 높였습니다.
  • 일관된 사용자 경험: 미들웨어에서 발생하는 인증 오류까지 SSR 페이지의 공통 오류 처리 flow에 통합해, 어떤 경로에서든 사용자에게 일관된 UI와 메시지로 오류를 전달할 수 있게 되었습니다. 사용자는 이제 URL 변경 없이 현재 페이지에서 오류 모달을 통해 문제 상황을 인지하고 적절한 후속 조치를 안내받을 수 있습니다.
  • 관심사 분리: 페이지 컴포넌트는 데이터 페칭과 렌더링에만 집중하고, 오류 처리와 같은 크로스-커팅 관심사는 HOC가 담당하게 되어 코드의 가독성과 역할 분리가 명확해졌습니다.
  • 견고한 결제 서비스: 결제와 같은 중요 flow에서 공통 처리 범위에 포함된 오류를 사용자에게 명확하게 전달할 수 있게 되었습니다.

이번 작업을 통해 Next.js SSR 환경에서 서버 측 오류와 미들웨어 오류를 하나의 flow로 처리할 수 있었습니다. 다만, HTTP 헤더를 통해 오류 메시지를 전달하는 방식은 헤더 크기 제한에 유의해야 합니다. 현재는 간단한 메시지를 전달하므로 문제가 없지만, 복잡하거나 긴 메시지를 전달해야 할 경우 다른 방법을 고려해야 할 것입니다. 또한, 오류 메시지에 민감한 정보가 포함되지 않도록 항상 주의해야 합니다.

회고 및 다음 단계

미들웨어에서 NextResponse.rewrite와 커스텀 헤더를 활용하여 서버 렌더링 단계로 오류 정보를 전파하는 방식은, Next.js SSR 환경에서 미들웨어와 페이지 컴포넌트 간의 통신 채널로 효과적이었습니다.

향후에는 클라이언트 측 Error Boundaries와 연동하여, SSR 단계뿐 아니라 클라이언트 렌더링 중 발생하는 오류까지 포괄하는 시스템을 구축할 계획입니다.

참고 자료

수정 이력

  • 2026-09-15
    • 동기 함수에서 오류가 호출 스택을 따라 상위 호출자에게 전파되는 과정 보강
    • async/await에서 throw, Promise rejection, await의 관계와 await하지 않은 비동기 작업의 한계 추가
    • HOC의 공통 오류 처리 원리와 처리할 수 없는 React 오류의 범위 명확화