Suspense 근데 Next를 곁들인

endmoseung·2024년 10월 27일
12
post-thumbnail

1. Suspense가 뭔가요 ?

In React 공식문서
Suspense 는 자식 요소가 로드되기 전까지 화면에 대체 UI를 보여줍니다.

<Suspense fallback={<Loading />}>
  <LazyLoadingComponent />
</Suspense>

Suspense는 React 16.6버젼에서 실험적 버젼으로 새로 나왔습니다.
Suspend라는 보류하다라는 사전적의미를 가진 동사의 명사형인만큼 Suspense는 Suspend된 컴포넌트의 렌더링을 보류하고 fallback으로 넘겨준 컴포넌트를 먼저 렌더링하여 Suspend된 컴포넌트가 모두 Load되면 비로소 렌더링하게 됩니다.
suspense는 처음 나왔을 땐 lazyLoading된 컴포넌트를 로드하기 위한 목적이었습니다.
이는 기능이 점차 확장돼 React18버젼에 이르러선 비동기 요청을 선언적으로 관리할 수 있게 도와주는 API가 됩니다.
그리고 이는 React를 사용해 보다 고수준의 어플리케이션을 만들도록 도와주는 NextJS에서 SSR을 관리할 수 있는 용도로도 사용됩니다.

React 16.6 업데이트 공식문서

2. Why Suspense(feat. Fiber)

리액트의 방향성

Suspense에 대해서 더 자세하게 알기위해선 React의 방향성에 대해서 이해할 필요가 있습니다.
React는 기본적으로 CSR을 지원하여 SPA로써의 어플리케이션을 지원하는 라이브러리 였습니다.

CSR은 매끄러운 UX라는 장점이 있지만, JS가 모두 Load되야지 비로소 화면에 렌더링되기에 그만큼의 네트워크 Latency가 발생할 수 박에 없고 SEO에 불리했으며 이는 CSR의 단점으로 꼽혀왔습니다.
그리하여 UI 업데이트를 더 유연하게 관리할 수 있도록 완전히 새롭게 재설계된 아키텍처 Fiber Reconciler가 도입됐고, 이로 인해 비동기 렌더링의 가능성이 열렸습니다.

React 16

React는 16버젼에서 이런 문제를 해결하기위해 Fiber아키텍처를 도입하고, 컴포넌트를 동적으로 렌더링하고 이를 선언적으로 관리하기 위한 Lazy, Suspense, ErrorBoundary로 해결하고자 했습니다.

Fiber

이 글의 주제는 아니지만 뒤의 내용의 상관성을 위해 Fiber에 대해서 잠깐 짚고 가려 합니다.

React는 16버젼이전에는 Stack Reconciler를 채택했습니다. 이는 말 그대로 Stack구조로 재조정하는걸 의미하는데 이는 성능의 문제를 야기 했습니다.
Stack Reconciler는 전체 렌더링 작업을 동기적으로 수행했습니다. 즉, 컴포넌트 트리를 한 번에 모두 재귀적으로 탐색하며 작업을 수행했습니다. 이로 인해 업데이트가 중단 없이 한 번에 진행되어야 했고, 작업이 길어질 경우 UI가 멈추는 현상이 발생할 수 있었습니다.
컴포넌트 트리의 깊이나 크기에 따라 렌더링 시간이 늘어나면서, 복잡한 UI에서는 렌더링 작업이 메인 스레드를 점유해 사용자 경험이 저하되는 문제가 있었습니다.

그래서 이를 해결하기 위해 Fiber Reconciler를 도입했고 렌더링 작업을 세분화하여 비동기적 렌더링 처리를 가능하게 됩니다.
아래는 리액트 공식문서에서 말하는 Fiber에서 도입된 기능입니다.

  • 작업을 중단 가능한 청크로 나눌 수 있는 방법
  • 작업의 우선순위 지정, 재조정 및 진행 중인 작업 재사용 가능
  • 레이아웃 지원을 위해 부모와 자식 간에 작업을 주고받는 능력
  • render() 함수에서 여러 요소를 반환할 수 있는 기능
  • 에러 바운더리에 대한 더 나은 지원

Fiber에 대해서 더 궁금하다면 ?
Andrew Clerk의 react-fiber-architecture

React 18

16버전에 도입된 Fiber덕분에 React는 비동기적 처리를 더욱 더 잘하게 됩니다.
React 18에서 AutoBatching같은 많은 기능들이 있지만 이 글에서는 Suspense와 SSR에 집중하기 위해 강화된 Suspense에 집중하겠습니다.
React18에서의 가장 큰 목표는 Concurrent입니다.
Concurrent의 사전적 의미는 동시성입니다. 보다 설명을 확실하게 하기 위해 React에서 작성한 공식 글에서 내용을 인용하겠습니다.

React 18 소개 공식문서에서 인용

  • Concurrent React의 핵심은 렌더링을 중단 가능하다는 것입니다.
  • React는 업데이트된 렌더링을 시작하고 중간에 일시 중지했다가 나중에 계속할 수 있습니다.
  • Concurrent 렌더링은 React의 강력한 새 도구이며 Suspense, Transitions, 스트리밍 서버 렌더링 등 대부분의 새로운 기능이 이를 활용하기 위해 만들어졌습니다.

Suspense In React 18

그럼 Suspense는 React18에서 어떻게 달라졌을까요?
우선 React18에서는 자식요소에 ReactChild외에 RSC(React Server Component)를 받을 수 있습니다. 18버젼 이전에는 아래처럼 Promise형태(정확히 말하자면 RSC)를 받을 수 없었고 18버젼에선 이를 RSC형태로 내려줘 Suspense의 응답값으로 받을 수 있습니다. 이것이 가장 큰 차이점입니다.

// In React 18.2
import { delay } from "@/utils/delay";

const Server1 = async () => {
  await delay(1000);

  const random = Math.floor(Math.random() * 3) + 1;

  const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${random}`);
  const pokemon = await response.json();
  return <div>{pokemon.name}</div>;
};

export default Server1;

lazyLoading한 컴포넌트의 return값은 LazyExoticComponent라는 특별한 type을 가지고 있고 내부적으로 Promise를 리턴하여 동작한다고 유추만 가능하며 실제 구현부는 찾아볼 수 없었습니다.

How to type Lazy Components
이 이슈에서 찾아보니 해당 타입을 찾아볼 수 있는 방법은 없다는 것 같습니다. 혹시 아시는 분 있다면 댓글 부탁드립니다.

그리고 Suspense는 이전 16.6버젼과 비교하면 보다 강력한 기능들을 탑재했습니다. 기존 16.6에서는 renderToString라는 메서드로으로 초기 render된 HTML을 받아볼 수 있었지만 18버젼부터는 Streaming SSR이 가능한 형태로 발전된 Suspense를 사용가능한 renderToPipeableStream메서드로 발전했습니다.

  1. 서버에서 데이터가 모두 로드돼야지 HTML을 보내주는 기존 SSR과는 다르게 streamingSSR로 되는데로 html을 보여주는 방법으로 해결 (NextJS같은 서버사이드 프레임워크와 통합)
  2. 하이드레이션을 시작하기 위해서는 모든 자바스크립트가 다운로드 돼야하지만 이젠 청크화된 자바스크립트 단위로 하이드레이션을 즉각적으로 시작할 수 있습니다.
  3. 기존 하이드레이션은 컴포넌트별로 부분적으로 할 수 없었지만 이제 선택적 하이드레이션으로 부분적으로 인터랙티브하게 할 수 있게 됐습니다.

정리

정리하자면 React18버젼에서는 Concurrent한 렌더링을 위해 서버에서 렌더링할 수 있도록 지원했고 Suspense도 같이 강화했습니다.

New Suspense SSR Architecture in React 18
위 글을 한글로 번역한 블로그

3. NextJS에서의 Suspense

말씀드렸던것처럼 Suspense는 비동기처리를 선언적으로 관리할 수 있는 목적과 함께 NextJS에서는 추가적으로 StreamingSSR을 가능하게 해줍니다.

기존 SSR의 문제점

기존 SSR은 Server에서 가능한 모든 처리를 해야 Rendering을 가능하게 해줍니다. 이는 그만큼의 Fallback이 발생할 수 밖에 없습니다.
예를들면 DB에서 데이터를 가져와야하는데 A,B,C 쿼리가 있을때 A,B 쿼리는 1초가 걸렸지만 C쿼리가 10초가 걸린다라고 했을때 C쿼리까지 받아와야지 비로소 클라이언트에 내려줄 수 있었습니다.
그리고 이 초기 Render된 HTML을 다운로드받고 그에 필요한 JS를 다운로드 받아야 비로소 hydration이 가능해졌고 이는 TTI(Time To Interaction)가 길어질 수 밖에 없었습니다.

StreamingSSR

StreamingSSR은 기존 SSR의 단점을 해결해줍니다.
위 상황에서 시간차가 나는 컴포넌트를 예시로 들기 위해 포켓몬 이름 시간별로 가지고 오는 Server컴포넌트들을 가진 StreamingSSRPage 컴포넌트를 코드로 예시를 들려 합니다.

export default async function StreamingSSRPage() {
  return (
    <div>
      <Suspense fallback={<Loading />}>
        <Server1 />
      </Suspense>
      <Suspense fallback={<Loading />}>
        <Server2 />
      </Suspense>
      <Suspense fallback={<Loading />}>
        <Server3 />
      </Suspense>
    </div>
  );
}

// Server1 컴포넌트
import { delay } from "@/utils/delay";

const Server1 = async () => {
  // Server2, Server3은 delay가 1초씩 차이남 
  await delay(1000);

  const random = Math.floor(Math.random() * 3) + 1;

  const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${random}`, {
    next: { revalidate: 5 },
  });
  const pokemon = await response.json();
  return <div>{pokemon.name}</div>;
};

export default Server1;

그래서 실제로 초기 렌더된 HTML을 받아보면 아래처럼 특이한 구조를 가지고 있습니다.

렌더링 비교

Suspense를 감싸지 않았을 경우(기존 SSR)

모든 데이터를 받아오는동안 Pending발생

Suspense를 페이지 레벨에서 감쌌을 경우

일단 로딩중 fallback을 보여주고 모든 데이터를 받아오는동안 Pending발생

컴포넌트별로 Suspense를 감쌌을 경우

데이터가 받아와지는데로 화면에 보여줌

Streaming, 그리고 HTTP 프로토콜 1.1

Streaming이 좋은 건 알겠는데 어떻게 가능해졌는지가 궁금했습니다. 짤막지식으로 간단하게 공유드립니다.
HTTP 1.1버젼부터 가능해진 프로토콜로 HTML을 지속적으로 받을 수 있습니다. 2.0 버젼부터는 이것이 더 강화됐다고 합니다.

4. 끝으로

Suspense를 Next와 Server Components로 같이 풀어내다 보니 분량조절에 실패한듯한 느낌이 들기도합니다.
처음 NextJS를 쓸때는 뭣도 모르고 좋으니까 써봤다가 알면 알수록 업데이트 될수록 점차 내용이 방대해져가는 느낌이 들었습니다.

이 글로 Suspense에 대해서 알게되고 Next에서 어떻게 통합됐는지 이해가 됐으면 좋겠습니다.

다음은...

다음은 React가 19에서의 방향성과 기능들, Next15를 주제로 글을 공유드리겠습니다.
감사합니다.

profile
Walk with me

6개의 댓글

comment-user-thumbnail
2024년 10월 27일

좋은 글 감사합니다!

올려주신 Lazy Components 관련 이슈는 Module Federation을 사용하는 외부 모듈에서 컴포넌트를 동적으로 import 할 때 타입 에러가 발생하는 문제인 것 같네요🤔

제가 알고 있는 React.lazy의 내부 구현부는 이거인걸로 알고 있습니다🙃

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) { // 상태가 Uninitialized 일때 초기화 시작
    const ctor = payload._result; // 컴포넌트를 동적으로 import하는 함수
    const thenable = ctor();

    thenable.then(
      moduleObject => { // promise 성공 시 처리
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) { 
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved; // 상태를 Resolved로 변경
          resolved._result = moduleObject; // promise 결과 저장
        }
      },
      error => { // promise 실패 시 처리
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected; // 상태를 Rejected로 변경
          rejected._result = error; // promise 에러 저장
        }
      },
    );
    if (payload._status === Uninitialized) { // Promise가 아직 해결되지 않았다면
      const pending: PendingPayload = (payload: any);
      pending._status = Pending; // 상태를 Pending로 변경
      pending._result = thenable; // Promise 저장
    }
  }
  if (payload._status === Resolved) { 
    // 상태가 Resolved면 로드된 모듈의 default export를 반환
    const moduleObject = payload._result;
    return moduleObject.default;
  } else {
    // 그 외의 경우(실패 or 대기)에는 에러를 throw
    throw payload._result;
  }
}

export type LazyComponent<T, P> = {
  $$typeof: symbol | number, // React 앨리먼트 타입
  _payload: P, // 컴포넌트의 로딩 상태와 결과를 저장하는 객체
  _init: (payload: P) => T, // 실제 컴포넌트를 초기화하는 함수
  _debugInfo?: null | ReactDebugInfo, // 디버깅 정보(옵셔널)
};

export function lazy<T>(
  ctor: () => Thenable<{default: T, ...}> // 동적으로 import하는 함수로 인자를 받는다.
): LazyComponent<T, Payload<T>> { // LazyComponent 객체를 반환
  // payload 객체는 컴포넌트의 로딩 상태와 결과를 저장
  const payload: Payload<T> = { 
    _status: Uninitialized,
    _result: ctor,
  };

  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE, // React의 lazy 컴포넌트임을 나타내는 심볼
    _payload: payload, // 위에서 생성한 payload 객체
    _init: lazyInitializer, // 컴포넌트를 초기화하는 함수
  };

  return lazyType;
}
1개의 답글