Next.js 에러 처리, 사용자와 개발자 모두를 위한 에러 시스템 만들기
1. 개요
Next.js App Router 환경에서, 서버에서 발생한 에러는 어떻게 처리하고 계신가요?
- 데이터를 불러오다 실패하면 어떻게 하나요?
- 인증이 만료되었거나, 네트워크가 끊겼거나, GraphQL 쿼리 결과가 비정상적이라면?
- 이런 에러들은 어떻게 클라이언트로 전달되고, 사용자에게는 어떤 방식으로 피드백되고 있나요?
이런 고민은 Next.js의 구조적 특징에서 비롯됩니다.
App Router 기반의 Next.js에서는 서버에서 데이터를 미리 가져와, 초기 HTML 렌더링에 활용할 수 있습니다.
덕분에 클라이언트는 별도 상태 관리 없이 UI만 깔끔하게 그리면 되는 구조를 가집니다.
하지만 이 구조에서 에러가 발생했을 때는 얘기가 달라집니다.
"에러는 서버에서 났는데, 사용자에겐 어떻게 보여주지?"
왜 이런 문제가 생길까요?

그 이유는 Next.js의 의도적인 보안 설계 때문입니다.
서버에서 발생한
Error
객체에는 API 경로나 DB 쿼리, 환경 변수 등 민감한 정보가 포함되어 있을 수 있기 때문에,- 프로덕션 환경에서는
Error
객체가 직렬화되지 않으며
error.tsx
,global-error.tsx
에서도 에러 메시지는 기본적으로 마스킹 처리됩니다.
예를 들어, 서버에서
throw new Error('UNAUTHORIZED')
를 해도→ 클라이언트에서는
"Something went wrong!"
같은 의미 없는 fallback 메시지만 보이게 됩니다.❗ 그 결과 어떤 불편함이 생길까요?
- 인증 오류가 발생해도 로그인 페이지로 리디렉션할 수 없고
- 입력 오류가 있어도 사용자에게 적절한 피드백을 줄 수 없으며
- 치명적인 서버 에러도 단 하나의 fallback UI로만 처리됩니다
결국 에러의 맥락을 전달할 수 없기 때문에, 사용자 경험도, 개발자 대응도 모두 제한적일 수밖에 없습니다.
특히 GraphQL + Apollo 환경에서는 더 복잡해집니다
에러는 단일 객체가 아닌 중첩된 구조로 전달되며,
ApolloError
networkError
graphQLErrors[]
extensions.code
처럼 계층을 따라 내려가야만 의미 있는 에러 정보를 추출할 수 있습니다.
그래서 이러한 계획을 세우게 됩니다. ⇒ 직렬화 가능한 커스텀 에러 객체를 만들고, 그걸 명시적으로 클라이언트에 전달하자. 그래서 사용자에겐 정확하고 일관된 피드백을 개발자에겐 추적 가능하고 실용적인 디버깅 환경을 제공하자!
시스템의 핵심 흐름
- 서버에서 발생한 에러를
ApiActionError
포맷으로 정형화하고 직렬화하여 클라이언트에게 전달
- 클라이언트에서는
category
값에 따라 alert, fallback, custom UI로 분기 처리
- 예상치 못한 에러는 ErrorBoundary를 통해 graceful하게 fallback
- 에러 정보는 소스맵 기반으로 Slack 등에 자동 리포팅
실제 서비스 환경에서 발생할 수 있는 다양한 에러 상황을 Next.js App Router 구조에 맞게 일관되게 처리하고 싶다면, 이 글에서 소개하는 구조가 좋은 출발점이 되기를 바랍니다.
2. 서버 사이드에서 발생한 에러의 정형화
3.1 ApiActionError 인터페이스
서버와 클라이언트가 공통으로 사용할 수 있는 에러 포맷을 정의합니다.
export interface ApiActionError { code: ErrorCode; message: string; category: ErrorCategory; originalError?: unknown; }
3.2 ErrorCode와 ErrorCategory 정의
에러 유형을 식별 가능한 코드화된 상수로 정의하고, 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
: 인증 관련 처리
3. 서버 함수 공통 처리 구조
3.1 withErrorHandling
– 모든 서버 액션을 감싸는 진입점
모든 액션마다
try-catch
를 반복하는 대신, 고차 함수 withErrorHandling
으로 감쌉니다.이 고차 함수는 서버 액션을 감싸고, try-catch 로직을 중앙 집중화하여 에러를 표준화된 형식으로 감싸 클라이언트에 전달합니다.
type ActionResult<T> = { data: T | null; error: Omit<ApiActionError, 'originalError'> | null; }; function withErrorHandling<TArgs extends any[], TResult>( action: (...args: TArgs) => Promise<TResult>, ): (...args: TArgs) => Promise<ActionResult<TResult>> { return async (...args: TArgs) => { try { const result = await action(...args); return { data: result, error: null }; } catch (error) { const apiError = await normalizeApolloError(error); console.error('Server Action Error:', apiError); const { originalError, ...serializable } = apiError; return { data: null, error: serializable }; } }; }
catch문에서 에러가 발생하면
normalizeApolloError
를 통해 구조화 합니다.3.2 normalizeApolloError
– ApolloError 정규화
이 함수는 GraphQL 에러 구조의 특성을 이해하고, ApolloError, networkError, graphQLErrors 등을 분석하여 하나의 표준 에러로 정규화합니다.
extensions.code
→ 에러 코드 분류
CODE_CATEGORIES
→ 에러 카테고리 지정
message
→ 사용자에게 보여줄 메시지 선택
정리된 결과는
ApiActionError
형태로 반환되어 클라이언트에서 즉시 분기 처리할 수 있습니다.export async function normalizeApolloError( error: ApolloError | Error | unknown ): Promise<ApiActionError> { if (isApiActionError(error)) return error; if (error instanceof ApolloError) { if (error.networkError) { return { code: 'NETWORK_ERROR', message: ERROR_MESSAGES.NETWORK_ERROR ?? '네트워크 연결을 확인해주세요.', category: 'ERROR_BOUNDARY', originalError: error, }; } if (error.graphQLErrors?.length > 0) { const gqlError = error.graphQLErrors[0]; const codeFromExtension = gqlError.extensions?.code as string; const messageFromGql = gqlError.message; let determinedCode: ErrorCode = 'UNKNOWN_ERROR'; let determinedCategory: ErrorCategory = 'ERROR_BOUNDARY'; if (codeFromExtension && ERROR_CODE_CONSTANTS.includes(codeFromExtension as ErrorCode)) { determinedCode = codeFromExtension as ErrorCode; } for (const cat in CODE_CATEGORIES) { if (CODE_CATEGORIES[cat as ErrorCategory].has(determinedCode)) { determinedCategory = cat as ErrorCategory; break; } } return { code: determinedCode, message: messageFromGql ?? ERROR_MESSAGES[determinedCode] ?? '알 수 없는 오류가 발생했습니다.', category: determinedCategory, originalError: error, }; } return { code: 'UNKNOWN_ERROR', message: error.message ?? ERROR_MESSAGES.UNKNOWN_ERROR, category: 'ERROR_BOUNDARY', originalError: error, }; } return normalizeError(error); }
- ApolloError 분석:
networkError
vsgraphQLErrors
- GraphQL extensions.code를 기준으로
ErrorCode
,ErrorCategory
추론
- 클라이언트 전달이 가능한 형태의 에러 객체 구성
3.3 normalizeError
– 일반 JS Error 정규화
Apollo 외 일반 JS 에러도 동일한 포맷으로 변환합니다.
이를 통해 에러 종류가 무엇이든 UI에서 분기처리가 가능하게 합니다.
/** * 다양한 형태의 JS 오류를 ApiActionError 형태로 정규화합니다. * * 이 함수는 서버에서 발생할 수 있는 모든 종류의 예외를 일관된 형태로 포맷하여, * 클라이언트에 직렬화 가능한 구조로 변환할 준비를 합니다. * * originalError는 서버 측 로깅용이며, 클라이언트 전송 전에는 제거합니다. */ export function normalizeError(error: unknown): ApiActionError { // 문자열을 던진 경우 if (typeof error === 'string') { return { code: 'UNKNOWN_ERROR', message: error, category: 'ERROR_BOUNDARY', }; } // 표준 Error 인스턴스인 경우 (예: throw new Error('...')) if (error instanceof Error) { return { code: 'UNKNOWN_ERROR', message: error.message, category: 'ERROR_BOUNDARY', originalError: error, // 서버 측 로깅용 (클라이언트 전송 전 제거 필요) }; } // 객체이지만 Error 인스턴스는 아닌 경우 if ( typeof error === 'object' && error !== null && 'message' in error && typeof (error as any).message === 'string' ) { return { code: 'UNKNOWN_ERROR', message: (error as any).message, category: 'ERROR_BOUNDARY', originalError: error, // 서버 측 로깅용 }; } // 위 조건에 모두 해당하지 않는 완전한 unknown (예: throw null, undefined, number, boolean 등) return { code: 'UNKNOWN_ERROR', message: '알 수 없는 오류가 발생했습니다.', category: 'ERROR_BOUNDARY', originalError: error, // 가능한 한 원본 형태를 서버에 남김 }; }
3.4 흐름도
graph TD A[서버 액션 호출] --> B[withErrorHandling 내부] B --> C[try 블록: 원래 액션 실행] B --> D[catch 블록: 에러 발생 시 처리] D --> E{에러 타입 확인} E -->|ApolloError| F[normalizeApolloError] E -->|일반 Error| G[normalizeError] F --> H[ApiActionError 반환] G --> H
4. 실제 사용 예시
4.1 서버 액션
// 내부에서 Apollo Client로 GraphQL 쿼리를 실행하고, 에러가 있으면 ApolloError를 던짐 async function _getPoiDetail(id: string) { const { data, errors } = await client.query({ query: POI_DETAIL_QUERY, variables: { id }, }); // GraphQL 응답에 errors가 포함되어 있다면 명시적으로 ApolloError를 발생시킴 if (errors && errors.length > 0) { throw new ApolloError({ graphQLErrors: errors }); } return data; } // 고차 함수로 감싸 에러를 정형화하고 클라이언트로 전달 가능하게 변환 export const getPoiDetail = withErrorHandling(_getPoiDetail);
4.2 서버 컴포넌트
import ServerErrorForwarder from '@/components/common/server-error-forwarder'; export default async function PoiPage({ params }: { params: { id: string } }) { const result = await getPoiDetail(params.id); // 서버에서 발생한 에러가 있다면, 클라이언트에서 throw 하도록 위임 if (result.error) { return <ServerErrorForwarder error={result.error} />; } // 데이터가 정상적으로 도착하면 클라이언트 컴포넌트에 전달 return <ClientPoiPage data={result.data} />; }
5. ServerErrorForwarder
– 서버에서 클라이언트로 에러 전달
서버 컴포넌트는 말 그대로 서버에서 실행되며 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
하여 에러를 감지하게 만드는ServerErrorForwarder
를 사용합니다.
서버 컴포넌트에서는 에러가 throw 되어도 클라이언트의 ErrorBoundary 에서 감지되지 않기 때문에, 클라이언트 컴포넌트에서 다시
throw
를 발생시켜 ErrorBoundary를 트리거 합니다.'use client'; import { useEffect } from 'react'; /** * 서버 컴포넌트에서 직렬화된 에러를 클라이언트에서 다시 throw 하여 * 클라이언트 측 ErrorBoundary가 감지할 수 있도록 만드는 컴포넌트 */ export default function ServerErrorForwarder({ error }: { error: unknown }) { useEffect(() => { /** * 클라이언트에서 강제로 에러를 throw 함으로써 * React ErrorBoundary가 이 에러를 감지하도록 유도 */ throw new Error(typeof error === 'string' ? error : JSON.stringify(error)); }, [error]); return null; }
6. 클라이언트에서 서버 액션 사용 시 핸들링 하는 useActionHandler
클라이언트에서 서버 사이드의 함수를 실행하는 경우 에러 발생 시 적절한 UI 처리나 fallback 유도를 하기 위한 커스텀 훅입니다.
import { useState, useCallback, useEffect } from 'react'; import { ApiActionError } from '@/utils/errors'; import { useSetAtom } from 'jotai'; import { alertModalAtom } from '@/atom/modal'; import { useRouter } from 'next/navigation'; import { useErrorBoundary } from 'react-error-boundary'; interface UseApiActionOptions<T> { initialData?: T | null; } // originalError는 클라이언트에는 전달되지 않도록 제거되어 있음 type ApiErrorObject = Omit<ApiActionError, 'originalError'>; interface UseApiActionResult<T, Args extends any[]> { execute: (...args: Args) => Promise<void>; data: T | null; isLoading: boolean; error: ApiErrorObject | null; reset: () => void; } interface UseActionHandlerConfig<T, Args extends any[]> { // 서버 액션 함수. 결과는 data 또는 error 중 하나로 반환됨 action: (...args: Args) => Promise<{ data: T | null; error: ApiErrorObject | null }>; options?: UseApiActionOptions<T>; key: string | number; // 키 변경 시 내부 상태 초기화 용도 } export default function useActionHandler<T, Args extends any[]>({ action, options, key, }: UseActionHandlerConfig<T, Args>): UseApiActionResult<T, Args> { const [data, setData] = useState<T | null>(options?.initialData ?? null); const [error, setError] = useState<ApiErrorObject | null>(null); const [isLoading, setIsLoading] = useState(false); const setAlertModal = useSetAtom(alertModalAtom); // 전역 alert modal 제어 const router = useRouter(); // history 제어용 const { showBoundary } = useErrorBoundary(); // react-error-boundary 사용 // key가 바뀔 때마다 내부 상태 초기화 (주로 detail 페이지 등) useEffect(() => { setData(options?.initialData ?? null); setError(null); setIsLoading(false); }, [key, options?.initialData]); // 실제 액션 실행 함수 const execute = useCallback( async (...args: Args) => { setIsLoading(true); setError(null); try { const result = await action(...args); // 서버 액션 실행 if (result.error) { // 에러가 발생한 경우 setError(result.error); setData(null); // 이전 데이터 초기화 // 에러 category에 따라 UX 분기 처리 switch (result.error.category) { case 'CUSTOM': // 클라이언트 컴포넌트에서 직접 처리하도록 함 return; case 'ALERT': // 모달을 띄우고 뒤로가기 setAlertModal({ visible: true, title: '알림', message: result.error.message, onConfirm: () => { setAlertModal({ visible: false }); router.back(); // 뒤로가기 }, }); return; case 'ERROR_BOUNDARY': // ErrorBoundary를 트리거 (fallback UI로 연결) showBoundary(result.error); return; } } // 정상적으로 결과가 있을 경우 setData(result.data ?? null); } finally { setIsLoading(false); // 무조건 로딩 종료 } }, [action, setAlertModal, router, showBoundary], ); return { execute, data, isLoading, error }; }
에러가
ERROR_BOUNDARY
라면, throw
하여 ErrorBoundary
가 잡게끔 유도합니다.그 외엔
error
상태값으로 남겨 UI에서 직접 처리하게 할 수 있습니다.7. ErrorBoundary와 Fallback UI
클라이언트 측 모든 예외는 ReactErrorBoundary로 포괄적으로 처리합니다.
7.1 ErrorBoundary.tsx
'use client'; import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; import { ErrorCode, getErrorMessage, parseErrorObject, reportError } from '@/utils/errors'; import ErrorFallback from './error-fallback'; /** * 클라이언트 전역에서 렌더링 중 발생하는 예외를 감지하고 fallback UI로 대체합니다. * - ReactErrorBoundary는 클라이언트에서만 동작합니다. * - 발생한 에러는 parseErrorObject로 파싱되고, Slack 등의 채널로 reportError를 통해 리포트됩니다. */ export default function ErrorBoundary({ children }: { children: React.ReactNode }) { return ( <ReactErrorBoundary // 실제 에러와 stack trace를 외부로 전송 onError={(error, info) => { reportError(error, info); }} // fallback UI는 에러 메시지를 가공하여 ErrorFallback 컴포넌트에 전달 fallbackRender={({ error, resetErrorBoundary }) => { const errorObject = parseErrorObject(error); const errorMessage = getErrorMessage(errorObject.code as ErrorCode); return ( <div className='w-full min-h-[60vh] flex items-center justify-center px-5'> <ErrorFallback errorCode={errorObject.code as ErrorCode} errorMessage={errorMessage} onRetry={() => window.location.reload()} // Retry는 기본적으로 페이지 새로고침 /> </div> ); }} > {children} </ReactErrorBoundary> ); }
7.2 ErrorFallback.tsx
이 컴포넌트는 일관된 UX를 위해 ErrorBoundary와 global-error.tsx에서 재사용 됩니다.
'use client'; import React from 'react'; interface ErrorFallbackProps { errorCode: string; errorMessage: string; onRetry: () => void; } /** * 클라이언트 에러 발생 시 사용자에게 보여주는 일관된 Fallback UI * - ErrorBoundary 또는 global-error.tsx에서 공통으로 사용 * - 접근성(aria), 디자인 통일성, 사용자 행동 유도 등을 고려 */ export default function ErrorFallback({ errorCode, errorMessage, onRetry, }: ErrorFallbackProps) { return ( <div role="alert" aria-live="assertive" className="flex flex-col items-center justify-center gap-4 px-6 py-10 text-center bg-gray-50 rounded-md shadow-md" > <h2 className="text-xl font-semibold text-red-600"> 문제가 발생했어요 </h2> <p className="text-sm text-gray-700"> <strong>코드:</strong> {errorCode} </p> <p className="text-base text-gray-800">{errorMessage}</p> <button onClick={onRetry} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition" > 다시 시도하기 </button> </div> ); }
8. Global Error Handling: app/global-error.tsx
global-error.tsx
는 Next.js App Router에서 에러가 최종적으로 도달하는 fallback 처리 컴포넌트입니다. 'use client'; import { useEffect } from 'react'; import { parseErrorObject, reportError } from '@/utils/errors'; import ErrorFallback from '@/components/ui/common/error-fallback'; /** * global-error.tsx는 Next.js App Router에서 렌더링 중 치명적인 에러가 발생했을 때 * 마지막으로 fallback되는 전역 에러 처리 UI입니다. * - 클라이언트 컴포넌트이며, HTML 구조까지 직접 정의합니다. * - 서버 사이드 렌더링 중 발생한 에러에도 대응됩니다. */ export default function GlobalError({ error }: { error: Error }) { const parsed = parseErrorObject(error); useEffect(() => { // error.stack 등을 포함한 추가 context와 함께 리포트 전송 reportError(error, { componentStack: error.stack }); }, [error]); return ( <html> <body> <ErrorFallback errorCode={parsed.code} errorMessage={parsed.message} onRetry={() => window.location.reload()} // 기본적으로 새로고침 제공 /> </body> </html> ); }
9. 전체 흐름 요약
graph TD %% 서버 컴포넌트 흐름 subgraph 서버 컴포넌트 흐름 SC1[서버 컴포넌트] SC2[withErrorHandling] SC3[normalizeApolloError] SC4[ServerErrorForwarder - 클라이언트] SC5[throw Error] SC6[ErrorBoundary] SC7[Fallback UI 렌더링] SC1 --> SC2 SC2 --> SC3 SC3 --> SC4 SC4 --> SC5 SC5 --> SC6 SC6 --> SC7 end %% 클라이언트 컴포넌트 흐름 subgraph 클라이언트 컴포넌트 흐름 CC1[클라이언트 컴포넌트] CC2[useActionHandler 실행] CC3[서버 액션 호출] CC4[withErrorHandling - normalizeApolloError] CC5{category 분기} CC6[Alert Modal 표시] CC7[컴포넌트에서 직접 처리] CC8[showBoundary - Fallback UI] CC1 --> CC2 CC2 --> CC3 CC3 --> CC4 CC4 --> CC5 CC5 -->|ALERT| CC6 CC5 -->|CUSTOM| CC7 CC5 -->|ERROR_BOUNDARY| CC8 end
위 구조를 통해 서버에서 발생한 에러를 클라이언트까지 일관되게 전달하고, UX에 따라 분기 처리할 수 있는 체계를 구축할 수 있었습니다.
- 서버와 클라이언트 간 일관된 에러 포맷 공유
- 에러의 유형을 분류하고, 분기에 따라 UX 제공
- 예상치 못한 에러는 자동 fallback + Slack 리포트로 빠른 대응 가능
10. 마무리
Next.js App Router 환경에선 에러를 단순히
try-catch
로 처리하는 것만으로는 충분하지 않습니다. 서버와 클라이언트가 맞물려 작동하는 구조 속에서, 에러는 언제 어디서든 발생할 수 있고, 그에 대한 처리 방식은 서비스의 안정성과 사용자 경험에 직결됩니다.
이번 글에서 소개한 에러 처리 구조는 단순한 예외 처리를 넘어, GraphQL → 서버 액션 → 클라이언트 렌더링에 이르는 전 과정을 하나의 흐름 안에서 정형화하고, 안전하게 전달하며, 일관되게 처리하기 위한 시도였습니다.
Next.js에서 에러 처리를 고민하는 분들께 조금이나마 도움이 되었길 바랍니다.
다음 글에선 Route Handler와 SourceMap을 활용해, 에러 발생 시 Slack으로 실시간 리포트를 전송하는 구조를 자세히 소개해보겠습니다.