프론트엔드 에러처리 (Toast UI & ErrorBoundary)

강혁준·2024년 11월 4일

프론트엔드

목록 보기
5/6

참고로, [10분 테코톡] 웨디의 프론트엔드에서의 에러 처리 영상의 아이디어를 많이 참고하였습니다.

👾 Custom Error 구현

서버에서 클라이언트 에러든, 서버 에러든 인지할 수 있는 에러가 발생했다면 errorCode를 함께 보내주는데 이 경우를 판별하기 위한 커스텀 에러를 구현했다.

만약 서버에서 인지할 수 있는 에러를 보내주었다면 Toast UI를 통해 에러 메시지를 보여줄 것이고, 그게 아니라면 에러를 throw해 ErrorBoundary에서 처리하도록 구현할 것이다.

export default class HttpError extends Error {
  errorCode: string

  constructor({ message, errorCode }: { message: string; errorCode: string }) {
    super(message)
    this.errorCode = errorCode
    this.name = this.constructor.name
  }
}

export const isHttpError = (error: Error): error is HttpError => {
  if ('errorCode' in error) {
    return true
  } else {
    return false
  }
}

🛜 Axios 및 React Query 설정

Axios의 interceptor를 활용해 error response를 응답받았을 때 에러 객체를 확인하는 로직을 작성하였다.

만약 error response에 errorCode라는 필드가 존재한다면 HttpError를 throw 하고, 그게 아니라면 기존 error를 그대로 throw 한다.

const apiClients = axios.create({
  baseURL: import.meta.env.BASE_URL,
  timeout: 0,
  headers: {
    'Content-Type': 'application/json',
  },
})

apiClients.interceptors.response.use(
  (response: AxiosResponse) => response,
  (error: AxiosError) => {
    if (error.response && isAxiosError<HttpErrorResponse>(error)) {
      const { errorCode, message } = error.response.data
      throw new HttpError({ message, errorCode })
    } else {
      throw error
    }
  },
)

그 후 React Query에서 QueryClient의 옵션을 지정해주었다. zustand를 통해 정의한 useErrorStore를 통해 전역으로 현재 발생한 에러 객체를 확인하고, 또 업데이트 해줄 수 있다.

QueryClient에서는 에러가 발생했을 때 에러 객체의 업데이트를 담당한다.

const QueryClientBoundary = ({ children }: QueryClientBoundaryProps) => {
  const updateError = useErrorStore(state => state.updateError)

  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        gcTime: 20000,
        throwOnError: true,
      },
    },
    queryCache: new QueryCache({
      onError: error => {
        updateError(error)
      },
    }),
    mutationCache: new MutationCache({
      onError: error => {
        updateError(error)
      },
    }),
  })

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

🎁 ErrorStore

다음과 같이 에러 객체를 store에 저장해둔다.

interface ErrorStore {
  error: Error | null
  updateError: (error: Error | null) => void
}

const useErrorStore = create<ErrorStore>(set => ({
  error: null,
  updateError: error => set({ error: error }),
}))

😭 Context API vs Zustand

처음에는 Context API를 사용해서 상태관리를 진행하려고 했으나, 몇가지 문제점이 보였다. 첫 번째는 전역 상태의 계층 구조이다.

Context API를 사용할 때에는 상태 및 메서드를 사용할 컴포넌트 전체를 Provider로 감싸야 하는데, 전역으로 사용해야 하는 상태 ex) 로그인 상태, 에러 상태 등이 많아질수록 앱 전체를 감싸야 하는 경우가 많아질 것이라고 생각했다.

그에 반해서 Zustand는 전역 스토어를 하나 두고, 따로 Provider를 감싸둘 필요 없이 useXXXStore 메서드를 사용해서 값에 바로 접근할 수 있으므로 더 편리하다고 생각했다.

두 번째는 불필요한 리렌더링이다. Context API를 사용할 때 아래와 같이 값을 전달하면 value를 사용하지 않아도 onChange 메서드를 사용하는 컴포넌트에서도 리렌더링이 발생하고 그 하위 컴포넌트에서도 리렌더링이 발생한다.

<MyContext.Provider value={{ value: 1, onChange: (newValue) => setValue(newValue) }}>
</MyContext.Provider>

물론 아래와 같이 useCallback으로 감싸면 이런 문제는 어느정도 해결이 되지만, 매번 메소드를 전달할 때마다 useCallback으로 감싸고 또 별도의 컴포넌트로 Provider를 매번 구현해야 한다는 점은 너무 불편하다고 생각했다.

Zustand에서는 별도의 Provider를 만들 필요도 없고, useCallback으로 감싸지 않아도 필요한 필드 혹은 메서드만 구독해서 리렌더링을 방지할 수 있으므로 편리하다고 생각했다. 물론 Context API의 하위 컴포넌트 리렌더링은 Zustand에서도 발생한다. Jotai와 같은 Atomic 패턴을 사용하는 상태관리 라이브러리에서는 구독한 컴포넌트에서만 리렌더링이 발생한다고 한다.

const onChange = useMyStore(state => state.onChange);

그 중 Atomic 패턴을 사용하지 않고 Flux 패턴을 사용하는 상태관리 라이브러리를 사용한 이유는, Atomic 패턴에서는 Bottom-up 방식을 사용해서 각 컴포넌트에서 상태를 변경할 수 있는 것과 달리 Flux 패턴을 사용한 라이브러리는 Top-down 방식을 사용하기 때문에 Store에서 상태를 변경하기 위한 방법을 명시해줄 수 있다. (React의 Reducer와 유사하게)

물론 우리 프로젝트의 규모가 큰 편은 아니긴 하지만, Zustand를 사용하는 것이 상태가 어떻게 변경되는지 파악하기 더 쉬울 것이라고 판단했기 때문에 Zustand를 사용하게 되었다.

🧤 HttpErrorCatcher

Zustand를 사용해 정의한 ErrorStore를 구독하여 에러가 변경되었을 때 그에 맞는 적절한 에러 처리를 진행하는 컴포넌트이다.

errorStore에서 error 객체가 새롭게 업데이트 되었을 때, 만약 HttpError 객체가 아니라면 error를 처리하지 않고 throw 해서 ErrorBoundary에서 처리하도록 한다.

그렇지 않고 HttpError라면 예상할 수 있는 에러이므로 useToast라는 toast ui를 보여주는 함수를 호출한다.

const HttpErrorCatcher = () => {
  const { error, updateError } = useErrorStore()
  const showToast = useToast()

  useEffect(() => {
    if (!error) {
      return
    }

    if (!isHttpError(error)) {
      throw error
    }

    showToast({
      message: '에러가 발생했습니다.',
      type: 'error',
      navigate: {
        to: '/menu',
      },
    })
    updateError(null)
  }, [error])

  return <></>
}
const useToast = () => {
  const showToast = useToastStore(state => state.showToast)
  return showToast
}

🙆‍♀️ ErrorBoundary

HttpErrorCatcher에서 처리할 수 없는 에러를 만났을 때 에러를 처리하는 컴포넌트이다. GlobalErrorBoundary는 에러가 난 컴포넌트 대신 에러 페이지를 보여주며, 에러가 발생하기 전 화면을 렌더링 할 수 있도록 하는 resetErrorBoundary라는 함수를 제공한다.

onReset props에 전달한 메서드를 resetErrorBoundary 함수를 호출했을 때 실행하는데, 이 때 errorStore에 저장해둔 error 객체를 초기화 하지 않으면 다시 에러 페이지가 렌더링 되기 때문에 clear를 해주었다.

const GlobalErrorBoundary = ({ children }: GlobalErrorBoundaryProps) => {
  const updateError = useErrorStore(state => state.updateError)

  const clearError = () => updateError(null)

  return (
    <ErrorBoundary FallbackComponent={GlobalFallback} onReset={clearError}>
      {children}
    </ErrorBoundary>
  )
}

🥪 Toast UI

// Toast.tsx
const Toast = () => {
  const navigate = useNavigate()
  const getToast = useToastStore(state => state.getToast)
  const isToastsEmpty = useToastStore(state => state.isEmpty)
  const [toast, setToast] = useState<ToastProps | null>(null)

  const { isActive: shouldAnimate, trigger: triggerAnimation } =
    useTimeoutToggle()

  const handleNavigate = () => {
    if (toast?.navigate) {
      navigate(toast.navigate.to, toast.navigate.options)
    }
  }

  useEffect(() => {
    const getNextToast = async () => {
      if (!shouldAnimate) {
        await delay(300)
        const nextToast = getToast()

        if (nextToast) {
          setToast(nextToast)
        }
      }
    }
    getNextToast()
  }, [shouldAnimate, isToastsEmpty])

  useEffect(() => {
    if (toast) {
      triggerAnimation()
    }
  }, [toast])

  return (
    <S.ToastContainer>
      <S.ToastWrapper
        animate={{ y: shouldAnimate ? '0%' : '-200%', x: '-50%' }}
        initial={false}
        $isClickable={shouldAnimate ? true : false}
        onClick={handleNavigate}
      >
        {toast?.type !== ('none' as keyof ToastProps['type']) && (
          <S.Icon src={getIcon(toast?.type)!} alt="toast icon" />
        )}
        <S.TextWrapper>
          <S.Text>{toast?.message}</S.Text>
        </S.TextWrapper>
      </S.ToastWrapper>
    </S.ToastContainer>
  )
}

Zustand로 toastStore를 생성한 후 toastStore의 값을 구독해서, 값이 있으면 꺼낸 후 toast 애니메이션을 실행시키는 방식으로 구현했다.

우선 useTimeoutToggle 이라는 훅이 있는데, 이 훅은 쉽게 말하면 trigger를 호출했을 때 isActivetrue로 만들고, 일정 시간이 지나면 false로 만드는 간단한 훅이다. Toast 컴포넌트에서는 isActiveshouldAnimate라는 이름으로, triggertriggerAnimation라는 이름으로 사용하고 있다.
참고로 애니메이션은, framer-motion 라이브러리를 사용했다.

// useTimeoutToggle.ts
const useTimeoutToggle = (ms: number = 1000) => {
  const [isActive, setIsActive] = useState(false)

  const trigger = useCallback(() => {
    setIsActive(true)
  }, [setIsActive])

  useEffect(() => {
    if (!isActive) return

    const timeout = setTimeout(() => {
      setIsActive(false)
    }, ms)

    return () => {
      clearTimeout(timeout)
    }
  }, [isActive, setIsActive])

  return {
    isActive,
    trigger,
  }
}

Toast 컴포넌트에는 총 두개의 useEffect가 존재한다. 첫 번째는 실제로 애니메이션을 trigger 하는 side effect를 담당하는 훅으로, toast 상태가 변경되었을 때 null이 아니라면 triggerAnimation을 호출해서 애니메이션을 보여준다.

const [toast, setToast] = useState<ToastProps | null>(null)

useEffect(() => {
  if (toast) {
    triggerAnimation()
  }
}, [toast])

두 번째 훅은 toastStore에서 toasts 스택의 값을 꺼내오는 getToast와, toasts가 비어있는지 확인하는 isToastsEmpty라는 값을 사용한다.

만약 외부에서 useToast 훅을 통해 toasts 값을 변경하면 isToastsEmpty 값이 변화하게 되면서 해당 useEffect 내부의 effect 함수를 호출한다. shouldAnimatefalse이고 (현재 애니메이션이 진행중이 아니고) getToast의 값이 null이 아니라면 setToast를 통해 toast 상태를 업데이트한다. toast가 업데이트 되면 첫 번째 useEffect에서 toasts 를 구독하고 있기 때문에 애니메이션이 실행된다.

첫 번째 useEffect에서 triggerAnimation을 호출해서 shouldAnimate가 변경되었을 때 만약 true로 변경되면 (애니메이션을 현재 진행중이라면) 내부 로직을 실행하지 않는다.

만약 useTimeoutToggle훅에서 일정 시간 후 애니메이션을 종료시키기 위해 shouldAnimatefalse로 만들면 그때 또 내부 로직을 실행해서 결과적으로는 toastStoretoasts 배열이 빌 때까지 Toast UI를 선입선출 구조로 보여주게 된다.

const getToast = useToastStore(state => state.getToast)
const isToastsEmpty = useToastStore(state => state.isEmpty)

useEffect(() => {
  const getNextToast = async () => {
    if (!shouldAnimate) {
      await delay(300) // 애니메이션 딜레이
      const nextToast = getToast()

      if (nextToast) {
        setToast(nextToast)
      }
    }
  }
  getNextToast()
}, [shouldAnimate, isToastsEmpty])

이 때, toasts 값을 직접 구독하지 않고 isToastsEmpty라는 값을 이용해서 toasts가 비어있는지 비어있지 않는지 확인한 이유는 useEffect 내부의 getToast 때문이었다.

만약 getToast를 호출하게 되면 toasts 배열이 변화하게 되는데, 이 때 useEffect의 deps 배열에 toasts가 추가되면 useEffect 내부 로직을 다시 실행한다. shouldAnimate라는 상태는 useAnimating 훅에서 useState를 통해 비동기적으로 업데이트가 되는데, 비동기적으로 업데이트 되는 특성 때문에 shouldAnimate가 바로 true가 되지 않아 조건문에서 처리하지 못하게 된다.

결국 toasts 배열의 값이 선입선출로 빼내어져 Toast UI에서 한번씩 보이는 것이 아니라, toasts 배열을 한번에 비우고 맨 마지막 값만 Toast UI에서 보여지게 되었다.

그렇지만 isToastsEmpty 값을 통해 구독하면 toasts에 값이 여러개 있을 때 한번 getToast를 통해 값을 빼내어도 effect 함수를 호출하지 않기 때문에 isToastsEmpty를 사용하였다.

아래는 toast로 띄워줄 값을 관리하는 스토어이다. 외부에서 addToast를 통해 값을 toasts에 추가할 수 있다.

// toastStore.ts
import { create } from 'zustand'
import type { ToastProps } from '@/components/common/Toast/Toast'

interface ToastStore {
  toasts: ToastProps[]
  isEmpty: boolean
  addToast: (toast: ToastProps) => void
  getToast: () => ToastProps | null
}

const useToastStore = create<ToastStore>((set, get) => ({
  toasts: [],
  isEmpty: true,
  addToast: toast =>
    set(state => ({ toasts: [...state.toasts, toast], isEmpty: false })),
  getToast: () => {
    const { toasts } = get()
    if (toasts.length === 0) return null

    const [firstToast, ...remainToasts] = toasts
    set(() => ({ toasts: remainToasts, isEmpty: remainToasts.length === 0 }))
    return firstToast
  },
}))

export default useToastStore

📄 Main Page

아래와 같이 메인 페이지에서의 router 구성을 설정해두고, 임시로 메인 페이지에서 테스트를 해보았다.

{
  path: '/',
  element: (
    <GlobalErrorBoundary>
      <Toast />
      <HttpErrorCatcher />
      <Suspense fallback={<Spinner ms={300} />}>
        <MainPage />
      </Suspense>
    </GlobalErrorBoundary>
  ),
},

임시로 useErrorStore를 사용해서, 버튼을 클릭했을 때 updateError 메서드를 호출해서 HttpError 객체 혹은 Error 객체로 업데이트 하도록 했다.

TriggerHttpError 버튼을 누르면 Toast UI가 뜨고, TriggerError 버튼을 누르면 ErrorBoundary에서 에러를 처리한다.

const Page = () => {
  const updateError = useErrorStore(state => state.updateError)
	...
  return (
	   ...
        <button
          onClick={() =>
            updateError(
              new HttpError({
                message: 'message',
                errorCode: 'errorCode',
              }),
            )
          }
        >
          TriggerHttpError
        </button>
        <button onClick={() => updateError(new Error('message'))}>
          TriggerError
        </button>
     ...
  )
}

😀 결과

profile
안녕하세요 프론트엔드 개발자가 되고 싶은 강혁준 입니다.

0개의 댓글