Published on

Next와 ErrorBoundary - with. React Query


이전 게시물 내용에 이어 이번에는 Next.js 프로젝트에서 Error Boundaries를 적용해 보고자 한다. React에서 기본적으로 제공되는 Suspense와는 다르게 Error Boundaries 기능을 사용하기 위해서는 단순하게 import가 아닌 직접 구현이 필요하다.



한 가지 특이한 점이 있다면, 구현 과정에서 에러 발생 시 가장 에러 캐치 밑 해당 컴포넌트의 상태를 업데이트하는 중요한 역할을 수행하는 getDerivedStateFromError 생명 주기 메서드아직 Hook으로 구현되지 않았기 때문에 부득이하게도 Class 컴포넌트로 구현이 필요하다.

알뜰살뜰하게 32.8 KB를 아껴보자


이전 게시물에서는 react-error-boundary 라이브러리를 사용해 따로 구현하지 않고 사용했는데, 이번에 추가적으로 학습을 진행해 보며 따로 구현할 만큼 복잡하지 않고 커스텀 여부를 생각했을 때 굳이 불필요하게 라이브러리를 사용한다는 느낌이 들었다. 따라서 직접 ErrorBoundary 컴포넌트를 제작할 계획이다.



프로젝트 환경

  • Next14 - app Router
  • tanstack/react-query v5
  • tailwind CSS


본격적으로 Error Boundaries를 구현하기 전에 Next 프레임워크에서는 page.js, layout.js, error.js와 같은 파일들이 특수한 역할과 동작을 수행하게 된다.

처음 동작 이미지를 봤을 때 error.js 파일을 생성하고 ErrorBoundary 컴포넌트로 래핑 하면 동작하는 줄 알았지만, 알고 보니 error.js 파일만 생성해도 계층에 맞게 자동으로 fallback이 동작한다는 것을 알 수 있었다. 아마도 위 이미지에서는 fallbackError(error.js) 컴포넌트를 배치한 것처럼 동작한다는 것을 보여주는 것 같다.

만약 React Query를 사용하지 않았다면, error.js를 사용해 간단한 구현이 가능하겠지만 React Query를 사용하게 되면 작은 문제가 하나 발생하게 된다. button에 onClick 이벤트를 사용해 페이지를 새로고침(reset) 할 때, 새로고침은 동작하지만 다시 데이터가 갱신되지 않는다.

이 원인은 바로 React Query가 기본적으로 캐시 된 데이터를 메모리에 유지하고, 컴포넌트의 마운트 상태와 독립적으로 작동하기 때문이다. 다행히도 이미 React Query 공식 문서에서 해결 방법을 제시해 주고 있다.

크게 QueryErrorResetBoundary 컴포넌트를 사용하는 방법과 useQueryErrorResetBoundary 훅을 사용하는 방법이 존재하고 이번 프로젝트에서는 선언적으로 에러를 처리한다는 관점에서 QueryErrorResetBoundary를 사용할 계획이다.



Next 프레임워크에는 이미 ErrorBoundary가 구현되어 있다??


구현에 앞서, 흥미로운 파일을 발견하게 되었다. 앞서 언급했듯이 Error Boundaries는 직접 구현이 필요하다. 그런데 우연히 만들어 둔 커스텀 ErrorBoundary 컴포넌트를 사용하려던 중, 알 수 없는 경로의 ErrorBoundary를 발견하게 됐다.

next/dist/client/components/error-boundary??


호기심이 생겨 바로 기존 커스텀 ErrorBoundary를 대체해 사용해 봤다. 결과는 놀랍게도 정상 동작한다. 경로를 타고 코드를 살펴보니, 여전히 Class 컴포넌트 형태로 구현이 되어있었다.

그렇다면 이미 구현되어 있는 ErrorBoundary를 사용하면 좋겠다는 생각이 들었지만, 한편으로 호출하는 경로를 보고 찜찜한 감정을 느꼈다.


🚨 여기서부터는 주관적인 의견이 다수 포함될 예정이다. 🚨

단서를 찾기 위해 경로에 위치한 다른 파일들을 살펴보았다. 그리고 몇 개의 파일명을 보고 나름대로 ErrorBoundary 존재의 이유를 유추해 보았다.


경로 내에 파일명추측하는 역할
error-boundary.*error.js
layout.*layout.js
not-found.*not-found.js

error-boundary.js 파일 내부에는 ErrorBoundaryHandler 클래스가 존재하고 기존 ErrorBoundary 컴포넌트 구조와 큰 차이가 없었다. 이를 통해 error.js 컴포넌트의 동작 원리가 이 부분과 연관돼있을 것 같다는 추측을 하게 되었다.

또한 not-found.js 파일의 동작과 유사한 파일을 발견할 수 있었는데, 파일 안에는 NotFoundErrorBoundary라는 클래스가 존재했다. 그리고 ErrorBoundary와 마찬가지로 getDerivedStateFromError 메서드가 존재했고 404 에러를 감지 및 처리하는 로직으로 구성된 것 같다.

아쉽게도 위의 내용을 증명할 수 있는 정확한 레퍼런스를 찾을 수 없기 때문에 정확한 정보라고 단언할 수는 없다.



React Query(v5) + Suspense + Error Boundaries 사용하기


React Query를 사용해 Error Boundaries 구현하기 위해서는 throwOnError 옵션을 사용해 줘야 하는데, Suspense를 같이 사용할 경우 사라진 suspense 옵션이 아닌 throwOnError 옵션을 사용하면 따로 추가할 옵션 없이 Suspense와 Error Boundaries 사용이 가능하다.

만약 useQuery 훅이 아닌 useSuspenseQuery 훅을 사용할 경우 이름에서 볼 수 있는 것처럼 throwOnError 옵션 없이 자동으로 Suspense 적용되고, Error Boundaries 또한 사용 가능하다.


useQuery hookuseInfiniteQuery hookthrowOnError 옵션 사용 여부
useQueryuseInfiniteQuery사용 필요
useSuspenseQueryuseSuspenseInfiniteQueryX


ErrorBoundary 코드

'use client'

import { Component, ReactNode, ErrorInfo, ComponentType } from 'react'

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export interface FallbackProps {
  error: Error | null
  resetErrorBoundary: () => void
}

type ErrorBoundaryProps = {
  FallbackComponent: ComponentType<FallbackProps>
  onReset: () => void
  children: ReactNode
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props)

    this.state = {
      hasError: false,
      error: null,
    }

    this.resetErrorBoundary = this.resetErrorBoundary.bind(this)
  }

  /** 에러 상태 변경 */
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.log({ error, errorInfo })
  }

  /** 에러 상태 기본 초기화 */
  resetErrorBoundary(): void {
    this.props.onReset()

    this.setState({
      hasError: false,
      error: null,
    })
  }

  render() {
    const { state, props } = this

    const { hasError, error } = state

    const { FallbackComponent, children } = props

    if (hasError && error) {
      return (
        <FallbackComponent
          error={error}
          resetErrorBoundary={this.resetErrorBoundary}
        />
      )
    }

    return children
  }
}

export default ErrorBoundary


QueryErrorResetBoundary 컴포넌트 적용하기


적용 방법은 매우 간단지만, 주의 사항이 몇 가지 있다.

  1. useSuspenseQuery를 사용할 경우 Suspense 컴포넌트를 먼저 적용할 컴포넌트 상위에 래핑하고 사용하기 (무한 루프 발생 위험) - 참고
  2. ErrorBoundary 컴포넌트에 QueryErrorResetBoundary 컴포넌트에서 제공하는 쿼리 에러 상태 초기화 역할을 하는 매개변수와 fallback을 처리하는 로직이 필요하다.

적용 코드

return (
  <QueryErrorResetBoundary>
    {({ reset }) => (
      <ErrorBoundary onReset={reset} FallbackComponent={ErrorFullback}>
        <Suspense fallback={<Loading />}>
          <RankingList />
          <VoteList />
        </Suspense>
      </ErrorBoundary>
    )}
  </QueryErrorResetBoundary>
)

QueryErrorResetBoundary 컴포넌트를 사용해 정상적으로 코드를 구현했다고 생각했는데, 에러가 발생했다. 에러의 내용을 요약하면, 서버 컴포넌트에서 QueryErrorResetBoundary 컴포넌트를 사용할 수 없다는 내용이다.

QueryErrorResetBoundary 컴포넌트는 클라이언트 사이드에서만 동작하기 때문에 어찌 보면 당연한 에러 발생이지만 명확한 해결 방안을 찾지 못해 한 걸음씩 직접 걸어보기로 했다.

첫 시도로 서버 컴포넌트 하위에 클라이언트 컴포넌트를 추가해 이 문제를 해결하려 했으나 불필요한 Depth가 생기는 문제가 발생했다. 서버 컴포넌트는 많은 장점이 있지만, 역시 사용함에 있어 까다로운 부분이 있는 것 같다.

그렇게 고민을 이어가던 중 QueryErrorResetBoundary, ErrorBoundary, Suspense를 하나의 공통 컴포넌트로 만들고 이 컴포넌트를 합성(Composition) 방식으로 서버 컴포넌트에서 사용하는 방법을 고안하게 되었다.



ErrorHandlingWrapper 컴포넌트 코드

ErrorHandlingWrapper라는 컴포넌트를 만들고 재사용성을 고려해 ErrorBoundary와 Suspense fallback 컴포넌트는 props로 관리했다.

'use client'

import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ComponentType, ReactNode, Suspense } from 'react'
import ErrorBoundary, { FallbackProps } from '../errorBoundary'

interface PropsType {
  children: React.ReactNode
  fallbackComponent: ComponentType<FallbackProps>
  suspenseFallback: ReactNode
}

export default function ErrorHandlingWrapper({
  children,
  fallbackComponent: FallbackComponent,
  suspenseFallback: SuspenseFallback,
}: PropsType) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary onReset={reset} FallbackComponent={FallbackComponent}>
          <Suspense fallback={SuspenseFallback}>{children}</Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  )
}

그리고 page.tsx(서버 컴포넌트)에서 ErrorHandlingWrapper 컴포넌트를 사용하면 서버 컴포넌트에서도 QueryErrorResetBoundary 사용이 가능해 진다.

return(
  <ErrorHandlingWrapper
    fallbackComponent={ErrorFallback}
    suspenseFallback={<Loading />}
  >
    <RankingList />
    <VoteList />
  </ErrorHandlingWrapper>
)


테스트 환경 세팅하기


 const votePath = Math.random() < 0.5 ? 'votes' : 'vote'

💡 랜덤으로 요청 시 api 경로를 변경하도록 votePath라는 변수를 추가한다.


import { RankingInfo } from '@/app/_types/vote.type'
import { useSuspenseQuery } from '@tanstack/react-query'
import { voteKeys } from '.'

async function fetchVoteRanking(hobby: string): Promise<RankingInfo> {
  const votePath = Math.random() < 0.5 ? 'votes' : 'vote'

  const res = await fetch(
    `${process.env.NEXT_PUBLIC_BASE_URL}/api/${votePath}/ranking?hobby=${hobby}`,
    {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
      cache: 'no-store',
    },
  )

  const data = await res.json()

  if (!res.ok) {
    throw new Error(data.message)
  }

  return data
}

export const useVoteRanking = (hobby: string) => {
  const { data: rankingList } = useSuspenseQuery({
    queryKey: voteKeys.voteRanking(hobby).queryKey,
    queryFn: () => fetchVoteRanking(hobby),
    staleTime: 1000 * 60,
  })

  return { rankingList }
}

잘못된 API 경로 입력으로 에러가 발생했을 때 ErrorFallback 컴포넌트에 메인 이동, 다시 불러오기 버튼이 정상적으로 화면에 렌더링 되고 새로고침만 되던 이전과 달리 React Query로 캐시 된 데이터까지 정상적으로 재설정 하게 된다.



결과



성과

  1. Error Boundaries를 적용함으로써 사용자에게 에러 발생 상황을 인지시키고 해결 방안을 제시해 사용자 경험 향상
  2. isLoading, isError와 같은 코드를 SuspenseError Boundaries를 사용해 대체함으로써 Loading, Error 관심사 분리


마치며

이번 작업에서는 공통되는 QueryErrorResetBoundary, Suspense, ErrorBoundary 컴포넌트를 재사용하기 위해 많은 고민을 했었다. 초기에는 클라이언트 컴포넌트로 분리해 보기도 하고, props 및 children을 사용해 다양한 방법을 실행 및 분석해 보며 나름 만족스러운 방식을 찾을 수 있었다.

Next로 개발을 진행하며 아쉬운 부분이 있다면, 역시 관련 레퍼런스가 부족하다는 것이다. 이 부분 외에는 프레임워크가 제공하는 편리한 기능에 매우 만족하며 사용하고 있다.

이번에 Next 프로젝트로 Error Boundaries를 직접 구현하며 React에서 진행했던 방식과는 다른 문제들을 마주할 수 있었다. 단순히 문제를 해결한 것도 중요하지만, 문제 해결 과정에서 얻은 다른 지식들이 앞으로도 큰 도움이 될 것 같다. 앞으로도 습득한 지식을 잘 정리해서 기록하도록 하자. 👏




Reference