React Concurrency (feat.약간의 react source code..)

Daren Kim·2024년 2월 19일
0

React18

목록 보기
2/2

웹 개발을 공부하면서 리액트를 활용해 작게는 여러가지 컴포넌트를 만들고 크게는 하나의 프로젝트를 만들면서 잘 안다고 어쩌면 착각? 을 하면서 시간을 보냈다.
그러나 최근들어 리액트가 추구하려고 하는 방향성에 대해선 어쩌면 하나도 모르고 있던게 아닐까 하는 생각이 들었다.
계기는 저번 글에서도 알아봤던 SSR관련 내용이었는데, 이 중에서 특히 눈길을 끄는것은 next.js가 SSR을 구현함에 있어, 최초 html을 렌더링 할 때, renderToReadableStream() 이라는 메소드로 해당 기능을 구현하는데 이 메소드가 서스펜스를 지원한다는 부분이었다.
서스펜스는 실험적으로 리액트의 v16.6에서 추가되어 v.18에 정식 포함된 기능이다.

보다 자세히 그리고 정확하게 이해하기 위해선 먼저 동시성의 개념을 이해해야 했다.

동시성 in React.

동시성은 이름에서 유추 할 수 있듯, 동시에 여러가지 일을 할 수 있는 속성을 이야기한다.
리액트의 핵심은 컴포넌트를 렌더링 하는것 이다. 리액트의 동시성은 곧 동시에 여러 컴포넌트가 렌더링되는걸 떠올리면 한층 이해가 쉽지 않을까?

동시성은 주로 병렬성과 함께 등장하는 개념이다.

’동시성은 독립적으로 실행되는 프로세스들의 조합이다.’
‘병렬성은 연관된 복수의 연산들을 동시에 실행하는 것이다.’
’동시성은 여러 일을 한꺼번에 다루는 문제에 관한 것이다.’
‘병렬성은 여러 일을 한꺼번에 실행하는 방법에 관한 것이다.’

동시성은 하나의 프로세스로 병렬 처리를 가능하게 하지만 병렬처리를 뜻하는 말은 아니다. 즉, JS의 비동기처럼 하나의 스레드로 작업을 순차적으로 처리하지만, 마치 여러 개의 스레드가 사용되고 있는 것처럼 보이게 하는 것이다.
따라서 동시성은 싱글 코어에서도 동작하지만, 병렬성은 두 개 이상의 코어가 필요하다.

위 사진에서 볼 수 있듯, 동시성은 최소 두 개의 context를 통해 잘개 쪼개진 두 개 이상의 작업을 지속적으로 컨텍스트 스위칭하여 마치 동시에 이루어지는 것처럼 보이도록 한다.
반면에 병령처리는 말 그대로 두개의 코어를 통해 작업을 동시에 실시한다.

왜 동시성이 필요할까?

리액트가 동작하는 JavaScript는 굳이 말할필요 없이 싱글 스레드의 환경이다. 다시 말해 동기적으로 동작하는 코드속에서 태생적으로 한번에 여러개의 작업을 병렬적으로 처리 할 수 없다.

리액트 18버전 이전에서 렌더링은 중간에 멈출 수 없는 동작이었다. 동기적인 흐름 속에서 한번 렌더링이 실행되고 나면 중간에 다른 동작이 껴들어 올 수 없었다.

Naver Deveiw 2021 inside React 영상

보통의 경우에선 큰 문제가 되지 않지만
만약 연산하여 렌더링하는 과정이 오래걸린다면? 그 과정에서 사용자 input등의 렌더링 업데이트가 필요하다면?

위 출처에서 볼 수 있는 render blocking 관련 좋은 예시가 있어 가져와보았다.

이처럼 사용자 인풋이 늘어날 수록 렌더링 해야하는 색상의 갯수가 늘어나지만 렌더링 과정이 오래걸림에 따라, 사용자가 입력을 해도 즉각적으로 화면에 반영이 안되었다가 뒤늦게 반영되는걸 확인 할 수 있다.

리액트팀은 이러한 문제를 해결 하기 위하여 동시성을 도입하였고 동시성의 핵심에는 우선순위가 있다.

우선순위

리액트팀은 사용자 인터렉션으로 촉발된 이벤트 및 업데이트에 우선순위를 할당하여 사용자 경험을 높이고자 했다.
위 예제에서 우선순위를 나눠 본다면, 아마 아래와 같을것이다.

1순위: 사용자가 입력시 텍스트 변경의 반영
2순위: 그에 따른 리스트 혹은 색상값들의 반영

이에 따르면 우린 모든 렌더링이 한번에 이루어지는 과정을 나누어, 1순위에 해당하는 렌더링을 먼저 처리하고 2순위에 해당하는 렌더링을 그 이후에 처리하도록 조절 해야 한다.
만약 중간에 다시금 1순위 렌더링에 해당하는 이벤트가 발생 한다면 2순위 렌더링을 잠시 멈추고 다시 1순위 렌더링을 진행해야 한다. 그 후 펜딩 상태였던 2순위 렌더링을 다시금 진행하게 된다.

다시 말해 리액트는 동시성 렌더링 모드에선 하위 컴포넌트 렌더링을 잘게 나누어 처리한다고 이야기 할 수 있다.

리액트는 우선순위 할당 알고리즘을 내부적으로 Lane 이라고 하는 모델을 사용하여 구현했다고 한다.
Lane 모델은 도로의 차선을 모티브로 하여 리엑트에서 우선순위를 표현하기 위해 구현된 32비트 데이터로 표현된 비트맵이다.
작업의 스케쥴링 및 조정 작업 과정의 우선순위를 가진 고유의 작업 스레드를 표현한다.

실제 레인의 구현에서 확인할 수 있듯, 기본적으로 더 작은 숫자로 표현된 레인이 높은 우선순위를 갖는다.

아래와 같이 리액트 소스코드에서 Lane이 구현된 모습을 확인 할 수 있다.

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;

export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

export const SyncUpdateLanes: Lane = enableUnifiedSyncLane
  ? SyncLane | InputContinuousLane | DefaultLane
  : SyncLane;

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111110000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000001000000000000;


const RetryLanes: Lanes = /*                            */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;

react/packages/react-reconciler/src/ReactFiberLane.js

리액트는 Task prioritization과 Taks batching으로 개념을 분리하여 우선순위 할당을 가능케 하고자 했다.

Task prioritization: A가 B보다 우선순위인가?
Taks batching: A가 이 그룹 태스크에 속해 있는가?

이 두가지를 분리하여 만약 CPU -> I/O -> CPU의 작업 순이 있을때 CPU가 처리되는 작업의 병목현상을 막을 수 있게 되었다.

Task prioritization

1순위: 기한이 지났거나 동기화가 필요한 작업
2순위: 사용자 인터렉션
3순위: 일반적 우선순위(네트워크 요청 등)
4순위: Suspense

Taks batching

SyncLane: 이산적인(discrete) 사용자 상호 작용에 대한 업데이트
InputContinuousLane: 연속적인(continuous) 사용자 상호 작용에 대한 업데이트
DefaultLane: setTimeout, 네트워크 요청 등에 의해 생성된 업데이트
TransitionLane: Suspense, useTransition, useDefferredValue에 의해 생성된 업데이트

리액트는 아래와 같이 태스크의 우선순위를 분석하고 할당하는 과정을 통해 동시성을 구현했다.

export function higherEventPriority(
  a: EventPriority,
  b: EventPriority,
): EventPriority {
  return a !== 0 && a < b ? a : b;
}

export function lowerEventPriority(
  a: EventPriority,
  b: EventPriority,
): EventPriority {
  return a === 0 || a > b ? a : b;
}

export function isHigherEventPriority(
  a: EventPriority,
  b: EventPriority,
): boolean {
  return a !== 0 && a < b;
}

export function lanesToEventPriority(lanes: Lanes): EventPriority {
  const lane = getHighestPriorityLane(lanes);
  if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
    return DiscreteEventPriority;
  }
  if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
    return ContinuousEventPriority;
  }
  if (includesNonIdleWork(lane)) {
    return DefaultEventPriority;
  }
  return IdleEventPriority;
}

react/packages/react-reconciler/src/ReactEventPriorities.js

startTransitions

자, 리액트가 내부적으로 어떻게 우선순위를 할당하고 결정하여 동시성을 구현했는지 정말 간단하게 알아보았다.
그럼 이젠 이를 통해 동시성을 기반으로 새롭게 추가된것들이 어떤게 있는지 알아보자!

startTransition을 사용하면 UI를 차단하지 않고 상태를 업데이트할 수 있다.
우선순위를 의도적으로 낮춰서 긴급하지 않은 작업과 긴급한 작업의 우선순위를 조율 할 수 있다.
예를 들어, 사용자가 하나의 탭을 눌렀다, 중간에 마음을 바꾸어 다른 탭을 누르는 경우, 처음 누른 탭의 렌더링이 완료되기 까지 기다릴 필요 없이 인터렉션을 할 수 있게 되는것이다.

import { startTransition } from 'react';

// 실제와는 차이가 있는 간단한 예시입니다.

setInputValue(input);

startTransition(() => {
  setColorLists(input);
});

위처럼 사용자의 입력값으로 업데이트 되던 텍스트 컴포넌트와 별개로 아래 색상 리스트는 startTransition을 통해 분리되어 렌더링이 막히지 않게 되는걸 확인 할 수 있었다.

Concurrency with SSR

이러한 동시성을 통해 리액트는 기존의 SSR이 갖고 있던 문제 역시 해결하고자 하였다.

리액트의 코어 개발자였던 Dan Abromov는 리액트의 기존의 SSR문제에 대하여 지적하고 Suspense를 통한 해결방안을 제시하였다.

먼저 기존의 SSR 흐름은 다음과 같았다.

  • 서버에서 app에 필요한 전체 데이터를 fetch
  • 서버에서 HTML로 전체 app을 렌더링하고 응답
  • 클라이언트에서 JS를 로드.
  • 클라이언트에서 hydration을 통해 html과 js를 연동

여기서 가장 큰 문제는 각 단계가 완료되어야만 다음 단계로 넘어갈 수 있었으며 앱이 실행되기 위해선 모든 단계가 완료되어야만 했다는 것이다.
이는 앱이 최초 실행되고 사용자가 인터렉션 하기까지 많은 시간을 걸리게 만드는 요인이었다.
리액트는 서스펜스를 도입하여 각 단계가 나뉘어져 실행 가능하도록 하였고, 두가지 기능을 가능케 했다.

  1. Streaming HTML: 기존에 사용되던 renderToString 메소드를 renderToPipeableStream 메소드로 변경
  2. 선택적 hydration: hydrateRoot를 통해 컴포넌트를 서스펜스로 감싸기

그렇다면 서스펜스는 무엇이고 어떻게 이를 가능하게 하는걸까?

Suspense in SSR

먼저 SSR에서의 서스펜스 활용에 대해 알아보자!
서스펜스는 컴포넌트의 렌더링을 어떤 작업이 끝날 때까지 잠시 중단시키고, 다른 컴포넌트를 먼저 렌더링 할 수 있도록 도와주는 기능이다. 쉽게 말해 해당 컴포넌트가 data fetching 등의 작업을 이유로 완벽하게 렌더링이 되지 않아도 다른 컴포넌트가 렌더링 될 수 있도록 해주는 것이다.

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

위의 예시를 살펴보면 Comments 컴포넌트가 아직 준비되지 않아도 다른 컴포넌트들이 성공적으로 렌더링이 되는걸 볼 수 있다. Comments 컴포넌트는 다른 코드를 막지 않으며 필요한 데이터가 모일때까지 렌더링 되지 않고 fallback에 해당하는 컴포넌트를 대신 보여준다.

<main>
 <nav>
   <!--NavBar -->
   <a href="/">Home</a>
  </nav>
 <aside>
   <!-- Sidebar -->
   <a href="/profile">Profile</a>
 </aside>
 <article>
   <!-- Post -->
   <p>Hello world</p>
 </article>
 <section id="comments-spinner">
   <!-- Spinner -->
   <img width=400 src="spinner.gif" alt="Loading..." />
 </section>
</main>

이때, 서버에서 댓글에 해당하는 데이터가 준비되면 리액트는 동일한 Stream에 추가되는 HTML과, 해당 HTML을 올바른 “위치”에 주입하기 위한 작은 inline “script” 태그를 보내준다.

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

결과적으로, 리액트 자체가 로드되어 hydration이 일어나기 전에 모든 데이터를 포함한 html을 보여주게 되고 이를 통해 모든 데이터가 준비되어야만 화면을 보여줄 수 있다는 문제를 해결 할 수 있게 된다.

그럼 hydration은 역시 화면이 다 준비될때 까지 기다려야 하겠네?

전혀 아니다.
React 18에서 Suspense는 댓글 위젯이 로드되기 전에 애플리케이션을 Hydration 할 수 있게 해준다.
유저의 관점에서 최초에는 HTML로 스트리밍된 상호작용할 수 없는 콘텐츠를 보게 된다.
그러나 리액트는 Selective Hydration을 통해 댓글에 해당하는 코드를 제외하고 Hydration을 수행할 수 있다. (Suspense로 감싸져 있기 때문!)
Comments를 Suspense로 묶음으로써 리액트가 Streaming과 Hydration이 렌더링을 Block 하는 것을 막아준다.

hydration이 진행되는 동안 이벤트는 그럼 어떻게 돼?

Hydration 과정 자체가 더 이상 브라우저를 “완전히” 점유하지 않는다.
예를 들어, 댓글 부분의 Hydration이 진행되는 동안 유저가 사이드바를 클릭한다면, 당연하게도 브라우저는 해당 이벤트를 핸들링 할 수 있다. 위에서 알아보았던 동시성모드와 그 우선순위 할당 덕분이다.

여기서 더 나아가 만약 여러개의 Suspense가 있다고 가정했을때에도, 리액트는 우선순위에 따라 hydration을 진행한다.

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

이처럼 코드 상으로 먼저 hydration이 진행될 사이드바 컴포넌트의 경우에도 만약 댓글에 클릭 이벤트가 발생한다면 리액트는 이를 감지해서 댓글을 먼저 hydration 하게 된다.

Suspense

이처럼 서스펜스를 통해 기존의 리액트가 갖고 있던 SSR에서의 문제를 동시성의 구조와 엮어 해결하는 과정을 살펴 보았다.
서스펜스는 SSR에서의 동시성 지원 외에도 클라이언트 사이드에서 더 많은 역할을 할 수 있다.
대표적인건 아무래도 data fetching 이지 않을까?
리액트가 어떻게 서스펜스를 구현했는지, 어떻게 동작하는지에 대해 더 깊게 공부해보고 싶지만 전부 다 적기엔 글이 너무 길어질듯 싶어 나누어 작성 하고자 한다.
오늘 글은 여기까지!

참고

리엑트 동시성 매커니즘들은 어떻게 구현되어 있을까
React 공식 문서
Naver deview 2021
React 18 Concurrent Rendering
Dan Abramov SSR 관련 글

profile
안녕하세요!여기저기관심많은FE개발자지망생입니다.

0개의 댓글