[React] React.Suspense가 프라미스를 감지하는 법 (feat. lazy)

Gyuwon Lee·2023년 4월 30일
8
post-thumbnail

문제: 성능을 개선하자

현재 작업 중인 프로젝트는 오래된 레거시 코드들이 많아 재렌더링이 빈번하고, 각 API 엔드포인트가 지나치게 많은 데이터를 한꺼번에 전송하고 있는 문제가 있었다.

특히 Container - Presentational 패턴이 적용되어 있어 모든 상태를 각 도메인의 최상위 컴포넌트가 관리하고 있는 것이 문제였다. 하위 컴포넌트에서 상태를 변화시킬 때마다 상태를 갖는 최상위 컴포넌트가 재렌더링되었으므로 무거운 API 호출이 매번 다시 발생했고, 이로 인해 프론트와 백 모두 성능 문제를 겪게 되었다.

이로 인해 최근 일부 페이지를 리팩토링하는 작업을 진행했다. 주 목적은 재렌더링과 불필요한 API 호출을 줄이기 위해 컴포넌트를 쪼개고, 각 컴포넌트가 마운트될 때 필요한 API를 호출하도록 하는 것이었다.

즉 ‘성능 개선’ 이라는 목적을 명확히 달성하기 위해, 팀장님의 스켈레톤 코드를 따라 React.lazy 와 Suspense를 새롭게 적용해보았다.

const AsyncAdminPage = React.lazy(
  () => import('./AsyncAdminPage')
);

const AsyncUserPage = React.lazy(
  () => import('./AsyncUserPage')
);

const Component = React.useMemo(
  () => user.type === admin ? AsyncAdminPage : AsyncUserPage,
  [user.type]
);

return (
  <React.Suspense fallback={<Skeleton />}>
      <AsyncComponent {...props} />
  </React.Suspense>
);

위와 같이 어떤 조건에 따라 페이지 view(UI)가 두 종류로 나뉘는 경우, 정적으로 import하고 조건에 따라 다른 컴포넌트가 리턴되도록 했다면 어쨌든 두 종류의 페이지를 모두 가져왔을 것이다. 하지만 어차피 둘 중 한 종류의 페이지만 보이게 되므로, 모듈을 조건적으로 가져오는 동적 import를 적용하기 위해 React.lazy를 사용해 봤다. 이 lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야 하므로, 이 역시 사용해주었다.

React Suspense, 너무 편하잖아?

처음 접한 React Suspense는 정말 신기했다. 여태껏 매번 데이터 상태에 따라 로딩, 에러, 성공 UI를 조건문으로 처리해왔던 게 무색하게 아주 깔끔한 코드를 작성할 수 있게 해주었다.

특히 이번 작업에서는 여러 API를 복합적으로 호출해야 하는 컴포넌트가 많았는데, API 호출이 늘어날수록 각 호출의 loading, error 상태를 조합한 ‘완결 상태’의 경우의 수가 늘어나 조건을 처리하기가 너무 복잡해지는 상황이었다.

const {data: A, isLoading: isLoadingA} = useQuery(...)
const {data: B, isLoading: isLoadingB} = useQuery(...)
const {data: C, isLoading: isLoadingC} = useQuery(...)

if (isLoadingA || isLoadingA && isLoadingB || isLoadingC ...) {
	return <Loading />
}

return <Component />

Suspense를 사용하면 어떤 경우에 loading UI가 보여야 하는지 각 상태의 조합을 계산할 필요 없이, 컴포넌트의 proimse 상태에 따라 알아서 fallback UI가 보이므로 작업이 훨씬 간편해졌다.

Suspense Boundary

자식 컴포넌트의 Promise 상태에 따라 fallback UI를 보여주는 Suspense. 나는 어떻게 Suspense가 자식 컴포넌트의 async action을 감지할 수 있는지가 궁금했다. 리액트가 그렇게 똑똑할 수가 있을까?

참고한 글에 따르면, Suspense는 Error Boundary와 유사하게 작동한다. 둘 모두 try…catch 문이 그 본질이다.

다른 점이라면 Error Boundary는 던져진 Error를 캐치하고, Suspense는 던져진 프라미스를 캐치한다는 것이다. 그렇다. 우리가 보통 예외를 발생시키기 위해 사용하는 throw를 이용해, pending 상태의 프라미스를 던진다. throw된 값은 콜스택의 첫 번째 catch 블록으로 전달되므로, 자식 컴포넌트 바깥의 Suspense가 이를 캐치할 수 있다.

던지는 건 셀프입니다

여기서 중요한 것은, Suspense는 던져진 프라미스를 캐치하는 역할을 할 뿐, 프라미스를 던지는 건 자식 컴포넌트가 해주어야 한다는 점이다.

function suspensify(promise) {
	// 프라미스의 상태 추적
  let status = "pending";

  let result;
  let suspender = promise.then(
    (res) => {
      // 프라미스 성공 시 상태: success, result: 해당 데이터로 업데이트
      status = "success";
      result = res;
    },
    (error) => {
      // 프라미스 성공 시 상태: error, result: 해당 에러 객체로 업데이트
      status = "error";
      result = error;
    }
  );

  // 위에서 구현한 suspender를 throw할 수 있는 read() 메서드를 담은 객체를 리턴
  return {
    read() {
      if (status === "pending") {
        // !! pending 상태일 때 체이닝 된 프라미스를 Suspense에 throw
        throw suspender;
      } else if (status === "error") {
        // Suspense에 의해 처리된 결과 에러라면, 해당 결과를 다시 던져 Error Boundary에 전한다.
        throw result;
      } else if (status === "success") {
        // Suspense에 의해 성공적으로 처리되었다면, 해당 결과를 리턴한다.
        return result;
      }
    },
  };
}

이렇게 생긴 예제 코드를 쉽게 찾을 수 있다. 그 다음 컴포넌트 구현부에서 아래와 같이 사용해주면 된다:

function AsyncChildComponent() {
  const [promise, setPromise] = useState()

  const onClick = () => {
    setPromise(suspensify(fetchData(...)))
  }

  const data = promise ? promise.read() : null

  return <div>{data}</div>
}

앞서 구현한 suspensify 함수로 프라미스를 감싸, 이 프라미스를 throw해줄 수 있는 read 메서드를 호출한다.

호출된 read 메서드는 초기 상태(pending)에 따라 suspender를 던지게 된다. 그러면 Suspense가 이를 처리하는 동안 fallback UI를 보여주다 처리가 완료되면 상태와 result를 업데이트시키고, 변경된 값은 컴포넌트에 반영된다.

React.lazy가 프라미스를 던지는 법

처음에는 여러 글들을 읽고도 이해를 못 해서, ‘그래서 Suspense가 어떻게 async action을 감지한다는 거야?’ 하며 어리둥절했다. Suspense 밑에서 axios를 사용한 예제를 보고 나서야 Suspense를 작동시키기 위해서는 자식 컴포넌트가 프라미스를 던져주어야 한다는 것을 이해했다.

그렇다면 반드시 Suspense 아래에서 사용되어야 하는 React.lazy 역시, 내부적으로 상태에 따라 promise를 throw하는 로직을 갖고 있을 것이다.

우선, React.lazy는 동적 import를 호출하는 함수를 인자로 가진다. MDN에 따르면 import() 는 해당 모듈이 내보내는 것들을 모두 포함하는 객체를 담은 이행된 프라미스를 반환한다.

const AsyncAdminPage = React.lazy(() => import('./AsyncAdminPage'));

따라서 위의 suspensify 함수가 프라미스를 감싸 read() 메서드를 사용해 Suspense에 프라미스를 던지고 업데이트된 상태를 받았듯, React.lazy 역시 받은 콜백함수를 실행시켜, import()의 결과로 리턴된 프라미스를 감싸서 Suspense에 던지는 로직을 갖고 있을 것이다. 그래서 소스코드를 찾아보았다.

React.lazy 소스코드 해석

// ReactLazy.js

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) {
    const ctor = payload._result;
    const thenable = ctor();
    // Transition to the next state.
    thenable.then(
      res => {...},
      error => {...}
    );
    if (payload._status === Uninitialized) {
      // In case, we're still uninitialized, then we're waiting for the thenable
      // to resolve. Set it as pending in the meantime.
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  if (payload._status === Resolved) {
    ...
    return moduleObject.default;
  } else {
    throw payload._result;
  }
}

위 코드가 React.lazy에서 suspensify가 구현된 부분이다. 여기서 payload는 아래와 같은 형태의 객체다.

const payload: Payload<T> = {
  // We use these fields to store the result.
  _status: Uninitialized,
  _result: ctor,
}

status는 ‘Uninitialized’로 초기화되고, result에 들어가는 ctor 는 우리가 React.lazy의 인자로 넘겼던 콜백함수다.

if (payload._status === Uninitialized) {
  // In case, we're still uninitialized, then we're waiting for the thenable
  // to resolve. Set it as pending in the meantime.
  const pending: PendingPayload = (payload: any);
  pending._status = Pending;
  pending._result = thenable;
}

초기화된 값에 따라, 최초 실행 시 이 if 블록에 반드시 걸리게 된다.

thenable.then(
  moduleObject => {
    if (payload._status === Pending || payload._status === Uninitialized) {
      // Transition to the next state.
      const resolved: ResolvedPayload<T> = (payload: any);
      resolved._status = Resolved;
      resolved._result = moduleObject;
    }
  },
  error => {
    if (payload._status === Pending || payload._status === Uninitialized) {
      // Transition to the next state.
      const rejected: RejectedPayload = (payload: any);
      rejected._status = Rejected;
      rejected._result = error;
    }
  },
);
if (payload._status === Uninitialized) {
  // In case, we're still uninitialized, then we're waiting for the thenable
  // to resolve. Set it as pending in the meantime.
  const pending: PendingPayload = (payload: any);
  pending._status = Pending;
  pending._result = thenable;
}

전체 맥락은 이와 같은데, 위에서 thenable.then()을 실행시켜 프라미스가 처리되는 동안 비동기 동작에 따라 실행 결과를 기다리지 않고 이 if 블록으로 먼저 내려오게 된다(”In case, we're still uninitialized, then we're waiting for the thenable to resolve”). 따라서 then() 실행을 기다리는 동안 상태를 pending으로 업데이트해준다.

if (payload._status === Resolved) {
  ...
  return moduleObject.default;
} else {
  throw payload._result;
}

그러면 위의 조건문에 따라 pending 상태인 payload._result를 throw하게 된다. 이러한 로직 덕분에 Suspense와 React.lazy를 조합하여 사용할 수 있는 것이다.

TL;DR

  • Suspense와 React.lazy의 작동 방식을 이해해보았다.
  • Suspense를 사용할 때 중요한 것은 자식 컴포넌트가 프라미스를 던질 수 있도록 Promise wrapper를 구현하는 것이다.
    • 즉, 자식은 프라미스의 이행을 Suspense에 위임한다.
  • 아 참, Suspense는 던져진 프라미스를 처리하는 동안 자식 컴포넌트의 로딩을 중단한다!
    • 즉, 자식 컴포넌트가 Suspense에 여러 개의 프라미스를 던진다면, Suspense는 모든 프라미스를 순차적으로 처리하고 나서야 로딩을 완료시킬 것이다.
profile
하루가 모여 역사가 된다

0개의 댓글