본문으로 건너뛰기

Concurrent React가 가져온 변화: 급하지 않은 렌더링 구분하기

· 약 20분
이종은

Backend.AI의 MLOps 플랫폼인 FastTrack은 React 18을 사용하고 있습니다. React 18의 Concurrent 렌더러 덕분에 가능해진 급하지 않은 렌더링 구분하기에 대해 알아보겠습니다.

React의 Concurrent 기능은 Async Rendering이라는 이름으로 JSConf Iceland 2018 에서 처음 외부에 공개된 이후에 2022년이 되어서야 정식 기능으로 React 18에 포함되었습니다. 이 기간에서 예상할 수 있듯이 Concurrent 렌더러는 React 18에서 가장 크고 중요한 변화에 해당합니다. 렌더러가 변경되었지만 React 개발자들은 큰 변경 없이 React 18 이전 버전에서 제작한 React 코드를 React 18에서 실행 가능합니다. 심지어 React의 Concurrent 렌더러를 모르더라도 React로 UI를 만들 수 있습니다. 하지만 이 Concurrent 렌더러가 무엇이고 어떤 상황에서 유용한지를 이해한다면 React로 개발할 때 복잡했던 머릿속이 간단명료해지고 보다 나은 UX를 제공하는 UI를 개발할 수 있습니다. 이 글에서는 Concurrent 렌더러가 내부적으로 어떻게 동작하는지에 대해 이야기하지 않습니다. React를 이용해서 애플리케이션을 만드는 개발자에게 더 중요하다고 할 수 있는 Concurrent 렌더러가 무엇이며 React 개발자가 사고하는 방식이 어떻게 바뀔 수 있는지에 대하여 살펴봅니다.

이 글의 내용을 요약해 보자면 다음과 같습니다.

Concurrent 렌더러 덕분에

  • 컴포넌트 렌더링은 중단될 수 있습니다.
  • 화면에 보이지 않는 곳에서 트리의 일부를 렌더링 할 수 있습니다.
  • 이로 인해 React 개발자가 이전과는 달리 급하지 않은 렌더링을 구분할 수 있습니다.
노트

“React 컴포넌트는 추상적으로 순수 함수이다.”

React 컴포넌트는 실제로 자바스크립 함수로 만듭니다.(클래스로 만드는 방법도 있지만 대부분의 경우 추천하지 않습니다.) 함수는 입력을 주면 출력을 만듭니다. 입력이 변하면 출력이 달라질 수 있으므로 함수를 실행해서 새로운 출력을 만듭니다. (순수 함수는 입력이 같으면 출력이 같습니다.) React 컴포넌트의 입력과 출력은 무엇인가요? React 컴포넌트의 입력은 해당 컴포넌트가 함수로서 받게 되는 property(React에서는 props라 함)이며 출력은 함수가 리턴하는 React 엘리먼트입니다. hook을 통한 state도 입력일까요? hook도 추상적으로 함수의 입력이라 할 수 있습니다. React props와 동일하게 값이 변하면 다시 렌더링하게 만드는 트리거(trigger)이며 이 변화를 통해 React 컴포넌트의 출력을 달라지게 합니다. 자, 다시 렌더링에 대한 이야기로 돌아가겠습니다.

컴포넌트 렌더링은 중단될 수 있다.

Concurrent React의 핵심은 '렌더링은 중단될 수 있다.'입니다. React 18 이전에는 중단될 수 없었습니다(experimental 제외). React 컴포넌트가 함수로서 렌더링을 위해 실행되게 되면 return 하기 전까지 그 어떤 자바스크립 연산도 실행할 수 없었습니다. 렌더링을 위한 함수의 실행이 오래 걸린다면 엘리먼트를 return하기 전까지 사용자의 클릭을 처리하는 이벤트 핸들러 함수를 실행할 수 없다는 얘기입니다. 하지만 18버전부터는 중단될 수 있습니다.

const A = ({ count }) => {
return (
<div>
<span>{count}</span>
<B/>
<C/>
</div>
);
};

const B = () => {
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<D/>
</div>
);
};

const C = () => {
return <span>C</span>;
};

const D = ({ text }) => {
verySlowFunction(text); //연산이 수초 걸리는 함수라 가정합시다.
return <span>D</span>;
};

React 18 이전 버전에서는 A를 렌더링하면 B와 C가 렌더링되어야 하고, B를 렌더링하려면 D가 렌더링이 되어야 했습니다. A의 렌더링을 시작하면 필요한 B, C, D 가 모두 렌더링된 후 A의 리턴 값인 React 엘리먼트를 리턴하기까지 다른 자바스크립트 연산을 수행할 수 없습니다. A에서 리턴하는 컴포넌트 트리가 한 덩어리처럼 렌더링 됩니다. A의 렌더링이 시작되면 A의 렌더링을 중간에 중단하는 건 불가능했습니다.

Concurrent React에서는 렌더링을 중간에 중단할 수 있습니다. 렌더링을 중단하는 것은 왜 필요한 것일까요? 다음을 생각해 볼 수 있습니다.

  • 지금 진행 중인 렌더링이 더 이상 유효하지 않을 때(stale)
    • 예를 들어 위 코드에서 A의 count prop이 1인 상태로 렌더링 중인 상황을 생각해봅시다. 이 때 이 렌더링이 완료되기 전에 count가 2로 변해서 2일 때의 A에 대한 렌더링 요청이 발생했습니다. 그러면 1일 때의 렌더링 결과는 최신 값을 표시하는게 아니므로 더이상 필요없어 집니다. 이런 상황에서 바로 1일 때의 렌더링을 중단하고 2일 때의 렌더링을 시작할 수 있다면 더 빨리 사용자에게 최신값인 2일 때의 화면을 보여 줄 수 있게됩니다.
  • 진행 중인 렌더링이 보여주려는 화면의 갱신 보다 더 먼저 처리하고 싶은 것이 있을 때
    • 렌더링 중에 사용자 이벤트가 발생할 경우, 즉각적으로 반응하기 위하여 진행 중인 렌더링을 중단하고 이벤트 핸들러를 우선적으로 실행할 수 있습니다.

이러한 경우들은 모두 해당 컴포넌트의 렌더링을 중단해서 다른 처리를 할 수 있게 함으로써 UX를 개선하는 경우입니다.

화면에 보이지 않는 곳에서 트리의 일부를 렌더링 할 수 있다.

Concurrent React에서는 화면에 보이는 것과 일치하는 렌더링 외에 화면의 일부에 해당하는 컴포넌트만 별도로 렌더링 할 수 있습니다. 이는 기존 렌더링을 화면에 보여주고 계속 동작하게 하면서 앞으로 갱신될 화면의 일부를 미리 별도로 렌더링하고 렌더링이 완료되면 교체하게 됩니다. 렌더링을 필요 이상으로 하기 때문에 사용성을 떨어트리게 되는 게 아닌가 하는 걱정이 들 수 있습니다. 하지만 이 별도의 랜더링은 Concurrent 렌더러 덕분에 언제든지 중단될 수 있으므로 사용자 인터렉션을 방해하지 않게 됩니다. 오히려 이 특징을 이용하여 보다 나은 UX를 제공할 수 있습니다.

지금까지 Concurrent 렌더러의 두 가지 특징을 살펴봤습니다. 이번에는 이 두 특징이 활용되는 '급하지 않은 렌더링 구분하기'가 무엇인지 알아봅시다.

급하지 않은 렌더링 구분하기

노트

급한 렌더링과 급하지 않은 렌더링 예시

브라우저를 통해 사이트에 처음 방문하는 것을 생각 해봅시다. 하얀 빈 화면이 있는 상황에서 가장 급한 것은 무엇인가요? 어떻게든 빨리 사이트의 내용을 화면에 뿌려주는 게 가장 중요한 일일 겁니다. 하얀 화면에 오래 머문다면 사용자는 기다리지 않고 떠날 수 있으니까요. 그러니 첫 번째 렌더링은 딱히 중단할 일이 없습니다.

홈페이지의 좌측 사이드바를 보니 내비게이션에 해당하는 메뉴들이 있습니다. 메뉴 A를 누르려다가 메뉴 B를 잘못 눌렀습니다. 사용자가 다시 메뉴 A를 누르려고 하는데 메뉴 B 렌더링이 오래 걸린다면 메뉴 B에 해당하는 화면의 렌더링이 끝나고 메뉴 A에 해당하는 화면이 렌더링이 될 것입니다.

메뉴 B를 눌렀다가 메뉴 A를 바로 다시 누른 상황에서 메뉴 B에 대한 화면을 렌더링하는 것보다 메뉴 A에 대한 화면을 렌더링하는 것이 더 급합니다. B에 대한 화면은 이제 유효하지 않습니다.

React 개발자는 어떻게 어떤 렌더링이 급하지 않은 렌더링이라고 React에게 알릴 수 있을까요? 렌더를 트리거하는 입력의 변화 중에 어떤 입력의 변화가 급하지 않은지를 표시하면 됩니다. 이를 개발자가 손쉽게 표시할 수 있게 해주는 hook이 useDeferredValueuseTransition 입니다. 이 두 API 모두 리액트 18에 새롭게 추가되었으며 급하지 않은 렌더링을 지연시키는 동일한 효과를 가져옵니다. 이 두 hook을 하나씩 살펴보면서 이 둘의 차이점을 이해해 봅시다.

useDeferredValue: 변한 입력값을 이용하여 구분하기

특정 값을 사용하는 컴포넌트에서 해당 컴포넌트는 그 특정한 값의 변화를 급하지 않게 처리하고자 할 때 사용합니다.

function App() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={text} />
</>
);
}

위 예시 코드는 beta.reactjs.org의 useDeferredValue 예제 중 하나입니다.

여기서 text는 state이므로 text가 변하면 App이 다시 렌더링됩니다. 이 text<input><SlowList>의 입력(props)으로도 사용되고 있습니다. text가 변하면 App의 렌더링이 트리거가 되고 그 과정에서 inputSlowList가 바뀐 text를 사용하여 다시 렌더링됩니다. SlowList의 렌더링이 오래 걸린다면 사용자가 빠르게 타이핑을 이어가도 매 렌더링이 완료되기까지 사용자의 입력이 반영되지 않습니다.

여기서 inputSlowList에서 사용자의 키보드 입력에 해당하는 input의 렌더링은 급한 렌더링이고 SlowList는 사용자 입력에 따른 어떠한 결과이므로 input보다는 급하지 않은 렌더링이라고 볼 수 있습니다. 여기서 useDeferredValue를 사용하면 급한 렌더링을 트리거하는 text를 이용하여 급하지 않은 렌더링을 할 때 화면에 표시되는 deferredText를 만들 수 있습니다.

function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}

이렇게 하게 되면 text의 변화가 있을 때 deferredText는 바로 이전 text값을 갖게 되지만 화면에 보이지 않는 곳에서 deferredText도 최신의 값을 갖는 상태로 별도의 렌더링을 하게 되고 이 별도의 렌더링이 완료되어야 비로소 textdeferredText 모두 최신의 값을 갖는 상태로 렌더링됩니다. deferredText의 변화는 급하지 않은 렌더링이고 중단될 수 있습니다.

동일한 컴포넌트에 대해 급하지 않은 렌더링 요청이 연속적으로 있을 경우, 먼저 시작된 급하지 않은 렌더링이 끝나지 않았다면 바로 중단하고 최근 변화의 렌더링을 시작하게 됩니다. 예를 들어 text에서 사용자가 비어있는 input 상자에 ab를 순서대로 타이핑하게 되면 a가 입력되었을 때 별도의 렌더링이 시작되고, 이 렌더링 끝나기 전에 b를 눌러 ab가 되었다면 a일 때 시작된 별도의 렌더링은 중단하게 되고 ab를 위한 렌더링이 시작됩니다.

useTransition: 입력의 변화를 주는 함수를 이용하여 구분하기

앞서 useTransitionuseDeferredValue 모두 급하지 않은 렌더링으로 구분할 때 사용된다고 했습니다. 이 둘의 차이점을 살펴보면서 useTransition에 대해 알아봅시다.

주의

이 둘의 차이를 쉽게 이해하기 위해 useDeferredValue의 예제를 useTransition을 사용하도록 변경했습니다. useTransition은 업데이트가 동기적으로 일어나야 하는 input과 사용할 수 없습니다. 사용할 수 없는 이유는 beta.reactjs.org의 useDeferredValue 페이지의 Trouble shooting에서 확인하시기 바랍니다.

function App() {
const [text, setText] = useState("");
const [isPending, startTransition] = useTransition();
return (
<>
<button
onClick={(e) => {
startTransition(() => setText((v) => v + "a"));
}}
>
a 키
</button>
<SlowList text={text} />
</>
);
}

차이점

  • 급하지 않은 렌더링이라고 지정할 때 useDeferredValue는 그 값인 text를 이용했다면 useDeferredValue는 그 값(렌더 트리거)의 변화를 야기하는 setText를 이용합니다. text를 접근할 수 없는 상황에서 setText만 알아도 됩니다.
  • startTransition 안에서 변경되는 text의 변화를 바로 화면에 표시할 방법이 없습니다. 별도의 렌더링으로 변경된 text를 위한 렌더링이 시작되지만 별도의 렌더링일 뿐 실제 화면을 위한 렌더링에는 그 변경된 값을 알 수는 없습니다. 다만 isPending을 통해 별도의 렌더링이 진행 중임은 알 수 있습니다. useTransition은 state의 변경을 지연하게 되고 useDeferredValue은 변경된 state에 따른 일부 렌더링을 지연하게 됩니다.

공통점

  • startTransition을 통해 동일한 컴포넌트에 대해 급하지 않은 렌더링 요청이 연속적으로 있을 경우 useDeferredValue와 동일하게 먼저 시작된 별도의 렌더링이 아직 진행 중이라면 바로 중단되고 최신 값을 사용하는 별도의 렌더링이 시작됩니다.

정리

React 18의 Concurrent 렌더러 덕분에 가능해진 '급하지 않은 렌더링 구분하기'에 대해서 살펴보았습니다. useTransitionuseDeferredValue를 사용하여 급하지 않은 렌더링을 구분하게 되면 복잡한 구조의 화면 갱신도 사용성을 떨어뜨리지 않으면서 가능해집니다. React 18 이전에는 이러한 막힘없는 사용성을 제공하기 위해서는 많은 개발공수가 들었습니다. React 18에서 간편해진 '급하지 않은 렌더링 구분하기'를 통해 사용자에게 쾌적한 UX를 제공해 보시기 바랍니다.