Profile Image
블로그를 옮겼어요.
프론트 에러 핸들링하기
개발
2023.09.07.

점차 한 프로덕트를 만들어갈 수록 중요하다고 생각되는 점이 크게 두 가지 있다.

  1. 첫 번째는 테스트코드이다.

    TDD를 하고 있진 않는데, 이런 상황이 생기면 꼭 테스트코드를 작성하려고 한다.
    한 에러가 발생했는데, 이 에러가 어디서 발생했는지, 왜 발생했는지, 그리고 여러번 이 에러가 발생해서 수정해줬어야했을 때, 이럴 땐 꼭 테스트코드를 작성하려고 한다.


  1. 두 번째는, 에러 분기처리다.

    기존에는 console.error로 로깅만 했었는데, 문구도 network is not working 정도로만 작성했다. statusmessage를 따로 처리하지 않고 그저 string으로만 처리하는 상황이었다. 그 후, 같은 에러 문구가 여러 곳에서 뜨니 어디가 어딘지 알 수가 없었다. 그래서 에러를 한 번 정리해줄 필요성이 있었고, 이 글을 쓰게 됐다.


먼저 현재 사용하고 있는 기술스택은 다음과 같다

  • next.js, app dir
  • tanstack-query
  • Errorboundary

먼저 프론트 에러처리부터 작성해보려고 한다.

onSuccess와 onError

다음의 글을 참고했다.

Status Checks in React Query
React Query Error Handling
[번역] React Query API의 의도된 중단

순서대로 읽는 걸 권하고 싶다.
맨 마지막 글을 읽으며, tanstack-query v5에선 onError, onSuccess, onSettled가 없어졌다는 것을 알게 됐다.
사실 알게 된건 몇 달 전이었던 것 같은데, 문제에 직면하지 않아서 따로 찾아보지 않았다.
당시엔 server component에 대한 문제를 해결하기 바빴다.


그래서 먼저, useQuery 내에서 onSuccess와 onError를 제거해주었다.

// useRecordGetQuery.ts
const getMessage = async (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const encodedEmail = encodeURIComponent(userEmail || '');
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_SERVER_URL}/api/record/${encodedEmail}`,
    {
      method: 'GET',
      headers: {
        authorization: `Bearer ${token}`,
      },
    },
  );

  if (!res.ok) {
    throw new Error('메세지를 가져오는 getMessage에서 오류가 발생했어요.');
  }

  return res.json();
};

export const useRecordGetQuery = (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const { data: messages = [] } = useQuery(
    [...recordManagerKeys.record, userEmail],
    () => getMessage(userEmail, token),
    {
      enabled: !!userEmail,
      select: (data: Message[]) => data.map(decodeMessages),
    },
  );

  return {
    messages,
  };
};
  • 사실 여기선 select를 사용하고 있는데, 이 또한 onSuccess와 유사하기 때문에 onSuccess라고 생각하고 작성한다.
  • 참고로 useQuery 내에 callback만 제거된다. useMutation에선 콜백에 여전히 존재한다.
  • useMutation에서 onSuccess는 invalidateQueries를 무효화할 때 필수적이기 때문에 제거하면, tanstack-query를 사용할 이유가 많이 사라질 것이라고 개인적으로 생각한다..!

먼저 useQuery 내에서 Error부터 처리해주려고한다.
fetch API를 사용하고 있어서, if(!res.ok)와 같이 따로 분기처리를 해주어야한다.

// useRecordGetQuery.ts

class FetchError extends Error {
  constructor(public message: string, public status: number) {
    super(message);
  }
}

const getMessage = async (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const encodedEmail = encodeURIComponent(userEmail || '');
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_SERVER_URL}/api/record/${encodedEmail}`,
    {
      method: 'GET',
      headers: {
        authorization: `Bearer ${token}`,
      },
    },
  );

  if (!res.ok) {
    // 에러 처리는 다음과 같이 수정했다. api 서버에서 에러를 message와 status를 보내주도록 수정했다.
    // 그리고 throw new FetchError로 에러를 던져주었다.
    const { message, status } = await res.json();
    throw new FetchError(message, status);
  }

  const data = await res.json()
  const decodedData = data.map(decodeMessages) // 여기서 select 부분을 제거할 수 있다.

  return decodedData;
};

export const useRecordGetQuery = (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const { data: messages = [] } = useQuery(
    [...recordManagerKeys.record, userEmail],
    () => getMessage(userEmail, token),
    {
      enabled: !!userEmail,
    },
  );

  return {
    messages,
  };
};
  • 위와 같이 수정해서 onSuccess와 onError를 제거해주었다.
  • 여기서 onError는 조금 더 알아보자.

tanstack-query global Error

  • 위 글에서 React-query는 크게 세 가지로 에러를 처리할 수 있다고 제시한다.

    1. useQuery 내에서 오류 반환
    2. onError callback (query 자체에서 또는, QueryCache, MutationCache)
    3. ErrorBoundary로 처리

일단 여기서 나는, 1번은 내키지 않았다. onError가 없어지는 시점에선 meta 필드를 이용해서 Error를 처리할 수 있다.
하지만, 별도 에러를 분기처리하는 경우는 크게 없었고, 서버에서 status와 message를 온전히 QueryCache와, MutationCache에서 처리해주길 바랐다.
그래서 다음과 같이 구성했다.

import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';

const queryErrorHandler = (error: unknown) => {
  if (error instanceof Error) {
    toast.error(error.message);
  }

  return;
};

export const createQueryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.meta?.errorMessage) {
        // 만약 meta field를 사용한다면 여기서 tracking 할 수 있다.
        queryErrorHandler(query.meta?.errorMessage);
      }

      queryErrorHandler(error);
    },
  }),

  mutationCache: new MutationCache({
    onError: (error, _variables, _context, mutation) => {
      if (mutation.options.onError) {
        queryErrorHandler(mutation.options.onError);
      }

      queryErrorHandler(error);
    },
  }),

  defaultOptions: {
    queries: {
      // ...
      suspense: true, // 참고로 suspense를 true로 설정하면 useErrorBoundary도 true가 된다.

    },
  },
});
  • 예전 유데미에서 제공하는 react-query 강의에선 defaultOptions에 onError를 설정해주었었다.
  • 하지만, tkdodo 형님의 블로그를 확인하면, QueryCache와 MutationCache에 onError를 설정해야한다고 제시한다.
// TkDodo 형님의 블로그 댓글 중
No, the default options you provide are merged with the default options,
so unless you set refetchOnWindowFocus to false, it will stay on.

Further, this is not the way to provide a global onError handler.
As the article tried to explain,
you have to set onError on the QueryCache or the MutationCache for a truly global handler.

ErrorBoundary 사용하기

우리는 try…catch를 왜 사용할까? 사실 이 전까지 나는 try…catch가 단지 에어를 catch해 주는 용도로’만’ 존재하는줄 알았다.
하지만 try…catch로 감싸줬을 때, 에러가 떠도, 에러문을 catch에서 잡아주고, 애플리케이션은 계속 유지되도록 해준다.
즉, 사용자 경험을 훨씬 더 좋게 만들어주기 때문에 try…catch를 쓰는 것이었다. 쓰지 않는다면, 유저는 갑자기 에러문도 보고, 애플리케이션도 다운되어버린다.


근데 여기서 한 발 더 나아가, try..catch를 선언적으로 사용할 수 있게 해주는 ErrorBoundary가 존재한다.
하지만, 이는 오직 Class로만 제공해주고 있는데, react-error-boundary라는 라이브러리는 훨씬 더 ErrorBoundary를 편하게 사용할 수 있게 해준다.
나의 경우 app 디렉토리를 사용하고 있어서 다음과 같은 보일러플레이트 작업을 해주었다.

// components/common/ErrorBoundaryContext.tsx
'use client';

interface ErrorBoundaryContextProps {
  children: React.ReactNode;
}
const logError = (error: Error, info: { componentStack: string }) => {
  console.error('에러 정보를 로깅해요!', error, info);
};

const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
  const { status, message } = error;

  const onClickHandler = () => {
    if (isNotAuthorized) {
      return redirect('/');
    }

    return resetErrorBoundary();
  };

  return (
    <div className="flex h-screen flex-col items-center justify-center bg-gray-100">
      <div className="mb-4 text-6xl font-bold text-gray-500">404</div>
      <button
        className="rounded bg-yellow-400 px-4 py-2 text-white hover:bg-yellow-300 focus:border-blue-700 focus:outline-none focus:ring focus:ring-blue-200"
        onClick={onClickHandler}
      >
        {buttonMessage}
      </button>
    </div>
  );
};

const ErrorBoundaryContext = ({ children }: ErrorBoundaryContextProps) => {
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary
      onReset={reset}
      onError={logError}
      FallbackComponent={ErrorFallback}
    >
      {children}
    </ErrorBoundary>
  );
};

export default ErrorBoundaryContext;
  • 이를 layout 가장 바깥쪽에서 래핑해주었다.
// app/layout.tsx
import ErrorBoundaryContext from 'components/client/common/ErrorBoundaryContext';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body suppressHydrationWarning className={`${notoSansKR.className}`}>
        <div id="modal" />
        <ErrorBoundaryContext> // 감싸주었다.
          <QueryContext>
            <AuthContext>
              <Toaster />
              <ChakraContext>
                <main>
                  {children}
                </main>
              </ChakraContext>
            </AuthContext>
          </QueryContext>
        </ErrorBoundaryContext>
      </body>
    </html>
  );
}

그리고 여기서 한 단계 더 나아가고 싶다.


에러 핸들링 분기처리하기

음… 400번대 에러는 보통 client error를 처리한다. 예전 인프런에서 모든 개발자를 위한 HTTP 웹 기본 지식을 수강할 때 간단히 정리해 놓은 것을 참고하면, 400번대는 클라이언트 에러, 500번대는 서버에러를 의미한다.

그래서 이를 조금 분기처리하고 싶었다.

  • 클라이언트에러? → 이건 사용자가 알아야할 에러겠지? 이메일을 잘못입력 했을 수도 있고, 비밀번호가 틀릴 수도 있으니 말이다.

    • 하지만, ErrorBoundary에서 처리하고 싶지 않다. ErrorBoundary에서 처리한다면 404 페이지 같은 FallbackComponent가 실행되는데, 이는 사용자에게 알려주기에는 너무 무겁다.
    • 그래서 이 에러는 toast로만 사용자에게 알려주고 싶었다. 즉, 기존 페이지는 유지되었으면 한다.
    • 예를 들어 record 페이지에서 메시지를 불러온 상태에서 userEmail이 없다고 가정해보면, userEmail 없다는 toast를 띄우고, record 페이지는 유지되길 바랐다.
    • 또 이렇게 한 이유가, tanstack-query는 refetch가 빈번히 발생한다. 즉, stale 된 데이터라도, 유지된 상태에서, 다음 refresh 한 데이터의 요청이 실패했을 때, 404페이지로 이동하는게 아니라, stale한 데이터라도 보여주고, toast로 사용자에게 에러가 발생했음을 알려주고 싶었다.

  • 그럼 500번대 에러는? → 이것 또한 사용자에게 알려주어야한다.

    • 하지만, 이는 서버쪽에서 발생했을 가능성이 높다. 예를 들어 서버가 다운되었을 경우가 있을 것 같다.
    • 그럼 이때 FallbackComponent가 실행되어 user에서 404페이지를 띄워주고 싶었고, 이를 위해 ErrorBoundary에서 처리하고 싶었다.
    • 그리고 404페이지에 마찬가지로 어떤 상태메시지 오류인지 알려주고 싶었다.

// queryClient
export const createQueryClient = new QueryClient({
  // ...

  defaultOptions: {
    queries: {
      suspense: true, // 여기
    },
  },
});
  • 이를 위해 위에서도 잠깐 언급했지만, useErrorBoundary true가 되어있거나, suspense가 true가 되어있어야한다.
  • 그리고 useQuery내에서 useErrorBoundary를 다음과 같이 처리해주었다.
// useRecordGetQuery.ts
export const useRecordGetQuery = (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const messages = useQuery({
    queryKey: [...recordManagerKeys.record, userEmail],
    queryFn: () => getMessage(userEmail, token),
    useErrorBoundary: (error: any) => {
      return error.status >= 500; // 500번대 이상 에러가 발생하면, ErrorBoundary에서 처리, 500 미만이라면, 위에서 보여준 toast로만 처리
    },
  });

  return { messages };
};
  • 참고로 ErrorBoundary에서는 비동기 함수 / 이벤트 리스너 / 서버사이드렌더링 / 에러바운더리 자체에서 발생하는 에러는 잡지 못한다.
  • 그럼에도 불구하고 비동기 함수, 즉 tanstack-query에서 발생하는 에러는 어떻게 처리할 수 있는걸까?
  • 이는 위에 코드에서 볼 수 있듯이, suspense를 true로 설정하거나, useErrorBoundary를 true로 설정했기 때문이다.
  • 그리고 useQuery 인자내에서 useErrorBoundary의 특정 조건을 다음과 같이 처리해주면, ErrorBoundary에서 처리할 수 있다.

그 외.

export const useRecordGetQuery = (
  userEmail: string | null | undefined,
  token: string | undefined,
) => {
  const { data: messages = [] } = useQuery(
    [...recordManagerKeys.record, userEmail],
    () => getMessage(userEmail, token),
    {
      meta: {
        errorMessage: '이게 있다면 실패한 에러가 작성되겠죠.', // 이렇게 하면 위의 query.meta?.errorMessage에서 읽힌다.
      },
    },
  );

  return {
    messages,
  };
};

  • ChakraUI의 toast가 있는데도 불구하고 react-hot-toast를 사용한 이유

    • layout.tsx를 보면 ChakraContext가 존재한다. 즉 ChakraUI의 toast를 사용할 수 있다.
    • 하지만 queryClient에선 Chakra(이하 차크라)를 사용할 수 없었다.
    • 그 이유는 QueryClinet를 useState내에 넣어주게 되는데, 차크라 UI 같은 경우는 toast를 불러오기 위해 useToast hooks를 제공한다.
    • useState내에 useHooks가 들어가면 에러가 발생했다. 즉, react-hot-toast를 임의로 적용해주었다.

결론

  • 최대한 정확하게 적어보려고 했다. 혹시나, 잘못된 부분이 있다면 dlrmsgnl0823@gmail.com으로 꼭 연락주시길 바랍니다. 🙇‍♂️

참고자료

훨씬훨씬 많은데, 회사 탭에 열어놓은게 있어서 내일 업데이트 해야겠다..!


© 2025 Geuni, Powered By Gatsby.