본문 바로가기

Project/프리온보딩

[프리온보딩 프론트엔드 챌린지 1월] Suspense RFC 톺아보기

 

프로온보딩 챌린지 프로젝트에 Suspense를 적용해 봤다. 제대로 사용한 것인지 긴가민가한 상황이라 Reactjs RFC를 참고하여 React Suspense에 대해 알아보면 좋을 것 같다는 생각이 든다. 

 

 

 

GitHub - reactjs/rfcs: RFCs for changes to React

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

github.com

 

 

 


[ Summary ]

 

 

RFC의 Suspense의 요약을 확인하면 아래와 같다.

  • 동작 변경 : 커밋된 트리는 항상 일관성이 있습니다.
  • 새로운 기능 : 스트리밍을 통한 서버 측 렌더링 지원
  • 새로운 기능 : 기존 내용을 숨기지 않도록 전환
  • 동작 변경 : 콘텐츠가 다시 나타날 때 레이아웃 효과가 다시 실행

 

❓'커밋된 트리는 항상 일관성이 있다.'라는 요약은 아직 이해는 가지 않지만, 내가 알고 있는 Suspense의 기본 기능으로 나머지를 유추해 보면 '서버에서 데이터를 전발 받고 화면에 렌더링 하기까지의 텀 동안 별도의 렌더링을 지원하고, if문을 사용하여 렌더링을 하지 않아도 된다.'로 유추해 본다.

 

 

 

[ Basic example ]

 

Suspense를 사용하면 구성 요소 일부를 아직 표시할 준비가 되지 않은 경우 load 상태를 선언적으로 지정할 수 있다.

 

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

 

기본적인 사용법은 위와 같이 서버로부터 데이터를 전달받아 렌더링 하는 컴포넌트를 Suspense로 감싸주고, fallback으로 loading 컴포넌트를 전달해 준다.

 

 

 

[ Motivation ]

 

 

Suspense를 만든 동기는 리액트 16.0.0의 초기 릴리즈에서의 Suspense는 React.lazy로 클라이언트에서 코드 분할이라는 단일 사용 사례만 지원했다고 한다. 따라서 구성 요소에 <Suspense>로 감싸 줄 수 있지만 리액트에서는 다른 용도로 사용하지 않았다 한다. 또한 서버 렌더링과 함께 사용할 수 없었으며, 이러한 점들이 Suspense의 유용성을 제한시켰다고 한다.

 

위의 이유로 동일한 선언적 Suspense의 fallback이 비동기 작업(코드, 데이터, 이미지 등)을 처리할 수 있도록 하고자 했으며, 이러한 동기가 Suspense를 더 강력하게 만들기 위한 첫 번째 단계였다고 한다.

 

✏️리액트 애플리케이션은 일반적으로 많은 구성 요소, 유틸리티 메서드, 타사 라이브러리등으로 구성되어 있다. 때문에 필요할 때만 로드하지 않으면 사용자가 첫 페이지, 즉 메인 페이지에 들어오는 즉시 대규모의 JavaScript의 payload를 전송하게 된다. 이러한 문제는 저사양의 기계와 느린 네트워크에서 load 하는데 오랜 시간이 걸리게 된다. 

 

이때 Suspense는 React.lazy로 클라이언트에서 코드 분할을 하게 되면 위의 문제를 해결하는데 도움이 되지만, 해당 부분에 대해서만 지원을 했기 때문에 Suspense의 유용성이 제한되었다는 의미 같다.

 

 

 

 

[ Detailed design ]

 

Suspense의 작동 방식을 간략하게 요약하면 아래와 같다.

 

<Suspense fallback={<PageGlimmer />}>
  <RightColumn>
    <ProfileHeader />
  </RightColumn>
  <LeftColumn>
    <Suspense fallback={<LeftColumnGlimmer />}>
      <Comments />
      <Photos />
    </Suspense>
  </LeftColumn>
</Suspense>
Suspense를 사용하면 구성 요소의 트리 일부가 아직 렌더링 할 준비가 되지 않았을 때 리액트에 렌더링 할 내용을 선언적으로 지정할 수 있다.

 

예를 들어 Suspense로 감싸진 구성 요소인 Photos 컴포넌트는 서버에 이미지 데이터를 요청하고, 요청에 대한 응답으로 이미지를 전달받아 화면에 렌더링 하는 컴포넌트이다.

 

이때 Photos 컴포넌트를 Suspense로 감싸주게 되면 서버로부터 이미지를 전달받아 화면에 렌더링 시키는 시간 동안 Suspense의 fallback으로 전달한 컴포넌트 LeftColumnGlimmer가 렌더링 되어 화면에 보이며 Photos 컴포넌트의 렌더링이 끝나면 화면을 전환해 주는 것이다.

 

 

개념적으로는 Suspense가 catch 블록과 유사하다고 생각할 수 있지만, 오류를 감지하는 대신 컴포넌트를 서스펜딩 한다. 모든 컴포넌트의 작동은 일지 중지될 수 있으며 이는 아직 렌더링 할 준비가 되지 않았음을 의미한다. 렌더링 할 준비가 되지 않은 이유는 일반적으로 코드, 데이터 등이 누락되었기 때문이다.

 

JavaScript에서 throw를 하게 되면 함수가 여러 번 호출되더라도 가장 가까운 catch가 이긴다.(여기서 이긴다는 가장 가까운 catch 블록이 실행된다는 의미 같다.) Suspense 다르게 작동하지만, 정신적 모델은 유사하다. 만약 컴포넌트의 렌더링이 일시 중단될 경우, 그 사이에 있는 컴포넌트의 수에 관계없이 렌더링이 보류된 컴포넌트와 가장 가까운 Suspense가 catch 한다.

 

위의 예에서 컴포넌트 ProfileHeader의 렌더링이 일시 중지되면 전체 페이지가 fallback으로 전달한 컴포넌트 PageGlimmer로 바뀐다. 하지만 컴포넌트 Comment나 Photo의 렌더링이 일시 중지될 경우 컴포넌트 Comment나 Photo에 한해서 fallback으로 전달한 LeftColumnGlimmer으로 바뀐다.

 

이렇게 하면 시각적 UI 설계의 세분성에 따라 어떤 컴포넌트가 비동기 코드와 데이터에 의존하는지 걱정하지 않고 Suspense로 안전하게 감싸주거나 제거할 수 있다.

 

중요한 것은 Suspense가 코드와 데이터가 로드되는 방식과 완전히 분리된다는 것이다. 서로 다른 전송 계층(GraphQL 또는 REST), 데이터를 가져오는 다른 장소(framework method vs ad-hoc), 서로 다른 성능 특성(waterfall vs parallel pre-fetched), 서로 다른 환경(client vs server)을 사용하는 많은 전략이 있다. Suspense는 리액트가 선언적 loading 상태를 인식하도록 하는 메커니즘일 뿐 데이터나 코드를 가져오는 방법에 대해 규정을 하지 않는다.

 

✏️'Suspense로 감싼 컴포넌트가 렌더링 할 준비가 되지 않을 경우 fallback으로 전달한 컴포넌트가 감싸진 컴포넌트들을 대신해 렌더링 되며, Suspense로 다중으로 감쌀 경우 가장 가까이 있는 Suspense의 fallback으로 전달한 컴포넌트로 바뀐다. 코드/데이터가 로드되는 로직과 분리된다는 장점이 있다.' 정도로 정리할 수 있겠다.

 

 

Behavior change: Committed trees are always consistent

 

다음과 같은 코드를 고려해야 한다.

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

 

showComments가 false에서 true로 변한다고 가정하자. showComments 값의 변화에 따라 컴포넌트 Panel의  내용을 렌더링하지만 컴포넌트 Comments의 렌더링은 일시 중단된다. 따라서 컴포넌트 Panel를 렌더링할 수 없어진다. 가장 가까운 Suspense의 fallback까지의 전체 내용, 즉 Suspense로 감싸진 내부의 모든 컴포넌트들은 숨겨지고 fallback으로 전달한 컴포넌트가 렌더링 된다.

 

 

이전에 리액트는 다음과 같은 순서로 작업을 수행했다.

  1. 컴포넌트 Panel의 내용을 DOM에 배치했지만, 컴포넌트 Comments 내용이 비어있다.
  2. display : none 를 추가하여 컴포넌트 Panel에 불완전한 내용이 표시되지 않게 한다.
  3. Spinner(로딩 컴포넌트)를 DOM에 추가한다.
  4. 기술적으로 컴포넌트 Panel의 effect에 mount되어 있기 때문에 완전하지는 않지만 나타난다.
  5. 컴포넌트 Comments의 내용이 준비될 때까지 기다린다.
  6. 렌더링을 다시 시도한다.
  7. DOM에서 Spinner를 제거한다.
  8. 컴포넌트 Comments의 내용을 이지 DOM에 배치한 컴포넌트 Panel의 빈 곳에 배치한다.
  9. display : none을 컴포넌트 Panel에서 제거한다.

 

위의 순서는 불완전한 트리가 전혀 커밋되지 않기 때문에 더 직관적이다. 트리가 준비되지 않은 경우 트리는 삭제되고 나중에 트리 전체를 삽입한다. effect는 항상 빈 곳이 없는 완전한 트리를 관찰한다.

 

리액트 16.6에서 이러한 접근 방식을 사용하지 않는 이유는 이전 버전과의 호환성 때문이다. 당시 대부분의 리액트 코드는 클래스를 기반으로 사용했으며, 많은 클래스에서는 componentWillMount 메서드가 포함되어 있었다.

 

이것이 2018년에 componentWillMount의 이름을 UNSAFE_componentWillMount로 바꾸고 componentWillMount를 사용하지 않기 위한 전략을 설명한 이유이다.

 

UNSAFE_componentWillMount의 문제는 하위 컴포넌트가 일시 중단되었는지 여부를 알기 전에 렌더링 중에 문제가 발생한다는 것이다.

 

UNSAFE_componentWillMount가 이미 실행된 후 불완전한 트리를 버리면, 일치하는 componentDidMount 또는 componentWillUnmount 호출이 수신되지 않는다. 다시 componentDidMount 또는 componentWillUnmount를 다시 시도하는 동안 동일한 트리를 다시 렌더링하므로 다른 UNSAFE_componentWillMount와 componentWillUnmount에 의존하는 코드는 동일한 횟수로 호출될 경우 오류 또는 메모리 누수를 발생시킬 수 있다.

 

대부분의 유명한 오픈 소스 라이브러리는 위의 클래스 기반 컴포넌트에서 멀어졌다.

 

 

 

New feature: Server-side rendering support with streaming

 

 

이전에는 서버 렌더링 중에 컴포넌트가 일시 중단되면 리액트에서 hard error가 발생했다. 실제로 서버 렌더링을 사용하는 앱은 코드 분할을 위해 서스펜스를 사용할 수 없었다.

 

우리는 HTML의 불규칙한 스트리밍을 지원하는 server renderer를 지원하고 있다. 문자열을 동기적으로 생성하는 이전 server renderer와는 달리 새로운 server renderer는 새로운 stream을 생성하고, 생성된 stream은 초기에 플러시될 수 있는 초기 HTML로 시작한다.

 

그러나 새로운 renderer는 Suspense와도 완전히 통합되어 있는데, 이는 준비되지 않은 트리의 부분을 "wait"하고 이를 위한 fallback HTML(예 : Spinner)을 보내는 것을 의미한다.

 

콘텐츠가 준비되면 리액트는 콘텐츠 HTML을 작은 inline <script>와 함께 동일한 stream으로 내보내 원래 DOM 구조의 올바른 위치에 삽입한다. 결과적으로 페이지의 일부가 서버에서 느리게 렌더링되더라도, 사용자는 클라이언트가 JS가 로드되게 전에도 의도적으로 설계된 모든 중간 로드 상태를 가진 점진적인 로드 페이지를 볼 수 있다.

 

이러한 기능은 Suspense가 데이터 페칭을 지원할 때 유용할 것이며, 데이터를 기다리는 동안 streaming HTML을 잠금 해제할 것이기 때문이다.

 

lazy 컴포넌트가 아직 코드를 로드 하지 않았지만, Suspense로 감싸져 있다면 리액트는 코드분할 청크를 기다리지 않고 앱의 나머지 부분을 작동시킬 것이다. 리액트는 서버로부터 받은 콘텐츠 HTML을 보존한 다음 해당 클라이언트 코드가 로드된 후 작동한다. 이는 모든 코드 분할 청크가 로드를 완료할 때 까지 더 이상 기다릴 필요가 없기에 성능을 크게 향상시킬 수 있다.

 

✏️간단하게 정리하면 페이지의 일부, 즉 한 페이지에 구성된 여러개의 컴포넌트 중 Suspense로 감싸진 컴포넌트가 느리게 작동되어도 Suspense로 감싸진 영역에 fallback으로 전달한 컴포넌트가 렌더링된다. 추후 정상적으로 렌더링이되면 해당 영역에 기존의 컴포넌트가 들어간다. 또한 모든 코드 분할 청크가 로드될 때까지 기다릴 필요가 없기에 성능을 크게 향상 시킨다로 정리할 수 있다.

 

 

 

New feature: Using transitions to avoid hiding existing content

 

렌더링의 결과로 일부 컴포넌트가 일시 중단될 수 있다. 이 문제는 사용자에게 이미 표시된 컴포넌트에서 발생할 수 있다. 화면 내용이 항상 일관성을 유지하기 위햇 이미 표시된 컴포넌트가 일시 중단될 경우 리액트 트리를 가장 가까운 <Suspense>의 경계까지 숨겨야 한다. 그러나 사용자의 관점에서 이것은 방향을 잃을 수 있다.

 

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

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

 

위의 예에서 tab의 값에 따라 컴포넌트 Photos 또는 Comments가 렌더링되도록 설정되어 있지만, 만약 컴포넌트 Comments가 일시 중단된 경우 사용자는 컴포넌트 Spinner를 보게 된다.

 

이것은 사용자가 더 이상 컴포넌트 Photos를 보고 싶지 않고, Comments은 아무것도 렌더링할 준비가 되지 않았으며, 리액트는 사용자 경험을 일관되게 유지해야하기 때문에 컴포넌트 Spinner를 보여줄 수 밖에 없음을 의미한다.

 

그러나 이러한 사용자 경험이 바람직하지 않은 경우도 있다. 특히 새 UI를 준비하는 동안에는 이전의 UI를 보여주는 것이 좋을 때가 있다.

 

새로운 startTransition API를 사용하여 리액트에서 해당 작업을 수행할 수 있다.

 

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}
여기서 tab을 'comments'로 설정하는 것은 시간이 다소 걸리는 다른 상태로의 이행으로 가정한다. 리액트는 이전 UI를 제자리에 유지하고 컴포넌트 Comments를 보여줄 준비가 끝나면 전환된다.

 

 

Providing immediate feedback

 

어떠한 피드백도 없이 비동기 처리를 하는 것은 혼란스러울 수 있다. 이러한 이유로 리액트는 [isPending, startTransition]과 같은 튜플을 반환하는 useTransition Hook을 제공한다. isPending을 사용하여 사용자에게 어떤 일이 일어나고 있음을 알릴 수 있다. UI는 완전한 상호작용하는 상태를 유지한다. 예를 들어 사용자가 원할 경우 다시 tab을 'photo'로 전환할 수 있다.

 

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>
위의 예는 클릭하면 사용자가 잠시 동안 컴포넌트 Photo를 볼 수 있지만, 상위 div의 opacity가 0.8이므로 전환을 알 수 있다.

 

 

Avoiding waiting too long

 

컴포넌트 Photos 내부 어딘가에 매운 느리게 데이터 소스를 추가하는 경우를 가정해보자.

 

 

이제 이전에 빠르게 완료되었던 tab 전환이 더 오랜 시간 동안 고정되어 사용자가 이미 완료된 컴포넌트 MyPhotos를 볼 수 없게 된다. 이 문제를 해결하려면 컴포넌트 TaggedPhotosVerySlow를 Suspense로 감싸줘야 한다.

 

 

리액트의 전환은 새로운 Suspense의 경계가 추가될 때까지 기다려 주지 않는다. 사용자가 이전에 해당 경계의 내용을 본 적이 없기 때문에 즉시 해당 경계의 내용을 표시하는 것은 방해가 되지 않는다. 

 

이를 통해 사용자는 나머지 콘텐츠(예: MyPhotos)를 더 빨리 볼 수 있다. 즉, Suspense 노드를 트리의 위와 아래로 이동시키면 전환이 더 빠른(그러나 더 많은 로드 상태를 표시함) 또는 더 완전한지(그리고 한 번에 더 많은 것이 표시되기를 기다린다.)에 영향을 준다. transition 시간을 수동으로 제어하는 방법은 없다.

 

✏️Suspense로 감싸진 컴포넌트 내부에 로드하는데 오래 걸리는 컴포넌트가 존재한다면 해당 컴포넌트를 한 번 더 감싸주는 것이 좋다는 것 같다. 하위에 Suspense로 감싸진 컴포넌트가 로드되는 것을 기다리지 않고 감싸지지 않은 컴포넌트(MyPhotos)가 렌더링 준비가 끝나면 fallback으로 전달한 컴포넌트와 전환하여 보여준다로 정리할 수 있다.

 

 

 

Behavior change: Layout effects re-run when content reappears

 

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

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

 

transition을 사용하지 않고 tab을 전환할 때 Spinner가 표시된다고 가정한다. 또는 아직 개발자가 transition을 추가할 기회가 없었을 수도 있다. 이 경우 리액트는 내용을 숨기고 fallback을 표시한 후 나중에 다시 전환해야 한다.

 

이것의 문제는 트리 내부의 컴포넌트가 숨겨져 있가는 것을 알 방법이 없다는 것이다. 예를 들어 컴포넌트 AutoSize가 크기와 위치를 결정하는 DOM 레이아웃을 읽는 경우 숨겨져 있는 동안 0으로 읽힌다. 또한 리액트가 표시할 때 다시 표시된다는 알림이 표시되지 않는다. 

 

이러한 문제를 해결하기 위해 숨기기와 보기에만 레이아웃 효과를 실행할 것을 제안한다. 구체적으로 리액트가 Suspense를 숨겨야 할 경우, 해당 트리 내부의 레이아웃 효과에 대한 정리를 실행한다. 리액트가 Suspense의 내용을 다시 표시할 준비가 되면 트리가 처음 나타났을 때와 유사하게 해당 트리의 레이아웃 효과가 실행된다. 이러한 방식으로 AutoSize와 같은 컴포넌트가 레이아웃 효과에 레이아웃 관련 논리를 포함하는 한, 일반적으로 Suspense와 함께 작동한다. 

 

❓❓힝..😭 

 


[ 프로젝트 적용 ]

 

컴포넌트 ToDoList 내부의 useEffect에서 server에 저장된 데이터를 페칭하고 있다.

 

때문에 컴포넌트 ToDoList를 Suspense로 감싸주었고, 데이터가 페칭되어 렌더링되기 전까지 fallback이 작동하게 된다.

 

 

 

 

🔥React 공식 문서를 직접 살펴보면서 새롭게 알게된 부분, 여전히 아리쏭한 부분이 있지만 공식 문서를 보면서 공부하는 습관을 키우기 위해 처음으로 시도해본 RFC 살펴보기. 완벽하지는 않았지만 이해가 가지 않는 부분을 조금 더 찾아 봐야겠다는 생각과 무엇을 찾아봐야되는지 안 것으로도 반은 성공한 것 같다.