React

[React] Suspense 이해하고 활용하기 03 - Suspense 발전 과정

joseph0926 2024. 10. 2. 12:14

[참고: suspense-in-react-18]

 

rfcs/text/0213-suspense-in-react-18.md at main · reactjs/rfcs

RFCs for changes to React. Contribute to reactjs/rfcs development by creating an account on GitHub.

github.com

 

배경

`<Suspense />`는 React v16.6.0에서 최초로 선보여졌고, 이때에는 **code splitting**에서만 사용되었습니다

`React.lazy`를 통해 lazy 로드를 한 코드에 대해 Suspense 경계를 생성하고 해당 코드를 필요한(사용될) 경우에만 임포트 하는 방식으로 사용하였습니다.

즉 클라이언트 사이드에서만 사용되었습니다

 

React 팀은 v18에서 이 Suspense를 더욱 발전시켰고, 이 발전의 가장 중요한 점은 모든 비동기 작업을 처리할 수 있도록 지원을 확장하는 발전이였습니다.

 

Suspense의 기본 동작 방식

Suspense는 컴포넌트 트리의 일부(특정 컴포넌트)가 렌더링될 준비가 되지 않을 경우를 파악하고, 준비가 완료될때까지 선언적으로 fallback UI를 표시해줄 수 있게 해줍니다.

 

<Suspense fallback={<PageGlimmer />}>
  <RightColumn>
    <ProfileHeader />
  </RightColumn>
  <LeftColumn>
    <Suspense fallback={<LeftColumnGlimmer />}>
      <Comments />
      <Photos />
    </Suspense>
  </LeftColumn>
</Suspense>

 

Suspense의 기본 동작은 `try/catch`에서 `catch` 블록과 유사합니

  - 차이점은 `catch` 블록은 오류를 처리하지만 `Suspense`는 컴포넌트의 **중단된** 상태를 처리합니다

  - 즉 어떠한 이유(코드 스플리팅, 데이터 패칭 진행중 등,,,)로 인해 렌더링이 중단된 컴포넌트를 파악합니다

 

  - 공통점은 예외를 던졌을 때 가장 가까운 `catch` 블록이 이를 처리하듯, 어떤 컴포넌트가 **중단**되면 가장 가까운 `Suspense`(Suspense 경계)가 이를 잡아냅니다

  - 위의 예시에서 `<ProfileHeader />`이 중단되면 `<PageGlimmer />` fallback UI가 표시되고, `<Comments />`가 중단되면 `<LeftColumnGlimmer />` fallback UI가 표시됩니다

 

이를 통해 시각적 UI 설계의 세분화에 따라 안전하게 Suspense 경계를 추가하거나 제거할 수 있으며, 어떤 컴포넌트가 비동기 코드와 데이터를 의존하는지에 대해 걱정할 필요가 없습니다.

  - 즉, Suspense를 사용하여 UI 설계에 따라 원하는 위치에 Suspense 경계를 자유롭게 추가하거나 제거할 수 있다는 의미입니다.

  - 이로써 얻는 이점은 개발자가 어떤 컴포넌트가 비동기적으로 동작하는지 일일이 파악하지 않아도 된다는 것입니다. (왜냐하면 Suspense는 컴포넌트가 언제든지 서스펜드될 수 있다는 가정 하에 동작하기 때문)

 

아이디어 확장

확장되기 전에는 위의 개념이 적용되는 곳은 오직 `code splitting` 케이스 하나이며, 이를 확장하여 어떠한 임의의 컴포넌트가 중단될 수 있으며, 중단되면 React에게 Promise를 제공할 수 있게 하여 React가 Promise의 상태에 따라 fallback UI나 렌더링된 컴포넌트를 표시할 수 있게 해줍니다

 

이 확장에 핵심은 `Suspense`는 여전히 React가 중단된 컴포넌트를 파악하고, 중단된 동안 선언적으로 fallback UI를 표시하는 매커니즘일 뿐이며, 코드나 데이터를 "어떻게 / 무엇을" 가져오는지는 관심이 없습니다

 

변경 1 - 동작 변경: 커밋된 트리는 항상 일관성이 있음

<div>
  {showComments && (
    <Suspense fallback={<Spinner />}>
      <Panel>
        <Comments />
      </Panel>
    </Suspense>
  )}
</div>

 

위의 예제에서 `showComments`가 false -> true로 변경되면 `<Comments />`가 중단 됩니다

대신 리액트는 fallback UI인 `<Spinner />`를 표시합니다

 

확장되기 전 동작 방식

 

1. Panel 콘텐츠를 DOM에 배치하되, Comments 대신 `hole`을 생성합니다.

2. 불완전한 Panel 콘텐츠가 표시되지 않도록 display: none을 추가합니다.

3. 스피너 콘텐츠를 DOM에 추가합니다.

4. Panel이 "마운트"되었으므로 효과를 실행합니다(display: none이지만 마운트 자체는 되었으므로 내부 로직은 실행된다는 뜻).

5. Comments가 준비되기를 기다립니다.

6. 렌더링을 다시 시도합니다.

7. 스피너 콘텐츠를 DOM에서 제거합니다.

8. Comments 콘텐츠를 이미 DOM에 있는 Panel 콘텐츠에 배치합니다.

9. Panel 콘텐츠에서 display: none을 제거합니다.

 

변경 후 동작 방식

 

1. Panel 콘텐츠를 DOM에 추가하는 대신 제거합니다.

2. 스피너 콘텐츠를 DOM에 추가합니다.

3. Comments가 준비되기를 기다립니다.

4. 렌더링을 다시 시도합니다.

5. 스피너 콘텐츠를 DOM에서 제거합니다.

6. Comments가 포함된 Panel 콘텐츠를 DOM에 배치합니다.

7. Panel 효과를 실행합니다.

 

변경으로 얻는 효과

 

불안정한 커밋이 없어집니다

  - 확장되기 전 동작 방식에서의 문제점은 `<Panel />`이 실제로 표시되지는 않지만 DOM에 배치되고 효과가 실행되는 문제입니다

  - 변경 후에는 `<Panel />` 자체를 `<Comment />`가 렌더링된 후 DOM 배치, 효과 실행을 하므로 더욱 안정적입니다

 

변경 2 - 새로운 기능: 스트리밍을 지원하는 서버 측 렌더링

변경 전 동작

 

변경 전에는 컴포넌트가 서버 렌더링 중에 중단되면 React에서 오류를 발생시켰습니다.

실제로 이는 서버 렌더링을 사용하는 애플리케이션(또는 Next.js나 Remix와 같은 SSR 프레임워크로 빌드된 앱)에서 Suspense를 코드 분할에 사용할 수 없음을 의미했습니다.

 

변경 후 동작

 

변경 후에는 순서가 다른 HTML을 스트리밍하는 것을 지원하는 새로운 `server renderer`를 추가했습니다.

 

기존의 `server renderer`는 동기적으로 문자열을 생성하지만, 새로운 `server renderer`는 스트림을 생성합니다.

  - 이 스트림은 먼저 초기 HTML을 출력할 수 있습니다. 하지만 새로운 렌더러는 또한 Suspense와 완전히 통합되어 있어 준비되지 않은 트리를 "기다리고", 해당 부분에 대한 폴백 HTML(예: 스피너)을 출력할 수 있습니다.

 

콘텐츠가 준비되면 React는 동일한 스트림에서 콘텐츠 HTML을 내보내고, 원래 DOM 구조의 적절한 위치에 삽입하는 작은 인라인<script> 도 함께 출력합니다. 그 결과, 서버에서 페이지의 일부가 느리게 로드되더라도 사용자는 클라이언트 JS가 로드되기 전에 점진적으로 로딩되는 페이지를 보게 됩니다.

 

1. 기존에 동기적으로 문자열을 생성하여 그것을 통해 한번에 HTML을 출력했지만, 변경 후에는 스트림에서 HTML을 순차적으로 스트리밍합니다

2. 하지만 기존과 가장 큰 차이점은 `Suspense`와 완전히 통합되어, 중단된 트리의 일부분을 기다리고 기다리는 동안 fallback UI를 출력할 수 있습니다

3. 이후 중단된 트리의 일부분이 준비가 완료되면 React는 동일한 스트림에서 HTML을 내보냅니다

4. 해당 HTML을 기존 HTML의 적절한 위치에 삽입하는 인라인 `<script>` 태그도 존재하여 자연스럽게 컨텐츠가 추가되는 형식으로 페이지가 렌더링됩니다.

 

정리

  • 기존
    • 서버에서 전체 HTML을 생성하여 클라이언트로 보냅니다.
    • 클라이언트는 필요한 모든 자바스크립트 코드(코드 스플리팅된 청크 포함)가 로드될 때까지 수화를 시작할 수 없습니다.
    • 특히, 코드 스플리팅된 컴포넌트가 많을 경우, 모든 청크를 로드할 때까지 수화가 지연됩니다.
    • 이로 인해 초기 로딩 시간이 길어지고, 사용자가 페이지와 상호작용하기까지 시간이 더 걸립니다.
  • 새로운 방식
    • 서버는 스트리밍 방식으로 준비된 HTML을 순차적으로 클라이언트로 전송합니다.
    • 클라이언트는 메인 자바스크립트 번들이 로드되면 즉시 수화를 시작할 수 있습니다.
    • 만약 일부 컴포넌트의 클라이언트 측 코드가 아직 로드되지 않았더라도, 해당 컴포넌트를 Suspense로 감싸면 React는 그 부분을 건너뛰고 나머지 앱을 먼저 수화합니다.
    • 나중에 코드 스플리팅된 청크가 로드되면, React는 그 부분을 수화하여 전체 앱이 완전하게 동작하도록 합니다.

변경 3 - 새로운 기능: 전환을 사용해 기존 콘텐츠를 숨기지 않기

function handleClick() {
  setTab("comments");
}

<Suspense fallback={<Spinner />}>
  {tab === "photos" ? <Photos /> : <Comments />}
</Suspense>

 

위의 예제는 탭을 구현하고, 탭이 전환될때 해당 탭 컨텐츠의 내용이 준비가 되지 않았으면 `<Spinner/>`를 표시하는 간단한 예제입니다

탭의 동작을 예측해보면 `tab === "photos"`일때 `<Photos/>`를 렌더링하다가 `tab === "comments"`가 되면 `<Comments/>`로 전환될 것입니다

하지만 만약 `<Comments/>`가 준비가 끝나지 않았다면 `<Spinner/>`를 렌더링할것입니다

 

위의 동작은 매우 일반적인 흐름이지만 어쩌면 매끄럽지 않을 수 있습니다.

  - 만약 각 탭 컨텐츠 컴포넌트들이 준비에 시간이 오래걸린다면 사용자는 한동안 계속 로딩 스피너를 봐야합니다

  - 이것보다는 준비가 완료되기 전까지 이전 컨텐츠를 보여주다가 준비가 완료되면 전환하는게 더 자연스러울수있습니다

 

const [isPending, startTransition] = useTransition();

function handleClick() {
  startTransition(() => {
    setTab("comments");
  });
}

<Suspense fallback={<Spinner />}>
  <div style={{ opacity: isPending ? 0.8 : 1 }}>
    {tab === "photos" ? <Photos /> : <Comments />}
  </div>
</Suspense>;

 

추가적으로 `<Photos/>`나 `<Comments/>` 내부에 오래 걸리는 작업이 있다면 해당 부분을 또다시 `<Suspense/>`로 처리하는 것도 조금 더 빠른 컨텐츠 제공에 도움이 될 수 있습니다.