React, NextJS 14, TanStackQuery 환경에서 ErrorBoundary 사용

Ethan·2024년 8월 31일
0

F1 Info

목록 보기
1/5
post-thumbnail

왜 사용했는가?

며칠 전 개발을 하던 중 504 에러의 발생으로 레이싱 지역을 불러오지 못하는 오류가 발생했었다.

// F1 의 경주 위치 지역을 보여주는 Meeting List Render Page
export default function Meeting() {
  const { setSelectedMeeting } = useSliceMergeStore()
  const [isSelectedmeeting_key, setIsSelectedMeeting] = useState<string | null>(null)
  const { data, isSuccess } = useFetchMeetings()

  const handleMeetingClick = (meeting: MeetingInterface) => {
    setSelectedMeeting(meeting)
    setIsSelectedMeeting(meeting.meeting_key) // 클릭된 항목의 키 설정
  }

  return isSuccess ? (
    <CardWithHeader headerText="2024 F1 GP 일정" width="21vw" height="93vh">
      <StyledList display="flex" flexDirection="column">
        {data.map((meeting: MeetingInterface) => (
          <StyledListItem
            display="flex"
            alignItems="center"
            key={meeting.meeting_key}
            height="47px"
            onClick={() => handleMeetingClick(meeting)}
            $isSelected={meeting.meeting_key === isSelectedmeeting_key} // 선택 여부 전달
          >
            <Typography variant="body1">{meeting.meeting_official_name}</Typography>
          </StyledListItem>
        ))}
      </StyledList>
    </CardWithHeader>
  ) : null
}

해당 방식은 TanStackQuery 에서 주는 isSuccess 값으로 api 가 쿼리가 오류 없이 응답을 수신하고 데이터를 표시할 준비를 마친 상태일때 리턴된 데이터를 이용하는 형식으로 해뒀었다. 나중에 에러처리를 할 생각으로 우선은 이런식으로 작성했었고 ErrorBoundary 를 통해서 Loading, Error 등의 상황을 유연하고 코드의 관심사를 분리하는 형식으로 리팩토링을 할 것이다.

내가 사용했던 버전

"axios": "^1.7.2"
"next": "^14.2.5"
"react": "^18.3.1"
"@tanstack/react-query": "^5.51.9"

구현에 필요한 코드파일

ErrorBoundary 를 쉽게 지원하는 라이브러리가 존재한다. 하지만 나는 라이브러리의 용량 및 커스터마이징이 가능하고 재사용가능하도록 직접 만들었다.

1. ErrorBoundary

기본적으로 제공하는 코드는 React Document 를 참고 하였다.
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary

또한 예제를 잘 제공한 곳도 공유한다.
https://www.mintmin.dev/blog/2404/20240427

class 로 작성된 이유는 아직 getDerivedStateFromError 메소드가 hook 으로 만들어저 있지않기 때문에 그렇다.
getDerivedStateFromError 는 에러정보를 state에 저장해 화면에 나타내는 용도이다.

주요 기능:

  1. getDerivedStateFromError 메서드: 자식 컴포넌트에서 에러가 발생하면 호출되어 상태를 hasError: true와 에러 정보를 포함하는 error로 업데이트한다. 이를 통해 에러를 감지한다.

  2. componentDidCatch 메서드: 에러가 발생한 후 호출되어, 발생한 에러와 관련된 정보를 로그로 기록한다. 이를 통해 에러를 모니터링할 수 있다.

  3. resetErrorBoundary 메서드: 에러 상태를 초기화하기 위해 호출된다. 이 메서드는 에러 상태를 초기화하고, onReset 함수를 호출하여 추가적인 초기화 작업을 수행할 수 있게 한다. 또한 아래에서 작성할 다시 불러오기 버튼을 누르면 TanStackQuery로 캐시 된 데이터까지 정상적으로 재설정된다.

'use client'

import { Component, ReactNode, ErrorInfo, ComponentType } from 'react'
import { ErrorPageProps } from './ErrorPage'

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

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

export default 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
  }
}

2. ErrorBoundaryWrapper

같은 코드의 중복을 줄이기 위해 Wrapper 를 작성했다.

ErrorBoundaryWrapper 컴포넌트는 에러 처리와 비동기 로딩 상태 관리를 위한 래퍼 컴포넌트이고 주요 기능은 다음과 같다.

주요 기능:

  1. QueryErrorResetBoundary: React Query와 통합되어 쿼리 에러를 초기화할 수 있는 reset 함수를 제공

  2. ErrorBoundary: 자식 컴포넌트에서 발생한 에러를 포착하고, 대체 UI를 표시하여 사용자에게 에러를 알릴 수 있습니다. reset 함수를 사용해 에러 상태를 초기화

  3. Suspense: 비동기 작업이 진행 중일 때 로딩 상태를 관리하고, 대체 UI를 보여줄 수 있다.

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

interface ErrorBoundaryWrapperProps {
  children: React.ReactNode
  fallbackComponent: ComponentType<ErrorPageProps>
  suspenseFallback: ReactNode
}

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

3. ErrorPage

ErrorPage 컴포넌트는 애플리케이션에서 에러가 발생했을 때 사용자에게 에러 메시지를 보여주고, 에러를 복구할 수 있는 기능을 제공한다.
ColorsBox, Typography 같은 스타일링 컴포넌트는 내가 작성한 스타일 컴포넌트이다.

주요 기능:

  1. 에러 메시지 표시: 에러가 발생하면 Typography 컴포넌트를 사용해 사용자에게 에러 메시지를 표시한다.

  2. 복구 버튼 제공: Button 컴포넌트를 사용해 "다시 불러오기" 버튼을 제공하여, 사용자가 에러를 초기화하고 다시 시도할 수 있도록 한다.

import { Colors } from '../styles/Colors'
import Box from './Atoms/Box/Box'
import Button from './Atoms/Button/Button'
import Typography from './Atoms/Typography/Typography'

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

export default function ErrorPage({ error, resetErrorBoundary }: ErrorPageProps) {
  return (
    <>
      <Box>
        <Typography variant="h5" color={Colors.primary}>
          문제가 발생했습니다.
        </Typography>
        <Typography variant="caption3">{error?.message}</Typography>
        <Button onClick={resetErrorBoundary}>다시 불러오기</Button>
      </Box>
    </>
  )
}

4. LoadingPage

Loading 컴포넌트는 데이터를 로딩하는 동안 사용자에게 로딩 상태를 표시하기 위해 사용한다.

주요 기능:

  • 로딩 메시지 표시: Typography 컴포넌트를 사용하여 "Loading..." 텍스트를 보여준다.
import Typography from './Atoms/Typography/Typography'

export default function Loading() {
  return (
    <div style={{ zIndex: 9999 }}>
      <Typography variant="h5">Loading...</Typography>
    </div>
  )
}

5. useSuspenseQuery

기존에 작성했던 useQueryuseSuspenseQuery 로 변경한다.
그 이유는 아래와 같다.

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

useQuery hookthrowOnError 옵션 사용 여부
useQuery사용 필요
useSuspenseQueryX

throw new Error('500 Errror') 를 이용하면 에러 상황을 시뮬레이션 가능하다.

import { useSuspenseQuery } from '@tanstack/react-query'
import axios from 'axios'

export interface MeetingInterface {
  circuit_key: string
  circuit_short_name: string
  country_code: string
  country_key: string
  country_name: string
  date_start: string
  gmt_offset: string
  location: string
  meeting_key: string
  meeting_name: string
  meeting_official_name: string
  year: number
}

export const fetchMeetings = async (): Promise<MeetingInterface[]> => {
  // throw new Error('500 Errror')
  
  const response = await axios.get(`https://api.openf1.org/v1/meetings?year=2024`)
  return response.data
}

export const useFetchMeetings = () => {
  return useSuspenseQuery<MeetingInterface[], Error>({
    queryKey: ['meetings'],
    queryFn: fetchMeetings,
    select: (data) => data.reverse(), // 최신순으로 정렬
  })
}

ErrorBoundary 적용

Main Page

'use client'

import ErrorBoundaryWrapper from '../../components/ErrorBoundaryWrapper'
import ErrorPage from '../../components/ErrorPage'
import Loading from '../../components/Loading'
import CardWithHeader from '../../components/Molecule/CardWithHeader/CardWithHeader'
import Information from '../Information/Information'
import Meeting from '../Meeting/Meeting'
import { Container } from './Main.styled'

export default function Main() {
  return (
    <Container>
      <CardWithHeader headerText="2024 F1 GP 일정" width="21vw" height="93vh">
        <ErrorBoundaryWrapper fallbackComponent={ErrorPage} suspenseFallback={<Loading />}>
          <Meeting />
        </ErrorBoundaryWrapper>
      </CardWithHeader>
      <Information />
    </Container>
  )
}

위에서 작성했던 컴포넌트들을 모두 적용한다. 해당 파일은 Meeting 파일에서 API 를 호출 할때 Loading, Error 등을 보여준다.

Meeting

'use client'

import { StyledList, StyledListItem } from '../../components/Atoms/List/List.styled'
import { MeetingInterface, useFetchMeetings } from '../../features/Meetings/useMeetings'
import { useSliceMergeStore } from '../../stores/useSliceMergeStore'
import Typography from '../../components/Atoms/Typography/Typography'
import { useState } from 'react'

export default function Meeting() {
  const { setSelectedMeeting } = useSliceMergeStore()
  const [isSelectedmeeting_key, setIsSelectedMeeting] = useState<string | null>(null)
  const { data } = useFetchMeetings()

  const handleMeetingClick = (meeting: MeetingInterface) => {
    setSelectedMeeting(meeting)
    setIsSelectedMeeting(meeting.meeting_key)
  }

  return (
    <StyledList display="flex" flexDirection="column">
      {data.map((meeting: MeetingInterface) => (
        <StyledListItem
          display="flex"
          alignItems="center"
          key={meeting.meeting_key}
          height="47px"
          onClick={() => handleMeetingClick(meeting)}
          $isSelected={meeting.meeting_key === isSelectedmeeting_key}
        >
          <Typography variant="body1">{meeting.meeting_official_name}</Typography>
        </StyledListItem>
      ))}
    </StyledList>
  )
}

useFetchMeetings() 를 통해서 API 를 불러오게되면 Loading ... 이라는 문구가 나오고 Error가 발생할 경우 작성해뒀던 ErrorPage 가 나오게 된다.

결과

throw new Error('500 Errror') 를 이용하여 에러 상황을 시뮬레이션 했다.
결과적으로 아래와 같다.

ErrrorBoundary를 적용하며 막혔던 부분

prefetchQuery

진짜 이것때문에 머리터지게 짜증이 났었는데..

서버에서 React Query prefetching 한 데이터 사용하는 방법에 대한 설명이다.
https://soobing.github.io/react/server-rendering-and-react-query/

나는 이 글을 보고 prefetching을 구현했었고,이때 내가 임의로 에러를 발생시킬수가 없었다.

처음에는 원인을 모르고 있었는데 계속해서 throw new Error() 로 에러를 발생시키면 렌더링되지 않고 메인페이지가 500으로 처리되어 화면이 나오지 않는 오류가 있어 아무리 생각해도 데이터를 패칭하는 곳의 문제일 것이라고 생각이 들어 확인해보니 prefetchQuery 때문에 발생하는 오류였다.

fetchQuery 와 prefetchQuery 의 차이

메소드내용
fetchQuery실패할 경우 에러를 던지며, 결과값에 대한 return을 할 수 있음
prefetchQuerySSR을 사용할 때 데이터를 미리 가져오는 메서드로 항상 성공한 쿼리만 dehydrate를 해줌

메서드로 항상 성공한 쿼리만 dehydrate를 해주는 문제 때문에 그랬던 것이였다.

구현한 코드는 아래와 같았다.

변경전

// 서버에서 API 호출 후
// dehydrate 를 통해 서버에서 클라이언트로 전송하는 형식

export default async function InformationPage() {
  const queryClient = new QueryClient()

  // prefetchQuery 데이터를 서버에서 미리 생성한 후 해당 데이터를 사용.
  // 이거때문에 에러바운더리 및 로딩이 작동하지 않았음.
  await queryClient.prefetchQuery({
    queryKey: ['meetings'],
    queryFn: fetchMeetings,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Main />
    </HydrationBoundary>
  )
}

변경후

import Main from '../../containers/Main/Main'

export default async function InformationPage() {
  return <Main />
}

이렇게 하여 정상적으로 동작하는 것을 볼 수 있었다.

profile
돈은 목적이 아닌 수단이다.

0개의 댓글