
Next.js SSR 환경에서는 페이지 내부의 try-catch만으로 모든 오류를 일관되게 처리하기 어렵습니다. 특히 결제 서비스처럼 사용자 경험이 중요한 애플리케이션에서는, 페이지 데이터 패칭 오류뿐 아니라 미들웨어에서 발생하는 인증 오류까지 같은 흐름에서 다뤄야 했습니다.
문제의식: 분산된 오류 처리와 미들웨어의 한계
저희 서비스는 Next.js 기반의 SSR 애플리케이션으로, 사용자가 페이지에 진입하는 과정에서 다양한 서버 측 오류가 발생할 수 있습니다. 예를 들어, 데이터 페칭 실패, 권한 없음 등 여러 시나리오가 있습니다. 기존에는 이러한 오류들을 각 페이지 컴포넌트 내의 try-catch 블록에서 개별적으로 처리하고 있었습니다.
이 방식은 몇 가지 문제점을 야기했습니다.
- 코드 중복: 각 페이지마다 거의 동일한
try-catch로직이 반복되어 코드 중복이 심했습니다. - 유지보수 어려움: 오류 처리 로직을 수정하려면 모든 페이지를 수정해야 하는 번거로움이 있었습니다.
- 일관성 부족: 페이지마다 오류 메시지나 처리 방식이 미묘하게 달라질 위험이 있었습니다.
또한, 미들웨어에서 사용자 인증 여부를 확인하는 로직이 있었는데, 인증 쿠키가 없을 경우 단순히 로그인 페이지로 리다이렉트하는 방식이었습니다. 이 경우 사용자는 보고 있던 페이지를 잃게 되고, 왜 로그인 페이지로 이동했는지 명확한 안내를 받기 어려워 사용자 경험이 저해될 수 있었습니다.
이러한 문제들을 해결하기 위해, Next.js의 강력한 기능들을 활용하여 중앙집중식 오류 처리 시스템을 구축하고, 미들웨어에서 발생한 오류까지 SSR 페이지 컴포넌트로 안전하게 전파하는 전략을 세우게 되었습니다.
개념 설명: SSR, 미들웨어, HOC 그리고 헤더
이번 개선 작업의 핵심은 Next.js SSR의 특성을 이해하고, 미들웨어, HOC(Higher-Order Component), HTTP 헤더와 같은 기술 요소들을 유기적으로 연결하는 것이었습니다.
[사용자 요청]
│
▼
[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): 모든 페이지를 감싸는 래퍼입니다. 오류가 있으면 공통 에러 화면을 렌더링하고, 없으면 정상 페이지를 그대로 보여줍니다. 개별 페이지는 오류 처리를 신경 쓸 필요가 없어집니다. - 커스텀 HTTP 헤더 (
X-Error-Status,X-Error-Message): 미들웨어와 SSR 사이의 통신 채널입니다. 미들웨어에서 오류를 감지하면 이 헤더에 정보를 담아 보내고, SSR에서 읽어 HOC로 전달합니다.
구현 과정
1. 커스텀 오류 클래스 정의
먼저, 백엔드 API 오류를 표준화된 형태로 다루기 위한 커스텀 오류 클래스를 정의했습니다.
// 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)
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 하나로 모든 페이지의 오류 처리가 통일됩니다.
3. 페이지 컴포넌트에 HOC 적용
이제 구현한 HOC를 실제 페이지 컴포넌트에 적용하여 분산된 try-catch 블록을 제거합니다.
변경 전 (src/app/checkout/page.tsx)
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)
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의 인증 처리 로직)
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의 인증 처리 로직)
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로 넘깁니다. 다이어그램에서 본 흐름 그대로입니다.
5. HOC에 미들웨어 오류 처리 로직 추가
마지막으로, 앞서 구현한 withPageErrorHandling HOC에 미들웨어에서 전달된 커스텀 헤더를 감지하는 로직을 추가했습니다. 이를 통해 미들웨어 오류도 페이지 내부 오류와 동일한 방식으로 처리할 수 있게 됩니다.
개선된 HOC (src/libs/ssrErrorHandlingUtils.tsx)
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 흐름에 합류시킵니다. 미들웨어 오류든 페이지 오류든 동일한 경로로 처리되는 구조입니다.
결과 및 효과, 그리고 남은 과제
이러한 개선 작업들을 통해 저희 서비스는 다음과 같은 효과를 확인했습니다.
코드 중복 제거 효과
HOC 도입 전후를 비교하면 변화가 뚜렷합니다.
| 항목 | Before | After | 변화 |
|---|---|---|---|
| 페이지당 오류 처리 코드 | 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로직을withPageErrorHandlingHOC로 통합하여 코드 중복을 제거하고 유지보수성을 크게 향상시켰습니다. - 일관된 사용자 경험: 미들웨어에서 발생하는 인증 오류까지 SSR 페이지의 공통 오류 처리 흐름에 통합하여, 어떤 경로에서든 사용자에게 일관된 UI와 메시지로 오류를 전달할 수 있게 되었습니다. 사용자는 이제 URL 변경 없이 현재 페이지에서 오류 모달을 통해 문제 상황을 인지하고 적절한 후속 조치를 안내받을 수 있습니다.
- 관심사 분리: 페이지 컴포넌트는 데이터 페칭과 렌더링에만 집중하고, 오류 처리와 같은 크로스-커팅 관심사는 HOC가 담당하게 되어 코드의 가독성과 역할 분리가 명확해졌습니다.
- 견고한 결제 서비스: 결제와 같은 중요 흐름에서 발생할 수 있는 오류를 놓치지 않고 사용자에게 명확하게 전달할 수 있게 되었습니다.
이번 작업을 통해 Next.js SSR 환경에서 서버 측 오류와 미들웨어 오류를 하나의 흐름으로 처리할 수 있었습니다. 다만, HTTP 헤더를 통해 오류 메시지를 전달하는 방식은 헤더 크기 제한에 유의해야 합니다. 현재는 간단한 메시지를 전달하므로 문제가 없지만, 복잡하거나 긴 메시지를 전달해야 할 경우 다른 방법을 고려해야 할 것입니다. 또한, 오류 메시지에 민감한 정보가 포함되지 않도록 항상 주의해야 합니다.
회고 및 다음 단계
미들웨어에서 NextResponse.rewrite와 커스텀 헤더를 활용하여 서버 렌더링 단계로 오류 정보를 전파하는 방식은, Next.js SSR 환경에서 미들웨어와 페이지 컴포넌트 간의 통신 채널로 효과적이었습니다.
향후에는 클라이언트 측 Error Boundaries와 연동하여, SSR 단계뿐 아니라 클라이언트 렌더링 중 발생하는 오류까지 포괄하는 시스템을 구축할 계획입니다.
참고 자료
Related Posts

Next.js 15 App Router 마이그레이션 여정
Express 서버와 Page Router로 구성된 레거시 프로젝트를 Next.js 15 App Router 기반으로 전환한 대규모 마이그레이션 과정을 다룹니다.

대용량 엑셀 다운로드: 클라이언트에서 서버로
클라이언트 기반 엑셀 생성의 한계를 극복하고 SSE를 활용한 서버 사이드 아키텍처로 성능과 사용자 경험을 개선한 과정을 다룹니다.

외부 PG사 연동 시 리다이렉트 패턴 비교 - SDK vs Form POST vs GET
결제 시스템에서 다양한 외부 결제 서비스를 연동하면서 경험한 리다이렉트 패턴들의 차이점과 구현 방법을 정리합니다.