치춘짱베리굿나이스

Suspense와 Error Boundary를 이용한 로딩과 예외처리 본문

ClientSide/React

Suspense와 Error Boundary를 이용한 로딩과 예외처리

치춘 2022. 12. 10. 16:13

Suspense와 Error Boundary를 이용한 로딩과 예외처리

기존의 구현 방식

export const DetailPage = ({ isMine = false }: Props) => {
  const { id } = useParams();
    const nav = useNavigate();

  const [data, setData] = useState<ProfileType>({
    username: '',
    code: '',
    language: '',
    interest: '',
    techStack: [],
    worktime: '',
    worktype: '',
    email: '',
    requirements: [],
    liked: false, // useState 초기화
  });

  useEffect(() => {
    fetchUserData(id)
      .then((res) => {
        setData(res);
      })
      .catch((err) => {
        alert(err);
                nav('/');
      });
  }, []);

    return <ViewModeContainer
      userId={id}
      profileData={data}
    />;
};
  • 기타 로직을 덜어낸 간략화된 코드이긴 하나, Suspense와 Error Boundary를 적용하기 전 코드는 위와 같다
    • 초기화용 데이터 (username, code, language 등이 빈 문자열로 초기화된 객체) 로 상태값을 초기화한다
    • 컴포넌트가 Mount되었을 때 (useEffect) fetch를 진행한다 (비동기)
    • fetch 후 데이터를 받아왔을 때 then을 통해 상태값을 제대로 데이터가 들어있는 객체로 set한다
    • 에러가 발생할 경우, catch에서 잡아준다
    • 이 방식은 fetch-on-render 라고도 불리며, 렌더링 직후에 데이터를 불러온다는 뜻이다

문제점

  • 존재하지 않는 ID를 입력하여 fetch가 실패하고, 예외가 catch되었을 때에도 컴포넌트가 렌더링된다
    • 이때 데이터가 아무것도 들어있지 않는 컴포넌트가 기본적으로 렌더링되므로, 오히려 에러가 발생한 것 같지 않아 보이기도 한다
    • 아무런 내용이 없는 빈 객체를 이용하여 컴포넌트가 렌더링되는 탓에, 사용자가 혼란을 겪을 가능성이 있다
    • 불필요한 렌더링이 일어나므로, 에러 처리가 깔끔하게 되지 않았다는 느낌을 주며, 렌더링으로 인한 리소스 낭비가 존재한다

 

  • 정상적으로 데이터를 불러왔을 때, (초기 상태값이 빈 내용물이 담긴 객체이므로) 처음에는 데이터가 없는 컴포넌트가 렌더링된 뒤, 상태값이 변경되면서 내부 내용물을 한번 더 렌더링한다
    • 이 때에도 마찬가지로 불필요하게 컴포넌트가 2번 렌더링된다
    • 데이터 fetch가 완료되지 않은 시점에 빈 객체를 이용한 컴포넌트 렌더링이 발생하며, 빈 컴포넌트가 그대로 노출된다
    • 사용자의 컴포넌트 성능에 따라 fetch가 늦어질 경우, 그동안 빈 컴포넌트가 지속적으로 노출된다

총평

  • 상세 프로필 페이지의 경우, 실재하는 사용자의 프로필을 노출시키는 페이지인 만큼 컴포넌트에 존재하는 공백이 어색하게 느껴진다
  • 사용자의 프로필 fetch가 완료되기 전까지 컴포넌트 렌더링을 늦추고 싶다
  • fetch 도중 예외가 발생하면, 아예 프로필 페이지 렌더링이 안 되게 하고 싶다

Suspense

Suspense for Data Fetching (Experimental) - React

React Top-Level API - React

  • 리액트에서 공식적으로 지원하는 API로, 리액트 17에서는 시범적인 기능이었지만 18에서 정식 도입되었다
  • 18버전 공식 문서에서는 Lazy loading 컴포넌트에 대해서만 지원한다고 하지만, 17버전 공식 문서에서는 데이터와 컴포넌트 등 대부분의 것을 ‘기다릴’ 때 사용할 수 있다고 한다
    • Lazy Loading은 lazy() 메서드를 사용해서 구현할 수 있다
  • Suspense를 통한 데이터 렌더링 방식을 Render-as-you-fetch (렌더링과 동시에 fetch) 라고 부른다
    • fetch-on-render는 렌더링이 먼저 시작된 뒤 fetch되므로, 불러와야 하는 데이터가 많을 경우 컴포넌트가 순차적으로 그려지기 때문에 미관상 좋지 않다 (Waterfall 현상)
    • fetch-then-render는 불러와야 하는 데이터가 여러 종류일 경우 Promise.all을 이용하지 않는 이상 모든 불러오기를 차례로 진행하기 때문에 컴포넌트 렌더링이 매우 늦어진다
  • Suspense는 컴포넌트 내부에서 사용하는 값이 전부 fetch되었는지, 아닌지 여부를 판단하고, 데이터 불러오기가 끝나지 않았다면 prop으로 받아온 fallback 컴포넌트를 미리 렌더링한다
    • 이를 이용하여 로딩 컴포넌트를 쉽게 구현할 수 있다
    • 결론적으로, Suspense는 데이터 등의 로딩 여부에 따라 컴포넌트 렌더링을 늦출 수 있고, 그 동안 다른 컴포넌트를 렌더링하는 등의 처리가 가능하게끔 해준다

Suspense 를 위한 Promise 생성하기

const PENDING = 0;
const SUCCESS = 1;
const ERROR = 2;

export function fetchUserData(userID: string | null) {
  let status = PENDING;
  let result: Error | ProfileType;

  const suspender = fetch(`${process.env.REACT_APP_FETCH_URL}${API.DETAIL}${userID}`)
    .then(checkStatusCode)
    .then(checkCustomCode)
    .then(
      (res) => {
        status = SUCCESS;
        result = res;
      },
      (err) => {
        status = ERROR;
        result = err;
      }
    );

  return {
    read: () => {
      if (status === PENDING) throw suspender;
      else if (status === ERROR) throw result;
      else return result as unknown as ProfileType; // Error 타입의 변수의 경우 위에서 반드시 throw됨
    },
  };
}
  • 매우 복잡한 함수가 등장한다… 하나하나 분석해 보자
  1. statusPENDING, SUCCESS, ERROR 세 종류로 구성되며, 각각 fetch 중, fetch 완료, fetch 도중 에러 발생을 의미한다
    • 아래에 보면 알 수 있듯이, status는 처음에 PENDING으로 초기화되며, 모든 비동기 동작들이 완료된 후 SUCCESS 또는 ERROR로 변경된다
  2. result는 fetch 결과값을 의미한다
    • 응답이 정상적으로 처리되면 result에는 처리된 응답이 저장된다
    • 예외가 발생할 경우 result는 예외가 저장되며, 마지막에 throw된다
  3. suspender가 실제로 데이터를 fetch하는 영역이다
    • 상세 페이지의 유저 정보 를 가져오기 위해, fetch로 userID에 대한요청을 보낸다
    • 응답이 돌아오면, 응답의 상태 코드와 커스텀 상태 코드를 검사하기 위한 유틸 함수인 checkStatusCode, checkCustomCode가 호출된다
    • 예외가 발생하지 않았을 경우 statusSUCCESS가 되며, 결과값은 응답받은 데이터가 저장된다
    • 예외가 발생하였을 경우 statusERROR가 되며, 결과값에는 발생한 예외가 저장된다
  4. fetchUserData의 반환값은 read라는 함수로, 이 함수는 fetchUserData 스코프 내에서 statusresult를 사용한다
    • statusPENDING일 경우 (fetch 처리 중일 경우) suspender Promise를 throw한다
      • 예외가 아니라 Promise 그 자체를 throw하는 것이 요상해보일 수 있는데, Suspender에서는 자식 컴포넌트의 렌더링 도중 Promise가 throw될 경우 ‘Promise가 아직 처리되지 않았다’ 라고 판단하여 임시로 fallback 컴포넌트를 렌더링한다
      • throw라는 것은 일종의 ‘중단’ 을 나타내기 때문에, (예외가 throw되면 해당 라인에서 로직이 멈추는 것과 같은 원리) Suspense에서도 ‘렌더링의 중단’ 으로 인식하는 것이다
    • statusERROR일 경우 (fetch 도중 예외가 발생했을 경우) 예외를 상위 컴포넌트로 throw한다
      • 이 예외를 처리하는 것은 Suspense가 아닌 Error Boundary로, 하단에서 알아볼 것이다
    • statusSUCCESS일 경우, result를 반환한다
      • 예외 throw를 위해 result의 타입을 Error | ProfileType 으로 설정했기 때문에, 정상적으로 데이터가 들어왔을 경우엔 ProfileType을 반환하기 위해 타입 변환을 한다
      • ErrorProfileType은 상호간 연관성이 없어 명시적 타입 변환이 어려우므로, 중간에 unknown으로 한번 지정해 준다

Suspense 부착하기

import { Suspense } from 'react';

...

export const DetailInner = ({ userId, promise }: Props) => {
  const profileData = promise.read(); // promise를 읽어온다

  return 
    <ViewModeContainer
      userId={userId}
      profileData={profileData}
    />
};

export const DetailPage = ({ isMine = false }: Props) => {
  const { id } = useParams();
    const nav = useNavigate();
    const data = fetchUserData(id); // data 는 read 함수를 갖고 있다

    return (
        <Suspense fallback={<div>로딩 중...</div>}>
            <DetailInner
          userId={id}
          promise={data}
        />
        </Suspense>
    );
};
  • Suspense 컴포넌트를 리액트에서 가져와 직접 부착해 보자
    • fallback prop은 Promise가 완료되지 않았을 때 (하위 컴포넌트에서 promise가 throw되었을 때) 하위 컴포넌트 대신 렌더링되는 컴포넌트로, 이를 이용하여 로딩 화면을 만들 수 있다
  • DetailInner에서 read 함수를 호출하면,
    • Promise 처리가 완료되었을 경우 정상적으로 데이터가 반환된다
    • Promise 처리가 완료되지 않았을 경우 Promise가 throw되며, 이를 Suspense가 catch한다
  • Suspense는 Promise의 throw 여부를 이용하여 처리가 완료되었는지 여부를 알게 된다

적용 결과

  • fetch 속도가 조금 느려질 때, 빈 컴포넌트가 렌더링되는 것이 아니라 로딩 컴포넌트가 미리 보여짐으로써 데이터가 로딩중이라는 것이 조금 더 명확하게 표현된다

ErrorBoundary

Suspense까지 적용하고, 고의로 예외를 발생시켰을 때

  • 존재하지 않는 유저 ID를 입력하여 고의로 예외를 발생시킨 모습이다
    • 아예 모든 컴포넌트 렌더링을 하지 않고, 웹 사이트가 멈춰버렸다
    • 이렇게 되면 사용자는 에러의 원인도 파악하지 못하고 흰 화면만 마주하게 되며, 이는 좋지 못한 사용자 경험을 낳는다
  • 이 예외를 Error Boundary를 이용해 Catch하고, 적절한 예외처리를 적용해 보자

ErrorBoundary란?

Error Boundaries - React

  • React 16에서 도입된 API로, 하위 컴포넌트 (자식 컴포넌트) 트리 내에서 발생한 예외를 catch하는 기능이다
    • 예외가 발생했을 경우 다른 컴포넌트 (fallback) 를 렌더링하거나, 어떠한 동작을 취하도록 설정할 수 있다
    • 이벤트 핸들러의 예외, 비동기로 동작하는 코드의 예외 (이것은 별도로 catch해주어야 한다), 서버사이드 렌더링 시의 예외 등은 감지하지 못한다
    • 예외 catch는 클래스형 컴포넌트 내의 componentDidCatch 메서드를 통해 이루어진다
  • 이 기능의 최대 문제점으로, React 18버전에 와서도 함수형 컴포넌트를 지원하지 않으므로, 에러 바운더리 컴포넌트를 만들기 위해선 클래스형 컴포넌트를 사용해야 한다

Error Boundary 라이브러리

react-error-boundary

npm i react-error-boundary
  • Error Boundary를 쉽게 사용하게 해주는 라이브러리로, 내부적으로 클래스형 컴포넌트로 Error Boundary 기능이 구현되어 있고, 기타 자잘한 에러 처리 관련 메서드들을 지원해준다
  • 함수형 컴포넌트로 코드를 구성할 때, 클래스형 컴포넌트를 추가적으로 구현할 필요 없이 라이브러리로 import하면 되므로 코드의 통일성을 지킬 수 있다 🫡

Error Boundary 적용하기

import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

...
export const ErrorFallback = () => <div>오류!</div>

export const DetailInner = ({ userId, promise }: Props) => {
  const profileData = promise.read(); // promise를 읽어온다

  return 
    <ViewModeContainer
      userId={userId}
      profileData={profileData}
    />
};

export const DetailPage = ({ isMine = false }: Props) => {
  const { id } = useParams();
    const nav = useNavigate();
    const data = fetchUserData(id); // data 는 read 함수를 갖고 있다

    return (
        <ErrorBoundary fallbackComponent={<div>오류!</div>}>
            <Suspense fallback={<div>로딩 중...</div>}>
                <DetailInner
              userId={id}
              promise={data}
            />
            </Suspense>
        </ErrorBoundary>
    );
};
  • 크게 수정할 것은 없고, Error Boundary 기능만 사용하기 위해 ErrorBoundary 로 컴포넌트들을 감싸주면 된다
    • fallbackComponent는 예외가 발생하였을 때 렌더링할 컴포넌트를 지정한다
    • 하위 트리에 존재하는 모든 컴포넌트 (Suspense 이하) 에 대해, 예외가 발생할 경우 예외가 상위 컴포넌트 (상위 함수) 로 전달되므로 Error Boundary에 도달하여 catch할 수 있게 된다
    • catch한 예외의 내용을 인자로 받아오고 싶다면, fallbackComponent 대신 fallbackRender prop으로 Error를 인자로 받는 컴포넌트 또는 핸들러를 건네주면 된다

  • 존재하지 않는 유저 ID를 입력해서 fetch 도중 예외가 발생했기 때문에, 기존의 프로필 페이지 컴포넌트 대신 ‘오류’ 라는 글자가 렌더링되는 것을 확인할 수 있다

 

  • 실제 구현물에서는 컴포넌트 렌더링 대신, alert를 통한 에러 메시지 출력 및 useNavigate로 메인 화면 리디렉션을 시켜주었다
  • 사용자가 오류를 인지할 수 있고, 메인 화면으로 리디렉션시켜 줌으로써 사용자에게 정상적인 동작을 유도할 수 있다

'ClientSide > React' 카테고리의 다른 글

마운트와 렌더링  (0) 2023.07.24
React의 useContext  (0) 2023.05.06
React를 클론코딩 #1 가상 돔  (0) 2022.10.05
React  (0) 2022.10.01
useClickOutside 직접 구현하기  (0) 2022.07.01
Comments