Suspense, Error Boundary로 비동기 로딩, 에러 로직 공통화하기(feat. Next.js, React-Query)

bruney·2023년 1월 24일
41

SW Maestro

목록 보기
3/5
post-thumbnail

충림이, refashion 프로젝트를 진행하던 중 컴포넌트에서 반복적으로 작성했던 에러, 로딩 처리가 비효율적이라는 것을 계속 깨닫고 있었고 이를 꼭 개선하고 싶었습니다. 때문에 검색을 통해 토스의 한재엽님, 박서진님이 공유해주신 자료를 보며 개선할 수 있게 되었고 제가 겪었던 어려움과 학습했던 것들을 공유하고자 합니다.

Table of Contents

  1. 기존 코드의 문제점
  2. 도입한 이유와 목표
  3. 동작원리 파악하기
    a. Suspense 동작원리
    b. Error Boundary 동작원리
  4. 선언형으로 처리하기
    a. 커스텀 Suspense 만들기
    b. Error Boundary 만들기
    c. AsyncBoundary 만들기
  5. 컴포넌트에서 사용하기
    a. 주의할 점
  6. SSR에서 사용하기
  7. 마무리
  8. 참고자료

1. 기존 코드의 문제점

진행했던 프로젝트들은 공통적으로 React-Query를 사용하였고 아래 코드들과 같이 에러와 로딩을 처리하고 있었습니다.

function Restaurant() {
  const { data, isLoading, isError } = useGetRestaurant();
  
  if(isLoading) return <span>식당 로딩 중..</span>;
  if(isError) return <span>식당 에러발생..</span>;
  
  return(
    <div>...</div>
  );
}

컴포넌트에서는 API 요청의 에러, 로딩, 성공 상태를 모두 관리하고 있습니다.

React-QueryisLoading, isError라는 변수를 제공하고 있어 현재 코드만 봤을 때는 큰 문제는 없어 보입니다. 하지만 프로젝트가 커지고 컴포넌트가 많아질수록 반복적으로 작성해야하는 코드는 정말 많아져 이는 개발자의 경험을 불편하게 만들며 불필요한 코드를 늘리게 됩니다.

React-Query를 사용하지 않고 선언적으로 비동기 코드를 개선하기

2. 도입한 이유와 목표

1. 위에서 말씀드린 문제점을 개선하고 싶었습니다.
2. Suspense를 사용하는 것에 이점이 많았습니다.

Suspense, Error Boundary 이외에도 HOC 컴포넌트를 만들어 처리하는 방법이 있겠지만 New Suspense SSR Architecture in React 18가 소개됨에 따라 Suspense를 사용하는 것에 이점이 많다는 생각을 했습니다. 뿐만 아니라 Streaming and Suspense 에 따르면 Streaming Server RenderingSeletive Hydration이 가능해져 더 좋은 성능의 웹 애플리케이션을 서빙할 수 있겠다는 생각이 들었습니다. React문서에 따르면 향후에는 Suspense가 데이터 가져오기(fetching)등의 더 많은 시나리오를 처리할 수 있도록 할 계획입니다. 라는 문구가 있습니다. 하지만 앞서 소개드린 레퍼런스에 따라 프로덕션에서도 data fetching에 충분히 적용해볼만하다는 판단이었습니다.

3. Error와 Loading 상태가 발생한 부분만 fallback을 render하고 싶었습니다.
비동기 프로그래밍을 하는 목표는 애플리케이션이 멈추지 않고 다른 작업을 동시에 할 수 있도록 하는 것입니다. 만약 로딩, 에러 상태가 발생했다고 해서 전체 애플리케이션이 멈추는 것처럼 보이는 것은 치명적이며 사용자에게 좋지 않다는 생각입니다. 따라서 부분적으로 로딩, 에러 상태를 보여주는 것이 좋다고 생각했습니다.

Suspense를 사용하면 로딩이 발생하는 부분에만 fallback을 render할 수 있습니다. 또한, Error Boundary로 에러가 발생하는 부분에만 fallback을 render할 수 있습니다. 레퍼런스처럼 이 2개를 결합하면 사용자, 개발자 모두에게 행복을 줄 수 있겠다는 생각이었습니다.

3. 동작원리 파악하기

처음 보는 Suspense와 Error Boundary를 사용하기 위해서는 동작원리를 파악하는 것이 먼저라고 생각했습니다.

학습하며 도움을 받았던 자료들은 아래 참고자료에 첨부하였습니다. 읽어보시면 도움이 될 듯 합니다.

Suspense

Suspense는 개발자가 Loading 상태를 선언적으로 관리할 수 있습니다. 여기서 Loading은 컴포넌트 Lazy Loading, Data Fetching 등에 해당됩니다. but React 팀에서는 Data Fetching에 Suspense를 사용하는 것을 공식적으로 권장하지는 않고 있지만 어떻게 Data Fetching이 가능할까요? 그 이유는 동작 원리에서 알 수 있습니다.

1. render method에서 캐시로부터 값을 읽는다.
2. value가 캐시되어 있으면 정상적으로 render한다.
3. value가 캐시되어 있지 않으면 캐시는 Promise를 throw한다.
4. promise가 resolve되면, React는 Promise를 throw한 곳으로부터 re-render한다.

위 내용처럼 핵심은 "컴포넌트는 Suspense(가장 가까운 Parent에 위치한)에게 Promise를 throw한다는 것", "데이터가 로딩되고 resolve되면 정상적인 컴포넌트를 render하는 것"입니다.

React Core Team의 개념적 구현 코드, React에서 제시한 Suspense for Data Fetching코드입니다. 이 코드들은 보면 React-Query에서는 정말 간단한 옵션(suspense: true)만으로 suspense를 사용할 수 있도록 한 것 같습니다.(물론 코드를 뜯어봐야 알겠지만요..)

Suspense에서 data fetching이 가능하게 하는 suspend-react라는 라이브러리도 있습니다.

Error Boundary

Error Boundary가 도입된 배경은 "UI의 일부분에 존재하는 JS 에러가 전체 애플리케이션을 중단시켜서는 안 된다."는 것입니다. 그리하여 에러를 어떤 경계 안에 가두고 정상적인 컴포넌트 대신 fallback UI를 보여주는 React의 컴포넌트입니다.

여기서 핵심은 getDerivedStateFromError, componentDidCatch 메소드입니다.
왜 Error Boundary는 class 컴포넌트로만 구현할 수 있을까요? 함수형 컴포넌트, hook은 컴포넌트의 생명주기 중 이 메소드들을 지원하지 않기 때문입니다.

getDerivedStateFromError
이 메소드는 정적 메소드로 하위의 자식 컴포넌트에서 오류가 발생했을 때 호출됩니다. 주의할 점은 render 단계에서 호출되므로, side effects를 발생시키면 안됩니다. 대신 아래에서 말씀드릴 componentDidCatch를 사용하면 됩니다.

componentDidCatch
이 메소드는 commit 단계에서 호출되므로, side effects를 발생시켜도 됩니다. 에러 로그 기록 등에 사용할 수 있습니다.

주의할 점
Error Boundary는 다음과 같은 에러는 포착하지 않습니다.
1. 이벤트 핸들러
2. 비동기 코드
3. SSR
4. 자식이 아닌 Error Boundary 자체에서 발생하는 에러

이 글을 쓰는 가장 큰 목적은 비동기 코드의 에러를 선언적으로 처리하는 것인데 그럼 어떻게 해결할까요? 한 번 찾아봤습니다. 이 글을 보면 매우 간단하게 setState를 이용하여 re-render하여 Error Boundary가 에러를 포착하도록 하는 모습입니다. 이 방법으로는 의문점이 해소되지 않아 다른 글을 통해 힌트를 얻을 수 있었습니다. 위 글에 따르면 unhandledrejection이벤트가 발생하면 Error Boundary를 업데이트하는 원리입니다.

그리하여 직접 코드를 작성하며 의문점을 해결해보았습니다. API에서 Promise.rejectthrow했더니 Error Boundary에서 에러를 캐치한 모습입니다.

아하! data fetching을 위해 suspense와 함께 사용된 wrapPromise으로 Promise.reject를 throw하면 Error Boundary에서 비동기 에러를 캐치할 수 있겠다는 생각이 들었습니다. React Query에서 제공하는 suspense 옵션도 이런 구조로 만들지 않았을까라는 의심을 해봅니다.

TKDodo's 블로그(Suspense)에 따르면 In case of errors, the error is bubbled up to the nearest ErrorBoundary.라고 쓰여 있습니다. 만약 Suspense를 사용하지 않는 분이시라면 React Query Error Handling을 읽어보시면 좋을 것 같습니다.

4. 선언형으로 처리하기

지금까지 문제점, 도입하는 이유와 목적, 사용할 React API의 동작 원리를 파악했으니 구현해보겠습니다!

커스텀 Suspense 만들기

가장 먼저 Suspense를 커스텀해보겠습니다. 제가 프로젝트를 진행했던 당시 Next.js 12버전에서는 SSR에서 Suspense를 지원하지 않았습니다.(13버전부터는 Suspense를 지원합니다.) 한재엽님 블로그를 참고하여 코드를 작성했습니다.(코드가 동일하여 따로 첨부하지는 않겠습니다.) const isServer = typeof window === "undefined"을 사용하지 않고 커스텀 훅을 사용하신 이유는 Hydration이슈 때문이라고 합니다.

Error Boundary 만들기

이제 Error Boundary를 만들어보겠습니다.

type ErrorFallbackProps<ErrorType extends Error = Error> = {
  error: ErrorType;
};

type ErrorFallbackType = <ErrorType extends Error>(
  props: ErrorFallbackProps<ErrorType>,
) => JSX.Element;

type Props = {
  errorFallback: ErrorFallbackType;
  children: ReactElement;
};

type State = {
  hasError: boolean;
  error: Error | null;
};
const initialState = { hasError: false, error: null };

class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = initialState;
  }
  
  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }
  
  render() {
    const { hasError, error } = this.state;
    const isErrExist = hasError && error !== null;
    const fallbacKUI = (err: ErrorFallbackProps['error']) =>
      errorFallback({ error: err });
    if (isErrExist) return fallbacKUI(error);
    return children;
  }
}

초기 코드입니다. 여기서는 에러가 발생하면 fallback UI를 보여주고 아니면 정상 컴포넌트를 보여주는 것이 목적입니다. 앞서 말씀드린 getDerivedStateFromError메소드를 추가하여 Error Boundary라는 것을 React에 알리는 것이 가장 중요합니다.

props로 받을 fallback 컴포넌트 type을 정의하며 기본 코드 작성을 마칩니다.

reset 기능 추가하기

Error Boundary 내부 상태에 hasError, error가 존재할 때, 초기화하고 싶은 경우가 있을 겁니다. 실패했던 API를 다시 요청하는 것이 그 예시입니다. 참고한 자료에서 볼 수 있듯이 정말 좋은 기능입니다.

사용자가 다시 시도버튼을 누르면 이전에 존재하던 에러를 초기화하고 다시 API 요청을 하는 것입니다. 다시 시도버튼 기능을 제공하면 사용자 입장에서도 새로고침하지 않아도 된다는 장점이 존재합니다.

  1. Fallback UI에서 reset하기
  2. reset을 선언적으로 호출하기

1. Fallback UI에서 reset하기

type ErrorFallbackProps<ErrorType extends Error = Error> = {
  ...
  reset: (...args: unknown[]) => void;
};

먼저 fallback UI에 reset 타입을 추가합니다.

원하는대로 reset함수를 바꿀 수 있도록 props에도 추가합니다.

type Props = {
  ...
  resetQuery?: () => void;
}

그 다음 Error Boundary에 reset메소드를 추가하고 fallbackUI에도 반영합니다.

resetBoundary = () => {
  const { resetQuery } = this.props;
  resetQuery?.();
  this.setState(initialState);
};

const fallbacKUI = (err: ErrorFallbackProps['error']) =>
  errorFallback({
    error: err,
    reset: this.resetBoundary,
  });

이 메소드를 사용함으로써 reset이 가능해졌습니다.

2. reset을 선언적으로 호출하기

React hook에서 사용하는 dependency array처럼 배열이 바뀌면 에러를 초기화하는 것입니다. 이 말만 봤을 때는 왜 추가하는 것인지 이해가 안될 수도 있습니다.

예시 영상 링크(velog에 video태그가 말을 안들어요..)

이 예시로 설명하자면 사용자가 본관버튼에서 에러를 마주한 상황에서 양진재 버튼을 눌렀을 때, 에러는 초기화되어야 합니다. 양진재에 대한 API를 요청했을 때, 성공 상태임에도 불구하고 에러 화면을 보여주면 안되니까요. 이 예시와 같이 메뉴, 필터 등 기능에 해당하는 항목들을 배열에 넣어 변경사항이 생긴다면 상태를 초기화하는 원리입니다.

const changedArray = (
  prevArray: Array<unknown> = [],
  nextArray: Array<unknown> = [],
) => {
  return (
    prevArray.length !== nextArray.length ||
    prevArray.some((item, index) => {
      return !Object.is(item, nextArray[index]);
    })
  );
};

type Props = {
  ...
  keys?: unknown[];
}

React 상태 비교에서 사용하는 Object.is를 통해 배열이 바뀌었는지 확인하는 함수를 작성해줍니다.

componentDidUpdate(prevProps: Props, prevState: State) {
  const { error } = this.state;
  const { keys } = this.props;
  
  if (
    error !== null &&
    prevState.error !== null &&
    changedArray(prevProps.keys, keys)
  ) {
    this.resetBoundary();
  }
}

componentDidUpdate 생명주기 함수에서 keys가 변경되면 resetBoundary함수를 호출합니다.

API 에러 예외 처리하기

여기까지 보았을 때는 에러가 발생하면 단순히 Error Fallback UI를 보여주는 형태입니다. 하지만 401, 403, 404 등 애플리케이션에서 따로 redirect 등 처리를 해야하는 경우도 있습니다. 그 기능을 만들어봅니다.

render() {
  ...
  if (isInstanceOfAPIError(error)) {
    const { redirectUrl, notFound, status } = error;
    if (redirectUrl) router.replace(redirectUrl);
    if (notFound) return <NotFoundPage />;
  }
  ...
}

isInstanceOfAPIError라는 API에러 종류를 판단하는 함수를 만들어 처리했습니다. 제가 고민하고 적용했던 예외 처리 구조 설계하기는 다음 포스팅에서 소개하도록 하겠습니다.

API 상태에 따라 Error Fallback 혹은 다른 UI를 보여주기

401과 같은 특수한 API 에러가 발생했을 때, 위에서 제시했던 Error Fallback 이외에 다른 UI를 보여주고 싶은 경우가 있을 겁니다. 그 기능을 구현해보겠습니다.

type Props = {
  ...
  otherRenderComponent?: JSX.Element;
  includedStatusCodes?: number[];
};
render() {
  ...
  const { otherRenderComponent, includedStatusCodes } = this.props;
  if (isInstanceOfAPIError(error)) {
    ...
    const isIncludeOtherStatus = includedStatusCodes?.some(
        (code) => code === status,
      );
    if (redirectUrl && !isIncludeOtherStatus) router.replace(redirectUrl);
    if (isIncludeOtherStatus) return otherRenderComponent;
  }
  ...
}

redirectUrl && !isIncludeOtherStatus 조건을 넣어준 이유는 otherComponent를 render하고 싶은데 redirect하는 경우가 생길 수도 있습니다. 그 경우를 방지하기 위함입니다. 즉, 순수하게 redirect하고 싶은 경우만을 정의한 것입니다.

여기까지 Suspense와 Error Boundary를 구현해보았습니다. 이제 컴포넌트에서 사용하기 위해 나아가봅시다!

Error Fallback 컴포넌트 만들기

에러 발생 시 사용자에게 보여줄 UI를 만들어보겠습니다. Error Boundary 컴포넌트에서 errorFallbackprops를 보면 error, reset이 있습니다. error가 들어 있는 이유가 뭘까요? 사용자에게 에러에 따라 서로 다른 메시지를 보여주기 위함입니다. 사용자가 에러 메시지를 꼭 알 필요는 없을 수도 있습니다. 하지만 필요한 경우도 있을 겁니다. 이 메시지는 Back-end 개발자와 상의하여 커스텀하여 보내주거나 Front-end에서 따로 예외 처리를 해주는 것이 좋을 것 같습니다.

type Props = {
  error: Error | ApiError;
  reset: () => void;
};

function ErrorFallback(props: Props) {
  const { error, reset } = props;

  return (
    <div>
      <Span>{error.message}</Span>

      <button type="button" onClick={reset}>
        <Span>다시 시도</Span>
      </button>
    </div>
  );
}

이렇게 UI를 구성해주면 사용자는 어떤 에러가 발생했는지 알 수 있으며 다시 시도버튼을 통해 새로고침하지 않고 API를 재요청할 수 있게 됩니다. 선언적으로 로직을 공통화하다 보니 UX도 개선할 수 있게 되었습니다.

AsyncBoundary 만들기

핵심은 개발자가 편하게 사용할 수 있도록 선언형으로 처리하기입니다. Suspense와 ErrorBoundary를 사용하는 것만으로도 충분히 선언적인데 컴포넌트에서 더더더 편하게 사용하기 위해 추상화하여 이 둘을 합쳐보겠습니다.

function AsyncBoundary(){
  return (
    <ErrorBoundary>
      <Suspense>
        {children}
   	  </Suspense>
    </ErrorBoundary>
}

와 같은 형태로 만드는 것입니다.

type ErrorBoundaryProps = ComponentProps<typeof ErrorBoundary>;

type Props = {
  suspenseFallback: ComponentProps<typeof SSRSafeSuspense>['fallback'];
  errorFallback: ErrorBoundaryProps['errorFallback'];
  keys?: Array<unknown>;
  otherRenderComponent?: JSX.Element;
  includedStatusCodes?: number[];
};

function AsyncBoundary(props: PropsWithChildren<Props>) {
  const { suspenseFallback, errorFallback, children, keys } = props;
  const { otherRenderComponent, includedStatusCodes } = props;
  const { reset } = useQueryErrorResetBoundary();
  const resetHandler = useCallback(() => {
    reset();
  }, [reset]);

  return (
    <ErrorBoundary
      resetQuery={resetHandler}
      {...{ errorFallback, keys }}
      {...{ otherRenderComponent, includedStatusCodes }}
    >
      <SSRSafeSuspense fallback={suspenseFallback}>{children}</SSRSafeSuspense>
    </ErrorBoundary>
  );
}

앞서 구현했던 Props에 따라 그대로 넣어주면 됩니다. 여기서의 핵심은 resetHandler를 만들어주는 것입니다. 저는 React-Query를 사용했기에 useQueryErrorResetBoundary라는 API를 사용하기로 했습니다. 자 이제 컴포넌트에서 사용하기만 하면 됩니다!

5. 컴포넌트에서 사용하기

function MainHeader() {
  const LoginBtn = (
    <Link href="/login">
      <button type="button" aria-label="로그인 버튼" className={$.login}>
        로그인
      </button>
    </Link>
  );

  const UserSkeleton = <div className={$['user-skeleton']} />;

  return (
    <div className={$['main-header']}>
      <h1 className={$.title}>re:Fashion</h1>
      <AsyncBoundary
        suspenseFallback={UserSkeleton}
        errorFallback={ErrorFallback}
        otherRenderComponent={LoginBtn}
        includedStatusCodes={[401, 403]}
      >
        <UserDropDown />
      </AsyncBoundary>
    </div>
  );
}

한 눈에 봤을 때, 어떤 동작을 하는지 파악할 수 있지 않나요? 만약 아니라면 죄송합니다..(이제 보니 변수명이 헷갈릴 소지가 있어 리팩토링 또한 진행해야겠군요..)

suspenseFallback에는 로딩 시 보여줄 스켈레톤 UI, 그리고 401, 403 에러 발생 시 로그인 버튼을 보여주기, 이외의 에러에는 errorFallback UI를 보여주는 것입니다.

여기서 주의할 점은 API 호출을 boundary 안에 가두는 것입니다. 그럼 내부 컴포넌트를 살펴보겠습니다.

function UserDropDown() {
  const { data } = useQuery(queryKey.myInfo, () => getMyInfo(), { suspense: true });
  ...

  return (
    <DropDown
      options={options}
      name="my-menu"
      top="40px"
      right="0"
      className={$['profile-container']}
    >
      {data}
      <SelectArrow className={$.arrow} stroke="#fff" />
    </DropDown>
  );
}

suspense: true옵션을 써주어야 합니다. 그 이유는 앞서 소개드린 Suspense의 동작 원리에서 보실 수 있습니다.

컴포넌트에서는 보시다시피 성공 상태에만 신경쓸 수 있습니다. 이로서 제시했던 문제와 목표 모두 해결할 수 있었습니다.

주의할 점

  1. API 호출은 boundary 안에 가두기
  2. 최대한 좁은 부분에 boundary 적용하기. 비동기 프로그래밍의 목적과도 비슷합니다.

6. SSR에서 사용하기

Next.js를 사용했기에 SSR에서도 사용해보았습니다. 결론은 잘 동작하였습니다. 이전까지 작성한 코드는 동일하며 추가적으로 getServerSideProps 함수를 구현하였고 dynamic을 사용했을 때 동작이 잘 되었습니다.(이유를 계속 고민해보고 있습니다..) React-Query에서 suspense는 실험 기능인지라 SSR과 함께 사용하기에는 불안정한 것 같습니다. 아래 사례가 있습니다.

마무리

비동기 로딩, 에러 로직을 공통화함으로써 얻은 가장 큰 이득은 컴포넌트에서 비동기 성공 상태와 비즈니스 로직에만 집중할 수 있다는 것입니다. "컴포넌트마다 에러, 로딩 처리 로직을 작성하는 것과 AsyncBoundary컴포넌트를 감싸는 것이 마찬가지"라고 생각할 수도 있습니다. but 위의 Error Boundary 컴포넌트에서도 다뤘듯이 API 에러 예외처리, 특정 API 에러에는 공통 fallback다른 fallback을 보여줘야 하는 경우 등 컴포넌트에서는 비즈니스 로직에만 신경쓰기도 힘든데 귀찮은 예외 처리까지 신경쓰기에는 개발자의 시간은 매우 소중합니다. 또한, Data Fetching에서 발생하는 예외 케이스는 대부분 공통적인 이유(401, 404 등)라고 생각합니다. 공통화하는 것은 분명한 이득이 있다고 생각합니다.

React 18에 들어서며 Suspense가 정말 핫한 이슈가 되며 많은 분들이 Suspense를 사용하는 것으로 보아 표준이 된 것 같기도(?) 합니다. 이 기술을 사용하기 위해 좋은 자료들을 참고하며 먼저 동작원리를 파악했고 제가 직면했던 문제점들을 해결할 수 있었습니다.(좋은 자료를 공유해주신 모든 분께 감사의 말씀을 드립니다. 🙏) 더 나은 예외 처리를 위해 고민도 해보았습니다. 추후에 예외 처리 구조 설계하기, SSR, CSR에서 Auth 처리하기 등 제가 고민했던 경험들을 정리하고자 합니다.

긴 글 봐주셔서 진심으로 감사합니다. 질문과 지적 환영합니다!

참고자료

profile
Detail makes difference.

1개의 댓글

comment-user-thumbnail
2023년 2월 13일

좋은 글 감사합니다 참고했어요~👍

답글 달기