Next.js App Router 환경에서, 서버에서 발생한 에러는 어떻게 처리하고 계신가요?
이런 고민은 Next.js의 구조적 특징에서 비롯됩니다.
App Router 기반의 Next.js에서는 서버에서 데이터를 미리 가져와, 초기 HTML 렌더링에 활용할 수 있습니다.
덕분에 클라이언트는 별도 상태 관리 없이 UI만 깔끔하게 그리면 되는 구조를 가집니다.
하지만 이 구조에서 에러가 발생했을 때는 얘기가 달라집니다.
"에러는 서버에서 났는데, 사용자에겐 어떻게 보여주지?"

출처: https://nextjs.org/docs/app/api-reference/file-conventions/error
그 이유는 Next.js의 의도적인 보안 설계 때문입니다.
서버에서 발생한 Error 객체에는 API 경로나 DB 쿼리, 환경 변수 등 민감한 정보가 포함되어 있을 수 있기 때문에,
Error 객체가 직렬화되지 않으며,error.tsx, global-error.tsx에서도 에러 메시지는 기본적으로 마스킹 처리됩니다.예를 들어, 서버에서 throw new Error('UNAUTHORIZED')를 해도
→ 클라이언트에서는 "Something went wrong!" 같은 의미 없는 fallback 메시지만 보이게 됩니다.
결국 에러의 맥락을 전달할 수 없기 때문에, 사용자 경험도, 개발자 대응도 모두 제한적일 수밖에 없습니다.
에러는 단일 객체가 아닌 중첩된 구조로 전달되며,
ApolloError처럼 계층을 따라 내려가야만 의미 있는 에러 정보를 추출할 수 있습니다.
그래서 이러한 계획을 세우게 됩니다. ⇒ 직렬화 가능한 커스텀 에러 객체를 만들고, 그걸 명시적으로 클라이언트에 전달하자. 그래서 사용자에겐 정확하고 일관된 피드백을 개발자에겐 추적 가능하고 실용적인 디버깅 환경을 제공하자!
ApiActionError 포맷으로 정형화하고 직렬화하여 클라이언트에게 전달category 값에 따라 alert, fallback, custom UI로 분기 처리실제 서비스 환경에서 발생할 수 있는 다양한 에러 상황을 Next.js App Router 구조에 맞게 일관되게 처리하고 싶다면, 이 글에서 소개하는 구조가 좋은 출발점이 되기를 바랍니다.
서버와 클라이언트가 공통으로 사용할 수 있는 에러 포맷을 정의합니다.
1export interface ApiActionError {
2 code: ErrorCode;
3 message: string;
4 category: ErrorCategory;
5 originalError?: unknown;
6}에러 유형을 식별 가능한 코드화된 상수로 정의하고, UX를 위한 분기 기준인 카테고리도 함께 지정합니다.
export const ERROR_CODE_CONSTANTS = [
'UNAUTHORIZED', 'NOT_FOUND', 'INTERNAL_ERROR', 'NETWORK_ERROR', 'POI_NOT_FOUND', 'UNKNOWN_ERROR',
] as const;
export type ErrorCategory = 'ALERT' | 'CUSTOM' | 'ERROR_BOUNDARY' | 'AUTH';ALERT: 사용자에게 모달로 보여줄 에러CUSTOM: 컴포넌트에서 UI로 직접 처리할 에러ERROR_BOUNDARY: fallback UI로 대체되어야 하는 심각한 에러AUTH: 인증 관련 처리withErrorHandling – 모든 서버 액션을 감싸는 진입점모든 액션마다 try-catch를 반복하는 대신, 고차 함수 withErrorHandling으로 감쌉니다.
이 고차 함수는 서버 액션을 감싸고, try-catch 로직을 중앙 집중화하여 에러를 표준화된 형식으로 감싸 클라이언트에 전달합니다.
1type ActionResult<T> = {
2 data: T | null;
3 error: Omit<ApiActionError, 'originalError'> | null;
4};
5
6function withErrorHandling<TArgs extends any[], TResult>(
7 action: (...args: TArgs) => Promise<TResult>,
8): (...args: TArgs) => Promise<ActionResult<TResult>> {
9 return async (...args: TArgs) => {
10 try {
11 const result = await action(...args);
12 return { data: result, error: null };
13 } catch (error) {
14 const apiError = await normalizeApolloError(error);
15 console.error('Server Action Error:', apiError);
16 const { originalError, ...serializable } = apiError;
17 return { data: null, error: serializable };
18 }
19 };
20}catch문에서 에러가 발생하면 normalizeApolloError를 통해 구조화 합니다.
normalizeApolloError – ApolloError 정규화이 함수는 GraphQL 에러 구조의 특성을 이해하고, ApolloError, networkError, graphQLErrors 등을 분석하여 하나의 표준 에러로 정규화합니다.
extensions.code → 에러 코드 분류CODE_CATEGORIES → 에러 카테고리 지정message → 사용자에게 보여줄 메시지 선택정리된 결과는 ApiActionError 형태로 반환되어 클라이언트에서 즉시 분기 처리할 수 있습니다.
1export async function normalizeApolloError(
2 error: ApolloError | Error | unknown
3): Promise<ApiActionError> {
4 if (isApiActionError(error)) return error;
5
6 if (error instanceof ApolloError) {
7 if (error.networkError) {
8 return {
9 code: 'NETWORK_ERROR',
10 message: ERROR_MESSAGES.NETWORK_ERROR ?? '네트워크 연결을 확인해주세요.',
11 category: 'ERROR_BOUNDARY',
12 originalError: error,
13 };
14 }
15
16 if (error.graphQLErrors?.length > 0) {
17 const gqlError = error.graphQLErrors[0];
18 const codeFromExtension = gqlError.extensions?.code as string;
19 const messageFromGql = gqlError.message;
20
21 let determinedCode: ErrorCode = 'UNKNOWN_ERROR';
22 let determinedCategory: ErrorCategory = 'ERROR_BOUNDARY';
23
24 if (codeFromExtension && ERROR_CODE_CONSTANTS.includes(codeFromExtension as ErrorCode)) {
25 determinedCode = codeFromExtension as ErrorCode;
26 }
27
28 for (const cat in CODE_CATEGORIES) {
29 if (CODE_CATEGORIES[cat as ErrorCategory].has(determinedCode)) {
30 determinedCategory = cat as ErrorCategory;
31 break;
32 }
33 }
34
35 return {
36 code: determinedCode,
37 message: messageFromGql ?? ERROR_MESSAGES[determinedCode] ?? '알 수 없는 오류가 발생했습니다.',
38 category: determinedCategory,
39 originalError: error,
40 };
41 }
42
43 return {
44 code: 'UNKNOWN_ERROR',
45 message: error.message ?? ERROR_MESSAGES.UNKNOWN_ERROR,
46 category: 'ERROR_BOUNDARY',
47 originalError: error,
48 };
49 }
50
51 return normalizeError(error);
52}networkError vs graphQLErrorsErrorCode, ErrorCategory 추론normalizeError – 일반 JS Error 정규화Apollo 외 일반 JS 에러도 동일한 포맷으로 변환합니다.
이를 통해 에러 종류가 무엇이든 UI에서 분기처리가 가능하게 합니다.
1/**
2 * 다양한 형태의 JS 오류를 ApiActionError 형태로 정규화합니다.
3 *
4 * 이 함수는 서버에서 발생할 수 있는 모든 종류의 예외를 일관된 형태로 포맷하여,
5 * 클라이언트에 직렬화 가능한 구조로 변환할 준비를 합니다.
6 *
7 * originalError는 서버 측 로깅용이며, 클라이언트 전송 전에는 제거합니다.
8 */
9
10export function normalizeError(error: unknown): ApiActionError {
11 // 문자열을 던진 경우
12 if (typeof error === 'string') {
13 return {
14 code: 'UNKNOWN_ERROR',
15 message: error,
16 category: 'ERROR_BOUNDARY',
17 };
18 }
19
20 // 표준 Error 인스턴스인 경우 (예: throw new Error('...'))
21 if (error instanceof Error) {
22 return {
23 code: 'UNKNOWN_ERROR',
24 message: error.message,
25 category: 'ERROR_BOUNDARY',
26 originalError: error, // 서버 측 로깅용 (클라이언트 전송 전 제거 필요)
27 };
28 }
29
30 // 객체이지만 Error 인스턴스는 아닌 경우
31 if (
32 typeof error === 'object' &&
33 error !== null &&
34 'message' in error &&
35 typeof (error as any).message === 'string'
36 ) {
37 return {
38 code: 'UNKNOWN_ERROR',
39 message: (error as any).message,
40 category: 'ERROR_BOUNDARY',
41 originalError: error, // 서버 측 로깅용
42 };
43 }
44
45 // 위 조건에 모두 해당하지 않는 완전한 unknown (예: throw null, undefined, number, boolean 등)
46 return {
47 code: 'UNKNOWN_ERROR',
48 message: '알 수 없는 오류가 발생했습니다.',
49 category: 'ERROR_BOUNDARY',
50 originalError: error, // 가능한 한 원본 형태를 서버에 남김
51 };
52}
531// 내부에서 Apollo Client로 GraphQL 쿼리를 실행하고, 에러가 있으면 ApolloError를 던짐
2async function _getPoiDetail(id: string) {
3 const { data, errors } = await client.query({
4 query: POI_DETAIL_QUERY,
5 variables: { id },
6 });
7
8 // GraphQL 응답에 errors가 포함되어 있다면 명시적으로 ApolloError를 발생시킴
9 if (errors && errors.length > 0) {
10 throw new ApolloError({ graphQLErrors: errors });
11 }
12
13 return data;
14}
15
16// 고차 함수로 감싸 에러를 정형화하고 클라이언트로 전달 가능하게 변환
17export const getPoiDetail = withErrorHandling(_getPoiDetail);1import ServerErrorForwarder from '@/components/common/server-error-forwarder';
2
3export default async function PoiPage({ params }: { params: { id: string } }) {
4 const result = await getPoiDetail(params.id);
5
6 // 서버에서 발생한 에러가 있다면, 클라이언트에서 throw 하도록 위임
7 if (result.error) {
8 return <ServerErrorForwarder error={result.error} />;
9 }
10
11 // 데이터가 정상적으로 도착하면 클라이언트 컴포넌트에 전달
12 return <ClientPoiPage data={result.data} />;
13}
14ServerErrorForwarder – 서버에서 클라이언트로 에러 전달서버 컴포넌트는 말 그대로 서버에서 실행되며 HTML을 만들어 클라이언트에 전달하는 역할만 수행하고, 클라이언트에 자바스크립트 번들로 전달되지 않습니다.
그 말은 즉, React의 클라이언트 라이프사이클(hooks, useEffect, state 등)을 사용할 수 없고, 그 안에서 발생한 throw는 React의 에러 바운더리(ErrorBoundary)에 잡히지 않는다는 의미이기도 합니다.
React의 ErrorBoundary는 클라이언트에서 렌더링 중 발생한 에러를 잡기 위한 메커니즘이기 때문에, 서버에서 발생한 에러에는 반응하지 않습니다.
서버 컴포넌트에서 서버 액션을 호출하다가 에러가 발생해도, 클라이언트에 전달되는 것은 단지 error.tsx 또는 global-error.tsx에서 사용하는 HTML fallback UI뿐입니다.
하지만 이 fallback은 정확한 에러 객체나 에러 코드 없이 그저 "무언가 잘못됐습니다" 수준의 정보만을 사용자에게 제공하게 됩니다.
ServerErrorForwarder서버에서 발생한 에러를 클라이언트로 직렬화 가능한 형태(ApiActionError)로 반환하고, 이 값을 클라이언트 컴포넌트로 전달한 후, 그 안에서 다시 throw하여 에러 바운더리를 트리거하는 방식입니다.
서버 컴포넌트에서는 클라이언트 라이프사이클 훅이 동작하지 않기 때문에, 에러를 throw해도 ErrorBoundary에서 감지할 수 없습니다.
서버 컴포넌트에서는 에러가 throw 되어도 클라이언트의 ErrorBoundary 에서 감지되지 않기 때문에, 클라이언트 컴포넌트에서 다시 throw를 발생시켜 ErrorBoundary를 트리거 합니다.
1'use client';
2
3import { useEffect } from 'react';
4
5/**
6 * 서버 컴포넌트에서 직렬화된 에러를 클라이언트에서 다시 throw 하여
7 * 클라이언트 측 ErrorBoundary가 감지할 수 있도록 만드는 컴포넌트
8 */
9export default function ServerErrorForwarder({ error }: { error: unknown }) {
10 useEffect(() => {
11 /**
12 * 클라이언트에서 강제로 에러를 throw 함으로써
13 * React ErrorBoundary가 이 에러를 감지하도록 유도
14 */
15 throw new Error(typeof error === 'string' ? error : JSON.stringify(error));
16 }, [error]);
17
18 return null;
19}useActionHandler클라이언트에서 서버 사이드의 함수를 실행하는 경우 에러 발생 시 적절한 UI 처리나 fallback 유도를 하기 위한 커스텀 훅입니다.
1import { useState, useCallback, useEffect } from 'react';
2import { ApiActionError } from '@/utils/errors';
3import { useSetAtom } from 'jotai';
4import { alertModalAtom } from '@/atom/modal';
5import { useRouter } from 'next/navigation';
6import { useErrorBoundary } from 'react-error-boundary';
7
8interface UseApiActionOptions<T> {
9 initialData?: T | null;
10}
11
12// originalError는 클라이언트에는 전달되지 않도록 제거되어 있음
13type ApiErrorObject = Omit<ApiActionError, 'originalError'>;
14
15interface UseApiActionResult<T, Args extends any[]> {
16 execute: (...args: Args) => Promise<void>;
17 data: T | null;
18 isLoading: boolean;
19 error: ApiErrorObject | null;
20 reset: () => void;
21}
22
23interface UseActionHandlerConfig<T, Args extends any[]> {
24 // 서버 액션 함수. 결과는 data 또는 error 중 하나로 반환됨
25 action: (...args: Args) => Promise<{ data: T | null; error: ApiErrorObject | null }>;
26 options?: UseApiActionOptions<T>;
27 key: string | number; // 키 변경 시 내부 상태 초기화 용도
28}
29
30export default function useActionHandler<T, Args extends any[]>({
31 action,
32 options,
33 key,
34}: UseActionHandlerConfig<T, Args>): UseApiActionResult<T, Args> {
35 const [data, setData] = useState<T | null>(options?.initialData ?? null);
36 const [error, setError] = useState<ApiErrorObject | null>(null);
37 const [isLoading, setIsLoading] = useState(false);
38
39 const setAlertModal = useSetAtom(alertModalAtom); // 전역 alert modal 제어
40 const router = useRouter(); // history 제어용
41 const { showBoundary } = useErrorBoundary(); // react-error-boundary 사용
42
43 // key가 바뀔 때마다 내부 상태 초기화 (주로 detail 페이지 등)
44 useEffect(() => {
45 setData(options?.initialData ?? null);
46 setError(null);
47 setIsLoading(false);
48 }, [key, options?.initialData]);
49
50 // 실제 액션 실행 함수
51 const execute = useCallback(
52 async (...args: Args) => {
53 setIsLoading(true);
54 setError(null);
55
56 try {
57 const result = await action(...args); // 서버 액션 실행
58
59 if (result.error) {
60 // 에러가 발생한 경우
61 setError(result.error);
62 setData(null); // 이전 데이터 초기화
63
64 // 에러 category에 따라 UX 분기 처리
65 switch (result.error.category) {
66 case 'CUSTOM':
67 // 클라이언트 컴포넌트에서 직접 처리하도록 함
68 return;
69
70 case 'ALERT':
71 // 모달을 띄우고 뒤로가기
72 setAlertModal({
73 visible: true,
74 title: '알림',
75 message: result.error.message,
76 onConfirm: () => {
77 setAlertModal({ visible: false });
78 router.back(); // 뒤로가기
79 },
80 });
81 return;
82
83 case 'ERROR_BOUNDARY':
84 // ErrorBoundary를 트리거 (fallback UI로 연결)
85 showBoundary(result.error);
86 return;
87 }
88 }
89
90 // 정상적으로 결과가 있을 경우
91 setData(result.data ?? null);
92 } finally {
93 setIsLoading(false); // 무조건 로딩 종료
94 }
95 },
96 [action, setAlertModal, router, showBoundary],
97 );
98
99
100 return { execute, data, isLoading, error };
101}
102에러가 ERROR_BOUNDARY라면, throw하여 ErrorBoundary가 잡게끔 유도합니다.
그 외엔 error 상태값으로 남겨 UI에서 직접 처리하게 할 수 있습니다.
클라이언트 측 모든 예외는 ReactErrorBoundary로 포괄적으로 처리합니다.
1'use client';
2
3import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
4import { ErrorCode, getErrorMessage, parseErrorObject, reportError } from '@/utils/errors';
5import ErrorFallback from './error-fallback';
6
7/**
8 * 클라이언트 전역에서 렌더링 중 발생하는 예외를 감지하고 fallback UI로 대체합니다.
9 * - ReactErrorBoundary는 클라이언트에서만 동작합니다.
10 * - 발생한 에러는 parseErrorObject로 파싱되고, Slack 등의 채널로 reportError를 통해 리포트됩니다.
11 */
12export default function ErrorBoundary({ children }: { children: React.ReactNode }) {
13 return (
14 <ReactErrorBoundary
15 // 실제 에러와 stack trace를 외부로 전송
16 onError={(error, info) => {
17 reportError(error, info);
18 }}
19 // fallback UI는 에러 메시지를 가공하여 ErrorFallback 컴포넌트에 전달
20 fallbackRender={({ error, resetErrorBoundary }) => {
21 const errorObject = parseErrorObject(error);
22 const errorMessage = getErrorMessage(errorObject.code as ErrorCode);
23
24 return (
25 <div className='w-full min-h-[60vh] flex items-center justify-center px-5'>
26 <ErrorFallback
27 errorCode={errorObject.code as ErrorCode}
28 errorMessage={errorMessage}
29 onRetry={() => window.location.reload()} // Retry는 기본적으로 페이지 새로고침
30 />
31 </div>
32 );
33 }}
34 >
35 {children}
36 </ReactErrorBoundary>
37 );
38}이 컴포넌트는 일관된 UX를 위해 ErrorBoundary와 global-error.tsx에서 재사용 됩니다.
1'use client';
2
3import React from 'react';
4
5interface ErrorFallbackProps {
6 errorCode: string;
7 errorMessage: string;
8 onRetry: () => void;
9}
10
11/**
12 * 클라이언트 에러 발생 시 사용자에게 보여주는 일관된 Fallback UI
13 * - ErrorBoundary 또는 global-error.tsx에서 공통으로 사용
14 * - 접근성(aria), 디자인 통일성, 사용자 행동 유도 등을 고려
15 */
16export default function ErrorFallback({
17 errorCode,
18 errorMessage,
19 onRetry,
20}: ErrorFallbackProps) {
21 return (
22 <div
23 role="alert"
24 aria-live="assertive"
25 className="flex flex-col items-center justify-center gap-4 px-6 py-10 text-center bg-gray-50 rounded-md shadow-md"
26 >
27 <h2 className="text-xl font-semibold text-red-600">
28 문제가 발생했어요
29 </h2>
30 <p className="text-sm text-gray-700">
31 <strong>코드:</strong> {errorCode}
32 </p>
33 <p className="text-base text-gray-800">{errorMessage}</p>
34 <button
35 onClick={onRetry}
36 className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
37 >
38 다시 시도하기
39 </button>
40 </div>
41 );
42}app/global-error.tsxglobal-error.tsx는 Next.js App Router에서 에러가 최종적으로 도달하는 fallback 처리 컴포넌트입니다.
1'use client';
2
3import { useEffect } from 'react';
4import { parseErrorObject, reportError } from '@/utils/errors';
5import ErrorFallback from '@/components/ui/common/error-fallback';
6
7/**
8 * global-error.tsx는 Next.js App Router에서 렌더링 중 치명적인 에러가 발생했을 때
9 * 마지막으로 fallback되는 전역 에러 처리 UI입니다.
10 * - 클라이언트 컴포넌트이며, HTML 구조까지 직접 정의합니다.
11 * - 서버 사이드 렌더링 중 발생한 에러에도 대응됩니다.
12 */
13export default function GlobalError({ error }: { error: Error }) {
14 const parsed = parseErrorObject(error);
15
16 useEffect(() => {
17 // error.stack 등을 포함한 추가 context와 함께 리포트 전송
18 reportError(error, { componentStack: error.stack });
19 }, [error]);
20
21 return (
22 <html>
23 <body>
24 <ErrorFallback
25 errorCode={parsed.code}
26 errorMessage={parsed.message}
27 onRetry={() => window.location.reload()} // 기본적으로 새로고침 제공
28 />
29 </body>
30 </html>
31 );
32}
33위 구조를 통해 서버에서 발생한 에러를 클라이언트까지 일관되게 전달하고, UX에 따라 분기 처리할 수 있는 체계를 구축할 수 있었습니다.
Next.js App Router 환경에선 에러를 단순히 try-catch로 처리하는 것만으로는 충분하지 않습니다.
서버와 클라이언트가 맞물려 작동하는 구조 속에서, 에러는 언제 어디서든 발생할 수 있고, 그에 대한 처리 방식은 서비스의 안정성과 사용자 경험에 직결됩니다.
이번 글에서 소개한 에러 처리 구조는 단순한 예외 처리를 넘어, GraphQL → 서버 액션 → 클라이언트 렌더링에 이르는 전 과정을 하나의 흐름 안에서 정형화하고, 안전하게 전달하며, 일관되게 처리하기 위한 시도였습니다.
Next.js에서 에러 처리를 고민하는 분들께 조금이나마 도움이 되었길 바랍니다.
다음 글에선 Route Handler와 SourceMap을 활용해, 에러 발생 시 Slack으로 실시간 리포트를 전송하는 구조를 자세히 소개해보겠습니다.