Logo

Prolip's Blog

효율적인 에러 처리를 위한 몸부림
  • #Etc
  • #Project
  • #ModuReview

효율적인 에러 처리를 위한 몸부림

Prolip

2025-04-16

프론트엔드에서 효율적으로 에러 처리하는 방법 고민한 흔적 남기기.. API 요청 모듈, 리액트 쿼리, zustand를 조합해 에러 중앙화하기.

시작하며..

이번에 진행한 모두의 후기라는 프로젝트에서 적용한 에러 핸들링 전략에 대해 기록해보려고 합니다.

프로젝트의 코드는 이 곳에서 확인하실 수 있습니다. 혹시 코드를 보고 이해가 가지 않는 부분이 있으시다면 꼭! 문의해주세요. 혹은 개선할 사항이 있다면 그것도 꼭!! 문의해주세요.

먼저 이 에러 핸들링 전략을 적용하기에 앞서 어떤 생각으로부터 시작됐는지 배경을 설명하고자 합니다.

여러분은 에러 핸들링을 어떻게 하고 계신가요?

전 부끄럽지만 지금까지 에러 핸들링을 제대로 하지 않았습니다.

// 1. alert async function fetchSomething() { try { const res = await fetch(); if(!res.ok) { throw new Error(); } return await res.json(); } catch (error) { alert("에러가 발생했습니다: ", error.message); } } // 2. 콘솔 출력 async function fetchSomething2() { try { const res = await fetch(); if(!res.ok) { throw new Error(); } return await res.json(); } catch (error) { console.error(error); } } // 3. 지역적으로 상태를 사용한 에러 처리 function Component() { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { const fetchSomething3 = async () => { try { const res = await fetch(); if(!res.ok) { throw new Error(); } const data = await res.json(); setData(data); } catch (error) { setError(error); } } fetchSomething3(); }, []) if(error) { // 에러 처리 } return ( // ) }

이렇게 어떤 곳은 alert, 어떤 곳은 콘솔 출력, 일관된 예외처리를 수행하지도 않았으며, 단순히 상태만을 사용한 미흡한 에러 핸들링을 해왔습니다.

실제로 이전에 제가 아르바이트하던 매장에 만들어줬던 웹 사이트에서 이용 중 에러가 발생하면 사이트가 그대로 다운되어 새로고침을 하기 일쑤였습니다.

이미 많은 기능이 구현된 프로젝트에 뒤늦게 에러 처리를 위한 코드를 도입하려니 정말 많은 부분을 손봐야했는데요. 모든 API 호출 함수마다 try-catch 문을 활용해 예외 처리를 하고 있었기 때문에 각 호출 함수마다 에러를 핸들링 하기 위한 코드를 추가해줘야만 했고, 이 과정에서 중복된 코드가 쌓여갔습니다.

이후에 에러 응답 구조가 바뀌면서 유지보수에 큰 어려움을 겪는 최후를 맞이하게 됩니다..

이처럼 try-catch를 활용한 예외 처리는 중복된 코드가 점점 많아지고 그로 인해 유지보수가 어려워지는 등 몇 가지 문제가 발생하는데요. 제가 골라본 문제점 몇 가지를 정리해보자면,

  • API 요청마다 try-catch 문을 작성하므로 중복 코드가 많아진다.
  • 에러 상태를 각 컴포넌트에서 개별적으로 관리하기 때문에 유지보수가 어려워진다.
  • 동일한 에러 메세지를 여러 곳에서 정의하기 때문에 일관성이 부족하다.

기존 try-catch 방식의 문제점

async function RenderUser() { try { const res = await fetch("endpoint1"); if (!res.ok) { throw new Error("유저 정보를 받아오다 에러가 발생했어요. 하지만 무슨 에러일까요?") } const data = await res.json(); setUser(data); } catch(error) { console.error(error); setErrorState(error); } } async function RenderProduct() { try { const res = await fetch("endpoint2"); if (!res.ok) { throw new Error("상품을 받아오다 에러가 발생했어요. 하지만 무슨 에러일까요?") } const data = await res.json(); setProduct(data); } catch(error) { console.error(error); setErrorState(error); } } async function RenderTodo() { try { const res = await fetch("endpoint3"); if (!res.ok) { throw new Error("투두를 받아오다 에러가 발생했어요. 하지만 무슨 에러일까요?") } const data = await res.json(); setTodo(data); } catch(error) { console.error(error); setErrorState(error); } }

이렇게 각 API 요청마다 try-catch 문을 사용해 에러를 핸들링한다면, 서버의 응답이 바뀌거나, 혹은 코드의 수정이 필요한 경우 모든 API 요청 함수를 돌아다니며 수정을 반복해야만 합니다.

어떻게 해결했을까요?

먼저 해결하고자 하는 문제들을 하나씩 나열해보자는 방식으로 접근했는데요.

  • ‘반복되는 try-catch문들을 어떻게 모을 것인가?'
  • ‘에러는 어디서 던질 것인가?’
  • ‘던진 에러는 어디에 저장할 것인가?’
  • ‘저장한 에러는 어떻게 UI까지 연결할 것인가?’
  • ‘모든 에러를 토스트로 보여주는게 과연 사용자에게 적절한가?’
  • ‘예측 가능한 에러와 예측 불가능한 에러는 어떻게 구분할 것인가?’

이렇게 문제를 정의하고 나니 각 문제마다 고민할 포인트가 분명해졌습니다.

1. 먼저 에러를 중앙화시키려면 API 요청을 중앙화 시켜야겠다. - 요청마다 사용되던 try-catch를 하나의 요청 함수 내에서 처리하도록 바꿔야겠다. 2. API 요청 과정에서 발생한 에러를 어떻게 효율적으로 관리할까? - 서버로부터 받아온 에러 코드, 메세지, 어떤 메서드를 사용했는지 파악할 수 있게 커스텀 에러 객체로 관리해야겠다. 3. 에러 상태를 어디에 저장할까? - API 요청 중 발생한 에러는 API를 호출한 지점에서 사라진다. - 발생한 에러를 전역 상태에 저장해야겠다. - 유저 상태 정보에 zustand 스토어를 사용하고 있으니 마찬가지로 zustand 스토어를 사용해야겠다. 4. 에러 상태를 어떻게 저장할까? - 중앙화된 요청 함수는 함수 레벨에서 동작하기 때문에 기본적으로 상태를 업데이트할 수 없다.. - 훅의 규칙을 위반하기 때문에.. - 발생한 에러를 판단하고 조기에 종료하는 등의 동작을 기대해야 한다. - 리액트 쿼리에 이미 위에서 서술한 기능을 지원해준다. - 바퀴를 재발명할 필요는 없으니 리액트 쿼리와 요청을 모두 통합해야겠다.. 5. 에러를 전역 상태에 저장했는데 어떻게 처리할까? - 에러가 변경되는 것을 감지할 수 있어야한다. - useEffect은 의존성 배열에 등록된 값이 변경될 때 실행할 함수를 등록할 수 있다. - 그럼 감지용 컴포넌트를 구현해야겠다. 6. 에러를 어떻게 처리할까? - 사용자에게 의미 있는 에러가 무엇일까? - 단순히 토스트로 제공하는 에러는 GET 요청 과정에서 발생한 에러에 친화적이지 못할텐데.. - GET 요청으로 발생한 에러는 에러 바운더리를 사용해 대체 UI를 표시할까..? - 그럼 에러 핸들링 방법을 나눠야겠다.. - API를 요청할 때 에러 핸들링 방법을 인자로 받아야겠다. - 에러 핸들링 방법이 에러 객체에 포함되어 있어야 분기처리가 가능하겠다. 7. 에러 구독자는 에러를 어떻게 판별할까? - 구독자는 업데이트된 에러를 어떻게 판단할까? - 미리 서버와 합의한 에러, 혹은 클라이언트 측에서 발생할 수 있는 에러는 예측이 가능하다.. - 500 등 서버의 내부적인 문제로 인한 에러는 예측할 수 없다.. - 그럼 미리 에러 코드와 코드에 상응하는 메세지를 정의해놔야겠다.. - 미리 합의한 에러에 대해 판별 가능한 유틸리티 함수들을 만들어야겠다..

이렇게 문제를 나열해놓고 그 문제를 해결하기 위한 방법들을 생각해보니 어느정도 틀이 잡혔는데요. 각각의 문제들을 어떻게 해결했는지 순서대로 정리해보겠습니다.

1. API 요청 모듈 만들기

먼저 모든 API 요청을 하나의 함수로 처리하기 위해 모듈화합니다.

// 문서에선 코드를 간단하게 표현할게요. async function request() { try { const response = await fetch(url); if (!response.ok) { const {title, detail, status} = await response.json(); throw new 커스텀에러객체({ ...필요한 인자들 }); } return await response.json(); } catch (error) { // 에러 처리.. } } export async function requestProducts() { return await request('상품 조회 엔드포인트'); } export async function requestPosts() { return await request('게시글 정보 조회 엔드포인트') }
  1. request 함수에서 try 블록을 통해 서버에 데이터를 요청하게 됩니다.
  2. 응답이 실패한 경우(response 객체의 ok 값이 false인 경우) 커스텀 에러 객체를 생성해 예외를 발생 시킵니다.
    • 우리 프로젝트는 에러를 보다 세밀하게 표시하기 위해 title, detail, status 필드를 사용하게 되었는데요.
    • title은 서버와 클라이언트 간에 발생 가능한 에러의 코드. (예: TOKEN_EXPIRED, USER_NOT_FOUND 등)
    • detail은 각 에러의 상세 내용. (예: 토큰이 만료되었습니다. 다시 로그인해주세요.)
    • status는 응답 상태 코드를 의미합니다. (예: 401, 404, 500)
    • 커스텀 에러 객체는 내부적으로 서버에서 전달 받은 각 필드와 HTTP 메서드, endpoint 등 에러 객체를 확장해 생성한 클래스로 우리가 API 요청 중 발생한 에러에 대한 정보를 담고 있는 에러 클래스입니다.
  3. 요청이 성공한 경우엔 데이터를 반환하고, 요청이 실패해 위의 try 문에서 발생한 예외를 catch 블록이 잡은 경우 에러를 처리하게 됩니다.

이렇게 중앙화된 request 함수를 사용하면 단 한 번의 try-catch문을 사용해 에러를 처리할 수 있게 됩니다.

2. 에러 상태를 커스텀 에러 객체로 관리하기

const err = new Error("에러 만들기"); err.name // 'Error' err.message // "에러 만들기"

단순히 에러 객체를 생성한다면 name과 message 필드만으로 판단해야 되기 때문에 에러를 판단하기 쉽지 않습니다.

class RequestError extends Error { constructor({title, detail, status, endpoint, method, requestBody}) { super(detail); this.name = title; this.status = status; this.endpoint = endpoint; this.method = method; this.requestBody = requestBody; } }

서버로부터 받은 에러 응답 본문과 더불어 어떤 메서드로부터 발생한 에러인지, 어떤 엔드포인트로부터 발생한 에러인지 등을 관리하기 위해 에러 객체를 상속해 인자로 전달 받은 값들을 멤버 변수로 등록해줍니다.

const requestError = new RequestError({ title: "Unauthorized", detail: "토큰이 만료되었습니다. 다시 로그인해주세요.", endpoint: "/products", method: "POST", requestBody: null }) requestError.name // "Unauthorized" requestError.message // "토큰이 만료되었습니다. 다시 로그인해주세요." requestError.endpoint // "/products" requestError.method // "GET"

위에서 정의한 RequestError 클래스를 사용해 커스텀 에러 객체를 생성하면 발생한 에러의 정보를 보다 정확하게 판단할 수 있습니다.

3. 에러 상태를 저장하기

반복되는 try-catch를 하나로 모듈화했다고 에러의 처리까지 중앙화 시켰다고 볼 수 없습니다. 단순히 try-catch만을 하나로 모은 것 뿐이니까요.

export async function requestProducts() { return await request(...options); } function ComponentA() { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { requestProducts() .then(setData) .catch(setError); }) if (error) { // 에러 처리.. } } function ComponentB() { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { requestProducts() .then(setData) .catch(setError); }) if (error) { // 에러 처리.. } }

여전히 위와 같이 에러 처리를 위한 코드는 호출하는 부분에서 작성해줘야만 하는데요. 그렇다면 어떻게 에러의 처리까지 중앙화시킬 수 있을까요?

우선 에러의 처리를 중앙화하기 위해선 에러의 발생지와 상관없이 모든 에러를 조회할 수 있어야만 합니다. 이 말은 에러를 전역적으로 관리해야 한다는 의미인데요.

아래와 같이 만들어진 스토어를 사용하면 어떤 컴포넌트에서든 전역으로 관리되는 에러 객체에 접근할 수 있게 됩니다. 이 글엔 zustand 사용법에 대한 내용은 작성하지 않겠습니다.

type State = { error: RequestError | ClientError | null; }; type Action = { updateError: (error: State['error']) => void; }; const globalErrorStore = create<State & Action>(set => ({ error: null, updateError: error => set({error}), })); export const useGlobalError = () => globalErrorStore(state => state.error); export const useUpdateGlobalError = () => globalErrorStore(state => state.updateError);

스토어가 생성되었으나, 해당 스토어로 에러 객체를 전달할 수 있어야 하는데요. 가장 간단한 방법은 아래와 같이 순수 함수 기반의 상태 변경 메서드를 사용해 전역 스토어에 에러가 흘러들어가도록 구성하는 방법입니다.

export const reportGlobalError = (error: State['error']) => globalErrorStore.setState({error}) async function request<T>(): Promise<T | undefined> { try { const response = await fetch(url); if (!response.ok) { const {title, detail, status} = await response.json(); throw new 커스텀에러객체({ ...필요한 인자들 }); } return await response.json(); } catch (error) { if (error instanceof RequestError) { reportGlobalError(error); return; } throw error; } }

하지만 해당 방식의 문제점이 하나 존재하는데요. catch 구문에서 RequestError가 발생했을 때, 자체적으로 에러를 핸들링하고자 상태를 업데이트하고 아래로 더 이상 코드가 실행되지 않도록 함수를 종료합니다.

값을 명시하지 않기 때문에 자바스크립트는 암묵적으로 undefined을 반환하게 되며 해당 request 함수가 반환하는 프로미스의 최종 상태는 Promise.reject(undefined)가 됩니다. 즉 request 함수의 반환 타입은 T | undefined이 됩니다.

호출 지점에선 항상 데이터가 존재하는지 판단해줘야 하는데요. 혹은 더 좋은 방법이 있을 수 있으나 이미 이런 동작을 추상화해 완벽하게 기능을 구현한 라이브러리가 존재했습니다.

리액트 쿼리

바로 리액트 쿼리였는데요. 이번 프로젝트에서 선언형 프로그래밍과 효율적인 클라이언트 상태 관리를 달성하기 위해 이미 리액트 쿼리를 도입한 상태였습니다.

사담으로 리액트 쿼리를 사용하며, 한 가지 생긴 의문이 있었는데요.

async const getSomething = () => { return await Promise.reject(); } const useGetSomething = () => { return useQuery({ queryFn: getSomething, queryKey: ["무언가.."] }) }

위의 코드에서 에러가 발생한다면, try-catch가 없으니 당연히 어플리케이션이 다운될 것이라 예상하지만 실제로 리액트 쿼리를 사용한 요청에선 어디선가 에러가 잡힙니다.

리액트 쿼리는 거부된 프로미스에 대한 자체적인 에러 처리 매커니즘이 존재하는데요. 사실 궁금해서 어떤 파일에서 도대체 이 동작이 일어날까 찾아봤습니다.

https://github.com/TanStack/query/blob/main/packages/query-core/src/queryObserver.ts#L226 이 queryObserver 파일이 그 역할을 수행하고 있다고 생각하는데요.

protected fetch( fetchOptions: ObserverFetchOptions, ): Promise<QueryObserverResult<TData, TError>> { return this.#executeFetch({ ...fetchOptions, cancelRefetch: fetchOptions.cancelRefetch ?? true, }).then(() => { this.updateResult() return this.#currentResult }) } #executeFetch( fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>, ): Promise<TQueryData | undefined> { this.#updateQuery() let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch( this.options as QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey>, fetchOptions, ) // fetchOptions 객체의 throwOnError가 명시적으로 false일 경우 if (!fetchOptions?.throwOnError) { promise = promise.catch(noop) } return promise }

fetch 멤버 함수는 내부적으로 executeFetch 함수를 호출하는데요. executeFetch 함수 내부의 this.#currentQuery.fetch()가 반환하는 프로미스는 queryFn이 실패할 경우 거부(rejected) 상태가 될 거예요.

일반적으로 거부된 프로미스에 대한 후속 조치가 없다면 Uncaught in Promise 에러를 발생시킵니다.

하지만 바로 아래에서 명시적으로 fetchOptions 객체의 throwOnError가 false일 경우(기본값은 false 입니다.) 반환된 프로미스에 .catch(noop)을 붙여버립니다.

noop(no-operation)은 아무것도 하지 않는 빈 함수인데요. 이 동작은 마치 “이거 실패해도 괜찮아! 던지지 말라고 했으니까 조용히 있을게!”라고 알려주는 것과 같습니다.

거부된 프로미스로 인해 발생한 에러는 이 .catch(noop)에 의해 그 자리에서 소멸해 더 이상 상위로 전파되지 않습니다.

이 동작으로 리액트 쿼리를 사용할 때 queryFn으로 전달하는 요청 함수에 별도의 에러 핸들링을 적용하지 않아도 어플리케이션이 죽지 않습니다.

onError 콜백

이제 리액트 쿼리의 자체적인 에러 처리 매커니즘에 대해 알아봤는데요. 리액트 쿼리는 요청 도중 에러가 발생하면 실행되는 콜백 함수가 존재합니다.

const ReactQueryProvider = ({children}: Props) => { const updateError = useUpdateGlobalError(); const queryClient = new QueryClient({ // 필요한 옵션들.. retry, refetchOnWindowFocus 등.. queryCache: new QueryCache({ onError(error) { if (error instanceof RequestError) { updateError(error); } }, }), mutationCache: new MutationCache({ onError(error) { if (error instanceof RequestError) { updateError(error); } }, }), }); return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; }; function layout({children}) { return ( // 모든 요청을 리액트 쿼리와 통합하면 에러의 중앙화 가능 <ReactQueryProvider> {children} </ReactQueryProvider> ) }

이렇게 리액트 쿼리의 기본 옵션을 지정할 때, 발생한 에러를 핸들링할 수 있는 콜백 함수를 등록할 수 있습니다.

useQuery, useSuspenseQuery 등 클라이언트 상태 관리에 사용되는 API를 호출할 때, 모든 쿼리 정보는 queryCache로 모입니다. 이 객체에 onError 콜백을 설정해두면, 어떤 쿼리에서 에러가 발생하든 한 곳에서 관리가 가능해집니다.

마찬가지로 mutationCache에 콜백 함수를 등록하면, 서버 상태 변경에 사용되는 뮤테이션 요청 중 발생한 에러에 대한 중앙 처리가 가능해집니다.

이렇게 만들어진 전역 에러 스토어와 모든 서버 요청을 리액트 쿼리와 통합함으로써 에러의 전역 공유가 가능해집니다.

4. 에러 구독하고 처리하기

에러를 전역 상태로 저장했다면, 저장된 에러를 사용해 에러를 처리할 수 있어야 하는데요. 각 컴포넌트의 에러 처리 코드를 하나의 컴포넌트로 중앙화 시키기 위해 에러 구독자가 필요해집니다.

function ErrorSubscriber() { const globalError = useGlobalError(); useEffect(() => { // 에러 핸들링 }, [globalError]); }

리액트의 useEffect은 의존성 배열에 등록된 인자가 변경되면 실행될 함수를 등록해놓을 수 있는데요. 가장 간단한 방법은

function ErrorSubscriber() { const globalError = useGlobalError(); useEffect(() => { // 토스트로 표시하기. toast.error({ title: globalError.name, description: globalError.message }); }, [globalError]); } function layout({children}) { return ( <ErrorSubscriber> <ReactQueryProvider> <main>{children}<main> <Toaster /> </ReactQueryProvider> </ErrorSubscriber> ) }

이렇게 에러의 상태가 변경되어 useEffect이 실행될 때 토스트 메세지로 사용자에게 에러의 상세 정보를 표시해주는 방법입니다. 여기까지만 적용해서 토스트 메세지를 사용자에게 제공하는 것도 충분히 좋은 에러 처리 전략이라고 생각합니다.

하지만 제가 고민했던 부분은 만약 토스트 메세지가 사용자에게 의미가 없다면? 입니다. 서버의 상태를 변경하는 요청(POST, PUT 등)의 경우, 대부분 클라이언트 측에서 입력 혹은 상호작용을 통해 요청을 보내게 됩니다. (예: 게시글에 댓글을 작성하는 경우, SNS에서 다른 사용자를 팔로우하는 경우)

이 때 요청이 실패해 “요청이 실패했습니다. 다시 시도해주세요.”와 같은 토스트 메세지를 표시한다면, 사용자는 자연스럽게 다시 제출 버튼을 클릭해 요청을 보내게 됩니다.

반대로 GET 요청과 같이 사용자가 어떤 데이터를 받길 기대하는 동작에서 단순히 토스트 메세지만을 표시한다면, 사용자는 요청이 실패했을 때 다음 행동을 예측하기 어렵거나, 어떤 조치를 취해야 할지 알 수 없게 됩니다.

반면, 데이터가 표시될 영역을 대체 UI로 표시해 발생한 에러를 표시하고 재시도 유도 버튼을 제공한다면, 사용자는 다음 행동을 예측할 수 있게 됩니다.

그렇다면 일반적으로 GET 요청에 대한 에러 처리는 전역 스토어에 업데이트해 토스트 메세지를 보여주기 보단, 대체 UI 표시를 위해 지역 에러 바운더리를 이용해야 합니다.

하지만 로그아웃 등 데이터를 화면에 표시하지 않는 간단한 GET 요청의 경우 토스트 메세지를 이용할 수 있으니 핸들링 방법을 선택할 수 있어야 하는데요.

그렇다면 요구사항은 다음과 같습니다.

  1. GET 요청은 에러 핸들링 방법에 따라 에러를 상위로 전파하거나 전역 스토어에 저장한다.
  2. 그 외 요청은 항상 전역 스토어에 저장한다.
throwOnError: (error: RequestError) => { if (error instanceof RequestError) { if (error.method === "GET" && error.errorHandlingType === "errorBoundary") { return true; } else { return false; } } } queryCache: new QueryCache({} onError: (error: RequestError) => { if (error instanceof RequestError) { if (error.method === "GET") { return; } else { updateGlobalError(error); } } } ), mutationCache: new MutationCache({} onError: (error: RequestError) => { if (error instanceof RequestError) { updateGlobalError(error); } } )

아까 위에서 자체적인 리액트 쿼리의 에러 처리 매커니즘에 대해 알아봤는데요. 명시적으로 options 객체의 throwOnError 값이 true라면 에러를 소멸시키지 않기 때문에 에러가 상위로 전파됩니다.

바로 그 throwOnError를 설정해주는 건데요. 직접 boolean 값을 사용할 수도 있고, 에러 발생 시 인자로 발생한 에러가 들어오기 때문에 에러에 따라 동적으로 지정해줄 수도 있습니다.

위에서 선언한 RequestError 클래스에 method 속성이 있으니 판단이 가능하지 않을까요? 별도로 errorHandlingType 속성을 추가해 ‘toast’, ‘errorBoundary’ 값을 전달하도록 설정해준다면 위의 요구사항을 만족할 수 있는데요.

하지만 단순히 RequestError 클래스에 errorHandlingType 속성을 추가해 if문으로 분기 처리하는 코드는 복잡하고, 가독성이 떨어질 수 있습니다. 또, 모든 RequestError 클래스가 errorHandlingType 속성을 가질 필요도 없습니다.

요구사항 2번에 따라 GET 요청 외의 요청은 모두 전역 스토어에 에러를 저장하는데요. 그 외 요청은 사실 errorHandlingType 속성이 없어도 전역 스토어에 저장된다는 사실을 이미 알고 있습니다.

위와 같이 HTTP 메서드와 에러 처리 방식을 동시에 고려하는 로직은 시간이 지날수록 유지보수가 어려워질 수 있습니다. 예를 들어 toast, errorBoundary 외 다른 에러 처리 방식이 추가된다면, 또 다른 분기 처리 로직이 추가될 수 있기 때문입니다.

따라서 이 문제를 클래스의 상속을 통해 해결하기로 했는데요. 에러의 종류에 따라 클래스를 분리하면 더 직관적으로 에러 타입을 판별하고 처리할 수 있습니다.

인스턴스화된 객체 타입을 판별할 때 instance of 키워드를 사용할 수 있는데요.

if (error instanceof 그냥에러) if (error instanceof GET에러)

위와 같이 에러가 어떤 생성자 함수로 생성되었는지를 판단할 수 있습니다. 그렇다면 기존 RequestError 클래스를 상속 받는 RequestGetError 클래스를 만들고, RequestGetError 클래스만 errorHandlingType 속성을 추가하면 됩니다.

class RequestError extends Error { constructor({title, detail, status, endpoint, method, requestBody}) { super(detail); this.name = title; this.status = status; this.endpoint = endpoint; this.method = method; this.requestBody = requestBody; } } class RequestGetError extends RequestError { constructor({errorHandlingType, ...rest}) { super(rest); this.errorHandlingType = errorHandlingType; } } function createError(method, ...필요한 인자들) { if (method === "GET") { return new RequestGetError({ title, detail, status, endpoint, method, requestBody, errorHandlingType }) } else { return new RequestError({ title, detail, status, endpoint, method, requestBody, }) } } async function request() { const response = await fetch(...필요한 인자들); if (!response.ok) { throw createError(...필요한 인자들) } const data = await response.json(); return data; }

위와 같이 GET 요청 중 발생한 에러라는 명확한 의미를 담은 클래스를 선언하고 에러 생성 책임만을 가진 createError 함수를 선언합니다.

에러가 발생하면 전달된 method 속성으로 RequestGetError 혹은 RequestError 인스턴스를 생성해 반환하며, 최종적으로 해당 에러 객체를 throw하게 됩니다.

// GET 요청 중 발생한 에러이며, 핸들링 타입이 명시적으로 에러 바운더리일 경우 상위로 전파 throwOnError: (error: RequestError) => error instanceof RequestGetError && error.errorHandlingType === "errorBoundary" queryCache: new QueryCache({} onError: (error: RequestError) => { // GET 에러이면서 핸들링 타입이 에러 바운더리일 경우 이미 에러를 전파하기 때문에 전역 스토어에 업데이트할 필요가 없음. if (error instanceof RequestGetError && error.errorHandlingType === "errorBoundary") return // 그 외 요청 중 발생한 에러는 전역 스토어로 업데이트 if (error instanceof RequestError) updateError(error); } ), mutationCache: new MutationCache({} onError: (error: RequestError) => { // mutation 요청 중 로그아웃 등의 간단한 요청도 존재하기 때문 if (error instanceof RequestGetError && error.errorHandlingType === "errorBoundary") return // 그 외 요청 중 발생한 에러는 전역 스토어로 업데이트 if (error instanceof RequestError) updateError(error); } )

위와 같이 클래스를 분리해 에러 타입을 명확하게 구분하면, instanceof 키워드를 통해 코드를 훨씬 간결하고 가독성 높게 구현할 수 있습니다.

GET 요청에 대한 에러는 RequestGetError로, 그 외 요청에 대한 에러는 RequestError로 분리함으로써, 각각의 에러가 어떤 방식으로 처리되어야 하는지 명확하게 정의할 수 있습니다.

function Component() { const {data} = useQuery({ queryFn: () => request({ errorHandlingType: 'errorBoundary' method: 'GET' }) }) return // } function Component2() { return ( <ErrorBoundary fallback={<div>에러가 발생하면 이 UI가 보여요.</div>}> <Component /> </ErrorBoundary> ) } function Component3() { const {mutate} = useMutation({ mutationFn: () => request({ errorHandlingType: 'toast' method: 'POST' })) }) } function Component4() { return ( // 에러 발생 시 별도의 핸들링 없이 토스트 메세지 출력 <Component3 /> ) }

이렇게 각각의 호출 메서드와 에러 핸들링 타입에 따라 중앙화된 에러 처리 방식을 달성할 수 있게 됩니다.

5. 에러 판단하기

4번까지의 구현 상태로는 강제되어야 하는 요소가 하나 존재하는데요. 바로 서버와 클라이언트 간 에러가 합의되어있어야 한다는 점입니다.

function ErrorSubscriber() { const globalError = useGlobalError(); useEffect(() => { // 토스트로 표시하기. toast.error({ title: globalError.name, description: globalError.message }); }, [globalError]); }

에러 구독자는 항상 생성된 에러 객체의 name, message 속성을 사용해 토스트 메세지를 표시하는데요.

만약 서버에서 알 수 없는 에러가 발생한 경우, 사용자에게 의미 없는 메세지를 표시할 수도 있습니다.

만약 500 Internal Server Error와 같은 서버 오류, 혹은 클라이언트 측의 네트워크 지연 오류 등 서비스 규격 외의 에러가 발생한다면, 의미 있는 메세지를 표시할 수 없는 문제 외에 어플리케이션이 예상치 못하게 종료되는 문제가 발생할 수 있습니다.

// 1. 에러 상수 const SERVER_ERROR_MESSAGE = [ // 서버와 합의한 에러를 키(서버와 주고받을 코드)-값(클라이언트에게 표시할 키에 해당하는 메세지) 쌍으로 관리. "Unauthrozied": "로그인 후 이용 가능한 서비스입니다." "EMPTY_USER_EMAIL": "서비스 이용에 이메일이 필요해요." ] // 2. 요청 중 발생한 에러인지 function isRequestError(error: Error): error is RequestError { return error instanceof RequestError; } // 3. 예측 가능한 서버 에러인지 function isPredictableServerError(error: Error) { if (isRequestError(error) && error.name === 'INTERNAL_SERVER_ERROR') return false; return isRequestError(error) && SERVER_ERROR_MESSAGE[error.name] !== undefined; } function ErrorSubscriber() { const globalError = useGlobalError(); useEffect(() => { if (!globalError) return; // 4. 예측 가능한 서버 에러라면 토스트 메세지로 표시 if (isPredictableServerError(globalError)) { toast.error({ title: globalError.name, description: SERVER_ERROR_MESSAGE[globalError.name] }) }; // 위에서 예측 가능한 서버 에러를 판단했기 때문에 throw 되는 에러는 예측 불가능한 에러 throw globalError; }, [globalError]); }

제가 해결한 방법은 다음과 같은데요.

  1. 키/값 형태로 서버와 합의환 에러 코드, 에러 코드에 상응하는 메세지를 상수 배열로 관리합니다.

  2. 전달된 에러 객체가 API 요청 도중 발생한 에러인지 타입 가드 유틸리티 함수로 판단합니다.

  3. 함수 말 그대로 예측 가능한 서버 에러인지 판단합니다. 전달된 에러 객체가 isRequestError를 통과해야 하며, 서버와 미리 합의한 에러 코드가 아닐 경우 false를 반환합니다.

  4. ErrorSubscriber 컴포넌트에서 예측 가능한 서버 에러일 경우에만 토스트 메세지를 출력하며, 그렇지 않을 경우 예측 불가능한 에러이기 때문에 상위로 에러를 전파합니다.

ErrorSubscriber 컴포넌트에서 예측 불가능한 에러는 상위로 전파시키기 때문에 해당 에러에 대한 후속 조치가 필요해지는데요.

function ErrorFallback() { return ( <section> <h2>예측 불가능한 에러가 발생했어요.</h2> {/* 재시도 및 후속 조치 */} </section> ) } function UnPredictableErrorBoundary({children}) { return ( <ErrorBoundary fallback={<ErrorFallback />}> {children} </ErrorBoundary> ) } function layout({children}) { return ( <UnPredictableErrorBoundary> <ErrorSubscriber> <ReactQueryProvider> <main>{children}<main> <Toaster /> </ReactQueryProvider> </ErrorSubscriber> </UnPredictableErrorBoundary> ) }

이렇게 레이아웃 최상위에 글로벌 에러 바운더리를 위치시켜 예측 불가능한 에러가 발생해 최종적으로 에러가 다다를 때, 사용자에게 재시도 혹은 새로고침 등 후속 조치를 적절히 제공해 어플리케이션이 종료되는 상황을 방지할 수 있게 됩니다.

마치며

생각보다 글이 정말 길어졌는데요. 프론트엔드에서 에러 처리란 정말 중요한 과정이며, 사용자 경험에 가장 큰 영향을 주는 요소라고 생각했기 때문에 최대한 많은 내용을 기록하려 했고, 제가 고민했던 내용들을 최대한 많이 공유하고 싶었습니다.

사실 에러를 전역 상태로 저장 및 공유하는 과정에서 모든 요청이 리액트 쿼리로 통합되어야만 했던 점이 제 기준에서 아쉬운 점으로 남아있습니다.

기존에 리액트 쿼리와의 통합을 포기하고 직접 함수 레벨에서 에러의 전파, 전역 저장을 가능하게 해보려고 노력했으나 꼼수가 필요하거나, 혹은 보일러 플레이트가 증가하는 문제로 결국 잘 만들어진 바퀴인 리액트 쿼리를 사용하게 되었는데요.

이 부분에서 보다 정밀하게 에러를 제어하고 싶었기 때문에 추후 더 좋은 방법이 있는지 찾아볼 생각입니다. 혹시 이 내용에 대해 좋은 의견을 남겨주실 분들은 부디 남겨주시면… 너무 너무 감사하겠습니다…

프리티어 환경에서 무중단 배포 달성하기

프리티어 환경에서 무중단 배포 달성하기

zustand 코드 까보기

zustand 코드 까보기