React 18 톺아보기 - 03. Transition Lane_1

Transition

Transition Lane을 알아보기에 앞서 전환(Transition)에 대해서 좀 더 익숙해질 필요가 있습니다.
다행히 React 18 Working Group에서 startTransition API에 대해 예제와 함께 친절히 설명하는 내용이 있습니다(참고). 이 내용을 바탕으로 살을 조금 더 보태어 전환을 완전히 이해하고 넘어가겠습니다.

다음은 슬라이더의 스코어에 따라서 버블 차트를 렌더링하는 어플리케이션 입니다.

사용함에 있어서 별문제가 없어 보입니다. CPU에 Throttling을 걸어보겠습니다.

무엇이 문제일까요? 메인 스레드를 들여다보겠습니다.

blocking

마우스 다운 이벤트 이후에 일련의 마우스 무브 이벤트들이 발생했습니다. 문제는 특정 마우스 무브의 렌더링 작업이 앱이 멈추었다고 느낄 수 있을 정도로 긴 시간이 걸렸습니다. 그리고 이 시간동안 사용자는 차단됩니다.

리액트는 이 문제를 어떻게 바라보았을까요? 이미 해답은 Intro에서 언급했습니다.

사용자는 물리적인 행위에 대해서 즉각적인 반응을 기대한다. 그렇지않다면 사용자는 뭔가 잘못되고 있다고 느낄 수 있다. 반면 A0 -> A1의 전환은 느릴수 있다고 무의식적으로 인지하고 있으며, 모든 전환에 대한 즉각적인 반응을 기대하지 않는다.

즉, “사용자 액션의 응답을 최우선적으로 처리하고 난 후 전환에 대한 응답을 처리하자.”입니다.

위 내용을 바탕으로 사용자와 밀접한 UI와 전환 UI를 구분합니다.

app updates

슬라이더는 사용자 행위에 밀접한 UI입니다. 그에 반해 차트는 슬라이더 상태에 따라서 A0 -> A1으로 전환되는 UI입니다. UI를 구분했으니 업데이트 또한 구분해야 합니다.

const [slideValue, setSlideValue] = useState(0);
const [score, setScore] = useState(0);

<Slider onChange={(...) => {
 setSlideValue(...);
 React.startTransition(() => setScore(...));
}}
/>

이전과 마찬가지로 CPU에 Throttling을 걸어보겠습니다.

이전과 다르게 사용자의 액션을 전혀 차단하지 않습니다. 일반 업데이트와 전환 업데이트의 렌더링에는 어떠한 차이점이 있는 걸까요?

app concurrent render

일반적으로 개발자가 업데이트를 생성하면 해당 업데이트는 동기식으로 렌더링을 진행합니다. 동기식은 업데이트가 렌더링을 시작했을 때 화면 반영까지 쭉 메인 스레드를 점유한 상태로 작업을 진행하는 방식입니다. 반면에 동시성 렌더링은 렌더링 과정이 비동기로 점진적으로 진행됩니다. 그래서 렌더링 작업이 동기식과는 다르게 잘게 쪼개져(Time Slicing) 있는 것을 확인할 수 있습니다. 여기에 우선순위 기반의 렌더링이 더해져 전환 렌더링 중이라도 우선순위가 더 높은 사용자 액션의 업데이트가 발생했다면 해당 업데이트를 먼저 렌더링하면서 사용자를 차단하지 않고 높은 응답성을 가질 수 있게 되었습니다. 리액트에서 전환 업데이트의 비동기식 점진적 렌더링을 Concurrent Render, 동기식을 Sync Render라고 합니다.

단일 상태로 렌더링하는 것과 전환 업데이트로 나눈 것의 단일 업데이트, 순차적인 업데이트 생성 시의 렌더링 과정은 다음과 같습니다.

단일 업데이트 생성

짧은 간격으로 두 개의 업데이트 생성

증명을 위한 예제 코드

Transition Lane의 설명과 이를 증명하기 위해서는 렌더링 과정을 들여다보아야 합니다. 이를 위해서 다음의 케이스 실행기를 사용할 예정입니다.

각 케이스별로 예제 코드와 버튼이 있습니다. 버튼을 클릭하면 케이스를 실행하고 로그를 찍습니다. case 1을 한번 실행시켜 보겠습니다.

로그 설명

로그는 렌더링 단계별(Render, Commit)로 묶이며, Render Phase에서는 컴포넌트의 렌더링 정보(렌더링 순서, 보류 상태)를 확인할 수 있습니다.

render phase log

Commit Phase가 끝나면 DOM에 반영됩니다. 반영된 DOM의 내용은 다음과 같이 출력됩니다.

commit phase log

컴포넌트

공통 컴포넌트는 <Text/>, <BusyText/>, <AsyncText/>, <AsyncBusyText/>가 있습니다.
<Text/>는 넘겨받은 값을 그대로 렌더링합니다.
<BusyText/><Text/>와 같지만, 렌더링이 완료되기까지 3ms~가 걸립니다. 여러 번의 Render Phase가 진행되도록 하기 위한 컴포넌트입니다.
<Async**/>는 렌더링 보류 상태를 증명하기 위한 컴포넌트입니다. 이를 위해 내부적으로 <Suspense /> 인터페이스를 구현한 Data fetch API를 사용합니다. fetch가 완료되지 않은 상태에서 렌더링이 되면 "Suspend [<text>]" 로그를 찍고 렌더링을 보류합니다.

모듈 설명

usecase structure

src

  • components: 공통 컴포넌트가 위치합니다.
  • data-fetch: <Suspense/> 인터페이스를 구현한 fake data fetch API입니다.
  • log: 로그 모듈입니다.
  • react-libs: 리액트 내부 의존성이 있는 모듈입니다.
  • runner: use case 실행기입니다. 리액트의 profiling API를 활용하여 렌더링 시점을 잡습니다.

use-cases

use case에는 예제용 App 컴포넌트13와 케이스 코드47가 있습니다.

케이스 코드에는 렌더링 단계별 yielding 포인트가 있습니다. 원하는 테스트 동작을 한 후, 캐치하려는 단계를 명시합니다.

가령, 첫 마운트 이후의 시점을 잡고 싶다면 다음과 같이 작성합니다.

vdomRoot.render(<App />);
yield PHASE.COMMIT;
// 여기서 부터는 첫 Commit이 완료된 이후의 시점이 됩니다.

<AsyncText/>를 사용했다면 데이터 요청을 완료하고 싶을 수 있습니다. resource API을 통해 요청 중인 데이터를 resolve 할 수 있습니다.

import resource from '../src/data-fetch'
resource.resolve('Hello')

케이스 별로 코드에 설명이 포함되어 있습니다. 필수적으로 확인하시길 바랍니다.

case 2

가볍게 전환 업데이트가 정말 Time Slicing 되는지 확인해보겠습니다. 일반 업데이트와 전환 업데이트를 생성한 후 Render Phase가 몇 번 진행되는지 확인해보면 됩니다.

케이스별 설명은 코드에 작성되어 있습니다. case_2의 코드는 왼쪽 Resize Bar를 움직이거나 Open Sandbox 버튼을 통해 프로젝트에 직접 접근하여 확인할 수 있습니다.

사전준비는 모두 끝났습니다. 이제 본격적으로 Transition Lane을 시작합니다.


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그