Original article: React 18 New Features – Concurrent Rendering, Automatic Batching, and More

2022년 3월에 리액트 18이 발표되었습니다. 성능 향상과 렌더링 엔진 개선에 초점이 맞춰졌습니다.

리액트 18은 향후 출시될 리액트 기능의 토대가 될 동시성 렌더링 API의 초석을 다졌습니다.

이번 튜토리얼에서는 리액트 18에 발표된 기능을 빠르게 훑으며, 동시성 렌더링과 자동 일괄 처리, 전환(transitions) 같은 몇 가지 중요한 개념을 설명하고자 합니다.

한눈에 보는 리액트 18

구분기능
개념리액트의 동시성
기능자동 일괄 처리, 변이, 서버에서의 Suspense
APIcreateRoot, hydrateRoot, renderToPipeableStream, renderToReadableStream
HooksuseId, useTransition, useDeferredValue, useSyncExternalStore, useInsertionEffect
개선엄격 모드(Strict mode)
지원 중단ReactDOM.render, renderToString

이제 변경 사항을 더 자세히 살펴봅시다. 우선 리액트를 업데이트하지 않았다면, 이것부터 짚어 봅시다.

리액트 18로 업그레이드하는 방법

npm이나 yarn을 통해 리액트 18과 React DOM을 설치합니다.

npm install react react-dom

이제부터는 render 대신 createRoot를 사용하게 될 것입니다.

index.js에서 ReactDOM.renderReactDOM.createRoot로 변경해 루트를 생성하고, 이를 통해 앱을 렌더 합니다.

기존 리액트 17에서는 아래와 같았습니다.

import ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app'); 

ReactDOM.render(<App />, container);

React 18에서는 이런 모습입니다.

import ReactDOM from 'react-dom';
import App from 'App'; 

const container = document.getElementById('app'); 

// 루트를 생성합니다.
const root = ReactDOM.createRoot(container); 

// 루트를 통해 앱을 렌더 합니다.
root.render(<App />);

리액트 18에서의 동시성

리액트 18 작업 그룹 토론 때 등장한 Dan Abramov의 예시를 생각하며 동시성을 이해해 봅시다.

앨리스와 밥 두 사람과 통화해야 하는 상황이라고 해봅니다. 먼저 앨리스에게 전화를 걸고 통화가 끝나면 밥을 부르는 식으로, 비동시적 환경에서는 한 번에 한 명에게만 전화를 걸 수 있습니다.

통화 시간이 짧다면야 괜찮지만, 앨리스와 통화하는 데 드는 시간이 길어진다면(가령 대기 중이라거나), 엄청난 시간 낭비일 것입니다.

시간을 나타내는 x축 위에 앨리스와 통화한 시간이 일정 부분 파란색으로 표시되어 있고, 밥과 통화한 시간이 그 이후부터 또 일정 부분으로 주황색으로 표시되어 있습니다.
그림은 전형적인 비동시적 전화 대화의 모습을 보여준다. 전화를 끝내야 전화를 새로 걸 수 있습니다.

동시성 모드에서는, 앨리스에게 전화를 건 후 대기 상태에 들어간다면 밥에게 전화할 수 있습니다.

이는 두 사람에게 동시에 전화를 건다는 뜻은 아닙니다. 다만 같은 시간 동안 동시에 전화를 두 번 이상 진행하면서도 이 중 어떤 전화가 더 중요한지 결정할 수 있다는 의미입니다.

시간을 나타내는 x축 위에 앨리스와 통화한 시간이 일정 부분 파란색으로 표시되어 있으며, 그 내부에 밥과의 통화 시간이 겹치는 영역으로써 표시되어 있습니다.
그림은 앨리스와의 전화를 대기 상태로 두고, 밥과의 통화를 더 급하게 받음으로써 앨리스와 밥 사이의 전화 통화가 동시적으로 이루어지는 상황을 보여줍니다.

이와 유사하게, 리액트 18에 등장한 동시성 렌더링을 통해서 리액트는 렌더링 자체에 개입하고, 이를 중단하거나 재개하고 또는 폐기할 수 있습니다. 이로써 리액트는 무거운 렌더링 작업을 하는 동안에도 사용자와의 상호작용에 더 빨리 반응할 수 있게 되는 것이죠.

리액트 18 이전의 렌더링이란 개입할 수 없는 단 하나의 동기적 처리였기 때문에 한 번 렌더링이 시작되면 중단할 수 없었습니다.

동시성은 리액트 렌더링 메커니즘의 근본적인 개선입니다. 동시성을 통해 리액트는 렌더링에 개입합니다.

동시성 렌더링의 토대가 도입된 덕에 리액트 18에서는 suspense, 스트리밍 서버 렌더링, 변이 같은 새로운 기능이 소개되기도 했습니다.

리액트 18의 새로운 기능

자동 일괄 처리 (Automatic Batching)

리액트 18은 자동 일괄 처리라는 신규 기능을 도입했습니다. 위에서 살펴본 작업 그룹 토론에 등장한, 식료품 가게에서의 쇼핑 이야기를 통해 일괄 처리를 이해해 봅시다.

저녁으로 파스타를 만든다고 해봅시다. 식료품 쇼핑을 최적화하려면, 구입해야 할 모든 식재료를 목록화해, 단 한 번 만에 모든 재료를 구매하고자 할 것입니다.

이것이 바로 일괄 처리이며, 이렇게 하지 않는다면 요리를 시작하고 나서 재료가 필요해졌을 때 가게에 가서 재료를 사고, 다시 요리를 시작하고 다른 재료가 필요해진다면 또 가게에 가고…. 결국 미쳐버리고 말겠죠.

리액트에서 일괄 처리는 setState를 사용할 때마다 상태가 변하면서 생기는 렌더링 횟수를 줄이는 데 도움이 됩니다. 예를 들어, 이전에는 이벤트 핸들러의 상태 변화를 한 번에 처리했습니다.

const handleClick = () => {
  setCounter();
  setActive();
  setValue();
};

// 마지막에 한 번에 리렌더링 되었다.

그러나 이벤트 핸들러 바깥에서 진행된 상태 업데이트는 일괄 처리되지 않았습니다. 예를 들어 promise가 있거나 네트워크 호출을 하는 상황에서 상태 업데이트는 일괄 처리되지 못했습니다.

fetch('/network').then(() => {
  setCounter(); // 한 번 리렌더링됨.
  setActive(); // 두 번 리렌더링됨.
  setValue(); // 세 번 리렌더링됨.
});

// 총 세 번 리렌더링 됨.

이는 그다지 효율적인 방법이 아닙니다. 리액트 18은 자동 일괄 처리를 도입함으로써 promise, setTimeouts, 이벤트 콜백에서든 모든 상태 업데이트가 빠짐없이 일괄로 처리되도록 했습니다. 이는 백그라운드에서 리액트가 수행해야 할 작업의 상당수가 줄어드는 효과를 보여줍니다. 리액트는 이제 리렌더링 되기 전 아주 잠깐의 작업 시간만큼만 기다리면 됩니다.

자동 일괄 처리는 리액트에서 바로 사용할 수 있지만, 이 기능을 사용하지 않으려면 flushSync를 사용하면 됩니다.

전환(Transitions)

전환은 자원이 급하게 필요하지 않은 업데이트 상황에서 UI 변화를 표시하는 데 쓸 수 있습니다.

예를 들어, 자동 완성(typeahead) 양식에 입력하는 동안에는 두 가지 일이 일어납니다. 커서가 깜빡이면서 입력된 콘텐츠에 대한 시각적 피드백이 일어나는 동시에 백그라운드에서는 입력된 데이터에 대한 검색이 진행됩니다.

사용자에게 시각적 피드백을 주는 것은 중요하므로 긴급한 사항이라 말할 수 있습니다. 검색은 그렇게까지 중요하지 않으므로 긴급하지 않은 것으로 여길 수 있죠.

전환은 이렇게 시급하지 않은 업데이트에 대한 부분을 말합니다. UI 업데이트 사항을 긴급하지 않은, 즉 "전환(Transitions)"으로 간주함으로써, 리액트는 우선순위에 따라 업데이트를 진행할 수 있습니다. 이미 오래된 부분을 처리하니 렌더링 최적화가 더욱 쉬워집니다. (역자 주 : stale은 이미 오래된 낡은, '신선하지 않은'이라는 뜻으로, 오래된 렌더링은 긴급하지 않은, 그래서 우선순위에서 정리가 된 렌더링으로 이해할 수 있습니다.)

startTransition을 사용하면 긴급하지 않은 업데이트를 분류할 수 있습니다. 아래는 전환을 사용해 분류한 자동완성 컴포넌트의 예시입니다.

import { startTransition } from 'react';

// 긴급함 : 무엇이 입력되고 있는지 보여줍니다.
setInputValue(input);

// 전환 안에 두어, 긴급하지 않은 상태 변화를 별도로 표기합니다.
startTransition(() => {
  // 전환 : 결과를 보여줍니다.
  setSearchQuery(input);
});

debouncing, setTimeout과 전환은 어떻게 다른가?

  1. setTimeout과 다르게 startTransition은 즉시 실행됩니다.
  2. setTimeout을 사용하면 지연되는 것이 확실하지만, startTransition은 기기의 속도와 우선순위를 갖고 렌더링 되는 다른 부분에 따라서 지연됩니다.
  3. startTransition은 setTimeout과 다르게 방해받을 수 있지만, 페이지를 중단시키지는 않습니다.
  4. 리액트는 startTransition을 사용할 경우 보류된 상태를 추적할 수 있습니다.

서버에서의 Suspense

리액트 18에서 도입된 두 가지 기능은 아래와 같습니다.

  1. suspense 사용이 가능한 서버에서의 코드 분리
  2. 서버 측 스트리밍 렌더링

클라이언트 렌더링 vs 서버 렌더링

클라이언트 렌더링 되는 앱이라면 페이지를 실행시키고, 상호작용하도록 하기 위해 필요한 JavaScript와 함께 HTML을 로드합니다.

그런데 만일 JavaScript 번들이 지나치게 크거나 연결이 느리다면, 이 과정에 시간이 무척 소요될 것입니다. 사용자는 페이지가 상호작용한 상태가 되거나 의미 있는 정보를 주기만을 마냥 기다릴 것이고요.

클라이언트 렌더링에서는 1. JS를 로드하고, 2. 데이터를 받아오며, 3. 컴포넌트를 렌더링한 후에 4. 상호작용한 웹 페이지가 된다는 것을 순서에 따라 보여주고 있습니다.
클라이언트 렌더링 흐름에서는 사용자는 페이지가 상호작용해질 때까지 기다리는 데 오래 걸립니다. 출처: React Conf 2021 Suspense 사용이 가능한 스트리밍 서버 렌더링, Shaundai Person

사용자 경험을 최적화하고 사용자가 빈 화면만 보고 앉아 있지 않도록 하기 위해 서버 렌더링을 사용할 수 있습니다.

서버 렌더링은 서버에서 React 컴포넌트의 HTML 출력을 렌더링하고 서버에서 HTML을 보내는 기술입니다. 서버 렌더링은 JS 번들이 로딩되고 앱이 상호작용 가능해지는 동안 사용자에게 UI의 일부를 보여줍니다.

클라이언트 렌더링과 서버 렌더링에 대한 더 자세한 설명은, Shaundai Person의 React Conf 2021 강연을 통해 참고할 수 있습니다.

서버 렌더링에서는 1. 데이터를 받아오고, 2. HTML을 렌더하고 3. JS를 로드한 후 4. Hydrate 작업을 거칩니다.
서버 렌더링 흐름에서는 서버로부터 HTML을 보내면서 사용자에게 의미 있는 데이터를 더 신속하게 출력해 줍니다. 출처: React Conf 2021 Suspense 사용이 가능한 스트리밍 서버 렌더링, Shaundai Person

서버 렌더링은 페이지를 로딩하고 상호작용을 위한 시간을 줄여서 사용자 경험을 훨씬 개선합니다.

앱이 매우 빠른데도, 어떠한 한 부분이 그러지 못한다면 어떨까요? 데이터가 느리게 로드되거나 상호작용을 위한 JS가 너무 큰 바람에 다운로드에 시간이 걸리는 것일 수 있습니다.

리액트 18 이전에 이런 부분은 앱의 병목을 유발해 컴포넌트가 렌더 되는 시간을 증가시켰습니다.

느린 컴포넌트 하나는 전체 페이지 로드를 늦춥니다. 서버 렌더링이란 아무것도 없거나 모두 렌더링이 된 상태로, 느린 컴포넌트의 로딩을 늦추거나 다른 컴포넌트의 HTML이라도 보내달라고 요청할 수는 없었습니다.

리액트 18에 와서 서버에서의 Suspense 지원이 등장합니다. Suspense 덕에 Suspense 컴포넌트 내부에 앱의 느린 부분을 감싸, 해당 부분의 로딩을 지연시킬 수 있게 된 것이죠. 로딩되는 동안 상태를 특정하기 위해서도 사용할 수 있습니다.

리액트 18에서는 한 컴포넌트가 느리다고 모든 앱의 렌더가 느려지지는 않습니다. Suspense를 사용한다면 로딩 바(loading spinner)와 같이 일종의 플레이스 홀더용 HTML을 먼저 보내달라고 요청할 수 있습니다. 느린 컴포넌트가 다 준비되어서 데이터가 다 받아진 상태라면, 서버 렌더러가 HTML과 같은 스트림에 들어와 동작할 것입니다.

서버에서의 Suspense. 서버로부터 클라이언트로 HTML을 보내는 도식 하나와 플레이스 홀더용 HTML 이미지, 로딩 바가 보여집니다.
서버에서의 suspense를 사용하면 다른 컴포넌트가 완전히 렌더링 되는 동안 느린 컴포넌트가 로딩 상태를 표시할 수 있음을 보여주는 이미지입니다.

이를 통해 사용자는 가능한 한 빠르게 페이지의 스켈레톤을 확인한 뒤 이후 HTML의 나머지 조각이 보내질 때마다 점진적으로 콘텐츠가 나타나는 것을 볼 수 있습니다.

이 모든 것은 페이지에 JS나 리액트가 로드되기 전 일어나기 때문에 사용자 경험과 사용자가 체감하는 지연 시간을 상당히 개선합니다.

엄격 모드(Strict mode)

리액트 18에서의 엄격 모드는 이전 상태 값을 가진 컴포넌트의 마운팅(mounting), 마운팅 해제(unmounting), remounting(재마운팅)을 시뮬레이션합니다. 이는 미래에 재사용할 수 있는 상태를 위한 토대를 마련한 것인데, 마운팅이 해제되기 전과 같은 컴포넌트 상태를 담은 트리를 재마운팅 함으로써 이전 화면을 재빨리 마운트 하는 것입니다.

엄격 모드는 컴포넌트가 여러 번 마운트 되고 해제되는 데 드는 비용이 좀 더 탄력적으로 만듭니다.

마무리

요약하자면, 리액트 18은 향후 출시를 위한 초석을 마련하고 사용자 경험을 개선하는 데 중점을 두었습니다.

리액트 18로 업그레이드는 직관적이며 업데이트 이후에도 기존 코드가 깨지진 않습니다. 업그레이드는 반나절 이상 소요되지 않고요.

한 번 도전해 보고, 어떻게 생각하는지 알려주면 좋을 것 같습니다!

출처

  1. React RFC
  2. My previous React 18 post
  3. React V18 blog
  4. React Conf 2021 - React for App developers
  5. React Conf 2021 - Streaming Server Rendering with Suspense

이 글이 좋았다면, ❤️를 눌러 다른 독자들 눈에 띄도록 부탁합니다.