[기록/회고] suspense, errorBoundary, QueryErrorResetBoundary를 활용한 공통 컴포넌트 구현

김하연·2024년 3월 29일
1

우당탕탕

목록 보기
11/11

리액트의 suspense와 errorBoundary, 그리고 react-query의 QueryErrorResetBoundary를 활용한 공통 컴포넌트 구현

상대 유저의 프로필 카드 내에 상대와 나의 성향을 기반으로 둘의 궁합을 점쳐주는 에픽을 진행하면서 작업하게 되었다.
해당 작업의 요구사항은 아래와 같았다.

  • 상대 유저의 프로필카드를 조회할 때, 스크롤이 궁합 섹션에 도달하면 궁합을 점쳐보고 있다는 내용의 의도적인 로딩 UI를 노출시킨다.
  • 궁합 정보 API의 경우 상대 프로필 정보를 획득하는 API와 별개라서 프로필 정보 조회는 성공하고 궁합 정보 조회는 실패하는 경우에 대비해 궁합 정보 실패 시 재시도 버튼을 노출시킨다.
    재시도 버튼 클릭 시 API를 다시 요청한다.

이렇게 봤을 때, 구현이 필요한 주요 기능은

  • 로딩 시 특정 UI 노출
  • 실패 시 특정 컴포넌트 노출 및 재시도 기능

으로 볼 수 있었고, 이 기능들만 봤을 때 바로 리액트의 suspense, errorBoundary 컴포넌트, 그리고 react-query의 QueryErrorResetBoundary 기능을 조합한 컴포넌트를 실무에서 적용해볼 수 있는 절호의 찬스!! 라는 생각이 들어 해당 기능들을 활용해 개발을 진행했다.


간단 개념

suspense

suspense 컴포넌트는 자식 컴포넌트가 로딩 작업을 끝낼때까지 fallback 컴포넌트를 보여준다.

suspense의 props

  • children
    실제로 보여주고자 하는 UI
  • fallback
    실제 보여주고자 하는 UI, 즉 children 컴포넌트가 아직 로딩중일 경우 대신 노출되는 placeholder 역할

errorBoundary

리액트에서는 컴포넌트 렌더링 중 에러를 만나면 렌더링을 멈추고 빈 화면을 띄워준다. 이를 대비해 컴포넌트가 깨질 경우 대체 컴포넌트를 보여주기위해 Error Boundary를 활용할 수 있다.

Error Boundary는 클래스형 컴포넌트로만 구현이 가능하다.
기본적으로 생명주기 함수인 getDerivedStateFromError()와 componentDidCatch()를 사용해 에러를 핸들링한다.

아래는 공식문서에서 제공하는 error boundary 코드이다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false }; // 에러 상태 초기화
  }

  static getDerivedStateFromError(error) {
    // state의 값을 업데이트하여 다음 렌더링 때 fallback UI를 노출한다.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // 해당 메서드가 에러를 캐치하여 로깅 등의 작업을 수행할 수 있다.
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
	  // hasError가 true일 경우 커스텀 fallback UI를 노출
      return this.props.fallback;
    }

    return this.props.children;
  }
}

QueryErrorResetBoundary

react-query에서 제공하는 컴포넌트로, reset 함수를 매개변수로 받아서 JSX를 렌더링하도록 설계되었다. reset 함수 실행 시 해당 컴포넌트의 바운더리 내에서 발생한 모든 쿼리 에러를 리셋해주는 기능을 한다.

import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

const App: React.FC = () => (
  <QueryErrorResetBoundary>
    {({ reset }) => (
      <ErrorBoundary
        onReset={reset}
        fallbackRender={({ resetErrorBoundary }) => (
          <div>
            There was an error!
            <Button onClick={() => resetErrorBoundary()}>Try again</Button>
          </div>
        )}
      >
        <Page />
      </ErrorBoundary>
    )}
  </QueryErrorResetBoundary>
)

출처: TanStack Query 공식 홈페이지


구현 계획

  • 상대 유저 프로필 페이지를 조회하면 프로필 정보와 동시에 궁합 컨텐츠 정보 데이터를 패칭한다.
  • 상대 유저 프로필 페이지를 조회했을 때, 중반부에 위치한 궁합 컨텐츠 상단에 화면 스크롤이 도달할 경우 의도적으로 1초간 로딩 컴포넌트를 노출시킨다.
    - 해당 영역까지 스크롤이 도달했음을 확인하는 기능은 기존에 사용중인 react-intersection-observer 라이브러리의 useInview를 활용한다.
  • 1초가 지난 후, 이미 패칭되어있던 궁합 정보를 화면에 보여준다.
    - 궁합 컨텐츠 컴포넌트는 <Suspense/> 컴포넌트로 감싸고 1초가 지나도 아직 궁합 데이터가 패칭되지 않았다면 fallback prop으로 전달한 로딩 ui를 노출시킨다.
    - 데이터가 이미 준비된 상태라면 정상적으로 children 컴포넌트인 궁합 컨텐츠 컴포넌트를 노출시킨다.
  • 궁합 정보 데이터 API에서 에러가 발생했을 경우
    - <ErrorBoundary/> 컴포넌트에 fallback prop으로 전달한 에러 ui 컴포넌트를 노출시킨다.
    - <ErrorBoundary/> 컴포넌트에 onReset prop으로 전달한 리셋 이벤트를 유저가 실행할 경우 궁합 정보 데이터 API 요청을 재시도 한다.

구현 코드

errorBoundary.tsx 코드 일부

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

    // error 에 대한 상태 초기화
    this.state = {
      hasError: false,
      error: null,
    };

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

  // 에러가 발생했을 때 이 메서드가 호출된다.
  static getDerivedStateFromError(
    error: Error | AxiosError,
  ): ErrorBoundaryState {
    // 에러가 발생했으므로 에러에 대한 새로운 상태값을 반환한다.
    return {
      hasError: true,
      error,
    };
  }

  // 에러 발생 시 보여지는 fallback 컴포넌트에서 리셋 시 실행되는 이벤트
  resetErrorBoundary(): void {
    const { props } = this;
    props.onReset();

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

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

    const { hasError, error } = state;

    const { fallback, children } = props;

    const fallbackProps: FallbackProps = {
      error,
      resetErrorBoundary,
    };

    const fallbackComponent = createElement(fallback, fallbackProps);

    // 에러가 발생했을 경우 fallbackComponent를 렌더링하고,
    // 에러가 발생하지 않았을 경우 children을 렌더링한다.
    return hasError ? fallbackComponent : children;
  }
}

궁합 컨텐츠 코드 일부

const { ref, inView } = useInView({ threshold: inViewThreshold });

return (
  <div className="chemistry-info-wrapper" ref={ref}>
    // 의도적으로 추가한 loading 시간인 1초가 지날 경우
    // isLoading값은 false로 변경됨
    {isLoading ? (
      <ChemistrySuspenseFallback />
    ) : (
      <Suspense fallback={<ChemistrySuspenseFallback />}>
        <QueryErrorResetBoundary>
          {({ reset }) => (
            <ErrorBoundary onReset={reset} fallback={ErrorFallback}>
              <Chemistry targetProfileId={targetProfileId} />
            </ErrorBoundary>
          )}
        </QueryErrorResetBoundary>
      </Suspense>
    )}
  </div>
)

위와 같이 구현 후 <Chemistry/>컴포넌트 안에서 패칭하는 데이터의 쿼리에 suspense: true 옵션을 추가하여 suspense 기능을 활성화 하였다.

useInview 라이브러리 사용으로 스크롤이 궁합 컨텐츠 포지션에 도달했을 때 1초의 의도적인 지연시간 이후 정상적으로 궁합 컨텐츠가 노출되었고, 의도적으로 API에 에러를 발생시켜 테스트했을 때 1초의 의도적인 지연시간 이후 ErrorBoundary의 fallback ui가 노출되었다.
그리고 ui가 노출되었을 때 QueryErrorResetBoundary의 reset 기능을 활용해 reset을 실행하자 QueryErrorResetBoundary 컴포넌트 하위의 모든 API에 대해 재요청을 시도했고, 다시 정상적인 데이터를 응답받아 궁합 컨텐츠가 노출됨을 확인할 수 있었다.

고도화 - 공통 컴포넌트로 구현하기

에픽 작업 시 종종 로딩 ui 노출이나 실패 시 재시도 컴포넌트 노출 등의 기능이 요구사항에 등장하곤 했다.
그래서 suspense + errorBoundary + QueryErrorResetBoundary 를 이용한 컴포넌트를 개발한 김에 해당 기능들을 공통 컴포넌트로 만들어 쉽게 사용할 수 있도록 구현해두면 좋을 것 같아 공통 컴포넌트화 작업을 추가로 진행해보았다.

조건

  • 너무 많은 API에 suspense 기능을 적용할 경우 네트워크 waterfall 현상이 발생하므로 해당 컴포넌트에는 최소 단위의 기능에만 적용하는 것을 조건으로 한다.
  • 다양한 기능에, 여러개의 컴포넌트로 구현할 필요가 생긴다면 각각의 기능을 각각 다른 Suspense 컴포넌트로 감싼다.

suspense의 무분별한 사용 시, 하나의 API에 suspense를 적용해 지연시키고 그 이후 또 나머지 API에 suspense를 적용해 지연시키고, 또 계속해서 이 과정이 반복될 수 있다는 부작용을 인지해 위 조건과 같이 해당 컴포넌트는 최소 단위의 기능에(가능하다면 1~2개의 API에) 사용하는 것을 목적으로 하였다.

공통 컴포넌트 구현 코드

// SuspenseWrapper.tsx
interface SuspenseWrapperProps {
  loadingFallback: ComponentType;
  errorFallback: ComponentType<FallbackProps>;
}

export const SuspenseWrapper = ({
  loadingFallback: LoadingFallback,
  errorFallback: ErrorFallback,
  children,
}: PropsWithChildren<SuspenseWrapperProps>) => {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => {
        return (
          <ErrorBoundary fallback={ErrorFallback} onReset={reset}>
            <Suspense fallback={<LoadingFallback />}>{children}</Suspense>
          </ErrorBoundary>
        );
      }}
    </QueryErrorResetBoundary>
  );
};

위와 같이 구현해 loadingFallback, errorFallback props에 각 컴포넌트를 전달받아 커스텀 UI가 적용될 수 있도록 하고,
errorFallback 컴포넌트의 경우 ErrorBoundary 컴포넌트 내부에서 props로 error와 resetErrorBoundary를 주입받아 렌더링된다.

// SuspenseWrapper 컴포넌트를 import 하여 사용하는 컴포넌트
<SuspenseWrapper
  loadingFallback={LoadingFallback}
  errorFallback={ErrorFallback}
>
    <FeatureComponent />
</SuspenseWrapper>

그리고 Suspense와 Error Boundary를 사용할 수 있도록 공통 컴포넌트로 구현된 컴포넌트는 위와 같이 loading, error에 대한 fallback 컴포넌트만 props로 넘겨주면 하위 컴포넌트의 요청이 처리중일 때, 혹은 에러가 발생했을 때 상황별 컴포넌트를 자동으로 보여줄 수 있게 된다.

고민했던 부분

SuspenseWrapper 에 loadingFallback, errorFallback 형식 통일하기

SuspenseWrapper 컴포넌트의 props인 loadingFallback, errorFallback을 어떤 형태로 넘겨야할지 고민했다.
<LoadingFallback/>, <ErrorFallback/> 과 같은 element 형태로 넘겨야 할지 혹은 LoadingFallback, ErrorFallback과 같이 컴포넌트 형태로 넘겨야 할지 고민을 했다.
그리고 고민의 결과는 임포트한 컴포넌트 형태 그대로 넘기는 방법으로 결정하였고 그 이유는 아래와 같다.

  • SuspenseWrapper 컴포넌트 내부에서 loadingFallback, errorFallback을 어떻게 사용할지, 어떻게 렌더링할지 등을 정의하기 때문에 외부에서는 fallback 컴포넌트를 props로 전달하는 역할만 해야한다.
  • element 형식으로 넘기게 될 경우 필요에 따라 <loadingFallback value="로딩"/> 등과 같이 새로운 props를 추가하고자 하는 욕구(?)가 생길수도 있고 해당 컴포넌트를 가공하고자 하는 마음이 생길 수 있으므로.. 이에 따라 의도치 않은 로직이 추가돼 사이드이펙트가 발생할 수 있다.

작업을 통해 얻은 것 & 느낀점

  • 공부한 내용을 실무에 문제 없이 적용했을 때의 짜릿함이란
    마침 suspense와 errorBoundary 사용에 대한 기술 블로그와 아티클을 본지 얼마 되지 않았던 시점인데, 개념 이해를 넘어 실제로 실무에서 적용해보는 경험은 재밌고 성취감이 더 컸던 것 같다. 그리고 공부를 꾸준히 해야 어떤 상황에서 어떤 기술을 사용하면 좋을지, 도입해도 될지 말지 등에 대한 판단력도 키울 수 있음을 더욱 깨달았던 에픽이었다.
  • 특정 목적으로 만들어진 기술을 사용하니 구현 난이도와가 감소한 부분은 좋았다!
    로딩 및 에러 상태를 캐치해 각 상황별 커스텀 ui를 노출하는 컴포넌트를 개발하는 것이 불가능한 것은 아니지만, 이 기능을 위해 만들어진 기술을 사용하니 개발 난이도가 훨씬 감소한다는 장점이 있었다. 물론 이전에 suspense와 errorBoundary, QueryErrorResetBoundary를 활용한 컴포넌트 구현에 대한 아티클을 미리 학습했기에 조금 더 시간이 단축된 부분도 있었지만 loading, error 상황별 fallback ui를 렌더링하는 컴포넌트를 내가 직접 구현했더라면 아마 해당 내용을 학습하고 구현하는데에 소요된 시간보다 더 오래 걸렸을 것 같다. 직접 만들었다면 고려해야하는 케이스가 다양했을 것 같은데, 해당 기술들 덕분에 수월하게 구현되어 뿌듯했다. 잘 만들어진 기술이 있고 도입하는데에 무리가 되지 않고 안정성이 어느정도 보장된다면 시도해보는 것도 나쁘진 않은 것 같다.
  • 조금 더 구체화된 개념을 이해하자
    suspense와 errorBoundary 컴포넌트를 조합해 사용했을 때 좋은 점은, 로딩과 에러 상태별 코드를 따로 추가 작성하지 않고 지정된 props에 컴포넌트만 넘겨주면 나머지는 컴포넌트가 알아서 처리해준다! 라는 편리함 정도로 생각했는데, 작업을 하면서 아티클과 기술 블로그 여러개를 찾아보고서야 구체적인 개념을 이해했다. 내가 이해한 부분을 개념적으로 설명하자면 로딩과 에러 발생 시 어느 컴포넌트를 노출할것인가에 대한 구현을 선언형으로 작성 가능하다는 의미였다. 막연하게 "편리함" 이라는 것만 생각했는데, 그 편리함이라는 개념을 개발적으로 구체화 하면 선언형이라고 말할 수 있는 부분이었다. 추상적인 개념을 이해하기보단 보다 더 개발적인, 구체적인 표현으로 명확하게 이해할 필요가 있다.

참고한 블로그

🔗 https://happysisyphe.tistory.com/54
🔗 https://yiyb-blog.vercel.app/posts/error-boundary-with-react-query
🔗 https://www.jbee.io/articles/react/React%EC%97%90%EC%84%9C%20%EC%84%A0%EC%96%B8%EC%A0%81%EC%9C%BC%EB%A1%9C%20%EB%B9%84%EB%8F%99%EA%B8%B0%20%EB%8B%A4%EB%A3%A8%EA%B8%B0

0개의 댓글