들어가는 말


리액트로 컴포넌트의 로딩 상태를 표현하는 방식에는 크게 두 가지가 있다. 리액트 상태와 이펙트를 사용해 직접 데이터 로딩 상태를 관리하는 방식이 하나이고, 리액트 서스펜스에 위임해서 쓰는 방식이 하나이다.

직접 관리하는 방식은 명령형이라 다소 번거롭지만 직관적이어서 이해하기가 쉽다. 반면, 서스펜스에 위임하는 방식은 선언형이라 코드가 깔끔해지지만 그 속내를 이해하기 쉽지 않다.

서스펜스는 마치 마법같다. 자식 컴포넌트 내부에 데이터가 로딩중임을 감지해서 폴백 컴포넌트를 대신 표시한다. 그러다가 로딩이 끝나면 원래 컴포넌트를 표시한다. 어떻게 이런 마법 같은 감지와 전환이 가능한걸까?

이번 글에서는 그러한 서스펜스의 마법을 파헤쳐보기로 한다. 그 원리와 패턴들을 살펴보겠다.

리액트 서스펜스의 시작


리액트 서스펜스는 원래 컴포넌트의 지연 로딩(코드 스플리팅)을 위해 태어났다. 출시도 React.lazy 와 함께였고, 당시 공식 문서에도 이런 사용 예시가 실려 있었다.

(데이터 패칭 관련해서는 “In the future, it will support other use cases like data fetching.” 라는 코멘트가 붙어있을 뿐이다.)

// 참조: <https://16.reactjs.org/docs/react-api.html#reactsuspense>

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

function MyComponent() {
  return (
    <React.Suspense fallback={<Spinner />}>
      <div>
        <OtherComponent />
      </div>
    </React.Suspense>
  );
}

이 기능을 가능하게 하는 비밀은 lazy<Suspense> 사이의 약속이었다.

이 두 약속을 간단한 의사 코드로 표현하면 다음과 같다.

// 참조: <https://github.com/facebook/react/blob/250f1b20e0ac8e6c1ba03f2466ad63ff9b5104de/packages/react/src/ReactLazy.js>

function lazy(load) {
  let status = "uninitialized"
  let result

  function LazyComponent(props) {
    if (status === "uninitialized") {
      const promise = load() // Dynamic import
      status = "pending"
      result = promise

      promise.then(
        module => {
          status = "resolved"
          result = module.default
        },
        error => {
          status = "rejected"
          result = error
        }
      )
    }

    if (status === "pending") {
      throw result // Promise throw -> Suspense fallback
    }
    if (status === "rejected") {
      throw result // Error throw -> ErrorBoundary
    }
    if (status === "resolved") {
      const Component = result
      return <Component {...props} />
    }
  }

  return LazyComponent
}
// 참조: <https://github.com/facebook/react/blob/250f1b20e0ac8e6c1ba03f2466ad63ff9b5104de/fixtures/dom/src/components/fixtures/suspense/index.js>

function Suspense({ fallback, children }) {
  try {
    return children()
  } catch (thrown) {
    if (thrown instanceof Promise) {
      thrown.then(() => {
        reRender() // Promise 리졸브 -> 리렌더링
      })
      return fallback
    } else { 
      throw thrown // Promise 아닌 에러 -> 그냥 전파
    }
  }
}

따라서, React.lazy 를 사용한다는 건 동적 임포트를 수행하면서 프로미스의 상태를 던져주는 랩핑 컴포넌트를 만들겠다는 뜻이고, <Suspense> 를 사용한다는건 던져지는 상태를 받아 그에 따른 컴포넌트를 렌더링하겠다는 뜻이 된다.

리액트 서스펜스의 발전된 응용법


서스펜스를 데이터 패칭에 활용하고 싶다는 요구는 사용자들 사이에서 꾸준히 있었다. 리액트 공식 문서에는 ‘미래에 추가 예정’이라고만 적혀 있었지만, 실제로는 리액트 쿼리가 먼저 그 기능을 제공하기 시작했다.

프로미스를 캐치하는 서스펜스. 이를 활용하기 위해, 리액트 쿼리는 사용자로부터 데이터 패치 함수를 받아 다음과 같이 가공해주었다. (의사 코드)

// 참조: <https://github.com/TanStack/query/blob/main/packages/query-core/src/queryObserver.ts>

function useQuery(key, fetchFn) {
  let status = "uninitialized"
  let data, error, promise

  if (status === "uninitialized") {
    promise = fetchFn()
    status = "pending"

    promise.then(
      result => {
        status = "success"
        data = result
      },
      err => {
        status = "error"
        error = err
      }
    )
  }

  if (status === "pending") {
    throw promise // Promise throw -> Suspense fallback
  }

  if (status === "error") {
    throw error // Error throw -> ErrorBoundary
  }

  if (status === "success") {
    return data
  }
}

사실상 React.lazy와 거의 같은 원리다.

이후 리액트 서스펜스와 리액트 쿼리의 조합은 프론트엔드 개발자들 사이에서 널리 애용되었고(물론 나도), 선언적이고 가독성 좋은 방식으로 로딩 UI를 표현할 수 있게 됐다.

function MyComponent() {
  return (
    <Suspense fallback={<Spinner />}>
      <ComponentWithDataFetching />
    </Suspense>
  )
}

지금의 리액트 서스펜스


리액트 서스펜스가 처음 겨냥했던 문제, 즉 SPA에서 발생하는 거대한 단일 번들 문제는 이제 Next.js가 대부분 해결해 주고 있다. Next.js 앞선 글에서 살펴 봤듯이, 화면별 렌더링 전략을 통해 각 페이지별로 HTML, 서버 JS, 클라이언트 JS로 나누어 빌드한다. 덕분에 단일 번들 크기 문제는 거의 사라졌다.

지금의 리액트 서스펜스는 데이터 패칭 UI 표현에 더 집중하고 있는 듯하다. 최근 리액트는 use라는 훅을 도입하여, 클라이언트 컴포넌트에서 useStateuseEffect 없이도 데이터를 패칭하고 이를 서스펜스와 연동하는 방식을 제시했다.

export default function ArtistPage({ artist }) {
  return (
    <Suspense fallback={<Loading />}>
      <Albums artistId={artist.id} />
    </Suspense>
  );
}

export default function Albums({ artistId }) {
  const albums = use(fetchData(`/${artistId}/albums`));
  return (
    // ...
  );
}

use는 최근에 등장한 기능으로, 완전히 새로운 패턴을 요구하기 때문에 실무에서 자리 잡기까지는 시간이 더 필요해 보인다. 물론 리액트 내장 API라 가볍게 쓸 수 있다는 장점은 있다지만, 리액트 쿼리가 제공하는 안정성, 캐싱, 방대한 생태계 같은 부가 가치가 워낙 크다. 그래서 지금 당장 리액트 쿼리를 대체하기에는 시기상조라는 생각이 든다.

어쨌든, 현재 리액트 팀이 서스펜스를 초기의 코드 스플리팅 지원 목적이 아니라 데이터 패칭 중심으로 이끌어 가고 있다는 사실은 명백하다.

정리하기


이번 글에서는 리액트 서스펜스의 원리를 자세히 살펴보았다. 객체를 던지고 받는 단순한 과정만으로 얼마나 강력한 패턴을 만들어낼 수 있는지를 잘 보여주었다. 그 원리를 이해하니 서스펜스가 리액트 쿼리 같은 라이브러리와 결합해 데이터 패치까지 지원하는 방식도 자연스럽게 받아들일 수 있었다.

또한 흥미로운 점은, 서스펜스가 등장했던 시절과 현재 활용되는 방향이 조금 다르다는 사실이다. 본래는 코드 스플리팅과 지연 로딩을 위한 도구였지만, 이제는 데이터 패치와 렌더링 제어라는 역할을 하고있다. 이는 도구가 발전하는 과정에서 만들어진 목적과 실제 쓰이는 형태가 달라질 수 있음을 보여준다.

결국 중요한 건, 도구의 원리를 이해하고 있으면 단순히 매뉴얼대로 쓰는 것에 그치지 않고 상황과 환경에 맞게 응용할 수 있다는 것이다. 이번 글을 통해 개발자에게 유연한 사고가 얼마나 중요한지 다시금 느낄 수 있었다.