에러를 알아야 핸들링할 수 있다.
Xperience

에러를 알아야 핸들링할 수 있다.

프론트엔드에서 주로 마주치는 에러와 처리 방법을 알아보자

#Error
12
2,335

0) 들어가며

요즈음 부쩍 새롭게 프로젝트를 시작하는 일이 많아졌습니다.

초기 개발 환경을 구축하다보면 항상 막히는 부분이 있는데 바로 에러 핸들링 시스템입니다.

없는 페이지를 방문하면? 없는 데이터를 조회하면? 네트워크가 불안정하면? 이상한 주소에 요청을 보내면? 백엔드 응답이 이상하면? 데이터 센터에 불이나면?

아 몰라! 우리도 샤따 내려!
아 몰라! 우리도 샤따 내려!

라고는 할 수 없으니….

어떠한 상황에도, 우리 프론트엔드 개발자들은 무엇이 잘못되었는지 명확히 알리고, 다음에 무엇을 해야 할지 친절히 안내하여 안정적인 사용자 경험의 울타리를 만들어주어야 합니다.

그렇기에 예측할 수 없는 에러의 세상에서 사용자들을 구해주려면, 우리는 적어도 마주할 에러의 종류를 알고, 그에 맞는 방화벽을 구축해야합니다.

이번 포스팅에서는 견고한 에러 핸들링 시스템을 구축하기 위해, 프론트엔드 개발자가 주로 마주하는 다양한 에러 객체와 라이브러리별 처리 방식에 대해 알아보겠습니다.


1) Native JS Error

1-1) Error (기본 에러 클래스)

class Error { constructor(message) { this.message = message || ""; this.name = "Error"; this.stack = "<call stack>"; } }

JavaScript의 모든 내장 에러 객체의 프로토타입이 되는 기본 클래스입니다. 따라서 앞으로 소개할 모든 에러는 Error 객체를 상속하므로, name, message, stack 속성을 공통으로 가집니다.

const response = { status: 404, statusText: "Not Found" }; const error = new Error(`HTTP ${response.status}: ${response.statusText}`); console.log(error.name); // "Error" console.log(error.message); // "HTTP 404: Not Found" console.log(error.stack); // "Error: HTTP 404: Not Found\n at <anonymous>:1:13" // toString() 메서드는 name과 message를 결합 console.log(error.toString()); // "Error: HTTP 404: Not Found" const typeError = new TypeError("Cannot read property of null"); console.log(typeError.name); // "TypeError" const refError = new ReferenceError("variable is not defined"); console.log(refError.name); // "ReferenceError"

이후 후술할 클래스들은 모두 Error 객체를 상속받기 때문에 name, message, stack 속성을 공통으로 가집니다.

1-2) TypeError (타입 에러)

값이 기대하던 자료형이 아니어서 연산을 수행할 수 없을 때 발생하는 에러입니다. 타입스크립트 환경에서도 얼마든지 런타임에서 발생할 수 있습니다.

const obj = null; obj.property; // TypeError: Cannot read property 'property' of null const notFunction = 'string'; notFunction(); // TypeError: notFunction is not a function

1-3) ReferenceError (참조 에러)

현재 스코프에서 존재하지 않거나 아직 초기화되지 않은 변수를 참조했을 때 발생하는 에러입니다.

console.log(undefinedVariable); // ReferenceError: undefinedVariable is not defined

1-4) SyntaxError

문법적으로 유효하지 않은 코드를 실행하려고 할 때 발생하는 오류입니다.

JSON.parse('invalid json'); // SyntaxError: Unexpected token i in JSON

1-5) Others

그 외에도 다음과 같은 다양한 하위 에러 클래스들이 존재합니다.

  • RangeError: 숫자 변수나 매개변수가 유효한 범위를 벗어났을 때 발생
  • URIError: encodeURI()decodeURI() 함수에 부적절한 매개변수를 제공했을 때 발생
  • EvalError: 전역 함수 eval() 에서 오류가 발생했을 때 생성
  • InternalError: JavaScript 엔진 내부에서 오류가 보고될 때 발생

2) HTTP 요청 에러

2-1) Fetch API 에러

Fetch API는 HTTP 요청과 응답을 JavaScript에서 조작할 수 있는 인터페이스를 제공합니다. 전역 fetch() 메소드를 통해 네트워크의 리소스를 비동기적으로 가져올 수 있습니다.

const response = await fetch('/api/data');

fetch 메서드의 가장 큰 특징은 서버가 404나 500 같은 HTTP 에러 상태 코드로 응답하더라도, 네트워크 요청 자체는 성공한 것으로 간주하여 Promise를 reject하지 않는다는 점입니다. 대신 ok 상태가 falseResponse 객체를 반환합니다.

따라서 우리는 Response 객체의 상태를 직접 확인하고 에러 케이스를 처리해야합니다.

Response 객체의 주요 속성과 메서드는 다음과 같습니다.

주요 속성

interface Response { ok: boolean; // 상태코드가 200-299 범위인지 여부 status: number; // HTTP 상태 코드 (200, 404, 500 등) statusText: string; // 상태 메시지 ("OK", "Not Found" 등) headers: Headers; // 응답 헤더 url: string; // 요청 URL redirected: boolean; // 리다이렉트 여부 type: ResponseType; // 응답 타입 ("basic", "cors", "opaque" 등) }

주요 메서드

응답 본문을 다양한 형태로 파싱
response.json(): Promise<any> // JSON으로 파싱 response.text(): Promise<string> // 텍스트로 파싱 response.blob(): Promise<Blob> // Blob 객체로 파싱 response.arrayBuffer(): Promise<ArrayBuffer> // ArrayBuffer로 파싱 response.formData(): Promise<FormData> // FormData로 파싱 // 스트림 관련 response.body: ReadableStream | null // 응답 본문 스트림

이 중 눈여겨봐야 할 속성은 2xx 범위의 응답인지를 나타내는 ok입니다. ok 속성이 true인지를 확인하는 것이 fetch API 에러 처리의 핵심입니다.

const response = await fetch('/api/data'); // ok 속성으로 응답 성공 여부를 반드시 확인 if (!response.ok) { throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); } const data = await response.json();

만약 백엔드와 협의하여 에러 발생 시에도 특정 형식의 JSON 데이터를 보내주기로 했다면, response.json()을 호출하여 에러 내용을 파싱하고, 이를 바탕으로 세분화된 커스텀 에러 객체를 생성하여 처리하는 인터페이스를 직접 구현할 수 있습니다.

만약 직접 패칭 인터페이스를 구현하는 경우, 합의된 에러 상황(JSON 데이터를 받는 상황)과 예기치 못한 에러 상황을 구분하고, 어떠한 에러가 발생하더라도 일관된 형태의 에러 객체가 반환되도록 하는 것이 중요합니다.

하지만, "특별한 컨벤션은 없는데 에러 상황을 좀 더 명확히 하고 싶어!" 라고 하신다면, 그런 당신께 준비된 특별한 라이브러리가 있습니다. 바로 아주 유명하고 무거운 axios 입니다.

2-2) Axios 에러

HTTP 요청과 응답을 처리하는 작업을 간편하게 만들어주는 라이브러리입니다.

Axios의 장점은 많지만, 여기서는 간단히 에러 처리에 대해서만 말하고자 합니다.

Axios는 2xx 범위 외의 상태 코드를 받으면 자동으로 Promise를 reject하여 에러로 처리합니다. 즉, fetch처럼 response.ok 를 확인하는 작업을 생략할 수 있습니다.

이때 Axios는 내부적으로 발생한 모든 종류의 에러를 Error 를 상속한 AxiosError 객체로 래핑하여 일관된 에러 인터페이스를 제공합니다.

AxiosError의 핵심 구조
interface AxiosError<T = any> extends Error { config: AxiosRequestConfig; // 요청 설정 code?: string; // "ECONNABORTED" 등 에러 코드 request?: any; // 요청 객체 (브라우저: XMLHttpRequest) response?: AxiosResponse<T>; // 서버 응답 객체 isAxiosError: boolean; // Axios 에러 판별 플래그 } interface AxiosResponse<T = any> { data: T; // 서버가 응답한 데이터 status: number; // HTTP 상태 코드 statusText: string; // 상태 메시지 // ... }
  • 서버 응답 에러 (4xx, 5xx): AxiosError 객체 안에 response 속성이 존재합니다.
  • 네트워크 에러: response는 없고 request 속성만 존재합니다.
  • 요청 설정 에러: responserequest 가 모두 없을 수 있습니다.

이처럼 Axios를 사용하면 통합된 AxiosError 객체로 에러를 편리하게 처리할 수 있습니다.

서버의 응답이 있을 경우, AxiosResponse 타입의 response를 확인하여 앞서 fetch API의 Response 타입에 있던 status, statusText, headers와 동일한 데이터를 확인할 수 있고, 백엔드에서 실패 JSON 데이터를 제공한 경우에는 data 에서 찾아볼 수 있습니다.

하지만 catch 블록에서 error를 곧바로 AxiosError로 단정하면 TypeError가 발생할 수 있습니다.

JavaScript에서는 throw 문으로 Error 객체뿐만 아니라 문자열, 숫자 등 어떤 값이든 던질 수 있기 때문에, 타입스크립트가 catch 블록의 에러를 안전하게 unknown 타입으로 추론하기 때문입니다.

이에 맞춰 axios에서는 isAxiosError라는 타입 가드를 기본적으로 제공합니다. 이를 활용해 AxiosError를 type-safe하게 사용할 수 있습니다.

axios.get('/api/data') .catch((error: unknown) => { if (axios.isAxiosError(error)) { // error -> AxiosError if (error.response) { // 서버가 응답을 했을 경우 console.error(error.response.status, error.response.data); } else if (error.request) { // 서버가 응답하지 못했을 경우 (네트워크 문제 등) console.error('Network Error:', error.message); } else { // 요청 설정 중 발생한 에러 console.error('Error:', error.message); } } else if (error instanceof Error) { // Axios 외의 다른 에러 (e.g., 커스텀 로직) console.error('A general error occurred:', error.message); } else { // 예상치 못한 에러 console.error('An unexpected error occurred:', error); } });

3) React 생태계 에러

3-1) React Router 에러

React Router는 Error Boundary 패턴을 기반으로 한 선언적 에러 처리를 제공하여 라우팅 과정에서 발생하는 에러를 우아하게 처리할 수 있습니다.

라우팅 객체에 errorElement 속성을 정의하면, 해당 라우트의 loader, action, 렌더링 과정에서 발생하는 모든 에러를 감지하여 지정된 컴포넌트를 렌더링합니다. 에러는 자식 라우트에서 부모 라우트도 버블링되므로, 상위 라우트에서 공통 에러 페이지를 설정할 수 있습니다.

Routes.tsx
const router = createBrowserRouter([ { path: "/", element: <RootLayout />, errorElement: <RootErrorPage />, // 최상위 에러 처리 children: [ { path: "dashboard", element: <Dashboard />, errorElement: <DashboardErrorPage />, // 대시보드 전용 에러 처리 // ... }, ], }, ]);

errorElement로 지정된 컴포넌트에서는 useRouteError훅을 사용해 발생한 에러 정보를 가져올 수 있습니다. 이 에러 또한 unknown 타입이므로, React Router가 제공하는 isRouteErrorResponse라는 타입 가드를 사용하여 에러의 종류를 판별할 수 있습니다.

isRouteErrorResponseFetch API의 Response 객체를 통해 throw된 에러인지 확인하며, 좁혀지는 타입은 ErrorResponse 타입으로 다음과 같이 정의되어있습니다.

function isRouteErrorResponse(error: any): error is ErrorResponse { return ( error != null && typeof error.status === "number" && typeof error.statusText === "string" && typeof error.data !== "undefined" ); } export type ErrorResponse = { status: number; statusText: string; data: any; };

자 여기까지 눈에 힘 빡 주고 집중해서 따라오셨다면 뭔가 이상한 걸 느끼셨을 것입니다.

🤔 '엥? Fetch API의 Response 객체를 통해 throw된 에러? 아니 그러면 AxiosError 는? AxiosError response 속성 안에 status, statusText, data 가 있어서 저 타입 가드로 안좁혀지는데?'

✅ 정 답 !

React Router 내부에서 axios를 사용한 데이터 패칭 도중 발생한 error를 가공없이 throw할 경우 이 error(AxiosError)는 isRouteErrorResponse타입 가드를 통과하지 못합니다.

❗️ 따라서 axios를 사용한다면 에러 컴포넌트에서 isRouteErrorResponseisAxiosError 를 모두 처리하거나, 아래와 같이 AxiosErrorResponse 객체로 변환하여 다시 throw하는 전략이 필요합니다.

import axios from "axios"; export async function userLoader({ params }) { try { const { data } = await axios.get(`/api/users/${params.id}`); return data; } catch (error) { if (axios.isAxiosError(error) && error.response) { // AxiosError를 Response 객체로 변환하여 throw throw new Response(error.response.statusText, { status: error.response.status, data: error.response.data }); } throw error; } }

3-2) Tanstack Query (React Query) 에러

Tanstack Query는 서버 상태 관리에 특화된 라이브러리인 만큼, 비동기 데이터 통신에서 발생하는 에러를 다루는 강력하고 다양한 방법을 제공합니다.

1. useQuery의 기본 에러 상태: isError, error

Tanstack Query의 가장 기본적인 에러 처리 방식은 useQuery가 반환하는 isError 상태값과 error 객체를 활용하는 것입니다.

  • isError: queryFn에서 에러가 발생했을 때 true가 됩니다.
  • error: queryFn에서 throw된 에러 객체(AxiosError 등)가 담깁니다.
function UserProfile({ userId }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['user', userId], queryFn: () => axios.get(`/api/users/${userId}`).then(res => res.data), }); if (isLoading) return <span>Loading...</span>; if (isError) { if (axios.isAxiosError(error) && error.response?.status === 404) { return <span>존재하지 않는 사용자입니다.</span>; } return <span>An error has occurred: {error.message}</span>; } return <div>{data.name}</div>; }

이처럼 컴포넌트 레벨에서 isErrorerror 객체를 통해 UI를 분기 처리하는 것이 가장 일반적인 패턴입니다.

2. 선언적 에러 처리: throwOnErrorError Boundary

throwOnError 옵션을 true로 설정하면 Tanstack Query는 에러를 내부 상태에 저장하는 대신 렌더링 과정에서 에러를 다시 throw 합니다. 이 에러는 가장 가까운 React Error Boundary에 의해 잡히므로, React Router의 errorElement와 자연스럽게 연동하여 에러 UI를 선언적으로 관리할 수 있습니다.

UserProfile 컴포넌트 내
useQuery({ queryKey: ['user', userId], queryFn: fetchUser, throwOnError: true, // 에러를 Error Boundary로 전파 }); const router = createBrowserRouter([ { path: "/users/:userId", element: <UserProfile />, errorElement: <UserErrorPage />, // 여기서 useQuery의 에러를 처리 }, ]);

throwOnError 옵션은 불리언 값 뿐만 아니라, 에러 객체를 인자로 받아 특정 조건의 에러만 throw하는 함수를 전달할 수도 있습니다. 이를 통해 4xx 에러는 컴포넌트 내에서 처리하고, 5xx 서버 에러는 ErrorPage로 보내는 등 세분화된 에러 처리가 가능합니다.

useQuery({ queryKey: ['todos'], queryFn: fetchTodos, throwOnError: (error) => { if (axios.isAxiosError(error)) { return error.response?.status >= 500 } return false }, })

3. 전역 에러 처리: QueryClient

만약 애플리케이션 전반에 걸쳐 일관된 에러 처리를 하고 싶다면 QueryClient를 활용할 수 있습니다.

QueryClient를 설정할 때 QueryCache 설정 객체 내에 onError 콜백을 등록하면 애플리케이션의 각 useQuery 호출마다 onError 콜백을 넣을 필요 없이 모든 쿼리에서 발생하는 에러를 한 곳에서 일관되게 관리할 수 있습니다. 특히 에러 로깅, 공통 토스트 메시지 표시 등에 매우 유용합니다.

const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { // Sentry, Datadog 등 에러 로깅 서비스에 리포트 logErrorToService(error, query.queryKey); // 공통 에러 토스트 표시 toast.error('데이터 처리 중 문제가 발생했습니다.'); }, }), });

4. 실패 쿼리 자동 재시도: retry

Tanstack Query는 일시적인 네트워크 불안정에 대응하여 쿼리 실패 시 기본적으로 3번의 재시도를 수행합니다.

useQueryretry 옵션을 통해 쿼리 실패 시 재시도 횟수나 쿼리의 상태에 따라 선택적으로 재시도할 수 있도록 커스터마이징이 가능합니다.

useQuery({ queryKey: ['user', userId], queryFn: fetchUser, retry: 1, // 재시도 횟수를 1번으로 설정 retry: false, // 재시도를 하지 않음 retry: (failureCount, error) => { // 특정 상태 코드에서는 재시도하지 않도록 함수를 제공할 수도 있습니다. if (axios.isAxiosError(error) && error.response?.status === 404) { return false; // 404 에러는 재시도 X } return failureCount < 3; // 그 외에는 3번까지 재시도 }, });

3-3) (+ 추가됨) Jotai 에러

Jotai는 아토믹 모델을 기반으로 하는 React 상태 관리 라이브러리입니다.

Jotai의 강력한 기능 중 하나는 바로 비동기 작업을 아톰 자체에 선언적으로 정의할 수 있다는 점입니다. 하지만 비동기 아톰을 직접 사용할 경우, 해당 아톰의 상태에 따라 로딩 중일 경우에는 Suspense를, 에러가 발생하면 ErrorBoundary를 필요로 합니다.

때로는 비동기 아톰의 에러를 전역 Error Boundary 대신 특정 컴포넌트 내에서 에러를 직접 처리하고 싶을 때가 있습니다. 이때 jotai/utils 에서 제공하는 loadable 유틸리티가 사용됩니다.

  1. Loadable 유틸리티

loadable은 비동기 아톰을 입력받아 suspend되거나 에러를 throw하지 않는 새로운 동기 아톰을 반환합니다. 이 아톰은 비동기 작업의 현재 상태와 데이터 혹은 에러를 가집니다.

Loadable 타입은 다음과 같습니다.

type Loadable<Value> = | { state: 'loading' } | { state: 'hasData'; data: Awaited<Value> } | { state: 'hasError'; error: unknown };
  • state: 'loading': 비동기 작업이 진행 중인 상태입니다.
  • state: 'hasData': 작업이 성공하여 data를 사용할 수 있습니다.
  • state: 'hasError': 작업이 실패하여 error를 확인할 수 있습니다.

이렇게 loadable 아톰을 사용하면 컴포넌트 내에서 if문이나 switch문을 통해 각 state에 따른 UI를 손쉽게 분기 처리할 수 있습니다.

import { atom } from 'jotai'; import { useAtom } from 'jotai/react'; import { loadable } from 'jotai/utils'; // 1. 비동기 작업을 수행하는 원본 아톰 const userAtom = atom(async () => { const response = await axios.get('/api/user/1'); return response.data; }); // 2. loadable로 감싸 상태를 추적하는 새로운 아톰 생성 const loadableUserAtom = loadable(userAtom); function UserInfo() { const [userState] = useAtom(loadableUserAtom); if (userState.state === 'loading') { return <div>로딩 중...</div>; } if (userState.state === 'hasError') { // userState.error는 unknown 타입이므로 타입 가드가 필요 const message = (userState.error as Error).message; return <div>오류가 발생했습니다: {message}</div>; } // userState.state === 'hasData' return <div>사용자 이름: {userState.data.name}</div>; }

loadable은 Tanstack Query의 useQuery와 유사하게 비동기 로직의 상태를 컴포넌트와 분리하지만, 서버 상태뿐만 아니라 모든 종류의 비동기 작업을 다루는 데 초점을 맞춘다는 점에서 차이가 있습니다.

4) 마무리하며

지금까지 JavaScript의 기본 에러부터 시작하여 fetch, axios와 같은 데이터 패칭 라이브러리, 그리고 React 생태계의 React Router, Tanstack Query, Jotai에 이르기까지 프론트엔드 생태계의 다양한 에러 처리 방식을 살펴보았습니다.

이 과정에서 우리는 에러를 다루는 여러 가지 접근법을 확인할 수 있었습니다.

  • 컴포넌트 레벨의 try-catchonError에서 if (isError)처럼 명령적으로 분기하는 방식
  • errorElementthrowOnError처럼 ErrorBoundary를 활용해 선언적으로 위임하는 방식
  • QueryCacheonError를 설정해 애플리케이션 전역에서 대응하는 방식

여기서 중요한 것은 에러 처리에 하나의 완벽한 정답은 없다는 것입니다. 어떤 에러는 사용자에게 즉각적인 피드백을 주기 위해 로컬에서 처리해야 하고, 어떤 에러는 조용히 로깅만 해야 하며, 또 어떤 에러는 앱의 일부를 대체하는 별도의 UI를 보여주어야 합니다.

결국 우리가 해야 할 일은, 프로젝트 환경과 사용하는 라이브러리, 설계한 아키텍처의 특성을 이해하고, “이 에러는 어디서 발생시켜, 어디에서 어떻게 처리할 것인가?”에 대한 팀만의 일관된 약속과 흐름, 즉 에러 처리 전략을 수립하는 것입니다

잘 설계된 에러 핸들링 시스템은 단순히 앱의 안정성을 높이는 것을 너머, 예기치 못한 상황에서도 사용자에게 신뢰를 주는 훌륭한 사용자 경험의 기반이 됩니다.

이 글이 여러분의 프로젝트에 견고한 방화벽을 구축하는 데 작은 도움이 되었기를 바랍니다!