React Concurrent 모드란 무엇인가?

  • 이 글은 What is React Concurrent Mode?를 번역한 글입니다.
  • 매끄러운 문맥을 위하여 의역을 한 경우가 있습니다. 원문의 뜻을 최대한 해치지 않도록 노력했으니 안심하셔도 됩니다.
  • 영어 단어가 자연스러운 경우 원문 그대로의 영단어를 적었습니다.
  • 저의 보충 설명은 인용문에 작성하거나, 괄호 안에 역자 주 문구를 통하여 달았습니다.

React 코어 팀은 지난 3년 동안, 사용자 경험과 개발 과정 모두에 큰 영향을 미치게 될 주요 기능을 만들어왔습니다. 바로 Concurrent 모드입니다. 비록 해당 작업은 아직 진행 중이고 대부분의 경우 공식 발표된 것은 아니지만, 조만간 우리를 맞이할 새로운 기능들이 무엇인지 한번 알아보도록 합시다.

우선, "동시성"이란 무엇일까요?

대부분 아시다시피 자바스크립트는 싱글 스레드 언어입니다. 각 작업들은 실행될 때마다 스레드를 블록하고, 해당 작업을 끝마칠 때까지 다음 작업은 실행되지 않습니다. 하지만, 이말이 곧 동시에 둘 이상의 작업을 처리할 수 없다는 의미는 아닙니다. 헷갈리신다고요? 실생활의 사례를 들어서 설명을 해보도록 하겠습니다.

당신이 아침형 인간이고, 차 한잔, 그리고 라즈베리 잼을 펴서 바른 토스트가 없다면 하루 일과를 시작할 수 없는 사람이라고 합시다. 바로 방금 전에 논의한 방식을 그대로 적용한다면, 우리는 우선 차를 내리고, 차를 완전히 다 내리고 난 뒤에야 토스트를 만들 수 있게 됩니다.

하지만 이는 썩 똑똑한 방식이 아닙니다. 기왕이면 차가 식지 않았어면 좋겠습니다. 좀 더 효율적으로 바꿔서 차와 토스트가 둘 다 따뜻하고 막 만든 상태가 될 수 있도록 하려면, 각 작업은 좀 더 작은 단위의 작업(Micro-task)로 나누고, 적절하게 진행 순서를 다시 정하면 됩니다.

짠! 혼자서 2개의 서로 다른 작업을 처리하였음에도, "동시에" 두 개 작업을 처리할 수 있었습니다.

다시 돌아와서, 동시성이란 무엇일까요? 바로 독립적으로 실행될 수 있는 여러 조각으로 나누어서 프로그램을 구조화하는 방식입니다. 싱글 스레드의 한계에서 벗어나, 어플리케이션을 효율적으로 만들 수 있는 방법입니다.

이제 정의를 알았으니, 이제 이것이 React와 어떤 관련이 있는지 알아보도록 하죠.

사용자 경험을 완벽하게 만드는 것과 관련이 있습니다

브라우저의 UI 스레드는 CSS, 사용자 입력, 자바스크립트 등으로 인하여 유발된 변동 사항을 사용자의 화면에 적용하는 역할을 수행합니다. 현대의 디바이스에서는, 최고의 사용자 경험을 제공하려면 초당 60프레임(FPS)이 렌더링될 것을 일반적으로 기대합니다. 그러려면 각 렌더링마다 코드가 실행되는 데에 16.67밀리초 미만이 걸려야 하며, 현실적으로는 더 적은 시간, 10ms 정도에 맞추어야 합니다(왜냐하면 앞서 언급하였듯, 브라우저는 다른 UI 작업들 또한 다루어야 하기 때문입니다).

오늘날 대부분의 기기 화면이 대부분 60Hz 이상의 주사율로 화면을 표시한다는 사실에 입각한 설명입니다. 자세한 설명은 이 링크를 참조하시면 좋습니다.

React는 자바스크립트이고 따라서 동일한 제한 사항에 묶여있습니다. 현재로서는, React가 재조정(Reconciliation) 과정을 한번 시작하면, 이 과정이 완전히 끝나기 전까지는 이를 멈출 수 없습니다. 그러면 브라우저의 메인 UI 스레드는 사용자 입력을 받는 등의 다른 작업을 실행할 수 없게 됩니다. 설령 React의 재조정 알고리즘이 놀라울 정도로 효율적일지라도, 웹 어플리케이션이 비대해지고 DOM 트리가 커지면 프레임 저하로 인한 화면의 버벅임(Jank)이나 어플리케이션이 반응하지 않는 문제는 흔히 발생하게 상황입니다.

아래는 예시 프로그램으로, 키를 입력할 때마다 랜덤 색상의 픽셀로 이루어진 100 X 100 크기의 그리드를 렌더링하는 프로그램입니다. 무언가 한번 입력해보세요.

거슬리지 않나요? 10,000개의 DOM 노드를 렌더링하는 건 좀 과한 예시일 수 있지만, 문제를 설명하기에는 더 없이 빠른 방법이죠.

대부분의 개발자들의 메모이제이션이나 디바운싱 등의 기법을 사용하여 사용자 경험을 개선시키고자 하겠지만, 이는 그저 주된 문제 해결을 뒤로 미뤄두는 것일 뿐입니다. 렌더링은 여전히 길을 가로막는 큰 트럭과 같은 존재입니다.

하지만 사용자 경험 개선이라는 주제는 단순히 성능 문제에 국한되지 않습니다. 사용자들이 무엇을 더 나은 경험이라고 인식하는지 또한 고려해야 합니다.

당신이 최고급 성능의 기기를 사용하든, 제일 저렴한 가격의 기기를 사용하든, 스피너나 스켈레톤, 플레이스홀더 등으로부터 벗어날 수 없습니다. 이걸 사용하면 뭔가 일어나고 있다는 것을 알 수 있는 좋은 표시를 나타내기는 하겠지만, 너무 많이 남발하거나 불필요한 상황에서 표시한다면, 경험을 향상시키기보다는 오히려 악화시킵니다. 만약 당신의 기기에서 데이터를 충분히 빨리 불러올 수 있다면, 굳이 처음부터 스피너를 봐야 할 필요가 있을까요? 몇초 조금 기다린 뒤 모든 데이터가 준비되어 렌더링까지 완료된 페이지를 보는 것이 더 나은 경험이지 않을까요?

설명적 렌더링이라는 대안

맨 처음에, 동시성이란 결국 여러 작업을 처리할 수 있도록 작업들을 작은 조각들로 나누는 방법이라고 말씀드렸던 것을 기억하시나요? 이것이 바로 React가 지금 하려고 하는 것입니다. 즉, 렌더링 과정을 더 작은 작업들로 나누고, 스케줄러를 통하여 각 작업들에 중요도에 따른 우선 순위를 부여합니다("Time-slicing"이라고도 불립니다). 이렇게 하면 Concurrent React가 아래의 것들을 할 수 있게 됩니다.

  • 메인 스레드를 블록하지 않는다
  • 동시에 여러 작업들을 처리하고, 우선 순위에 따라 각 작업들 간에 전환할 수 있다
  • 최종 결과로 확정하지 않고도 부분적으로 트리를 렌더링할 수 있다

렌더링 과정이 더 이상 스레드를 블록하지 않으므로, 이는 설명적(Interpretative)이며 이제 사용자가 키를 누르는 등의 좀 더 높은 중요도를 가지는 작업이 실행되었을 때 렌더링이 나중으로 미뤄질 수 있습니다.

세부적인 내용과 React Fiber(새로운 재조정 알고리즘)이 어떻게 작동하는지에 관심이 있다면, Lin Clark가 발표한 이 영상을 시청하시길 강력히 추천합니다.

이제 배운 이론을 적용하여, Concurrent 모드의 새로운 기능들을 써보도록 합시다!

하지만 그 전에 유의할 점 하나가 있습니다. 아래의 기능들은 아직 공식 React 라이브러리의 일부로 적용되지 않았습니다. 향후 변경될 수 있으니, 프로덕션 환경에서는 사용하지 않을 것을 권합니다. 또한, 아래 기능들을 활성화하려면 아래 단계를 따르면 됩니다.

  1. reactreact-dom의 'experimental' 패키지를 사용합니다.
  2. 초기 렌더링 호출 구문을 아래와 같이 변경합니다.
// Concurrent Mode
const rootElement = document.getElementById("root");
ReactDOM.createRoot(rootElement).render(<App />);

이 글은 2020년 4월 3일에 작성되었습니다.

useDeferredValue

앞서 보여드렸던, 키를 입력할 때마다 화면 UI가 멈췄던 데모를 기억하시나요? 사용자 경험을 개선하려면 사용자 입력에 우선 순위를 부여하고, 그리드 렌더링은 후순위로 고려해야 합니다. 다행히 이제 이를 위한 방법이 생겼습니다.

useDeferredValue는 Prop/State 값을 래핑하고 최대 지연 시간을 받을 수 있는 훅입니다. 이것을 사용하면, React에게 "이 값에 의존하는 컴포넌트라면 좀 나중에 렌더링되어도 괜찮아"라고 말해주게 됩니다.

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

이 기능은 렌더링을 디바운싱하는 것이 아니라는 것에 유의하세요! React는 여전히 "이면에서" 컴포넌트를 렌더링할 것이고, 주어진 시간(역자 주: timeoutMs)이 지나가기 전에 준비가 끝난다면, 변동 사항을 DOM에 적용할 것입니다.

디바운싱과 쓰로틀링에 대한 좋은 글이 있어 공유합니다

주의: 이 기능을 사용한다면 화면이 일관성있게 표시되지 않을 수 있습니다. 왜냐하면 UI의 특정 부분을 표시되는 작업에 우선 순위를 부여하는 것이기 때문입니다.

데이터 불러오기를 위한 <Suspense>

또다른 좋은 (그리고 개인적으로 제가 가장 좋아하는) 기능은 데이터 불러오기를 위한 Suspense입니다. React의 최신 기능을 들어보셨다면 이미 이 기능을 아실 겁니다. 이 기능은 16.8 버전에서 React.lazy 소개된 바 있습니다. Suspense를 사용하면 어플리케이션에서 코드 스플리팅이 된 부분을 대기할 때 플레이스홀더를 표시할 수 있습니다.

데이터 불러오기를 위한 Suspense는 바로 위에서 말씀드린 Suspense와 동일한 아이디어를 공유하는데, 프라미스를 사용한다는 차이가 있습니다.

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

이 기능의 강력함을 제대로 알 수 있도록, Suspense를 사용하지 않는 간단한 어플리케이션을 보도록 하죠.

이 어플리케이션은 TV 프로그램의 목록을 불러옵니다. TV 프로그램 하나를 누르면, 댓글 영역이 있는 상세 페이지를 불러오게 됩니다.

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>

위 코드에서 데이터를 불러오는 방식을 자세히 살펴봅시다.

  1. 데이터를 불러오는 동안에 플레이스홀더를 표시하는 통일된 방식이 존재하지 않습니다. 개발자가 각자 개별적으로 이를 구현해야 합니다.
  2. 댓글 영역의 컴포넌트는 상세 페이지 컴포넌트가 모두 준비되어 렌더링이 된 이후에야 렌더링될 수 있기 때문에, 연쇄적인(Waterfall) API 호출이 발생합니다. 이를 다룰 여러 방법들이 있겠지만, 모두 단순하거나 직관적이지는 않습니다.

Suspense는 이 두 문제를 모두 해결해주는데, 우선 플레이스홀더를 표시할 간단한 문법을 제공하기 때문입니다. 또한, Concurrent 모드 기능을 사용하면 (역자 주: 자신을 감싸는 컴포넌트의 렌더링이 완료되지 않은 상황이더라도) 컴포넌트의 렌더링을 그 이면에서 시작할 수 있고, 뒤이어서 API 호출도 시작할 수 있고, 그 동안에 플레이스홀더를 표시할 수도 있게 됩니다. 이는 데이터를 다룰 때에 발생하는 여러 가지 고통스러운 점을 날려버릴 수 있는 아주 큰 기능입니다.

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

데이터 불러오기를 위한 Suspense를 사용하려면 (역자 주: 데이터 불러오기에 사용되는) 프라미스를 함수로 감싸야합니다(아래 데모에서 wrapPromise 함수를 확인하시기 바랍니다). 이 함수는 각 처리 단계에서 Suspense가 기대한 값에 따라 다른 결과를 반환합니다. React 팀은 이러한 기능의 함수를 제공하는 react-cache라는 라이브러리를 만들고 있지만, 아직 완성되지 않았습니다.

어쨌든, 이 문법을 사용하면 컴포넌트를 좀 더 단순하게 만들어줍니다. useEffect를 더 이상 사용하지 않아도 되며, 데이터가 준비되지 않았을 때에 대한 걱정을 더 이상 하지 않아도 됩니다. 마치 데이터가 이미 존재하는 것처럼 컴포넌트를 다룰 수 있게 되고, 나머지 작업은 Suspense가 알아서 처리해줍니다.

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

하지만 여기서 새로운 문제가 생깁니다. 만약 내부 컴포넌트의 API 호출이 바깥 컴포넌트보다 먼저 완료된다면? 내부 컴포넌트를 먼저 표시하는 것은 이상하게 보일 것입니다. 감사하게도, Suspense를 사용하면 컴포넌트들이 표시 순서를 조율할 수 있습니다.

<SuspenseList>

SuspenseList는 여러 Suspense 컴포넌트들을 감쌀 수 있는 컴포넌트입니다. 이 컴포넌트는 revealOrdertail라는 두 개의 Prop을 받을 수 있습니다. 이 Prop들을 사용하면 React로 하여금 Suspense로 감싸진 자식 컴포넌트들의 표시 순서를 제어하도록 만들 수 있습니다 (자세한 사항은 공식 문서를 확인하시기 바랍니다).

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

이 컴포넌트는 렌더링 순서 또는 API 호출 순서와는 상관이 없다는 점에 유의하시기 바랍니다. 단지 불러온 컴포넌트를 언제 표시할지 React에게 지시하는 역할만을 담당합니다.

useTransition

앞서 스피너가 무엇인지, 그리고 인터넷 연결이 빠른 사용자라면 스피너를 아예 표시하지 않는 것으로 더 나은 경험을 만들어낼 수 있다는 점을 말씀드렸습니다.

이 아이디어는 이제 useTransition 훅을 사용하여 구현할 수 있습니다. useTransition을 사용하면 DOM에 변동 사항을 적용하기 앞서 데이터가 모두 준비될 때까지 기다릴 수 있습니다. 따라서, 변동이 실제 발생하기 전까지 사용자는 아무 것도 보지 않게 됩니다. 이 훅을 사용할 때, 스피너를 표시하거나 그 밖의 대체(Fallback) 컴포넌트를 표시하기 전까지 대기할 시간값을 인자로 받을 수 있습니다.

또한 이 훅은 두 값을 반환합니다.

  • startTransition: 어떤 State를 지연시키고자 하는지 React에게 알려주는 함수
  • isPending: 전환(Transition)이 현재 이루어지고 있는지 알려주는 불리언 값
const [id, setId] = useState(1);
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
const onClick = id => {
  startTransition(() => {
    setId(id);
  });
};

이 훅을 사용하면 자연스럽게 데이터 불러오기를 위한 Suspense를 사용하게 됩니다. 아래의 데모를 확인해보세요.

보시다시피 React의 Concurrent 모드는 놀라운 기능들로 채워져 있습니다. 흔히 만날 수 있는 실제 사례에서의 골치아픈 상황을 떨쳐낼 뿐만 아니라, 더 나은 사용자 경험을 만들어낼 수 있도록 해주는 기능들입니다. 아직 공식적인 릴리즈 날짜는 나오지 않았지만, 그리 오래 기다리지 않아도 되리라 기대합니다!

더 읽을 거리

2개의 댓글

comment-user-thumbnail
2021년 8월 5일

정말 좋은 번역글이네요. 감사합니다.

1개의 답글