[번역] What is React Concurrent Mode?

kelly woo
14 min readAug 25, 2020

--

Sveta Slepner

이 글은 Sveta Slepner의 ‘What is React Concurrent Mode?’ 를 저자의 허락을 받고 번역한 글입니다.
의역이 많이 포함되어 있어 원본으로 읽기 원하시는 분들은 아래의 링크로 이동하시면 됩니다.

What is React Concurrent Mode?

지난 3년간 React코어팀은 사용자 경험과 개발 프로세스에 지대한 영향을 미칠 주요 기능인 Concurrent Mode 를 작업해왔다. 현재도 작업이 진행중인 비공식 기능이지만 어떤 결과가 우리를 기다릴지 미리 확인해보자.

Concurrency란 무엇일까?

Javascript는 싱글스레드(single-threaded) 언어이다.
우리가 만드는 모든 작업은 그 작업이 끝날때까지 다음 작업을 블럭시킨다.
하지만 동시에 한개의 작업만 진행해야 한다는 말은 아니다.
혼란스러운가? 우리 생활을 예시로 들어보자.

만약 당신이 아침형 인간이고 아침에 일어나 따뜻한 차 한잔과 잼이 발린 토스트가 없이는 하루를 시작할 수 없다고 해보자.

싱글스레드에 대입해보면 우리는 차를 만든 다음에야 토스트를 만들어야 한다.

차 -> 토스트

하지만 좋은 처리 방식은 아니다. 토스트를 만드는 동안 차는 식어버릴 것이다.
(반대도 마찬가지이다. 결국 우린 따뜻한 차와 따뜻한 토스트를 원한다.)

그래서 따뜻한 차와 따뜻한 토스트를 효율적으로 얻기 위해 각 과정을 잘게 쪼개어 순서에 맞게 정렬할 것이다.

Concurrency 리얼 라이프

짜잔! 2명 없어도 우리는 두개의 작업을 동시에 처리할 수 있다.

Concurrency는 이처럼 큰 작업을 작은 여러개의 독립적인 작업으로 나누는 프로그래밍 구조로 싱글스레드의 한계를 뛰어넘어 우리의 앱을 더 효율적으로 만들어 준다.

이제 정의는 이해했으니 React 와의 상관성을 알아보도록 하자.

모든것은 사용자 경험의 최상화를 위한 것이다.

브라우저의 UI 스레드는 CSS, 사용자의 입력, Javascript로 인한 화면의 변화를 책임지고 있다. 최고의 사용자 경험을 지원하기 위해 최신 디바이스들은 1초에 60프레임(60fps)를 지원한다. 그러기 위해 프레임당 16.67 ms이하로 렌더링이 되어야 하며 브라우저가 다른 UI 작업도 실행한다는 것을 고려할때 실질적으로는 그 이하인 10ms 정도로 렌더링이 되어야 한다.

React 역시Javascript로 이루어져 있으므로 이 룰이 적용된다.

이제까지 React가 reconciliation(변경된 데이터와 현데이터의 diff를 파악하고 변경을 업데이트 하는 단계) 단계에 들어서면 해당 단계가 끝난 후에야 메인 스레드에 대한 컨트롤을 반환한다. 브라우저의 메인 UI 스레드는 이 단계 동안 사용자의 입력같은 다른 작업을 진행할 수 없는 것이다.

React의 reconciliation 알고리즘이 매우 효과적일지라도 웹앱의 크기가 커지고 내부의 DOM 트리가 복잡해지면 화면에 렌더되는 프레임 횟수가 줄어들고 이로 인해 jank나 무응답상태까지도 가능하다. 이는 누구에게나 발생할 수 있는 흔한 이슈라는 것을 쉽게 이해할 수 있을 것이다.

**jank (긴 태스크로 인해 메인 스레드가 블락되면서 사용자화면의 렌더 속도가 현저히 줄거나 필요이상으로 백그라운드 작업에 많은 파워가 소모되어 나타나는 사용자 화면의 부진함)

다음은 위를 설명하기 위한 예제이다.

여기 keypress 이벤트가 발생할때마다 100X100 그리드 에 랜덤으로 색깔을 지정하는 어플리케이션이 있다. 아무 글자나 입력해 보자.

Blocking Rendering

키보드 입력이 바로바로 되지 않아 불편한 것을 바로 알 수 있을 것이다.
10000개의 DOM을 동시에 렌더링한다는 설정이 과장될 수 있으나 문제점을 보여주기에는 충분하다.

사용자 경험을 위해 대부분의 개발자들은 memoization이나 debounce 같은 테크닉을 사용할테지만 이는 문제를 뒤로 미루는 것일 뿐이다. 여전히 렌더링은 입력을 방해한다.

사용자 경험을 개선한다는 것은 단순히 성능 이슈만 해결하는 것이 아니라 사용자들이 어떤것을 더 나은 경험으로 여기는지 생각하는 과정이다.

좋은 성능의 디바이스를 사용하거나 저가 보급형 핸드폰을 사용하거나 우리는 로딩 스피너나 스켈레톤 레이아웃, 플레이스홀더를 버리기 어렵다. 이들은 작업의 진행을 시각적으로 알려주는 반면 너무 많이 사용이 될 경우에는 오히려 사용자의 경험을 해친다.

만약 당신의 디바이스에서 데이터를 빠르게 로드할 수 있다면 왜 이런 장치를 보아야할까? 잠시 기다리더라도 모두 렌더가 되어 준비된 화면을 바로 보여주는것이 더 좋은 경험이 아닐까?

양보 가능한 (해석적) 렌더링

(*본문에는 interpretive 입니다. 한줄 씩 해석 처리한다는 의미에 가깝지만 여기서는 메인스레드의 주도권을 계속 잡지 않고 다시 반환한다는 의미에서 양보가능으로 해석했습니다.)

concurrency는 하나의 작업을 작은 여러개의 작업으로 나누어 동시에 여러개를 진행할 수 있도록 한다는 말을 기억할 것이다. 이것이 현재 React가 작동하는 방법이다. 렌더링 프로세스를 여러개의 작은 작업으로 나누어 중요성에 따라 스케줄러가 재정렬한다. 이로 인해 Concurrent React는

  • 메인 스레드를 블럭하지 않는다.
  • 여러 작업을 동시에 진행가능하며 우선 순위에 따라 순서 변경이 가능하다.
  • 결과를 DOM에 커밋하지 않고 트리의 일부 렌더만 할 수 있다.
    ( 렌더는 ReactDOM 이 변경해야할 사항을 계산하는 단계이고 커밋은 이 변경을 실제 DOM에 업데이트 하는 단계이다.)

렌더링 프로세스는 더이상 스레드를 블럭하지 않으며 사용자의 입력같은 더 높은 중요도의 작업이 있다고 판단되면 우선순위를 내어줄 수도 있다.

만약 당신이 원리나 React Fiber가 어떻게 작동하는지 관심이 있다면 Lin Clark의 A Cartoon Intro to Fiber — React Conf 2017 를 시청하는 것이 많은 도움이 될 것이다.

자, 그럼 이제 이 이론을 concurrent 모드의 실제 기능과 함께 적용해 보자.

다음에서 사용하는 기능은 아직 공식 라이브러리에 포함되어 있지 않으며 앞으로 변경될 가능성도 있으므로 프로덕트에 사용하는 것은 권장하지 않음을 밝혀둔다.

사용을 위해서는 다음의 준비가 필요하다.

A. reactreact-dom experimental 버전 패키지를 사용한다

npm install react@experimental react-dom@experimental

B. 어플리케이션의 진입 부분을 아래와 같이 바꾼다.

const rootElement = document.getElementById("root");ReactDOM.createRoot(rootElement).render(<App />);

useDeferredValue

글자 입력시 입력이 멈추던 예제를 기억할 것이다. 이 사용자 경험을 고치기 위해 우리는 사용자의 입력에 더 우선순위를 두고 그 이후에야 저 거대한 색깔 그리드의 렌더링을 처리할 수 있는 방법이 필요하다.
운 좋게도 useDeferredValue 가 이를 가능하게 해준다.

useDeferredValue 는 최대 지연시간 입력과 함께 Prop/State 값을 래핑하는 hook이다. 이는 React에게 이 값을 사용하는 컴포넌트의 렌더를 뒤로 미뤄도 된다고 이야기 해준다.

import { useState, useDeferredValue } from 'react';const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value, {
timeoutMs: 5000
});

이 기능이 렌더를 debounce 시키는 것은 아니다. React는 여전히 렌더를 시키고 만약 입력된 시간보다전에 준비가 되면 DOM에 해당 변화를 업데이트 시킨다.

Note: 하나의 UI에 다른 UI보다 높은 우선순위를 주기 때문에 view에서의 일관성은 깨어질 수 있다.

Blocking VS Interruptible rendering

Suspense for Data Fetching

여러 새 기능 중 놀라우며 개인적으로 가장 기대하고 있는 부분은 data fetch를 위한 Suspense이다. 이미 React의 최신 기능을 접해보았다면 16.8 version에서 소개된 React.lazy 함께 소개된 이 기능에 대해 이미 알고 있을 것이다. Suspense는 split 된 코드를 로드하여 실행하는 동안 placeholder를 보여줄 수 있다.

data fetch를 위한 Suspense는 기존과 동일한 컨셉이지만 이는 promise를 사용하는 모든 코드에 사용이 가능하다.

import Spinner from './Spinner';<Suspense fallback={<Spinner />}>
<SomeComponent />
</Suspense>

이 강력한 기능을 설명하기 위해 Suspense가 없는 간단한 어플리케이션의 예제를 먼저 보자.

No Suspense

이 어플리케이션은 tv show 리스트를 로드한다.
리스트의 tv show를 누르면 해당 상세 페이지와 코멘트를 보여 준다.

const [tvData, setTvData] = useState(null);useEffect(()=>{
setTvData(null);
tvDataApi(id).then(value =>{
setTvData(value);
})
}, [id]);
if (tvData === null) return <Spinner />;return <div className="tvshow-details">
<div className="flex">
<div>
<h2>{tvData.name}</h2>
<div className="details">{tvData.description}</div>
</div>
<div>
<img src={`${id}.jpg`} alt={tvData.name} />
</div>
</div>
<Comments id={id} />
</div>

데이터를 로드하는 방법을 자세히 보면:

  • A. 데이터가 로딩되는 동안 로딩스피너를 보여줄 방법이 통일되어 있지 않아 따로 만들어야 한다.
  • B. 상세 컴포넌트가 준비되어야 코멘트 컴포넌트를 렌더할 수 있기때문에 waterfall API 콜로 이루어진다. 물론 다르게 처리할 방법이 있으나 직관적이거나 간단하지 않다.

Suspense는 위의 두 이슈 모두를 간단한 코드로 처리할 수 있도록 도와준다. 또한 concurrent 모드의 사용은 하나의 컴포넌트를 한쪽에서 렌더링하면서 API 콜을 요청하고 그 동시에 placeholder를 보여 준다. 데이터 관련 작업의 고통을 줄여줄 수 있는 좋은 기능인 것이다.

export const TvShowDetails = ({ id }) => {
return (
<div className="tvshow-details">
<Suspense fallback={<Spinner />}>
<Details id={id} />
<Suspense fallback={<Spinner />}>
<Comments id={id} />
</Suspense>
</Suspense>
</div>
);
};

data fetch를 위한 Suspense로 작업하기 위해 우리는 promise를 함수(예제의 wrapPromise 를 보자)로 감싸야한다. 이 함수는 각 단계에서 Suspense 가 기대하는 다른 값을 반환한다. React팀은 이 함수를 제공하는react-cache 라는 라이브러리를 작업중이며 아직 완성단계는 아니다.

Suspense

이 문법은 우리의 컴포넌트를 단순화시키기에 충분하다. useEffect를 제거하고 더이상 데이터가 준비되지 않은 상황에서 어떤일이 일어날지 걱정하지 않아도 된다.
데이터가 준비된 이후에 대한 처리만 해두고 나머지는 Suspense에 맡기면 된다.

const detailsData = detailsResource(id).read();return <div>{detailsData.name} | {detailsData.score}</div>

이제 우리는 다른 문제를 대면하게 된다. 만약 코멘트 컴포넌트의 API 콜이 먼저 응답하면 어떻게 해야할까?
코멘트 컴포넌트만 먼저 보여주는 것은 어색할 수 있다.

다행히 Suspense를 통해 보여지는 컴포넌트의 순서를 재조정할 수 있다.

SuspenseList

SuspenseList 다른 Suspense 컴포넌트를 외부에서 감싸는 컴포넌트이다.

이 컴포넌트는 revealOrder 과 옵셔널 tail 라는 2개의 prop을 받는다.
이는 자식 컴포넌트를 감싸고 있는 Suspense의 디스플레이 순서를 React에게 전달하는데 이용할 수 있다. (자세한 내용은 documentation을 참고하자)

const [id, setId] = useState(1);return <SuspenseList revealOrder="forwards">
<Suspense fallback={<Spinner />}>
<Details id={id} />
</Suspense>
<Suspense fallback={<Spinner />}>
<Comments id={id} />
</Suspense>
</SuspenseList>

이는 API의 순서나 렌더링의 순서를 제어하는 것이 아니라 단순히 준비된 컴포넌트를 어떤 순서로 보여줄지에 대해 처리한다는 명심하자.

추가)
revealOrder
: 보여지는 순서
- forwards: 앞에서부터 보여준다
- backwards: 뒤에서부터 보여준다
- together: 모두 준비가 되면 동시에 보여준다

tail : 아직 준비가 안된 컴포넌트에서 fallback의 디스플레이 방법
- default(지정하지 않은 상태): 모든 fallback을 보여준다.
- collapsed: 처음에 위치한 fallback만 보여준다.
- hidden: 아무것도 보여주지 않는다.

useTransition

앞서 스피너에 대해 이야기 하면서 빠른 속도로 처리가 되는 사용자의 경우는 애초에 이런 스피너를 보여주지 않는 것이 더 나은 경험이라고 이야기 했었다.

우리는 이것을 useTransition 을 통해 처리할 수 있다.

useTransition 은 DOM에 업데이트 하기 전 데이터가 준비 될 약간의 시간을 마련해 준다. 입력된 시간동안 DOM에 업데이트 하지 않고 기다리는 때문에 화면에는 아무것도 그려지지 않는다. 딜레이 할 시간을 인자로 받으며 이 시간 이후에야 fallback component가 그려지게 된다.

useTransition의 리턴 값은 아래와 같다.

startTransition — React에 어떤 스테이트의 업데이트를 지연할 것인지 알려주는 함수이다.

isPending — transition이 아직 진행중인지 알려주는 Boolean 값이다.

const [id, setId] = useState(1);const [startTransition, isPending] = useTransition({ 
timeoutMs: 3000
});
const onClick = id => {
startTransition(() => {
setId(id);
});
};

자연스럽게 이 hook은 Data Fetch를 위한 Suspense 와 함께 사용 된다.

예제:

useTransition

React의 concurrent mode는 우리에게 고통을 주는 흔한 케이스에 대한 해결책을 제시하며 더 나은 사용자 경험을 제공하는데 도움을 준다. 아직 공식 배포일은 확정되지 않았지만 너무 오래 기다리지 않기를 바랄 뿐이다.

더 읽을거리:

--

--