next.js14 TanStack Query v5 중앙 에러 핸들링 적용하기

dobby·2024년 11월 27일
post-thumbnail

처음엔 인증 에러에 대한 공통 실패 응답은 fetchWrapper에서 잡아주고, 개별 API 실패 응답은 각 API 호출부에서 처리해주었다.
하지만, API의 양이 많아지고 복잡도가 높아지면서 문제가 발생하였고, 이를 해결하고 에러를 더욱 쉽게 관리하기 위해 react-query의 중앙 에러 핸들링을 적용하기로 했다.

FetchWrapper -> React Query 변경

📌 react query 에러 핸들링 사용 이유

처음 프로젝트를 진행했을 때, react-query에서 에러 핸들링을 제공한다는 것은 알고 있었지만, 에러 페이지나 에러 컴포넌트만 제공한다고 생각했다.
그래서 fetchWrapper로 공통 에러를 잡고, 기존에 하던대로 API 호출부에서 요청 응답 관리를 해주도록 했었다.

프로젝트가 진행되면서 react-query를 많이 사용하다보니 query에서 제공해주는 기능을 조금씩 추가로 알게되었다.
하지만 알게 된 시점에 적용하기엔 거의 모든 파일을 수정해야하는 대공사를 치뤄야 했고, fetchWrapper 만으로도 충분했기에 수정 없이 가기로 했다.

그러다 프로젝트가 MVP2로 넘어가면서 API가 늘어나고 신경써야하는 부분이 늘어나면서 코드의 일관성이 떨어지게 되었다.

발생했던 문제들은 아래와 같다.

  1. UI와 관련없는 API 호출 ts 파일에서 toast 컴포넌트 출력
  2. 서버 BASE_URL 호출을 위한 코드로 인해 fetchWrapper에서 비동기 waterfall 에러 발생
  3. 비회원 로그인에서 회원 로그인으로 로직이 변경되면서 세션 업데이트를 위한 메서드 및 session 호출이 fetchWrapper 에서 불가능한 문제 발생 (fetchWrapper가 클라이언트단에서 호출되기도, 서버 단에서 호출되기도 하였다.)

그래서 프로젝트 진행도가 잠시 떨어졌을 때, 다른 프론트 팀원과 의논하여 이를 바로잡기로 결정했다.


📌 Provider에 에러 핸들링 적용

React-Query를 사용하기 위해선 Provider를 설정해줘야 했는데, 이곳에서 에러 핸들링 코드를 추가해주면 된다.

// src/app/config/RQProvider.tsx
'use client';

import { useNetworkStatus } from '@/hooks/useNetworkStatus';
import {
  QueryCache,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React, { useState } from 'react';
import useApiError from '@/hooks/useApiError';
import NetworkErrorNotification from '../_components/utils/NetworkErrorNotification';

type Props = {
  children: React.ReactNode;
};

export default function RQProvider({ children }: Props) {
  // 오프라인 상태일 때 사용자에게 알림을 띄워주기 위한 커스텀 함수
  const { isNetworkOffline } = useNetworkStatus();
  // 에러 핸들러 함수 추출
  // useApiError: 커스텀 에러 핸들러 훅
  const { handleError } = useApiError();

  const [client] = useState(
    new QueryClient({
      defaultOptions: {
        queries: {
          networkMode: 'always',
          refetchOnWindowFocus: false,
          retry: 2,
          retryOnMount: true,
          refetchOnReconnect: true,
        },
        mutations: {
          // handleError 함수 안에 비동기 코드가 있기에 async await를 추가해주었다.
          onError: async (error) => {
            await handleError(error);
          },
          networkMode: 'always',
          retry: 1,
        },
      },
      queryCache: new QueryCache({
        onError: async (error, query) => {
          const { queryKey } = query;
          // 토큰 재발급 후 API retry를 위해 queryKey 전달
          await handleError(error, undefined, queryKey);
        },
      }),
    }),
  );

  return (
    <QueryClientProvider client={client}>
      {isNetworkOffline && <NetworkErrorNotification />}
      {children}
    </QueryClientProvider>
  );
}

mutationqueryCache를 추가해서 에러 발생시 실행할 함수를 넣어주면 된다.
나는 토큰 재발급 후 기존 API 요청을 재시도할 것이기에 queryCachequeryKey를 추가로 넣어주었다.
mutation도 retry를 위한 로직을 추가했는데, 이는 아래에서 천천히 설명하겠다.

  • 렌더링 될 때마다 queryClient 가 새로 생성되지 않도록 useState로 관리
  • mutationsonError 옵션에 useApiError 훅에서 가져온 handleError 함수를 할당
  • useQuery는 tanstack-query가 useQuery에 대해서만 캐시한다는 점을 이용해 queryCache 옵션을 통해 새로운 캐시가 생성될 때 onError 콜백을 설정
  • queryCacheonError 인자에서 query를 사용해 해당 API의 queryKey 추출 후 handleError로 전달

처음 query 설정에 대한 코드는 아래 블로그를 참고했다.
혹 query에 대한 설명이 필요하다면, 아래의 블로그를 보는걸 추천!
ebokyung

networkMode에는 3가지가 있는데, 그 중 always를 사용해 네트워크 연결 여부와 상관없이 항상 요청을 실행시켜 "error"를 발생시키고자 했다.
네트워크 연결 부재로 쿼리가 중지되면, 에초에 에러가 발생하지 않아 에러 핸들러가 먹히지 않기 때문이다.
이와 같은 네트워크 연결 여부로 발생한 에러는 useNetworkOffline custom hook을 이용해 networkErrorPage가 나타나도록 처리했다.
(나는 toast를 출력해주었다.)


📌 Custom Error Handler

// src/hooks/userApiError.ts
'use client';

import showToast from '@/utils/showToast';
import { signOut } from 'next-auth/react';
import { useCallback } from 'react';
import {
  AUTHORIZATION_FAIL,
  AUTH_MESSAGE,
  SIGNIN_REQUIRED,
} from '@/constants/authErrorMessage';
import {
  QueryClient,
  QueryKey,
  UseMutateFunction,
} from '@tanstack/react-query';
import isNull from '@/utils/validation/validateIsNull';
import retryConfig from '@/lib/retryConfig';
import useUpdateSession from './useUpdateSession';

// useQuery에서의 API Retry를 위한 query 객체 생성
const queryClient = new QueryClient();

const useApiError = () => {
  // auth의 session update를 위한 custom hook
  const { callReissueFn } = useUpdateSession();

  // 인증 에러 핸들러 (권한 부족 등의 에러)
  const authErrorHandlers = useCallback(
    // retryMutation: useMutation Retry
    // queryKey: useQuery Retry를 위한 키
    async (
      retryMutation?: UseMutateFunction<any, Error, void, unknown>,
      queryKey?: QueryKey,
    ) => {
      // 토큰 재발급 후 결과 리턴
      const reissueResponse = await callReissueFn();

      // 토큰 재발급 실패 혹은 재발급 불가시 로그아웃 및 '/'로 이동
      if (reissueResponse === AUTHORIZATION_FAIL) {
        showToast(
          '로그인 세션이 만료되었습니다. 다시 로그인 해주세요.',
          'error',
        );
        retryConfig.tokenReissuance = 3;
        signOut({ redirect: true });
      }

      // mutation retry
      if (!isNull(retryMutation) && retryConfig.tokenReissuance > 0) {
        retryConfig.tokenReissuance -= 1;
        retryMutation();
        return;
      }

      // query retry
      if (!isNull(queryKey) && retryConfig.tokenReissuance > 0) {
        retryConfig.tokenReissuance -= 1;
        await queryClient.refetchQueries({ queryKey, exact: true });
        return;
      }

      // 토큰 문제로 실패한 경우 최대 3번까지 반복하도록 설정
      retryConfig.tokenReissuance = 3;
    },
    [callReissueFn],
  );

  // 중앙 에러 핸들러
  const handleError = useCallback(
    async (
      error: Error,
      retryMutation?: UseMutateFunction<any, Error, void, unknown>,
      queryKey?: QueryKey,
    ) => {
      const response = error as any;
      if (error instanceof Error) {
        // 인증 에러라면 인증 에러 핸들러로 에러 전달
        if (AUTH_MESSAGE.includes(error.message)) {
          await authErrorHandlers(retryMutation, queryKey);
        } else if (response.status === 500) {
          showToast(
            '서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.',
            'error',
          );
        } else if (error.message.length > 0) {
          // 권한 부족 에러라면 로그인 페이지로 이동
          // session이 존재할 수도 있으니 로그아웃 처리
          if (error.message === SIGNIN_REQUIRED) {
            showToast('로그인이 필요합니다.', 'error');
            signOut({ redirect: true });
            return;
          }
          // 그 외 개별 API에서 발생한 에러는 메시지를 toast로 띄워줌
          showToast(error.message, 'error');
        }
      }
    },
    [authErrorHandlers],
  );

  return { handleError };
};
export default useApiError;

설명을 위해 주석을 추가했더니 코드가 길어졌다..!
handlerError 함수로 에러를 전달해주어서 전달받은 에러의 메시지를 toast로 띄워주는게 주 역할이다.

  • 만약 인증 에러라면, 토큰 재발급 로직을 실행시키고 retry를 진행한다.
  • 토큰 재발급 후 retry를 진행하지만, 해당 경우도 실패한다면 최대 3번까지 재시도한다.

한 번 실행했는데 실패했다고 바로 알리는건 UX에 좋지 않을 것 같았다.
물론, 그 과정에서 지연이 발생할 수도 있다.
이건 추후에 더 의논해서 수정하는것으로!


📌 fetchWrapper 수정

에러 핸들러를 fetchWrapper가 아닌 rq로 변경했으니, fetchWrapper도 수정해줘야 한다.

사실, 아예 없애버릴까 싶었지만 서버 BASE_URL 문제로 코드 사이즈를 줄여 사용하는걸로 결정했다.

import getKey from '@/utils/getKey';
import isNull from '@/utils/validation/validateIsNull';

export class FetchWrapper {
  static #baseURL: string;

  static async setBaseUrl() {
    this.#baseURL = (await getKey()).BASE_URL || '';
  }

  static async call(url: string, fetchNext: any): Promise<any> {
    if (isNull(this.#baseURL)) {
      if (isNull(process.env.NEXT_BASE_URL)) {
        await this.setBaseUrl();
      } else if (!isNull(process.env.NEXT_BASE_URL)) {
        this.#baseURL = process.env.NEXT_BASE_URL;
      }
    }

    let response;

    try {
      response = await fetch(`${this.#baseURL}${url}`, {
        ...fetchNext,
      });

      return await response.json();
    } catch (error) {
      throw new Error('알 수 없는 에러가 발생했습니다.');
    }
  }
}

// 서버 컴포넌트는 aysnc 함수를 export 해야한다.
export async function callFetchWrapper(url: string, fetchNext: any) {
  return FetchWrapper.call(url, fetchNext);
}

fetchWrapper가 서버 사이드에서 실행되기도, 클라이언트단에서 실행되기도 하여 BASE_URL 설정을 두가지로 해주었다.
(이게 맞는지는 모르겠다..)

  • env에서 뽑아올 수 있다면 서버단인 것이므로 뽑아서 저장해준다.
  • 클라이언트단이라면 getKey 모듈을 사용해 저장한다.

참고로, isNull 함수는 null, undefined, '' 등 빈 값인지 확인하는 로직이 중복되어 함수로 만들어 사용해준 것이다. (편하다..!!!)

fetchWrapper 코드가 꽤 길었는데, 필요한 부분만 남겨두고 모두 제거해주었다.


📌 사용 방법

API 호출 ts 파일 중 하나를 가져왔다.

react-query의 infiniteQuery를 사용하는 부분이다. (GET)

// src/app/(main)/_lib/getAgoraCategorySearch.ts
import { AgoraData } from '@/app/model/Agora';
import {
  AGORA_CATEGORY_SEARCH,
  NETWORK_ERROR_MESSAGE,
} from '@/constants/responseErrorMessage';
import { callFetchWrapper } from '@/lib/fetchWrapper';
import { QueryFunction } from '@tanstack/react-query';

type SearchParams = {
  status?: string;
  category?: string;
  q?: string;
};

export const getAgoraCategorySearch: QueryFunction<
  { agoras: AgoraData[]; nextCursor: number | null },
  [_1: string, _2: string, _3: string, searchParams: SearchParams],
  { nextCursor: number | null }
> = async ({ queryKey, pageParam = { nextCursor: null } }) => {
  const [, , , { status = 'active', category = '1' }] = queryKey;
  const searchParams = { status, category };

  const urlSearchParams = new URLSearchParams(searchParams);

  const res = await callFetchWrapper(
    `/api/v1/open/agoras?${urlSearchParams.toString()}&next=${pageParam.nextCursor ?? ''}`,
    {
      next: {
        tags: [
          'agoras',
          'search',
          'category',
          searchParams.category,
          searchParams.status,
        ],
      },
      headers: {
        'Content-Type': 'application/json',
      },
      credentials: 'include',
      cache: 'no-cache',
    },
  );

  if (!res.ok && !res.success) {
    if (!res.error) {
      throw new Error(AGORA_CATEGORY_SEARCH.UNKNOWN_ERROR);
    }

    if (res.error.code === 1001) {
      throw new Error(AGORA_CATEGORY_SEARCH.NOT_ALLOWED_STATUS);
    } else if (res.error.code === 1301) {
      throw new Error(AGORA_CATEGORY_SEARCH.NOT_ALLOWED_CATEGORY);
    } else if (res.error.code === -1) {
      throw new Error(res.error.message);
    } else if (res.error.code === 503) {
      throw new Error(NETWORK_ERROR_MESSAGE.OFFLINE);
    }

    throw new Error(AGORA_CATEGORY_SEARCH.FAILED_TO_GET_AGORA_LIST);
  }

  return {
    agoras: res.response.agoras,
    nextCursor: res.response.hasNext ? res.response.next : null,
  };
};

코드를 통째로 보여주기만 하는 것 같긴 한데,
개발하다 막히는 부분이 있으면 블로그를 찾지만 부분만 알려주고 전체 혹은 과정을 다 안알려주는 글들을 보며 나는 안그래야지... 하는 마음이 쌓이다보니 이렇게 됐다.. ㅎ

useApiErrorhandleError는 에러를 받기 때문에 에러를 throw 해준다.

  • 메시지를 실패 응답에 맞는 메시지로 담아 throw 해주면 된다.
  • 먼저 이 ts 파일을 거치고, 이곳에서 발생한 에러가 handleError로 넘어가게 된다.

위 코드는 권한이 필요한 API가 아니기 때문에 인증 관련 에러가 없다.
권한이 필요한 API라면 아래의 코드를 추가해주면 된다.

else if (AUTH_MESSAGE.includes(res.error.message)) {
      throw new Error(res.error.message);
    }

mutation에 적용 (POST, PATCH)

기본적인 API 호출에 대한 실패 응답은 GET과 동일하다.
차이점은, retry를 위한 코드를 추가해줬다는 것이다.

useMutation을 사용한 부분에 적용하면 된다.

const { handleError } = useApiError();

  const failedCreateAgora = async (
    error: Error,
    mutation: UseMutateFunction<any, Error, void, unknown>,
  ) => {
    setIsLoading(false);
    await handleError(error, mutation);
  };

  const mutation = useMutation({
    mutationFn: async () => {
      const info = {
        ...createAgora,
      };
      return postCreateAgora(info);
    },
    onSuccess: async (response) => {
      ...
    },
    onError: (error) => {
      failedCreateAgora(error, mutation.mutate);
    },
  });

mutate 함수를 같이 handleError로 넘겨주면 handleError에서 retry 할 때 사용할 수 있다.


마무리

에러 핸들링 작업 규모가 커서 생각보다 오래 걸렸다.
거의 모든 파일을 손봐야 했기 때문인데, git에 올릴 때 수정된 파일이 80개가 넘어간 걸 보고 충격받았다 ㅋㅋㅋㅋㅋㅋ..... (라쿤 쏘리... ㅎ)

처음엔 구현 위주(기능 구현)로 작업을 했고, 규모가 커지다보니 리팩토링의 필요성을 깨닫게 되어 조금씩 수정하고 있다.

멘토라고 할 만한 사람이 없어서 내가 하고 있는 작업들이 올바른 방법인지 사실 잘 모르겠다.
다음에 또 벽에 부딪힐 수도 있을 것 같지만, 계속하다보면 감을 익힐 수 있지 않을까?!

profile
성장통을 겪고 있습니다.

0개의 댓글