(번역) Concurrent 리액트에 대해 알아야 할 모든 것(그리고 Suspense에 대해서 약간)

Chanhee Kim·2023년 2월 27일
59

FE 글 번역

목록 보기
11/22
post-thumbnail

그리고 이것이 게임 체인저인 이유

원문: https://blog.codeminer42.com/everything-you-need-to-know-about-concurrent-react-with-a-little-bit-of-suspense/

목차

  • 서론
  • 문제
    • 동기 렌더링
  • 해결책
    • Concurrent 목록 필터링
    • 브랜치 전략으로 이해하는 Concurrent 렌더링
  • Concurrent 기능
    • 트랜지션
    • 지연된 값
  • Suspense
  • 추가로 살펴볼 점들
    • 서스펜션 포인트(Suspension Points)
    • 낮은 우선순위 및 높은 우선순위 업데이트는 단일 계층화됨
    • Concurrent 렌더링 디버깅
  • 마지막으로 살펴볼 점

서론

UI는 다양한 "부분"으로 구성되며, 각 부분은 사용자 상호 작용에 각기 다른 속도로 응답합니다.

양식의 입력 필드와 같은 일부 부분들은 사용자 상호 작용에 거의 즉각적으로 응답하는 반면, 매우 긴 필터링된 목록이나 페이지 간 탐색과 같은 부분은 느리고 응답하는 데 시간이 걸릴 수 있습니다.

concurrent 기능이 없는 리액트(및 다른 모든 JS UI 라이브러리/프레임워크)가 작동하는 방식인 동기 렌더링을 사용하면 UI의 느린 부분이 실행을 차단해 빠른 부분을 끌어내림으로써 응답성이 저하되는 경우가 있습니다.

React의 concurrent 렌더러는 빠른 부분을 차단하지 않고 느린 부분을 백그라운드에서 렌더링함으로써 빠른 부분과 느린 부분을 분리하여 각 부분이 자신의 속도로 사용자 상호작용에 응답할 수 있도록 합니다.

따라서 React의 concurrent 렌더링이 애플리케이션을 더 빠르게 만들지는 못하지만, UI의 응답성을 높여 더 빠르게 느껴지도록 할 수 있습니다.

이 글에서는 리액트 Concurrent에 대해 자세히 살펴보고, 어떤 문제를 해결하는지, 어떻게 작동하는지, concurrent 기능을 사용하여 어떻게 활용할 수 있는지 알아볼 것입니다.

문제

한번 상상해 보죠.

필터링된 목록을 렌더링하는 컴포넌트를 작성 중이고 이 필터링은 클라이언트에서 이루어집니다.(필터링으로 인해 서버에 대한 추가 호출이 발생하지 않음) 또한 어떤 이유로든 필터링된 목록을 렌더링하는 것은 CPU 집약적인 작업이므로 렌더링하는 데 몇 밀리초가 걸린다고 가정해 보겠습니다.

위 가정에서 목록 필터링에 사용될 텍스트 입력과 목록 자체의 두 가지 주요 UI 요소가 있습니다.

다음은 상황을 설명하기 위한 예시입니다.

라이브 데모 — https://stackblitz.com/edit/react-xsgxkg?file=src%2FApp.js

import React, { useState } from 'react';
import { list } from './list';
import './style.css';

export default function App() {
  const [filter, setFilter] = useState('');

  return (
    <div className="container">
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />

      <List filter={filter} />
    </div>
  );
}

const List = ({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
};

const sleep = (ms) => {
  const start = performance.now();

  while (performance.now() - start < ms);
};

이 예시에서는 CPU를 많이 사용하는 작업을 시뮬레이션하기 위해 메인 스레드를 동기적으로 차단하는 sleep 함수를 사용해 <List />속도를 인위적으로 낮췄습니다.

여담: 과중한 CPU 부하를 시뮬레이션하기 위해 방대한 목록을 사용하는 대신 이 sleep 함수를 사용하면 다양한 워크로드를 쉽게 시뮬레이션하는 동시에 매우 다양한 하드웨어 사양으로 데모를 실행하는 독자들에게 보다 일관된 경험을 제공할 수 있습니다.

목록 필터링을 시작하면 모든 항목을 처리할 수 있을 때까지 전체 UI가 잠시 멈췄다가 최종 상태로 "점프"하는 것을 볼 수 있습니다.

인위적으로 속도를 늦추지 않았을 때의 모습입니다.

이 컴포넌트를 최적화하여 렌더링 속도를 높이는 방법을 찾는 것이 이상적이지만, 최적화 측면에서 할 수 있는 일은 한정되어 있으며 때로는 최적화로는 해결할 수 없는 느린 렌더링이 발생할 수 있습니다.

그러나 여기서는 다른 부분은 느려지더라도 UI의 빠른 부분은 응답성을 유지하고자 합니다.

이 예시에서는 <List />만 느린데, 왜 느린 컴포넌트 하나 때문에 전체 UI가 느려지고 응답하지 않아야 할까요? <List />가 느려도 입력은 계속 응답성을 유지할 수 없을까요?

그리고 범인은 바로...

동기 렌더링

concurrent 기능을 사용하지 않는 경우(즉, startTransition, useTransition 또는 useDeferredValue를 사용하지 않는 경우) React는 컴포넌트를 동기적으로 렌더링 합니다. 즉, 렌더링을 시작하면 예외를 제외하고는 어떤 것도 렌더링을 중단할 수 없으므로 렌더링이 끝난 후에만 다른 작업을 수행합니다.

렌더링이 얼마나 오래 걸리든 렌더링 중에 발생하는 새로운 이벤트는 렌더링이 완료된 후에만 처리됩니다.

여기 좋은 예시를 하나 보여드리겠습니다.

라이브 데모 — https://stackblitz.com/edit/react-slj4mv?file=src%2FApp.js

여담: 하단의 탭을 클릭하여 실시간 미리 보기에서 콘솔을 열 수 있습니다.

export default function App() {
  const [value, setValue] = useState('');
  const [key, setKey] = useState(Math.random());

  return (
    <div className="container">
      <input
        value={value}
        onChange={(e) => {
          console.log(`%c Input changed! -> "${e.target.value}"`, 'color: yellow;');
          setValue(e.target.value);
        }}
      />

      {/* 
        임의의 숫자를 <Slow /> 의 키로 사용합니다.
        메모이징 되었더라도 이 버튼을 클릭할 때마다 리렌더링 됩니다.
      */}
      <button onClick={() => setKey(Math.random())}>Render Slow</button>

      <Slow key={key} />
    </div>
  );
}

/**
 * 이 컴포넌트를 메모이징 해 입력이 변경돼도 리렌더링 되지 않게 합니다.
 */
const Slow = memo(() => {
  sleep(2000);

  console.log('%c Slow rendered!', 'color: teal;');

  return <></>;
});

이 실험에는 제어 컴포넌트인 input 요소(controlled input), 렌더링하는 데 2초가 걸리는 <Slow /> 그리고 강제로 <Slow />를 리렌더링 하는 버튼이 있습니다.

<Slow />는 props를 사용하지 않고 메모이징 되었으므로, input 값이 변경되어도 <Slow />가 리렌더링 되지는 않습니다. 하지만 key로 키가 설정 되어있고, 버튼을 클릭할 때마다 key다른 임의의 숫자로 설정되므로 클릭하면 <Slow />가 강제로 리렌더링 됩니다.

input에 "Hello"를 작성해 실험과 상호작용을 시작하고, 보시다시피 input 값이 변경될 때마다 변경 사항을 콘솔에 기록합니다.

그런 다음 버튼을 클릭하면 <Slow />가 리렌더링 되고 React가 렌더링하는 동안 input에 "World"를 작성합니다.

자바스크립트가 리액트 컴포넌트를 렌더링하는 동안 브라우저와 상호작용할 수 있음에도 불구하고, 이런 상호작용은 렌더링을 중단시키지 않습니다. 이는 <Slow />sleep 호출 이후 콘솔에 "Slow rendered!" 로그를 기록하고 input과의 상호작용은 대부분 sleep이 종료되기 전에 발생하지만, input과의 상호작용으로 인해 발생하는 모든 로그가 "Slow rendered!" 로그 뒤에 나타난다는 사실에서 분명하게 알 수 있습니다.

느린 컴포넌트 렌더링을 시작하면 느린 컴포넌트 렌더링을 마친 후에야 UI의 다른 부분에 대한 새로운 업데이트를 처리할 수 있기 때문에 느린 컴포넌트가 UI의 빠른 부분을 차단하게 되는 것입니다.

해결책

이 문제를 해결하기 위해 리액트 18은 concurrent 렌더링을 도입했는데, 아래에서 설명하겠습니다.

업데이트부터 살펴보죠.

concurrent 렌더링의 맥락에서 업데이트리렌더링이 발생하는 모든 것을 의미합니다. 예를 들어 새로운 값으로 setState를 호출할 때마다 업데이트가 발생합니다.

const Component = () => {
  const [count, setCount] = useState(0);

  // 리렌더링을 유발하기 때문에 업데이트입니다.
  const handleIncrement = () => {
    setCount((count) => count + 1);
  };

  // `setCount`에 동일한 값을 전달하기 때문에 업데이트가 아닙니다.
  // 이는 리렌더링을 유발하지 않습니다.
  // (실제로 어떤 이유에서인지 처음 호출할 때 리렌더링을 유발하지만,
  // 이후 호출은 그렇지 않음)
  const handleSame = () => {
    setCount(count);
  };

  return (
    <>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleSame}>Same</button>
    </>
  );
};

그런 다음 업데이트를 두 종류로 나눕니다:

  1. 우선순위가 높은(긴급) 업데이트
  2. 우선순위가 낮은(긴급하지 않음) 업데이트

우선순위가 높은 업데이트는 setState 호출, useReducerdispatch 또는 useSyncExternalStore(및 구독 중인 스토어 조각 업데이트)를 사용할 때 발생하며, 우리가 익숙한 일반적인 동기식 렌더링인 우선순위가 높은 렌더링을 트리거합니다. 우선순위가 높은 렌더링이 시작되면 완료될 때까지 중지되지 않습니다.

또한 앱이 처음으로 렌더링 될 때 ReactDOM.createRoot를 호출한 결과인 첫 번째 렌더링은 항상 우선순위가 높은 렌더링이라는 것이 중요합니다.

const Component = () => {
  const [filter, setFilter] = useState('');

  const handleInputChanged = (e) => {
    // 우선순위가 높은 업데이트 발생
    setFilter(e.target.value);
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

우선순위가 낮은 업데이트는 startTransition 또는 useDeferredValue 호출로 인해 발생합니다. 우선순위가 높은 렌더링이 완료된 후에만 실행되기 시작하고 우선순위가 높은 업데이트에 의해 중단되는 우선순위가 낮은 렌더링을 트리거합니다.

const Component = () => {
  const [filter, setFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleInputChanged = (e) => {
    // 우선순위가 높은 업데이트 발생
    setFilter(e.target.value);

    startTransition(() => {
      // 우선순위가 낮은 업데이트 발생
      setDelayedFilter(e.target.value);
    });
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

우선순위가 낮은 리렌더링이 중단되면 중단된 우선순위가 높은 리렌더링이 완료될 때까지 기다린 다음 처음부터 다시 시작합니다.

업데이트의 중요한 특성은 우선순위가 같은 다른 업데이트와 함께 일괄 처리되므로 동일한 호출 스택에서 발생하는 모든 우선순위가 높은 업데이트가 함께 일괄 처리되어 우선순위가 높은 단일 리렌더링이 발생한다는 점입니다. 우선순위가 낮은 업데이트도 마찬가지입니다.

const Component = () => {
  const [filter, setFilter] = useState('');
  const [otherFilter, setOtherFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const [delayedOtherFilter, setDelayedOtherFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleInputChanged = (e) => {
    // 두 가지가 일괄 처리되어
    // 우선순위가 높은 단일 업데이트가 발생
    setFilter(e.target.value);
    setOtherFilter(e.target.value.toUpperCase());

    startTransition(() => {
      // 두 가지가 일괄 처리되어
      // 우선순위가 낮은 단일 업데이트가 발생
      setDelayedFilter(e.target.value);
      setDelayedOtherFilter(e.target.value.toUpperCase());
    });
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

다음은 이 과정을 설명하는 다이어그램입니다.

UI의 다른 부분들이 느린 와중에도 빠른 부분의 응답성을 유지하기 위해 빠른 부분의 업데이트 우선순위를 높이느린 부분의 업데이트 우선순위를 낮춥니다.

이렇게 하면 백그라운드에서 느린 부분을 렌더링하는 중에 빠른 부분을 업데이트해야 할 때마다 빠른 부분에 대한 업데이트가 우선순위가 높으므로 빠른 부분의 응답성을 유지하기 위해 느린 부분의 렌더링을 중단하고 빠른 부분의 렌더링이 끝나면 다시 느린 부분의 렌더링으로 돌아가게 됩니다.

실제로 어떻게 동작하는지 살펴봅시다.

Concurrent 리스트 필터링

다음은 앞서 살펴본 것과 동일한 목록 필터링 에제지만, 이번에는 concurrent 기능을 사용하여 빠른 부분(입력)과 느린 부분(목록 필터링)을 분리하고 빠른 부분의 응답성을 유지합니다.

라이브 데모 — https://stackblitz.com/edit/react-gqqvon?file=src%2FApp.js

export default function App() {
  // 이 상태는 우선순위가 "높은" 업데이트에 의해
  // 업데이트됩니다.
  const [filter, setFilter] = useState('');
  // 이 상태는 우선순위가 "낮은" 업데이트에 의해
  // 업데이트됩니다.
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  // 지금은 무시하세요.
  // 그냥 concurrent 기능 디버깅에 도움이 되는 훅입니다.
  // 어떻게 동작하는지는 나중에 설명하겠습니다.
  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            // 여기서 `delayedFilter`의 값을 변경하는
            // 우선순위가 낮은 업데이트를
            // 트리거 합니다.
            setDelayedFilter(e.target.value);
          });
        }}
      />

      <List filter={delayedFilter} />
    </div>
  );
}

// 이제 List가 메모이징된다는 것을 알 수 있습니다.
const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

설명할 내용이 많으니 먼저 코드에 적용한 변경 사항부터 살펴보겠습니다.

먼저, <List />에 전달하고 우선순위가 낮은 업데이트에 의해 업데이트될 새로운 delayedFilter 상태를 만들었습니다.

둘째로, 사용자가 입력과 상호작용할 때 value를 수정하는 우선순위가 높은 업데이트와 delayedFilter를 수정하는 우선순위가 낮은 업데이트(startTransition 내부에서 setDelayedFilter를 호출해)를 모두 트리거합니다.

마지막으로 <List />를 메모이징 해 두었으므로 이제 부모(이 경우 <App />)가 렌더링 될 때마다 리렌더링 하는 대신 전달하는 props가 지난번 렌더링할 때 전달된 props와 다른 경우에만 리렌더링 합니다.

이제 어떻게 작동하는지 분석해 보겠습니다.

<App />이 처음 렌더링 될 때, 이 첫 번째 렌더링은 ReactDOM.createRoot에 의해 트리거 되었기 때문에 우선순위가 높은 렌더링이며, filterdelayedFilter 모두 동일한 빈 문자열 값을 갖습니다.

그런 다음 "tasty"를 작성하여 입력과 상호작용을 시작합니다.

각 키 입력에 대해 우선순위가 높은 업데이트와 우선순위가 낮은 업데이트가 모두 트리거 됩니다.

우선순위가 높은 업데이트는 항상 우선순위가 낮은 업데이트보다 먼저 처리되므로 첫 번째 키 입력 후 우선순위가 높은 리렌더링이 시작되고 이 리렌더링에서는 우선순위가 높은 업데이트에 포함된 변경 사항만 적용되므로 filter"t" 값을 가지지만 delayedFilter"" 값으로 변경되지 않은 상태로 유지됩니다.

따라서 <List />를 리렌더링 하는 우선순위가 낮은 리렌더링이 완료될 때까지 <List />최신이 아닌 상태가 됩니다.

delayedFilter가 우선순위가 높은 업데이트에서 변경되지 않는다는 사실은 입력(UI의 빠른 부분)의 응답성을 유지하는 데 중요한 요소입니다. <List />가 메모이징 되고 이전 렌더링에서와 동일한 delayedFilter를 전달받으면 리렌더링 하지 않기 때문입니다.

이 우선순위가 높은 리렌더링이 완료되면 VDOM에 커밋되고 생명 주기(이펙트 주입 -> DOM 변경 -> 레이아웃 -> 레이아웃 이펙트 -> 페인트 -> 이펙트)를 계속 진행합니다.

그 후, filter"t" 값을 갖는(우선순위가 높은 업데이트에 의해 변경되었기 때문에) 우선순위가 낮은 리렌더링을 시작하고 delayedFilter도 낮은 우선순위 업데이트에서 비롯된 "t" 값을 갖습니다.

우선순위가 낮은 리렌더링 중에 다음 문자 "a"를 입력하면 우선순위가 높은 업데이트와 우선순위가 낮은 업데이트 두 개가 다시 발생합니다.

우선순위가 높은 업데이트가 트리거 되었으므로 우선순위가 낮은 리렌더링이 중단되고 우선순위가 높은 리렌더링이 시작됩니다.

이 우선순위가 높은 리렌더링에서 filter는 새로운 값 "ta"를 갖지만 delayedFilter는 여전히 "" 값을 갖는데, 왜 그런 걸까요?

우선순위가 낮은 업데이트로 인한 상태 변경은 우선순위가 낮은 렌더링이 완료될 때만 커밋되며, 그동안 우선순위가 높은 업데이트는 변경이 커밋되지 않았기 때문에 해당 값을 변경되지 않은 상태로 "보게" 됩니다.

우선순위가 낮은 리렌더링은 우선순위가 높은 업데이트에 의해 중단되는 즉시 버려지기 때문에 일종의 리렌더링 "초안"이라고 생각할 수 있습니다.

이 우선순위가 높은 리렌더링에서 delayedFilter는 여전히 이전과 동일한 값을 가지며, 속도가 느린 <List />는 렌더링 되지 않습니다.

우선순위가 높은 렌더링이 완료되면 우선순위가 낮은 렌더링으로 돌아가서 입력과의 상호작용을 중단할 때까지 사이클이 계속됩니다.

그러면 우선순위가 낮은 업데이트를 중단시키는 높은 우선순위 업데이트가 더 이상 발생하지 않으며, 마지막으로 우선순위가 낮은 최신 렌더링이 완료될 수 있습니다.

방금 설명한 내용은 위의 gif 이미지의 콘솔을 보면 확인할 수 있습니다.

또한 가끔 우선순위가 낮은 리렌더링을 먼저 시작하고 중단하지 않고 우선순위가 높은 리렌더링을 시작하는 경우가 있는데, 이는 너무 빨리 입력해서 우선순위가 낮은 리렌더링을 시작하기 전에 이미 우선순위가 높은 업데이트가 예약되어 있기 때문에 발생하는 현상입니다.

이 모든 과정이 처음에는 약간 혼란스러울 수 있지만, 이러한 개념을 이해하는 데 도움이 되는 매우 적절한 비유가 있습니다.

브랜치 전략으로 이해하는 Concurrent 렌더링

애플리케이션을 개발 중이고 Git을 사용하여 코드베이스를 추적한다고 가정해 보겠습니다.

프로덕션에 있는 코드를 나타내는 main 브랜치가 있습니다.

새 기능을 작성하고 싶을 때는 main에서 feature/awesome-feature와 같은 기능 브랜치를 만들고, 작업이 끝나면 다시 main에 병합합니다.

그리고 프로덕션에서 심각한 문제가 발생하면 hotfix/fix-nasty-bug과 같이 메인에서 분기된 핫픽스를 만들고, 작업이 완료되면 다시 main으로 병합하기도 합니다.

그럼, 어떤 기능을 개발하던 중 갑자기 핫픽스를 배포해야 하는 상황이 발생하면 어떻게 될까요?

핫픽스 배포가 기능 배포보다 더 시급하므로 (프로젝트에서 작업 중인 개발자가 저희뿐이라고 가정할 때) 기능 작업을 중단하고 핫픽스 작업을 시작하고, 완료하고 병합할 때까지 작업을 해야 합니다.

핫픽스를 배포한 후에야 기능 작업을 다시 시작할 수 있지만, 해당 기능이 핫픽스를 병합하기 이전의 main에 기반했기 때문에 기능 작업을 계속하려면 먼저 main과 기능 브랜치를 병합하거나 리베이스 해 main을 "따라잡아야" 합니다.

이 예시에서는 핫픽스를 완료한 후 기능 작업으로 돌아가서 마무리할 수 있었지만, 프로덕션에서 핫픽스가 필요한 다른 버그로 인해 기능 작업이 다시 중단되는 경우가 발생할 수 있습니다.

주의 깊게 보셨다면, 기능 브랜치 작업은 우선순위가 높은 업데이트에 해당하는 핫픽스 배포로 인해 언제든지 중단될 수 있기 때문에 우선순위가 낮은 업데이트와 유사하다는 것을 눈치채셨을 것입니다.

또한 핫픽스를 완료한 후 기능 작업으로 돌아가기 전에 핫픽스로 인한 변경 사항을 기능 브랜치로 가져와야 하는데, 이는 (여기서는 비유가 약간 달라지지만) 우선순위가 낮은 렌더링 작업을 다시 시작할 때 우선순위가 높은 업데이트의 수정 사항을 통합하기 위해 렌더링을 처음부터 다시 시작해야 하는 것과 비슷합니다.

이제 React의 concurrent 렌더링에 대해 잘 이해했으니 이제 concurrent 기능에 대해 자세히 살펴보겠습니다.

Concurrent 기능

이 글을 게시하는 시점에는 concurrent 렌더링을 "활성화"하는 두 가지 concurrent 기능, 트랜지션(useTransition 또는 독립형 startTransition)과 지연된 값(useDeferredValue)만 존재합니다.

이 두 가지 기능은 모두 동일한 원리에 따라 작동합니다. 특정 업데이트를 우선순위가 낮은 것으로 표시하면 앞서 살펴본 것처럼 concurrent 렌더러가 나머지를 처리합니다.

트랜지션 (useTransition)

트랜지션 API는 내부에서 발생하는 모든 상태 업데이트가 낮은 우선순위 업데이트로 지정 되도록하는 함수를 인수로 받는 startTransition을 노출함으로써 일부 업데이트를 낮은 우선순위로 표시하는 명령형 방법을 제공합니다.

이미 느린 필터링 목록을 처리하는 데 트랜지션이 사용되는 것을 보았지만 다시 한번 살펴볼 가치가 있습니다.

라이브 데모 — https://stackblitz.com/edit/react-bucnxz?file=src%2FApp.js

export default function App() {
  // 이 상태는 우선순위가 "높은" 업데이트에 의해
  // 업데이트됩니다.
  const [filter, setFilter] = useState('');
  // 이 상태는 우선순위가 "낮은" 업데이트에 의해
  // 업데이트됩니다.
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  // 지금은 무시하세요.
  // 그냥 concurrent 기능 디버깅에 도움이 되는 훅입니다.
  // 어떻게 동작하는지는 나중에 설명하겠습니다.
  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            // 여기서 `delayedFilter`의 값을 변경하는
            // 우선순위가 낮은 업데이트를
            // 트리거 합니다.
            setDelayedFilter(e.target.value);
          });
        }}
      />

      {/*
       * isPending을 사용해 트랜지션이
       * 보류 중임을 알릴 수 있습니다.
       */}

      {isPending && 'Recalculating...'}

      <List filter={delayedFilter} />
    </div>
  );
}

// 이제 List가 메모이징 된다는 것을 알 수 있습니다.
const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

이 예시가 이전 예시와 유일하게 다른 점은 사용자에게 "recalculating"이라는 피드백을 표시하기 위해 isPending을 사용한다는 점입니다.

세부 동작

트랜지션 API에는 몇 가지 주목할 만한 동작이 있습니다.

startTransition"항상" 우선순위가 높은 업데이트와 우선순위가 낮은 업데이트를 모두 트리거합니다.

startTransition을 호출하면 내부에서 업데이트를 트리거 하지 않더라도 우선순위가 높은 업데이트(네, 읽으신 게 맞습니다. 우선순위가 높은 업데이트입니다.) 우선순위가 낮은 업데이트가 트리거 됩니다.

솔직히 이 동작의 목적이 무엇인지는 잘 모르겠지만, concurrent 렌더링을 디버깅할 때 유용하게 사용할 수 있는 정보입니다.

다음은 예시입니다.

라이브 데모 — https://stackblitz.com/edit/react-tamorr?file=src%2FApp.js

export default function App() {
  const [isPending, startTransition] = useTransition();

  useDebug();

  return (
    <button
      onClick={() => {
        startTransition(() => {});
      }}
    >
      Start Transition
    </button>
  );
}

startTransition의 콜백이 "즉시" 실행됩니다

startTransition에 인자로 전달한 함수는 즉시 동기적으로 실행됩니다.

이는 디버깅 목적으로도 중요하지만 콜백 내부에서 비용이 많이 드는 작업을 수행해서는 안 된다는 의미입니다. 그렇지 않으면 렌더링을 차단하게됩니다.

`startTransition'의 콜백에서 트리거 된 업데이트는 백그라운드에서 실행되므로 비용이 많이 드는 리렌더링이 발생해도 괜찮지만(어느 정도 예상되는 부분이기도 합니다), 콜백 자체에서 이 비용이 많이 드는 작업이 발생하는 것은 좋지 않습니다.

라이브 데모 — https://stackblitz.com/edit/react-dppjz4?file=src%2FApp.js

export default function App() {
  const [isPending, startTransition] = useTransition();

  useDebug();

  return (
    <button
      onClick={() => {
        console.log('Clicked!');
        startTransition(() => {
          console.log('Callback ran!');
        });
      }}
    >
      Start Transition
    </button>
  );
}

startTransition의 콜백 내부의 상태 업데이트는 "반드시" 콜백 자체와 동일한 호출 스택에 있어야 합니다.

상태 업데이트가 낮은 우선순위로 지정되려면 startTransition의 콜백 내에서 콜백과 동일한 호출 스택에서 호출되어야 하며, 그렇지 않으면 소용이 없습니다.

실제로 이것은 다음이 기대한대로 작동하지 않음을 의미합니다.

startTransition(() => {
  setTimeout(() => {
    // setTimeout의 콜백이 호출되었을 때는
    // 이미 다른 호출 스택에 있게 됩니다.
    // 낮은 우선순위 대신
    // 높은 우선순위 업데이트로 지정됩니다.
    setCount((count) => count + 1);
  }, 1000);
});
startTransition(async () => {
  await asyncWork();

  // 여기서는 다른 호출 스택에 있습니다.
  setCount((count) => count + 1);
});
startTransition(() => {
  asyncWork().then(() => {
    // 다른 호출 스택
    setCount((count) => count + 1);
  });
});

이러한 구문을 사용해야 하는 경우 대신 이렇게 해야 합니다.

setTimeout(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
}, 1000);
await asyncWork();

startTransition(() => {
  setCount((count) => count + 1);
});
asyncWork().then(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
});

"모든" 트랜지션은 "단일" 리렌더링으로 일괄 처리됩니다.

이는 앞서 언급했듯이 우선순위가 낮은 업데이트의 특성으로, 우선순위가 낮은 여러 업데이트가 단일 리렌더링을 통해 일괄 처리됩니다.

하지만 트랜지션의 맥락에서 이 점을 다시 한번 강조할 필요가 있다고 생각합니다.

우선순위가 낮은 업데이트가 보류 중일 때 첫 번째 우선순위가 낮은 업데이트가 처리되기 전에 다른 우선순위가 낮은 업데이트가 트리거 되면 모든 우선순위가 낮은 업데이트가 동일한 리렌더링에서 한꺼번에 수행됩니다.

또한 현재는 전부 완료한 것이 아니면 처음부터 렌더링하는 정책이 적용되어 있습니다. 예를 들어 우선순위가 낮은 업데이트가 컴포넌트 트리에서 전혀 관련이 없는 부분(예: 형제 컴포넌트)을 리렌더링하고 한 부분이 이미 리렌더링을 마쳤더라도, 중단되면 처음부터 렌더링을 다시 시작합니다.

라이브 데모 — https://stackblitz.com/edit/react-qiifbk?file=src%2FApp.js,src%2Fstyle.css

export default function App() {
  return (
    <div
      style={{
        display: 'flex',
      }}
    >
      <Component name="First" />
      <Component name="Second" />
    </div>
  );
}

export const Component = ({ name }) => {
  const [filter, setFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  useDebug({ name });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && 'Recalculating...'}

      <List filter={delayedFilter} />
    </div>
  );
};

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(500);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

트랜지션은 상태에만 작용하고, ref에는 작용하지 않습니다.

startTransition의 콜백 내에서 ref 변경을 포함해 거의 모든 작업을 수행할 수 있지만, 업데이트를 낮은 우선순위로 표시하는 유일한 방법은 setState를 호출하는 것입니다.

다음은 이를 보여주는 예시입니다.

라이브 데모 — https://stackblitz.com/edit/react-m341q9?file=src%2FApp.js

export default function App() {
  const [filter, setFilter] = useState('');
  const delayedFilterRef = useRef(filter);
  const [isPending, startTransition] = useTransition();

  const delayedFilter = delayedFilterRef.current;

  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            delayedFilterRef.current = e.target.value;
          });
        }}
      />
      {isPending && 'Recalculating...'}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

우선순위가 높은 리렌더링에서도 filterdelayedFilter가 항상 동일한 것을 확인할 수 있습니다.

startTransition의 콜백 내에서 ref를 수정할 수 있더라도 ref의 경우 트랜지션을 사용하지 않고 변경한 것과 동일하게 동작합니다.

지연된 값 (useDeferredValue)

명령형으로 작동하는 트랜지션 API와 달리, useDeferredValue선언적인 방식으로 작동합니다.

useDeferredValue는 우선순위가 낮은 업데이트의 결과인 상태를 반환하며, 이 상태는 인자로 전달한 값으로 설정됩니다.

앞서 언급한 상태를 업데이트하는 우선순위가 낮은 업데이트는 useDeferredValue에 전달된 현재 값이 이전에 받은 값과 다를 때 트리거 됩니다.

더 자세히 살펴보기 전에 useDeferredValue를 사용하여 필터링된 목록 문제를 다시 한번 해결해 보겠습니다.

라이브 데모 — https://stackblitz.com/edit/react-czrkqj?file=src%2FApp.js

export default function App() {
  // 이 상태는 우선순위가 "높은" 업데이트
  // 에 의해 업데이트됩니다.
  const [filter, setFilter] = useState('');
  // 이 상태는 우선순위가 "낮은" 업데이트
  // 에 의해 업데이트됩니다.
  const deferredFilter = useDeferredValue(filter);

  useDebug({ filter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
        }}
      />

      <List filter={deferredFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

애플리케이션이 처음 렌더링 될 때는 이미 살펴본 것처럼 항상 우선순위가 높은 렌더링을 트리거 하고, useDeferredValue가 처음 호출될 때는 우선순위가 낮은 업데이트를 트리거 하지 않고 초기화된 값만 반환합니다.

따라서 첫 번째 렌더링에서 filterdeferredFilter는 모두 ""라는 동일한 값을 갖습니다.

첫 번째 사용자 인터렉션(입력에 "t"를 입력하는) 후에는 setFilter가 호출되어 우선순위가 높은 업데이트가 트리거 됩니다.

이 첫 번째 높은 우선순위 렌더링에서 filter의 값은 "t"이므로 useDeferredValue"t"와 함께 호출되지만 해당 낮은 우선순위 렌더링이 완료될 때까지 이전 값(이 경우 "")을 계속 반환합니다.

그런 다음 우선순위가 높은 첫 번째 리렌더링이 완료되면 useDeferredValue에 다른 값을 전달하여 트리거 된 낮은 우선순위의 리렌더링이 시작됩니다.

우선순위가 낮은 렌더링 내에서 useDeferredValue의 반환 값은 항상 최신 상태로 유지되며, 실제로는 낮은 우선순위의 렌더링 중에 받은 값인 useDeferredValue가 마지막으로 받은 값으로 설정됩니다.

이 시점부터는 트랜지션 API 예시와 유사하게 작동하며, 로그를 보면 거의 동일하다는 것을 확인할 수 있습니다.

세부 동작

우선순위가 낮은 업데이트 중에 useDeferredValue에 새 값을 전달해도 다른 업데이트가 트리거되지 않습니다.

앞서 말했듯이, 우선순위가 낮은 리렌더링 중에 전달받는 값이 이전의 높은 우선순위 리렌더링에서 전달받은 값과 달라 처음 우선순위가 낮은 업데이트를 유발한 값과 다르더라도 useDeferredValue는 항상 마지막으로 전달받은 값을 반환합니다.

따라서 우선순위가 낮은 업데이트 중에 useDeferredValue가 새 값을 전달받더라도 새로 전달받은 값은 훅을 "통과"하여 즉시 반환되므로 다른 낮은 우선순위 업데이트가 필요하지 않으며, 따라서 최신 업데이트된 값을 리렌더링에 사용할 수 있습니다.

라이브 데모 — https://stackblitz.com/edit/react-vgrsmu?file=src%2FApp.js

export default function App() {
  const [filter, setFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();
  // useDeferredValue는 높은 우선순위와
  // 낮은 우선순위 리렌더링 중에
  // 다른 값을 받습니다.
  const deferredFilter = useDeferredValue(isPending ? filter.toUpperCase() : delayedFilter);

  useDebug({ filter, delayedFilter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />

      <List filter={deferredFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

우선순위가 높은 리렌더링과 낮은 리렌더링 중에 useDeferredValue에 다른 값을 전달하더라도(isPending은 우선순위가 높은 리렌더링 중에만 참이기 때문에), 우선순위가 낮은 리렌더링 중에 받는 값이 중요하고 추가 리렌더링이 트리거되지 않기 때문에 모든 것이 거의 동일하게 유지된다는 점을 알 수 있습니다.

서로 다른 useDeferredValue 호출로 인한 업데이트는 "모두" 단일 리렌더링에서 일괄 처리됩니다.

이는 모든 트랜지션이 단일 리렌더링에서 일괄 처리되는 것과 유사합니다.

따라서 컴포넌트 트리의 관련 없는 부분에 여러 개의 useDeferredValue 호출이 있더라도 해당 업데이트는 모두 일괄 처리되어 우선순위가 낮은 단일 리렌더링에서 처리됩니다.

트랜지션에 대해 살펴봤던 것과 동일한 전부 아니면 전무 정책이 여기에도 적용됩니다.

Suspense

지금까지 CPU에 바인딩된(CPU-bound) 컴포넌트를 처리하는 데 concurrent 렌더링을 사용하는 방법을 살펴봤지만, I/O에 바인딩 된 컴포넌트를 처리하는 데에도 사용할 수 있습니다.

자바스크립트에서는 I/O 연산을 비동기적으로 수행할 수 있고, 논-블로킹으로 동작하기 때문에 I/O 바인딩(I/O-bound) 컴포넌트는 concurrent 렌더링이 없어도 CPU 바인딩 컴포넌트와 같은 문제가 발생하지 않습니다. 따라서 I/O를 기다릴 때 일반적으로 하는 일은 I/O가 완료될 때까지 스피너를 렌더링하는 것입니다.

하지만 서스펜스를 사용하여 데이터를 가져올 때의 경우에는 I/O 바인딩 컴포넌트를 처리할 때 다른 문제가 발생합니다.

서스펜스를 사용하면 렌더링 이후에 실행되는 이펙트 내부에서 데이터를 가져오기 시작하는 대신 렌더링 도중에 데이터를 가져오기 시작할 수 있습니다.

데이터를 가져오기 시작하기 위해 렌더링이 완료될 때까지 기다릴 필요가 없다는 점에서 매우 흥미롭지만, 데이터가 로드되는 동안 로딩 상태를 조정하는 측면에서 약간의 제어권을 잃게 됩니다.

예를 들어 새 데이터를 가져오는 동안 로딩 상태를 표시하는 대신 오래된 데이터를 계속 표시하고 싶다면 다음과 같은 방식으로 useEffect를 사용할 수 있습니다.

라이브 데모 — https://stackblitz.com/edit/react-4ffmx7?file=src%2FApp.js

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const createEntry = () => ({
  id: Math.random(),
  name: Math.random(),
});

// 데이터를 가져오는 것을 시뮬레이션합니다.
// 항목이 10개인 목록을 반환합니다.
const fetchData = async () => {
  await wait(600);
  return Array.from({ length: 10 }).map(() => createEntry());
};

export default function App() {
  const [data, setData] = useState();
  const [page, setPage] = useState(1);

  useEffect(() => {
    // race condition을 피하기 위함
    let ignore = false;

    fetchData(page).then((data) => {
      if (!ignore) {
        setData(data);
      }
    });

    return () => {
      ignore = true;
    };
  }, [page]);

  return (
    <div>
      <div>
        <button onClick={() => setPage((page) => page - 1)}>Previous Page</button>
        <button onClick={() => setPage((page) => page + 1)}>Next Page</button>
      </div>

      {data ? data.map((entry) => <div key={entry.id}>{entry.name}</div>) : 'Loading ...'}
    </div>
  );
}

페이지를 새로고침하고 컴포넌트를 처음 렌더링할 때는 데이터가 없으므로 로딩 상태를 렌더링합니다.

그런 다음 페이지를 변경할 때 로딩 상태로 되돌아가는 대신 새 데이터를 사용할 수 있을 때까지 이전 데이터를 계속 표시하는 stale-while-revalidate 전략을 사용합니다.

하지만 데이터를 가져올 때 Suspense를 사용하면 데이터를 아직 사용할 수 없는 컴포넌트를 렌더링하려고 할 때마다 일시 중단하고 fallback을 표시합니다.

서스펜스와 함께 stale-while-revalidate 전략을 사용하려면 concurrent 기능이 필요합니다.

일반적인 아이디어는 우선순위가 낮은 업데이트를 사용하여 데이터를 다시 가져오게 만드는 상태를 변경하는 것으로, 컴포넌트는 여전히 일시 중단되지만 백그라운드에서 중단하게 하고, 그동안 우선순위가 높은 리렌더링에서는 오래된 데이터를 계속 표시하는 것입니다.

다음은 concurrent 기능을 사용해 데이터 가져오기를 위한 서스펜스와 함께 stale-while-revalidate를 구현하는 두 가지 예시입니다. 하나는 트랜지션으로 다른 하나는 지연된 값으로 구현합니다.

여담: 현재 데이터 가져오기를 위한 안정적인 Suspense API가 없고 이를 기반으로 뭔가 하는 것을 원하지 않기 때문에 suspenseFetchData의 구현은 생략하지만, "정말로" 어떻게 구현되는지 보고 싶다면 라이브 데모에서 확인하실 수 있습니다.

라이브 데모 — https://stackblitz.com/edit/react-8aayzv?file=src%2FApp.js

export default function App() {
  const [page, setPage] = useState(1);
  const [delayedPage, setDelayedPage] = useState(page);
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setPage((page) => page - 1);
            startTransition(() => {
              setDelayedPage((page) => page - 1);
            });
          }}
        >
          Previous Page
        </button>
        <button
          onClick={() => {
            setPage((page) => page + 1);
            startTransition(() => {
              setDelayedPage((page) => page + 1);
            });
          }}
        >
          Next Page
        </button>
        Page: {page}
      </div>

      <Suspense fallback="Loading...">
        <Component page={delayedPage} />
      </Suspense>
    </div>
  );
}

const Component = ({ page }) => {
  const data = suspenseFetchData(page);

  return <>{data ? data.map((entry) => <div key={entry.id}>{entry.name}</div>) : 'Loading ...'}</>;
};

라이브 데모 — https://stackblitz.com/edit/react-zcfxm9?file=src%2FApp.js

export default function App() {
  const [page, setPage] = useState(1);
  const deferredPage = useDeferredValue(page);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setPage((page) => page - 1);
          }}
        >
          Previous Page
        </button>
        <button
          onClick={() => {
            setPage((page) => page + 1);
          }}
        >
          Next Page
        </button>
        Page: {page}
      </div>

      <Suspense fallback="Loading...">
        <Component page={deferredPage} />
      </Suspense>
    </div>
  );
}

const Component = ({ page }) => {
  const data = suspenseFetchData(page);

  return <>{data ? data.map((entry) => <div key={entry.id}>{entry.name}</div>) : 'Loading ...'}</>;
};

추가로 살펴볼 점들

이제 React의 concurrent 렌더링이 어떻게 작동하는지, 그리고 concurrent 기능을 사용해 concurrent 렌더링을 활용하는 방법을 이해했으니 몇 가지 추가로 살펴볼 점들을 공유하고자합니다.

서스펜션 포인트 (선점)

병렬성이 없는 동시성(단일 스레드에서 concurrent 렌더링이 이루어지는 경우)이 있을 때마다 선점, 즉 작업을 전환하려면 다른 작업으로 전환하기 전에 현재 수행 중인 작업을 중단해야합니다.

중단이 허용되는 "지점"을 서스펜션 포인트라고 부릅니다(여기서 "서스펜션"은 서스펜스와는 거의 관련이 없습니다).

멀티스레드 환경에서는 잠금을 사용하지 않는 경우 각 코드 줄이 일시 중단 지점이므로 실행 중인 작업과 관계없이 언제든지 스레드를 일시 중단하여 다른 스레드를 실행할 수 있습니다.

React에서 서스펜션 포인트는 컴포넌트의 렌더링 사이에 위치합니다.

이는 매우 중요한 정보입니다. 왜냐하면 개별 컴포넌트의 렌더링을 중단할 수 없으므로 컴포넌트가 렌더링을 시작하면 반환문에 도달할 때까지 렌더링한다는 것이기 때문입니다.

그래야만 다음 컴포넌트의 렌더링으로 넘어가기 전에 우선순위가 높은 업데이트가 있는지 확인할 수 있습니다.

다음은 데모입니다.

라이브 데모 — https://stackblitz.com/edit/react-xvlsro?file=src%2FApp.js

export default function App() {
  const [filter, setFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && 'Recalculating...'}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}

      {Array.from({ length: 10 }).map((_, index) => (
        <Slow key={index} index={index} />
      ))}
    </ul>
  );
});

const Slow = ({ index }) => {
  console.log(`Before sleep: ${index}`);

  sleep(50);

  console.log(`After sleep: ${index}`);

  return null;
};

이 예시는 트랜지션을 사용하여 concurrent 렌더링을 사용하는 목록 필터링 예시이지만 약간의 변경이 있습니다.

이제 <List /> 자체를 느린 컴포넌트로 만드는 대신, 이름에서 짐작할 수 있듯이 병목 현상이 발생하는 새로운 <Slow /> 컴포넌트 목록을 렌더링 하고 있습니다.

여기서 주목해야 할 두 가지 중요한 사항이 있습니다.

첫째로, 우선순위가 낮은 렌더링을 얼마나 빨리 중단하느냐에 따라 중단이 발생하기 전에 렌더링 되는 <Slow />의 인스턴스의 수가 달라진다는 점입니다.

둘째로,<Slow />의 콘솔 로그는 항상 쌍으로 구성된다는 점입니다. 이는 <Slow /> 렌더링을 시작하면 중단할 수 없다는 것을 의미합니다.

만약 중단할 수 있다면, <Slow />가 렌더링 되는 동안 첫 번째 로그 항목 이후 두 번째 로그 항목 이전에 중단이 발생하여 쌍이 없는 항목이 표시될 것입니다.

결과적으로 CPU를 많이 사용하는 작업이 여러 컴포넌트에 분산되지 않고 단일 컴포넌트에서 발생하는 것을 피해야 합니다.

이 경우 느린 컴포넌트의 렌더링을 시작하면 렌더링이 끝날 때까지 우선순위가 높은 업데이트를 처리할 수 없기 때문에 React의 concurrent 모드조차도 많은 일을 할 수 없습니다.

낮은 우선순위 및 높은 우선순위 업데이트는 단일 계층으로 구성됨

React에는 concurrent 렌더링에 대한 내부 우선순위가 많지만, 업데이트는 우선순위가 높거나 낮으며 중간은 없습니다.

우선순위가 높은 업데이트는 모두 동일한 우선순위를 가지며, 우선순위가 낮은 업데이트도 마찬가지입니다.

즉, 우선순위가 낮은 업데이트는 컴포넌트 트리의 관련 없는 부분에서 트리거 되든, 트랜지션에서 발생하든 지연된 값에서 발생하든 상관없이 동일한 우선순위를 가지며 동일한 리렌더링에서 함께 일괄 처리되고 앞서 살펴본 "전부 아니면 전무" 정책을 따릅니다.

아래 예시를 살펴봅시다.

라이브 데모 — https://stackblitz.com/edit/react-fwhrsk?file=src%2FApp.js

export default function App() {
  const [filter, setFilter] = useState('');
  const [delayedFilter, setDelayedFilter] = useState('');
  const deferredFilter = useDeferredValue(filter);
  const [isPending, startTransition] = useTransition();

  useDebug({ filter, delayedFilter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && 'Recalculating...'}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

delayedFilterdeferredFilter는 항상 동기화되어 있으며, 우선순위가 낮은 업데이트가 추가로 트리거 되지 않습니다.

Concurrent 렌더링 디버깅

concurrent 렌더링을 사용하는 컴포넌트는 우선순위가 높은 업데이트와 우선순위가 낮은 업데이트로 인해 두 번 렌더링 되기 때문에 디버깅이 까다롭습니다.

특히 우선순위가 높은 렌더링과 낮은 렌더링 간에 일부 값이 다를 수 있기 때문에 렌더링 중에 값을 확인(예: 콘솔에 로그)하고자 할 때 매우 혼란스러울 수 있습니다.

이러한 맥락에서 자연스럽게 발생하는 질문은 우선순위가 높은 렌더링인지 낮은 렌더링인지 그리고 우선순위가 낮은 렌더링이 중단되었는지를 어떻게 알 수 있는지입니다.

이를 식별하는 데 사용할 수 있는 몇 가지 지표가 있습니다.

첫 번째는 useDeferredValue의 반환 값을 살펴보는 것입니다.

useDeferredValue의 첫 번째 렌더링(마운트)에서는 전달받은 것과 동일한 값을 반환하지만, 그 이후에는 이전에 전달한 것과 다른 값을 전달하면 우선순위가 낮은 리렌더링인 경우에만 새 값을 반환합니다.

따라서 다음과 같이 할 수 있습니다.

// 모든 렌더링에서 새 참조를 생성하므로
// probe 는 항상 이전 렌더링에서와
// 다릅니다.
const probe = {};
const deferredProbe = useDeferredValue(probe);

// 첫 번째 렌더링이 아닌 경우
const isLowPriority = probe === deferredProbe;

컴포넌트의 첫 번째 렌더링의 경우 우선순위가 낮은 업데이트에 의해 트리거 되었을 수 있기 때문에 우선순위가 높은 렌더링인지 낮은 렌더링인지 쉽게 감지할 방법이 없습니다.

const App = () => {
  const [show, setShow] = useState(false);
  const deferredShow = useDeferredValue(show);

  return (
    <>
      <button onClick={() => setShow(true)}>Show</button>
      {/* 
        첫 번째 렌더링은 deferredShow에 대한 
        낮은 우선순위 업데이트에 의해 트리거 됩니다. 
      */}
      {deferredShow && <Component />}
    </>
  );
};

둘째로, 모든 이펙트(useInsertionEffect, useLayoutEffect, useEffect)는 렌더링 단계 이후에만 실행되므로, 이펙트가 실행될 때쯤이면 호출되는 컴포넌트의 렌더링이 끝났을 뿐만 아니라 커밋되어 렌더링이 예약된 전체 컴포넌트 서브트리의 리렌더링이 끝났을 것입니다.

이러한 지표들을 이용해 concurrent 기능을 사용 중인 컴포넌트를 디버깅하는 데 도움이 되는 훅을 만들 수 있습니다.

const useDebugConcurrent = ({
  onFirstRenderStart,
  onFirstRenderEnd,
  onLowPriorityStart,
  onLowPriorityEnd,
  onHighPriorityStart,
  onHighPriorityEnd,
}) => {
  const probe = {};
  const deferredProbe = useDeferredValue(probe);
  const isFirstRenderRef = useRef(true);
  const isFirstRender = isFirstRenderRef.current;

  const isLowPriority = probe === deferredProbe;

  if (isFirstRender) {
    isFirstRenderRef.current = false;
    onFirstRenderStart?.();
  } else {
    if (isLowPriority) {
      onLowPriorityStart?.();
    } else {
      onHighPriorityStart?.();
    }
  }

  useLayoutEffect(() => {
    if (isFirstRender) {
      onFirstRenderEnd?.();
    } else {
      if (isLowPriority) {
        onLowPriorityEnd?.();
      } else {
        onHighPriorityEnd?.();
      }
    }
  });
};

위 훅은 이전 예시에서 사용한 것을 변형한 것입니다. 차이점은 이 훅은 어떤 상황에서도 일관되게 작동해야 하는 일반적인 버전이므로, 중단을 안정적으로 감지(우선순위가 낮은 렌더링 시작이 우선순위가 낮은 렌더링 종료와 매칭되지 않는다는 것을 알아차리는 것 외에)하거나 첫 번째 렌더링의 우선순위를 감지하는 기능이 없다는 것입니다.

마지막으로 살펴볼 점

React의 concurrent 렌더링은 프런트엔드 개발의 오랜 문제를 우아하게 처리하여 훌륭한 사용자 경험을 만드는 능력을 한 단계 끌어올렸습니다.

특히 웹 워커(Web Worker)의 도움으로 백그라운드 렌더링을 다른 스레드에 위임할 수 있게 되면서 앞으로 React 팀이 concurrent 렌더링을 어떤 방향으로 발전시킬지 궁금합니다.

우리는 프런트엔드 개발에 있어 매우 흥미로운 시대에 살고 있습니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

9개의 댓글

comment-user-thumbnail
2023년 2월 28일

하지만 때때로 useEffect 를 쓸 때마다 뭔가 잘 들어맞지 않습니다. 뭔가 놓치고 있다는 기분을 느끼며 괴로움을 느끼기도 합니다. 처음에 보면 클래스 컴포넌트의 라이프사이클 메서드와 비슷하다고 느낍니다만… 정말 그럴까요? 점점 시간이 지나면서 스스로에게 아래와 같은 질문을 하게 됩니다. My Centura Health Login Page

답글 달기
comment-user-thumbnail
2023년 3월 8일

If you are tired of downloading Instagram reels, stories or photos from multiple sites, download Insta Pro APK now to do all these things from one app.
https://instaproapkdownload.info/

답글 달기
comment-user-thumbnail
2023년 3월 9일

This article is really amazing. Thanks for the sharing.

myEHtrip Employee Login

답글 달기
comment-user-thumbnail
2023년 3월 9일

PASACASİNO online giris adresi https://www.pimuzik.com

답글 달기
comment-user-thumbnail
2023년 3월 12일

글을 읽기 전에 useTransition나 useDeferredValue에 대한 개념을 알고 있어야 이해가 되는 부분이 많은 듯합니다.
해당 부분을 미리 알려주셔도 좋을 것 같아요. 스팸 댓글 삭제도 같이 해주시면 더욱 좋을 듯합니다!

답글 달기
comment-user-thumbnail
2023년 5월 4일

Good work friend I read some articles that you posted in your blog. I just want to admire blog and your work. Thanks for posting such posts here.
https://www.paybyplatema.website/

답글 달기
comment-user-thumbnail
2024년 3월 25일

This post is absolutely fantastic! I've never come across something so useful before. Your writing is impressive, and I'm thrilled to read about such an engaging topic.
https://www-mycenturahealth.us/

1개의 답글

That is what I was looking for, what information, present here at this site!
https://panoramacharter.run/

답글 달기