Next.js 에러 처리, 사용자와 개발자 모두를 위한 에러 시스템 만들기

date
Jun 7, 2025
slug
nextjs-error-handling-system
author
status
Public
tags
Next.js
summary
type
Post
thumbnail
thumbnail_.jpg
category
updatedAt
Jun 7, 2025 01:51 AM

1. 개요

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

왜 이런 문제가 생길까요?

notion image
 
그 이유는 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
처럼 계층을 따라 내려가야만 의미 있는 에러 정보를 추출할 수 있습니다.
 
그래서 이러한 계획을 세우게 됩니다. ⇒ 직렬화 가능한 커스텀 에러 객체를 만들고, 그걸 명시적으로 클라이언트에 전달하자. 그래서 사용자에겐 정확하고 일관된 피드백을 개발자에겐 추적 가능하고 실용적인 디버깅 환경을 제공하자!
 

시스템의 핵심 흐름

  1. 서버에서 발생한 에러를 ApiActionError 포맷으로 정형화하고 직렬화하여 클라이언트에게 전달
  1. 클라이언트에서는 category 값에 따라 alert, fallback, custom UI로 분기 처리
  1. 예상치 못한 에러는 ErrorBoundary를 통해 graceful하게 fallback
  1. 에러 정보는 소스맵 기반으로 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 vs graphQLErrors
  • 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 등)을 사용할 수 없고, 그 안에서 발생한 throwReact의 에러 바운더리(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.tsxNext.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으로 실시간 리포트를 전송하는 구조를 자세히 소개해보겠습니다.