teklog

Fluent React 8 - Concurrent

2024/03/20

n°55

category : React

챕터 7의 리액트 동시성Concurrency은 이전 글에서 이어진다. 이번 장에서 우선순위 업데이트 즉 '렌더링 스케줄링’이 리액트 내부에서 어떻게 이루어지며, 이를 활용하는 훅을 알아본다. 또한 이전 글에서 살펴본 '렌더 레인’과 같은 개념들을 조금 더 깊이 있게 알아본다. 긴 글이니 천천히 반복해서 읽어보자! (이탤릭체는 본문의 인용입니다)




동기식 렌더링의 문제점


  • 중요하지 않은 작업의 렌더링이 중요한 작업의 렌더링을 차단한다.


앞서 살펴본 스택 조정자의 문제점과 동일하다. 즉 동기식 렌더링은 메인 스레드를 차단하는 것이 문제다. 컴포넌트의 업데이트가 빈번하고, 구성이 복잡한 앱에서 사용자 경험 저하로 이어진다. (사용자의 input 입력이 비싸고 불필요한 다른 렌더링 때문에 차단되는 예시를 떠올려보자.)



동시 렌더링의 소개


동시 렌더링 Concurrent Rendering은 렌더링 차단 문제를 해결한다. 파이버 조정자와 같은 문제의식을 공유하며, 마찬가지로 리액트 내부에서 렌더링을 효율적으로 개선한다.


…동시(Concurrent) 렌더링으로, React는 업데이트의 중요성과 긴급성에 따라 업데이트를 우선 순위를 지정할 수 있으며, 덜 중요한 것들에 의해 중요한 업데이트가 차단되지 않도록 합니다. 이를 통해 React는 무거운 부하 하에서도 반응적인 UI를 유지할 수 있으며, 이는 더 나은 사용자 경험으로 이어집니다.
…동시 렌더링으로, CPU에 부하가 많은 렌더링 작업은 사용자 인터랙션과 애니메이션과 같은 더 중요한 렌더링 작업에 뒷자리를 할당받을 수 있습니다. 더욱이, React는 타임 슬라이스를 할 수 있습니다. 즉, 렌더링 과정을 더 작은 청크로 나누고 점진적으로 처리할 수 있다. 이를 통해 React는 여러 프레임에 걸쳐 작업을 수행할 수 있으며, *작업이 중단되어야 하는 경우 중단할 수 있습니다.


요약하자면 다음과 같다


  • 리액트는 렌더링 업데이트의 우선순위에 따라 작업할 수 있다
  • 렌더링 과정을 더 작게 나누어 점진적으로 처리할 수 있다 (파이버)
  • 우선순위가 낮은 작업은 중단할 수 있다


모두동기적 렌더링의 문제를 해결하는 방법으로 보인다. 또한 파이버 조정자가 하는 일과도 일맥상통해 보인다.



Fiber 재방문


파이버 조정자Fiber Reconciler는 렌더링 과정을 Fiber라고 하는 더 작고 관리하기 쉬운 작업 단위로 나누어 렌더링 작업을 일시 중지, 재개 및 우선 순위 지정할 수 있으며, 그 중요성에 따라 업데이트를 연기하거나 스케줄링할 수 있다.


파이버는 조정 단계에서 파이버 조정자가 작업을 처리하기 위한 작은 단위라고 하였다. 앞선 챕터의 내용을 복습하면 다음과 같다.


  • 리액트 엘리먼트로부터 생성되고, 엘리먼트와 마찬가지로 트리 구조를 이룬다
  • 파이버 노드 트리는 조정의 렌더 단계 중 작업 루프(beginWork와 completeWork) 매개변수로 사용된다 (현재 트리, 작업 중 트리)
  • 파이버는 인스턴스이며, 컴포넌트에 대한 다양한 정보와 상태를 갖고 있다
  • 엘리먼트가 일시적이고 상태를 갖지 않는데 반해, 파이버는 상태를 갖고 더 장기간 유지된다 (새 파이버 트리로 전부 교체되기 전까지 유지됨)


파이버와 파이버 조정에 대해 알아본 이전 챕터에서 아직 설명되지 않은 부분이 있다. “어떻게” 작업을 일시 중지, 우선순위 지정, 연기, 스케쥴링-하는지에 대해선 completeWork에서 만들어진 DOM이 폐기될 수 있다-는 내용으로 암시된 내용이 전부였다. 이번 챕터에서 동시성으로 어떻게 중요도에 따라 렌더링의 스케쥴이 결정되는지 더 깊이있게 살펴본다.



업데이트 스케줄링 및 연기


동시성 렌더링의 예시


아래는 메시지 인풋과 메시지를 렌더링하는 채팅 컴포넌트다. 이 예시는 React의 동시 렌더링 훅을 활용하여 인터랙션과 잦은 업데이트를 효과적으로 처리하는 간단한 예시를 보여준다.

const MessageInput = ({ onSubmit }) => {
  const [message, setMessage] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(message);
    setMessage("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
};

const MessageList = ({ messages }) => (
  <ul>
    {messages.map((message, index) => (
      <li key={index}>{message}</li>
    ))}
  </ul>
);

const ChatApp = () => {
  const [messages, setMessages] = useState([]);
  const [isOtherUserTyping, setIsOtherUserTyping] = useState(false)

  useEffect(() => {
    // 웹소켓 서버와 연결하여 새로 들어오는 메시지를 구독
    const socket = new WebSocket("wss://your-websocket-server.com");
    socket.onmessage = (event) => {
      setMessages((prevMessages) => [...prevMessages, event.data.messages]);
      setIsOtherUserTyping(event.data.otherUserTyping)
    };
    // 동시성 렌더링 적용 시
    startTransition(() => {
        setMessages((prevMessages) => [...prevMessages, event.data]);
      });

    return () => {
      socket.close();
    };
  }, []);

  const sendMessage = (message) => {
    // Send the message to the server
  };

  return (
    <div>
      <MessageList messages={messages} />
      // 상태 유저가 입력 중인지 나타내는 typing 인디케이터
      {isOtherUserTyping && <p>user is typing..</p>}
      <MessageInput onSubmit={sendMessage} />
    </div>
  );
};

  • 메시지 인풋을 입력할 때마다 서버 데이터와 연결된 messages 상태가 업데이트됨. -> 서버에서 응답을 받을 때마다 MessageList의 message가 렌더링 작업에 추가됨 -> 사용자 입력으로 input에 입력한 텍스트가 차단(혹은 지연)되면 안됨
  • 사용자가 input 입력 중에 메시지가 새로 계속 들어온다면 비효율적인 렌더링으로 부하가 생길 수 있음
  • 사용자가 메시지 입력을 마쳐 모두 렌더링된 후 MessageList의 messages가 렌더링 되어야함.
  • useTransition훅의 startTransition을 사용하여 우선 순위를 낮출 수 있음.


useTransition훅의 startTransition을 사용하여 MessageList의 업데이트를 더 낮은 우선 순위로 스케줄링하여 MessageInput의 UI를 차단하지 않고 렌더링한다. 이를 통해 사용자 입력은 중단되지 않으며, 사용자 인터랙션(input 입력)보다는 덜 중요한 새로 받은 메시지는 우선 순위에서 낮게 렌더링된다. 결과적으로 무거운 부하에 효율적으로 작동할 수 있게 되었다.


파이버 조정자Fiber Reconciler는 스케줄러와 여러 효율적인 API에 의존하여 이 기능을 가능하게 한다. 이러한 API를 통해 React는 유휴 기간 동안 작업을 수행하고 가장 적절한 시기에 업데이트를 스케줄링할 수 있다.



Diving Deeper


  • 동시성 렌더링: 고우선 순위 작업이 신속하게 처리되는 동시에 저우선 순위 작업이 연기될 수 있도록 한다.
  • 효과: UI가 무거운 부하 하에 있을 때도 부드럽게 유지될 수 있다.


동시성 렌더링의 핵심인 스케줄러, 작업의 우선 순위 수준, 업데이트를 연기하는 메커니즘을 더 깊이 살펴보자.



스케줄러


파이버 조정자Fiber reconcilier와 독립적으로 시간 관련 유틸리티를 제공하는 독립형 패키지


리액트 아키텍처의 핵심에는 렌더링의 스케줄을 관리하는 스케줄러가 있다. 이 스케줄러는 조정자reconcilier 내에서 사용된다. 스케줄러와 조정자는 렌더 레인을 사용하여 작업의 긴급함에 따라 우선 순위를 매기고, 조직화한다.


리액트에서 스케줄러의 주요 역할은 메인 스레드를 제어하는 것이다. 메인 스레드의 원활한 실행을 보장하기 위해 주로 자바스크립트의 마이크로태스크 큐를 사용한다.


조금 더 자세히 이해하기 위해 작성 시점의 리액트 소스 코드 일부를 살펴보자.

/*
 * 이 함수는 루트가 업데이트를 받을 때마다 호출된다. 
 * 이 함수는 두 가지 작업을 수행한다. 
 * 1) 루트가 루트 스케줄에 포함되어 있는지 확인하고, 
 * 2) 루트 스케줄을 처리할 대기 중인 마이크로태스크가 있는지 확인
 * 실제 스케줄링 로직의 대부분은
 * `scheduleTaskForRootDuringMicrotask`가 실행될 때까지 발생하지 않는다.
*/ 

export function ensureRootIsScheduled(root: FiberRoot): void {
	// 스케줄에 루트를 추가
  if (root === lastScheduledRoot || root.next !== null) {
    // 이미 스케줄된 루트root는 빠른 경로로 처리됨.
  } else {
    if (lastScheduledRoot === null) {
      firstScheduledRoot = lastScheduledRoot = root;
    } else {
      lastScheduledRoot.next = root;
      lastScheduledRoot = root;
    }
  }
  /*
  * 루트가 업데이트를 받을 때마다 다음 스케줄 처리까지 이 값을 true로 설정
  * 만약 이 값이 false라면, 스케줄을 확인하지 않고 flushSync를 빠르게 종료할 수 있다
  */
  
  mightHavePendingSyncWork = true;

  /*
  * 현재 이벤트의 끝에서, 각 루트를 통해
  * 올바른 우선순위에서 각각에 대한 작업이 스케줄되어 있는지 확인
  */

  if (__DEV__ && ReactCurrentActQueue.current !== null) {
    // 이 내부는 'act' 스코프 내부이다
    if (!didScheduleMicrotask_act) {
      didScheduleMicrotask_act = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  } else {
    if (!didScheduleMicrotask) {
      didScheduleMicrotask = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  }

  if (!enableDeferRootSchedulingToMicrotask) {
    /*
    * 이 플래그가 disabled되어 있는 동안, 
    * 마이크로태스크를 기다리는 대신 렌더 작업을 즉시 스케줄한다.
    * TODO: 우리가 계획한 추가 기능들을 해제하기 위해
    * enableDeferRootSchedulingToMicrotask를 가능한 빨리 적용해야한다.
    */
    scheduleTaskForRootDuringMicrotask(root, now());
  }

  if (
    __DEV__ &&
    ReactCurrentActQueue.isBatchingLegacy &&
    root.tag === LegacyRoot
  ) {
    // Special `act` case: Record whenever a legacy update is scheduled.
    ReactCurrentActQueue.didScheduleLegacyUpdate = true;
  }
}

ensureRootIsScheduled 함수


  • 리액트의 렌더링 프로세스를 관리하는 데 중요한 역할을 한다.


React 루트는 root: FiberRoot로 표현되며, 업데이트를 받으면 이 함수가 호출되어 두 가지 핵심 작업을 수행한다.


note: React 루트는 커밋 단계에서 업데이트를 수행하기 위해 최종으로 “스왑”(업데이트)된 트리이다. (앞선 글에서 실제 화면에 업데이트 되기 전에 미리 구성된 트리)


ensureRootIsScheduled이 호출되면,


1. 해당 루트가 루트 스케줄에 포함되어 있는지 확인한다.

  • 이는 어떤 루트가 처리되어야 하는지 추적하는 목록이다.

2. 이 루트 스케줄을 전용으로 처리하는 대기 중인 마이크로태스크가 있는 것을 보장한다.


마이크로태스크는 자바스크립트의 이벤트 루프와 연관된다. 자바스크립트의 이벤트 루프를 복습해보자.


마이크로태스크

  • 마이크로태스크는 JavaScript 이벤트 루프 관리에서의 개념으로, 마이크로태스크 큐에서 관리되는 작업이다.


이벤트 루프

  • 자바스크립트 엔진은 비동기 작업을 관리하기 위해 이벤트 루프를 사용.
  • 이벤트 루프는 계속해서 콜백 실행과 같은 작업이 필요한지 확인한다.
  • 이벤트 루프는 태스크 큐(매크로 태스크 큐)와 마이크로태스크 큐로 작동한다.


태스크 큐(매크로 태스크 큐)

  • 이벤트 처리, setTimeout 및 setInterval 콜백 실행, I/O 수행 같은 작업을 포함.
  • 태스크 큐의 작업은 한 번에 하나씩 처리되며, 현재 작업이 완료된 후에 다음 작업이 선택된다.


마이크로태스크 큐

  • 마이크로태스크는 더 작고 즉시 처리해야 하는 작업이다.
  • 프로미스, Object.observe, MutationObserver와 같은 작업에서 발생한다.
  • 이들은 일반 태스크 큐와 다른 마이크로태스크 큐에 저장된다.


실행

  • 마이크로태스크는 현재 작업이 끝난 후, JavaScript 엔진이 태스크 큐에서 다음(매크로) 작업을 선택하기 전에 처리된다.
  • 작업을 실행한 후, 마이크로태스크 큐에 있는 모든 마이크로태스크를 확인하고 실행한 다음 다음 작업으로 이동한다.


루트 스케줄은 마이크로 태스크 큐로 관리되어 렌더링이나 이벤트 처리와 같은 다른 작업에 앞서 빠르고 순서대로 처리되도록 보장된다.



특징 및 사용법


마이크로태스크는 태스크 큐의 다른 작업보다 우선순위가 높으므로 다음 매크로 태스크로 이동하기 전에 실행된다. 마이크로태스크가 계속해서 마이크로태스크 큐에 추가되는 경우, 태스크 큐가 처리되지 않을 수 있다. 이를 '스타베이션(starvation)'이라고 한다.


리액트와 ensureRootIsScheduled 함수 안에서 마이크로태스크는 루트 스케줄 처리가 우선순위가 낮은 다른 작업보다 높은 우선순위로 즉시 처리되도록 보장하는 데 사용된다. 이는 리액트 내에서 부드러운 UI 업데이트와 효율적인 작업 관리를 유지하는 데 도움이 된다.


ensureRootIsScheduled의 실행 순서


리액트 내부의 코드를 간략히 살펴보자. 이 함수가 하는 일은 이와 같다.


1. 함수는 먼저 루트를 스케줄에 추가한다.

  • 루트가 이미 마지막으로 스케줄된 루트이거나 스케줄에 이미 있는지 확인한다.
  • 루트가 없는 경우, 함수는 루트를 스케줄의 끝에 추가하여 lastScheduledRoot를 현재 루트를 가리키도록 업데이트한다.
  • 이전에 스케줄된 루트가 없는 경우(lastScheduledRoot === null), 현재 루트가 스케줄에서 첫 번째이자 마지막이 된다.


2. 함수는 플래그 mightHavePendingSyncWork를 true로 설정한다.

  • 이 플래그는 동기적인 작업이 보류 중일 수 있음을 나타낸다.
  • 다음 섹션에서 다룰 flushSync 함수에 필수적이다.


3. 함수는 루트 스케줄을 처리하기 위해 마이크로태스크가 스케줄되었는지 확인한다. (‘act’ 내부)

  • scheduleImmediateTask(processRootScheduleInMicrotask)를 호출하여 수행된다.
  • 이 스케줄링은 DEV 및 ReactCurrentActQueue.current에 의해 리액트의 act 테스트 유틸리티 범위 내외에서 모두 발생한다.


4. enableDeferRootSchedulingToMicrotask 플래그를 확인하는 조건 블록이다.

  • 이 플래그가 비활성화되어 있으면 함수는 마이크로태스크로의 지연이 아닌 즉시 렌더링 작업을 예약한다.
  • 이 부분은 (작성 당시) 미래의 기능 추가를 위해 이 기능을 활성화할 계획이 있음을 나타내는 TODO 주석으로 표시된다.


5. 함수에는 React의 act 유틸리티 내에서 레거시 업데이트를 처리하는 조건이 포함된다.

  • 이는 업데이트가 다르게 배치되는 테스트 시나리오에서 특정하며, 레거시 업데이트가 예약될 때마다 기록된다.



ensureRootIsScheduled의 효과


  • React의 스케줄링 및 렌더링 로직의 통합하여 React 루트에 대한 업데이트를 효율적으로 관리한다.
  • 작업 및 마이크로태스크를 전략적으로 예약하여 부드러운 렌더링을 보장한다.


이 함수를 통해 React에서 스케줄러의 역할을 이해할 수 있다. 작업을 렌더 레인에 기반하여 예약하는 것이다.


코드로 스케줄러의 동작을 모델링하면 다음과 같다:

if (nextLane === Sync) {
  queueMicrotask(processNextLane);
} else {
  Scheduler.scheduleCallback(callback, processNextLane);
}
  • 다음 레인이 Sync인 경우, 스케줄러는 다음 레인을 즉시 처리하기 위해 마이크로태스크를 예약한다.
  • 다음 레인이 Sync가 아닌 경우, 스케줄러는 콜백을 예약하고 다음 레인을 처리한다.


따라서 스케줄러는 이름 그대로 함수를 해당 함수의 레인에 따라 실행할 시스템이다. 이제 레인에 대해 자세히 알아보자.



Render Lanes


  • 우선 순위의 수준을 나타내는 작업 단위
  • 렌더 레인(Render lanes)은 스케줄링 시스템에서 효율적인 렌더링과 작업의 우선 순위를 보장한다.
  • 이전에는 만료시간(expiration times)를 사용했다.


이전 글에서 렌더레인이 조정의 '렌더 단계’의 두 함수, beginWork, completeWork에서 마지막 인자로 사용되는 것을 확인했다. ('renderLanes는 업데이트가 처리되고 있는 "레인"을 나타내는 비트마스크).


비트마스크: 비트마스크(Bitmask)는 데이터의 비트(bit) 연산을 이용하여 정보를 표현하고 조작하는 기법. 각 비트의 위치가 하나의 플래그(또는 스위치)로 작용하여, 여러 조건을 한 번에 저장하고 관리할 수 있게 한다.


이제 렌더 레인의 세부 사항과 작동 방식, 비트마스크로의 내부 표현에 대해 자세히 살펴보자.


렌더 레인은 React가 렌더링 프로세스 중에 처리해야 할 업데이트를 조직화하고 우선 순위를 정하는 가벼운 추상화이다.


ex) setState를 호출하면 해당 업데이트가 레인에 넣어진다. 업데이트의 컨텍스트에 따라 다른 우선 순위를 이해할 수 있다.


  • setState가 클릭 핸들러 내부에서 호출되면 Sync 레인(가장 높은 우선 순위)에 넣어지고 마이크로태스크로 예약된다.
  • setState가 startTransition에서 호출될 때는 transition 레인(낮은 우선 순위)에 넣어지고 마이크로태스크로 예약된다.


레인의 종류


각 레인은 특정 우선 순위 수준에 해당하며, 높은 우선 순위의 레인이 낮은 우선 순위의 레인보다 먼저 처리된다. React의 레인 종류는 다음과 같다.


  • SyncHydrationLane: hydration 중에 React 앱을 클릭할 때 클릭 이벤트가 이 레인에 넣어진다.
  • SyncLane: React 앱을 클릭할 때 클릭 이벤트가 이 레인에 넣어진다.
  • InputContinuousHydrationLane: hydration 중 호버 이벤트, 스크롤 이벤트 및 기타 연속적인 이벤트가 이 레인에 넣어진다.
  • InputContinuousLane: hydration된 후에 앞선 InputContinuousHydrationLane와 더동일한 이벤트가 이 레인에 넣어진다.
  • DefaultLane: 네트워크에서 이루어진 업데이트, setTimeout과 같은 타이머, 우선 순위가 추론되지 않는 초기 렌더링이 이 레인에 넣어진다.
  • TransitionHydrationLane: hydration 중에 startTransition에서의 모든 전환은 이 레인에 넣어진다.
  • TransitionLanes: hydration 후 startTransition에서의 모든 전환은 이 레인에 넣어진다.
  • RetryLanes: Suspense 컴포넌트의 재시도가 이 레인에 넣어진다.
  • 메커니즘을 이해하기 위한 것이며, 리액트가 업데이트 되면 변경이 생길 수 있다.



Render Lanes의 원리


  • 새로운 컴포넌트가 렌더 트리에 추가되거나 컴포넌트가 업데이트 될 때 레인을 사용하여 업데이트에 우선순위를 할당.
  • 앞서 살핀 바처럼 우선순위는 업데이트의 (사용자 상호작용, 백그라운드 작업같은)종류에 따라 다르게 결정됨.
  • React는 이러한 우선순위에 따른 업데이트를 올바른 레인에 할당하여 개발자가 직접 개입하지 않고 효율적으로 작동하도록 한다.



렌더 레인이 렌더-커밋 단계 중 우선순위를 결정하는 과정:


1. 업데이트 수집 (렌더 단계)

  • React는 마지막 렌더 이후 예약된 모든 업데이트를 수집하고 우선순위에 따라 해당 레인에 할당한다.


2. 레인 처리 (렌더 단계)

  • React는 각 레인에서 업데이트를 처리하며 가장 높은 우선순위 레인부터 시작한다. 동일한 레인의 업데이트는 함께 배치batch하여 하나의 패스로 처리.


3. 커밋 단계

  • 모든 업데이트를 처리한 후, React는 커밋 단계에 진입하여 DOM에 변경 사항을 적용하고 효과effect를 실행하며 다른 마무리 작업을 수행.


4. 반복

  • 각 렌더링에 대해 프로세스를 반복된다.
  • 이로써 업데이트가 항상 우선순위의 순서대로 처리되고, 높은 우선순위 업데이트가 낮은 우선순위 업데이트에 의해 starvation되지 않도록 한다.


우선 순위를 결정한다는 곧 우선순위에 맞는 '렌더 레인’에 배치한다로 이해할 수 있다. 우선순위의 결정은 렌더 단계에서 일어나며, 이 단계에서 렌더링의 스케줄러가 함께 작동한다. 렌더 단계에서 조정자가 beginWork를 진행하며 파이버 트리를 따라 업데이트 여부를 확인하고, 적절한 우선순위 lane에 따라 completeWork에서 새로운 트리를 구성하는 것으로 이해할 수 있다. 추가로 앞서 살펴본 스타베이션(계속 작업이 추가되어 대기 중인 작업이 계속 중단됨)을 막기 위해 이 과정을 매 렌더링마다 반복된다는 사실 또한 기억 할 만하다.


이제 렌더 레인이 할당되는 과정을 확대해서 살펴보자. 이 과정은 업데이트가 트리거될 때 다음 순서대로 수행하면서 우선순위를 결정하고 올바른 레인에 할당한다.


컨텍스트 결정 -> 컨텍스트 기반의 우선순위 추정 -> 우선순위 재지정 확인 -> 업데이트를 레인에 할당


1.업데이트 컨텍스트 결정

  • React는 업데이트가 트리거된 컨텍스트를 평가한다. 이 컨텍스트는 사용자 인터랙션, 상태나 prop 변경으로 인한 내부 업데이트 또는 서버 응답의 결과인 업데이트 등이 될 수 있다. 컨텍스트는 업데이트의 우선순위를 결정하는 데 중요한 역할을 한다.


2. 컨텍스트에 따른 우선순위 추정

  • 컨텍스트에 따라 React는 업데이트의 우선순위를 추정한다. 예를 들어, 업데이트가 사용자 입력으로 인한 것이면 더 높은 우선순위를 가질 가능성이 높고, 중요도가 낮은 백그라운드 프로세스에서 트리거된 업데이트는 낮은 우선순위를 가질 수 있다.


3. 우선순위 재지정 확인

  • 경우에 따라 개발자가 React의 useTransition 또는 useDeferredValue 훅을 사용하여 업데이트의 우선순위를 명시적으로 설정할 수 있다. 이러한 우선순위 재지정이 존재하는 경우 React는 추정된 우선순위 대신 제공된 우선순위를 고려한다.


4. 업데이트를 올바른 레인에 할당

  • 우선순위가 결정되면 React는 해당 레인에 업데이트를 할당한다. 이 프로세스는 방금 살펴본 비트마스크를 사용하여 수행되며, React가 여러 레인과 효율적으로 작업하고 업데이트가 올바르게 그룹화되고 처리되도록 한다.


이 프로세스 전반에 걸쳐 React는 내부 휴리스틱 및 업데이트가 발생하는 컨텍스트에 의존하여 우선순위에 대한 정보를 파악한다. 이러한 동적 우선순위 및 레인 할당은 React가 반응성과 성능을 균형 있게 유지할 수 있도록 하여 개발자의 수동 개입 없이도 응용 프로그램이 효율적으로 작동하도록 한다.


이어서 React가 각 레인에서 업데이트를 처리하는 방법을 살펴보자



Processing Lanes


업데이트가 각 레인에 할당되면 React는 그들을 우선순위 순서대로 처리한다. 앞서 살핀 ChatApp 컴포넌트 예시에서 React는 다음과 같은 순서로 업데이트를 처리할 것이다.


1. ImmediatePriority

  • 메시지 입력에 대한 업데이트를 처리하여 사용자가 입력하는 메시지를 보여주도록 빠르게 업데이트한다.


2. UserBlockingPriority

  • 만약 메시지 상대가 메시지를 입력중이라면, 타이핑 인디케이터('user is typing…'이라는 문구)를 보여준다.
  • 타이핑 인디케이터의 업데이트를 처리하여 사용자에게 실시간 피드백을 제공한다.


3. NormalPriority

  • 메시지 리스트()에 대한 업데이트를 처리하여 새 메시지와 업데이트가 합리적인 속도로 표시된다.


우선순위 순서로 업데이트를 처리하여 가장 중요한 사용자 입력의 렌더링의 반응성을 유지할 수 있도록 보장한다.



Commit Phase


모든 업데이트를 각각의 레인에서 처리한 후에 React는 커밋 단계로 진입한다. 커밋 단계에서는 DOM에 변경 사항을 적용하고 효과를 실행하며 기타 완료 작업을 수행한다.


채팅 앱의 예시에서는 이 단계에서 1) 메시지 입력 값 업데이트, 2) 타이핑 인디케이터의 표시/숨김, 3) 새 메시지를 메시지 목록에 추가하는 작업이 순서대로 실행된다. 그런 다음 React는 다음 렌더 사이클에서 업데이트 수집, 레인 처리, 변경 사항 커밋 등의 프로세스를 반복한다.


리액트 내부의 실제 메커니즘을 완전히 이해하는 것은 매우 복잡하다. 이 이상은 필요 이상으로 어렵기 때문에, 인용을 참고하고 이어서 동시성 관련 훅에 대해 더 알아보자.


note 더욱 깊게 알아보기


.…두 레인이 함께 처리되어야 하는지 결정하는 Entanglement와 이미 처리된 업데이트 위에 업데이트가 다시 기반화되어야 하는지 결정하는 Rebasing과 같은 개념이 있습니다. 예를 들어, 전환이 완료되기 전에 동기화 업데이트에 의해 전환 작업이 중단된 경우에는 두 작업을 함께 실행해야 합니다. 또한 flushing effect에 대해서도 알아야합니다. 예를 들어, 동기 업데이트가 있는 경우에는 React가 효과를 업데이트 전/후에 플러시하여 동기 업데이트 간 상태의 일관된 순서를 보장할 수 있습니다. ...최종적으로 이런 작업을 대신해주는 것이 React가 존재하는 이유이며, React가 우리가 계속해서 응용 프로그램에 집중하는 동안 업데이트 문제, 그들의 우선 순위 및 순서를 근본적으로 처리하는 방식으로 백그라운드에서 추가하는 실제 가치입니다.



useTransition


  • React는 우선 순위를 추정하지만 완벽하지는 않다.
  • useTransition 및 useDeferredValue와 같은 API를 사용하여 기본 우선 순위 할당을 재정의할 수 있다.
  • useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 재정의하여 UI의 업데이트가 반응하지 않는 것을 방지하기 위한 훅이다.
  • 새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다.


useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 관리하고 UI가 고우선 순위 업데이트로 인해 반응하지 않는 것을 방지하기 위한 훅이다. 새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다


useTransition 훅은 transition을 생성하고, 여기에 특정한 우선 순위를 할당하는 것으로 작동한다. 업데이트가 startTransition으로 래핑되면 transition이 된다. React는 이를 기반으로 할당된 우선 순위에 따라 업데이트를 예약하고 렌더링한다.


useTransition 훅을 사용하는 과정은 다음과 같다:


  1. 함수형 컴포넌트 내에서 useTransition 훅을 가져오고 호출한다.
  2. 훅은 두 개의 요소를 포함하는 배열을 반환한다. [isPending, startTransition]
  3. 우선순위를 낮추고자하는 (업데이트를 지연시키려는) 모든 상태 업데이트 또는 컴포넌트 렌더링을 startTransition 함수 내부로 옮긴다.
  4. isPending 상태는 전환이 여전히 진행 중인지 완료되었는지를 나타낸다.
  5. React는 startTransition에 래핑된 업데이트가 적절한 우선 순위 레벨로 처리되도록 보장한다. 이는 스케줄러와 렌더 레인 메커니즘을 통해 업데이트를 할당하고 관리함으로써 달성된다.


TransitionLanes은 SyncLane보다 우선 순위가 낮기 때문에 다른 고우선 순위 업데이트가 먼저 업데이트 되도록하여 부드러운 사용자 경험을 유지할 수 있다. startTransition 함수로 래핑된 모든 상태 업데이트는 TransitionLanes 레인에 들어가게 된다.


useTransition은 훅이므로 함수 컴포넌트 내에서만 사용할 수 있다. 두 개의 요소를 포함하는 배열을 반환한다.


startTransition

업데이트를 지연하거나 우선 순위를 낮게 할 때 사용할 수 있는 함수이다. 이 함수 내부에는 상태의 업데이트가 들어간다. 이 함수에 들어간 업데이트는 전환transition이 된다.


isPending

startTransition 내부의 전환transition이 진행 중인지 여부를 나타내는 boolean 값이다.

추가로 리액트는 useTransition의 반환값이 아닌 startTransition API도 제공한다. 이 API 일반 함수로 사용할 수 있다. isPending을 사용할 수 없다. useTransition과 같은 훅을 사용할 수 없는 코드 위치에서 React에 낮은 우선 순위 업데이트를 처리할 경우에 사용할 수 있다. (jsx 내부 혹은 util 함수 내부와 같은 경우에 사용할 수 있다.)



useTransition 예시

import React, { useState, useTransition } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    doSomethingImportant();
    startTransition(() => {
      setCount(count + 1);
    });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
      {isPending && <p>Loading...</p>}
    </div>
  );
}

export default App;

이 예제에서는 useTransition을 사용하여 카운터를 증가시키는 상태 업데이트의 우선 순위를 관리한다. startTransition 함수 내에서 setCount 업데이트를 래핑함으로써 이 업데이트를 지연시킬 수 있도록 하여 다른 고우선 순위 업데이트(doSomethingImportant)가 동시에 발생할 때, cout의 업데이트에 UI가 반응하지 않게 할 수 있다.


useTransition은 페이지 간 이동할 때도 유용하다. 페이지 이동과 관련된 업데이트의 우선 순위를 관리하여 복잡한 페이지 전환을 처리할 때도 사용자 경험을 부드럽고 반응적으로 유지할 수 있다. 예시는 SPA에서 페이지 전환을 예시이다. 그러나 SPA가 아니더라도, URL경로 이동 없이 탭 선택으로 컴포넌트가 변경되는 컴포넌트에 적용할 수 있다.

import React, { useState, useTransition } from "react";

const PageOne = () => <div>Page One</div>;
const PageTwo = () => <div>Page Two</div>;

function App() {
  const [currentPage, setCurrentPage] = useState("pageOne");
  const [isPending, startTransition] = useTransition();

  const handleNavigation = (page, _isPending) => {
	if(_isPending) return;
    startTransition(() => {
      setCurrentPage(page);
    });
  };

  const renderPage = () => {
    switch (currentPage) {
      case "pageOne":
        return <PageOne />;
      case "pageTwo":
        return <PageTwo />;
      default:
        return <div>Unknown page</div>;
    }
  };

  return (
    <div>
      <nav>
        <button onClick={() => handleNavigation("pageOne")}>Page One</button>
        <button onClick={() => handleNavigation("pageTwo")}>Page Two</button>
      </nav>
      {isPending && <p>Loading...</p>}
      {renderPage()}
      // 혹은 조건부 렌더링으로 currentPage와 PageOne,Two를 명시할 수 있다.
    </div>
  );
}

export default App;

만약 App 컴포넌트에 이동 시에 다른 고우선 순위 업데이트(예: input 입력)가 있다면, useTransition을 사용해 컴포넌트 전환을 우선순위 뒤로 밀어 렌더링 차단을 방지할 수 있다. 또한 페이지 전환이 진행되는 동안 isPending 상태를 활용해 사용자가 버튼을 클릭할 때 즉시 로딩 표시기를 표시할 수 있다. 전환이 완료되면 isPending 상태가 false가 되고 새 페이지가 렌더링된다. 다음 페이지가 Suspense를 사용하여 데이터를 가져와야 하는 경우에도 유용하게 사용할 수 있다. startTransition없이는 페이지 전환이 다른 업데이트를 차단할 수 있기 때문이다.



useDeferredValue


  • useDeferredValue은 특정 UI 업데이트를 나중에 처리할 수 있게 해주는 React 훅이다.
  • 특히 무거운 작업을 처리하는 경우에 유용하며, 이를 통해 업데이트 우선 순위를 관리할 수 있다
  • useDeferredValue는 계산이 무거운 작업에서, 이전 value를 새 value로 업데이트하기 전까지 더 오래 유지하여 부드러운 사용자 경험을 제공한다. (예시 참조)


useDefferedValue가 반환하는 value가 새 값으로 바뀔 때는 다중 리렌더링이 아니라 제어된 상태에서 업데이트 된다. 이는 stale-while-revalidate 전략과 유사하며, 새 값이 도착할 때까지 이전 값을 보유하여 UI가 반응성을 유지한다.


React의 이전 커밋에서 useDeferredValue의 첫 구현을 확인할 수 있다. 이는 useDefferedValue를 이해하는데 좋은 참고가 된다.

function useDeferredValue(value) {
  // 1. 인자를 초기값으로만 사용하고
  const [newValue, setNewValue] = useState(value); 
  useEffect(() {
    // 2. startTransition의 전환transition 내부에서 상태가 바뀔 때마다 업데이트하여 '연기defer'시킨다.
    startTransition(() => {
      setNewValue(value);
    });
 }, [value]);

 return newValue;
}

useDeferredValue의 사용목적


useDeferredValue의 사용 목적은 덜 중요한 업데이트의 렌더링을 지연시키는 것이다. 사용자 인터랙션과 같은 더 중요한 업데이트를 먼저 업데이트하고 서버에서 업데이트된 데이터를 표시하는 것과 같은 덜 중요한 작업을 나중에 업데이트할 수 있다.


다음은 useDeferredValue를 사용하는간단한 예시다.

import React, { memo useState, useDeferredValue } from "react";

function App() {
  const [searchValue, setSearchValue] = useState("");
  const deferredSearchValue = useDeferredValue(searchValue);

  return (
    <div>
      <input
        type="text"
        value={searchValue}
        onChange={(event) => setSearchValue(event.target.value)}
      />
      <SearchResults searchValue={deferredSearchValue} />
    </div>
  );
}

const SearchResults = memo(({ searchValue }) => {
  // Perform the search and render the results
})

이 예제에서는 useDeferredValue를 사용하여 검색 결과의 렌더링을 지연시킨다. 검색 결과를 보여주는 SearchResult는 상대적으로 렌더링하는 데 더 많은 비용이 든다. 이때 사용자 입력에 따라 searchValue가 먼저 업데이트 되어, SearchResults의 렌더링 작업이 input의 searchValue 렌더링을 막지않아 UI 응답성을 유지할 수 있다. 또한 SearchResults에 memo를 사용하여 불필요한 업데이트가 발생하지 않도록 하였다.deferredSearchValue은 setSearchValue로 더 중요한 업데이트가 끝난 이후에 업데이트되고, 이를 prop으로 사용하는 컴포넌트도 그때 업데이트 된다. 결과적으로 컴포넌트는 텍스트 입력 필드를 업데이트하는 것과 같이 더 이상 중요한 작업이 없을 때에만 렌더링되는 효과를 얻을 수 있다.


debounce, throttle 보다 나은 이유


Debouncing

사용자가 타이핑을 마칠 때까지 업데이트를 지연시키는 일정 시간의 일시 정지. (ex: 1초의 지연)


Throttling

일정한 간격으로 목록을 업데이트한다. (ex: 1초에 한 번 이상)


차이점:

  • useDeferredValue는 지연된 리렌더링을 중단할 수 있다
  • 고정된 임의의 지연값(delay)를 사용하는 대신 **렌더링 최적화에 특화된 해결책
  • 사용자의 장치device 성능에 동일하게 기능한다. 임의의 delay값 사용의 한계를 방지한다


useDeferredValue는 지연된 리렌더링을 중단할 수 있는 장점을 갖는다. React가 큰 목록을 처리하는 동안 사용자가 새로운 키를 입력하는 경우, React는 다시 렌더링을 일시 중지하고 새 입력에 응답 한 다음 백그라운드에서 다시 렌더링 프로세스를 재개 할 수 있다. 이는 업데이트를 지연시켜도, 렌더링 중에 인터랙션을 차단할 가능성이 있는 debouncing 및 throttling과 대조적이다. useDefferedValue를 통해 이러한 방해 없이 일관성있는 경험을 제공할 수있다.


사용자가 만일 고성능의 노트북과 같은 디바이스를 사용한다면, 다시 렌더링하는 임의의 delay가 거의 눈에 띄지 않으며 거의 즉시 발생한다. 반면 느린 디바이스에서는 delay 시간이 그에 따라 조정되어 입력에 대한 응답에 대한 디바이스의 속도에 비례하여 지연이 발생할 수 있다.


하지만 debouncing과 throttling은 렌더링과 직접적으로 관련되지 않은 시나리오에서 여전히 유용할 수 있다. 예를 들어 네트워크 요청의 빈도를 줄이는 데 효과적일 수 있다. 이런 경우 debouncing과 throttling은 포괄적인 최적화 전략으로 useDeferredValue와 함께 사용될 수도 있다.


useDeferredValue의 장점


개선된 응답성

이 예에서 사용자가 검색 상자에 입력하면 입력 필드가 즉시 업데이트되고 결과는 지연된다. 사용자가 연속으로 다섯 문자를 빠르게 입력하면 입력 필드가 즉시 다섯 번 업데이트되고, 검색 결과는 사용자가 타이핑을 멈출 때까지 한 번만 렌더링된다. 문자 1~4에 대해서는 새 값으로 렌더링이 중단된다.


선언적 우선 순위 지정

useDeferredValue는 애플리케이션에서 업데이트의 우선 순위를 관리하는 간단하고 선언적인 방법을 제공한다. 훅 내에서 업데이트를 지연시키는 논리를 캡슐화함으로써 컴포넌트 코드를 깔끔하고 앱의 중요한 측면에 집중시킬 수 있다.


자원 활용률 향상

useDeferredValue를 사용하여 중요하지 않은 업데이트를 지연시킴으로써 애플리케이션은 사용 가능한 자원을 더 효율적으로 활용할 수 있다. 이는 성능 병목 현상의 발생 가능성을 줄이고 애플리케이션 전반적인 성능을 향상시킬 수 있다.



useDeferredValue를 사용할 때


useDeferredValue는 특정 업데이트를 다른 것보다 우선순위를 두어야 하는 상황에서 가장 유용하다. useDeferredValue가 유용한 시나리오는 다음과 같다.


  • 대량의 데이터 세트를 검색하거나 필터링하는 경우
  • 복잡한 시각화나 애니메이션을 렌더링하는 경우
  • 백그라운드에서 서버에서 데이터를 업데이트하는 경우
  • 사용자 인터랙션에 영향을 줄 수 있는 계산 비용이 많이 드는 작업을 처리하는 경우



useDeferredValue가 유용할 수 있는 예시:

  • 사용자 입력에 따라 필터링된 대량의 항목 목록
  • 대량의 목록을 필터링하는 것은 계산 비용이 많이 들기 때문에 useDeferredValue를 사용하여 개선할 수 있음
import React, { memo, useState, useMemo, useDeferredValue } from "react";

function App() {
  const [filter, setFilter] = useState("");
  const deferredFilter = useDeferredValue(filter);

  const { data: items } = useQuery({
	queryKey: ['searchList'],
	queryFn: ...
  })
  
  const filteredItems = useMemo(() => {
    return items.filter((item) => item.includes(deferredFilter));
  }, [items, deferredFilter]);

  return (
    <div>
      <input
        type="text"
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
      />
      <ItemList items={filteredItems} />
    </div>
  );
}

const ItemList = memo(({ items }) => {
  // 아이템 항목 렌더
});

useDeferredValue를 사용하여 필터링된 목록의 렌더링을 지연시킨다. 사용자가 필터 입력란에 타이핑하는 동안 filteredItems은 덜 자주 업데이트되어 사용자 입력을 우선하고 반응성을 유지할 수 있다.


useMemo 훅은 items와 filteredItems 배열을 메모이제이션하여 불필요한 다시 렌더링과 다시 계산을 방지하여 성능을 더욱 향상시킬 수 있다.



useDeferredValue를 사용하면 안될 때


  • 지연된 렌더링 업데이트로 오래된 값을 보여주면 안될 때


useDeferredValue를 사용하는 것이 특정 시나리오에서 유용할 수 있지만, 이에 대한 트레이드오프를 인식하는 것도 중요하다. 업데이트를 지연시켜 사용자에게 표시되는 데이터가 오래된 경우가 있을 수 있기 때문이다. 중요하지 않은 업데이트는 상관없지만, 사용자에게 오래된 데이터를 표시하는 결과를 보여줘선 안되는 경우도 고려해야한다.


useDeferredValue를 사용할지 여부를 결정할 때 스스로에게 물어볼 좋은 질문은 "이 업데이트가 사용자 입력인가요?"입니다. 사용자가 반응을 기대하는 모든 것은 지연해서는 안되지만, 그 외의 중요하지 않은 것들은 지연시켜도 괜찮습니다.


useDeferredValue의 사용은 UI의 반응성을 향상시킬 수 있지만, 모든 것의 해결책으로 보면 안된다. 성능을 향상시키는 가장 좋은 방법은 효율적인 코드를 작성하고 불필요한 계산을 피하는 것이다.


useTransition과 useDefferedValue 차이점


혼동될 만한 두 훅을 다시 한번 정리해보자.


useTransition
  • 코드 내부의 있는 상태 업데이트(setter)를 지연시키기 위해 사용
  • 배열을 반환한다. [isPending, startTransition]
  • startTransition 함수 내부에 다른 상태 업데이트 로직을 넣어 '전환’으로 변경한다
  • startTransition 내부에 들어간 상태 업데이트는 낮은 우선순위로 처리된다
  • stratTransition 내부에서 상태 업데이트가 되는 동안, isPending은 true가 된다

useDefferedValue
  • 코드 내부의 특정 상태(state)를 더 오래 유지하기 위해 사용.
  • 상태가 빈번히 업데이트 되는 경우, useDefferedValue훅에 인자로 상태를 사용하여 업데이트가 끝난 후 최종 상태값을 사용할 수 있다.
  • ex: input text에 따라 렌더링되는 리스트 컴포넌트. 사용자가 입력을 끝마쳐 더이상 업데이트가 없는 최종의 상태를 사용한다.


'업데이트를 지연시킨다’는 문구로 혼동이 있을 수 있다. 하지만 두 훅의 사용 범위와 목적이 다르다는 것을 숙지하면 좋다. startTransition은 컴포넌트에서 특정 업데이트setter의 우선순위를 지연시키기 위해 사용하며, useDefferedValue는 특정 상태state가 자주 업데이트 될 때 (ex: input 입력), 이 상태에 더이상 업데이트가 없을 때까지 값을 유지시키기 위해 사용한다.



동시Concurrent 렌더링의 문제


동시 렌더링의 대표적인 문제로, 업데이트가 처리되는 순서를 이해하기 어려운 경우가 있다. 그리고 이는 예상치 못한 동작 및 버그로 이어질 수 있다.


Tearing


  • 업데이트가 순서대로 처리되지 않아 UI가 불일치하게 되는 버그
  • 컴포넌트가 렌더링 중에 업데이트되는 값에 의존할 때 발생할 수 있음
  • 이로 인해 같은 상태임에도 일관되지 않은 데이터로 렌더링될 수 있음


동기적인 React는 컴포넌트 트리를 따라 내려가 하나씩 렌더링한다. 이는 상위에서 하위로, 순서대로 이루어진다. 각 컴포넌트가 최신 상태로 렌더링되어 상태가 렌더링 프로세스 전체에서 일관되게 유지된다. 하지만 동시성 렌더에선 문제가 생길 수 있다.


동시성 렌더링에서 Tearing 버그 재연:

import { useState, useSyncExternalStore, useTransition } from "react";

// 외부 상태
let count = 0;
setInterval(() => count++, 1);

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

  const updateName = (newVal) => {
    startTransition(() => {
      setName(newVal);
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => updateName(e.target.value)} />
      {isPending && <div>Loading...</div>}
      <ul>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
      </ul>
    </div>
  );
}

const ExpensiveComponent = () => {
  const now = performance.now();

  while (performance.now() - now < 100) {
    // 대기
  }

  return <>Expensive count is {count}</>;
};

  • ExpensiveComponent : 계산 비용이 큰 컴포넌트. 렌더링에 오랜 시간이 걸린다. count를 표시.
  • count: 전역 변수. 밀리초마다 증가한다.
Expensive count is 568
Expensive count is 568
Expensive count is 569
Expensive count is 569
Expensive count is 570

버그: input에 몇 글자를 입력한 후, 다섯 개의 ExpensiveComponent에서 count의 각각 다른 값으로 렌더링된다. 즉 동시성 렌더링 사용 시 같은 값이 다르게 표시될 수 있다.


원인: React가 렌더링 과정 중 다른 작업으로 '중단’되었다가 다시 시작할 때, ExpensiveComponent는 이미 변경된 count 값을 반영하지 않은 채 렌더링될 수 있다. 이는 ExpensiveComponent가 여러 번 렌더링되는 동안 count 값이 계속 업데이트되기 때문에, 각 컴포넌트가 조금씩 다른 시점의 count 값을 사용하여 렌더링하기 때문이다.


이것을 티어링tearing이라고 한다. 아직 렌더링되는 동안 일부 상태에 의존하는 컴포넌트가 업데이트될 때 발생할 수 있는 버그이다. 컴포넌트가 렌더링 과정 동안에도 업데이트되는 상태(count 변수)에 의존한다면, 일관성 없는 데이터가 렌더링될 수 있다.



중단 이전 컴포넌트 렌더링 -> 업데이트된 카운트 값이 DOM에 플러시되거나 커밋 -> transition 작업으로 중단 -> 중단 이후 컴포넌트 계속 렌더링 -> 새로운 카운트 값으로 바뀐 채로 DOM에 업데이트


리액트는 최종적으로 일관된 상태를 렌더링할 것이기 때문에 큰 문제는 아니다. 하지만 더 큰 문제는 다음과 같은 경우이다:

<UserDetails id={user.id} />

이 코드는 렌더링 사이에 전역 상태에서 사용자가 삭제된 경우 큰 오류를 발생시킬 것이다. 이것이 tearing이 큰 문제가 될 수 있는 이유다.



해결 방법 :useSyncExternalStore


티어링tearing 문제를 해결하기 위해 앞서 살펴본 useSyncExternalStore 훅을 활용할 수 있다. 이전 상태 관리 글에서 간략히 언급하고 넘어갔던 부분이지만, 조금 더 깊게 알아보자.

const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

// useSyncExternalStore의 인자로 사용할 객체
const store = {
  subscribe(callback) {
	  callback();
	  return () => callback()
  },
  getSnapshot() {
    return state;
  },
};


subscribe:

첫 번째 인자로 콜백 함수를 받는 함수. 스토어가 변경될 때 콜백함수를 호출한다. 이는 컴포넌트의 리렌더링을 유발한다. subscribe 함수는 구독을 정리하는 clearn up 함수를 반환해야한다.


getSnapshot:

컴포넌트가 필요로 하는 스토어 내 데이터의 스냅샷을 반환하는 함수. 스토어가 변경되지 않았다면, getSnapshot에 대한 반복적인 호출은 동일한 값을 반환한다. 스토어가 변경되어 반환된 값이 다르다면(Object.is로 비교했을 때), React는 컴포넌트를 리렌더링.


반환 값:

store에 저장된 값의 현재 스냅샷.

  • caveat: 리렌더링 동안 다른 subscribe 함수가 전달되면, React는 새롭게 전달된 subscribe 함수를 사용하여 스토어에 다시 구독하게 됩니다. 이를 방지하기 위해 컴포넌트 외부에서 subscribe를 선언할 수 있습니다.

(출처: react.dev)


useSyncExternal이 반환하는 스냅샷을 통해 ExpensiveComponent는 count의 전역 변수를 직접 읽는 대신 일관된 상태를 보장할 수 있다. 이는 count가 변경될 때마다 ExpensiveComponent가 동기적으로 리렌더링되도록 하여, 모든 ExpensiveComponent 인스턴스가 동일한 count 값을 표시하게 한다.


이 예제에서는 store.subscribe 함수를 통해 count 상태 변경을 감지하지 않는다. 대신 getSnapshot에서 매 밀리초마다 업데이트 되는 count 값의 변화가 ExpensiveComponent에 바로 반영되어, UI의 일관성을 유지하게 된다.


import { useState, useSyncExternalStore, useTransition } from "react";

let count = 0;
setInterval(() => count++, 1);

const store = {
// 1. 이 예시에서는 subscribe 콜백은 사용하지 않는다
  subscribe(callback) {
    callback();
  },
  getSnapshot() {
  // 매 밀리초마다 업데이트 되는 count의 스냅샷을 얻는다
    return count;
  },
};

const ExpensiveComponent = () => {
  // 2. count의 스냅샷을 얻어 모든 컴포넌트가 동일한 값을 갖고 리렌더링된다
  const consistentCount = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot
  );

  const now = performance.now();
  while (performance.now() - now < 100) {
    // 대기
  }

  return <>Expensive count is {consistentCount}</>;
};

export default function App() {
 .... 위 예시와 동일
 }

이제 count가 변경될 때마다 ExpensiveComponent는 새로운 count 값으로 다시 렌더링되며, 모든 ExpensiveComponent 인스턴스에서 동일한 count 값을 볼 수 있다. subscribe로 구현되는 변경 감지 로직 자체는 구현사항에 따라 반드시 필요하게 될 것이다. 여기선 useSyncExternalStore가 주요 기능을 수행하는 메커니즘을 이해하기 위해 생략되었다.


useSyncExternalStore를 통한 해결 방법:


  • 스토어가 변경될 때 동기적으로리렌더링을 강제함
  • 동시concurrent 렌더링 중 일관된 상태 보장


티어링 문제와 해결법은 상태 관리 글에서 살펴봤던 내용과 일맥상통한다. 예시의 count처럼 여러 컴포넌트에서 공유된 상태를 일관되게 유지하기 위해 전역 상태를 사용했다. 리액트 내부 훅으로는 Context, useSyncExternalStore를, 외부 라이브러리는 Zustand, Jotai, Valtio를 살폈다.


스냅샷(혹은 스토어, 파생 상태, 아톰, 셀렉터)은 상태가 변경될 시에 리렌더링을 유발한다. 이는 useSyncExternalStore의 해결방법과도 일맥상통한다. 라이브러리에 따라 구현 방법은 다르지만, 충분히 대체 가능해 보인다. 이로써 전역 상태를 사용해 동시성 렌더링의 tearing 문제와 각종 라이브러리의 렌더링 최적화 효과를 한번에 해결할 수도 있을 것이다.



Summary


동기 렌더링의 문제


  • 중요하지 않은 작업이 중요한 작업의 업데이트를 차단함


동시Concurrent 렌더링


  • 리액트는 렌더링 업데이트의 우선순위에 따라 작업할 수 있다
  • 렌더링 과정을 더 작게 나누어 점진적으로 처리할 수 있다 (파이버)
  • 우선순위가 낮은 작업은 중단할 수 있다
  • 작업의 우선순위 할당은 개발자의 직접적인 개입 없이 리액트가 추정한다


스케줄러


  • 파이버 조정자Fiber reconciler 내에서 사용된다
  • 리액트 내부에서 렌더링 스케줄을 관리하기 위해 사용한다
  • 우선순위를 관리하기 위해 렌더 레인을 사용한다
  • 스케줄러(의 ensureRootIsScheduled 함수)는 루트 스케줄을 마이크로태스크 큐로 관리하여 렌더링이나 이벤트 처리와 같은 다른 작업에 앞서 빠르고 순서대로 처리되도록 보장한다
  • 우선순위가 높은 작업은 마이크로 태스크큐로, 이외 낮은 작업은 태스크 큐로 관리된다.


렌더 레인


  • 우선 순위의 수준을 나타내는 작업 단위
  • 렌더 레인(Render lanes)은 스케줄링 시스템에서 효율적인 렌더링과 작업의 우선 순위를 보장한다.
  • 새로운 컴포넌트가 렌더 트리에 추가되거나 컴포넌트가 업데이트 될 때 레인을 사용하여 업데이트에 우선순위를 할당
  • 우선순위는 업데이트의 (사용자 상호작용, 백그라운드 작업같은)종류에 따라 다르게 결정된다.
  • React는 이러한 우선순위에 따른 업데이트를 올바른 레인에 할당하여 개발자가 직접 개입하지 않고 효율적으로 작동하도록 한다.
  • SyncLane, DefaultLane, TransitionLane 등 다양한 종류의 레인으로 우선순위를 분류한다.
  • 렌더레인은 파이버 조정자 내에서 비트마스크로 우선순위를 판별할 때 사용한다.


useTransition


  • React는 우선 순위를 추정하지만 완벽하지는 않다.
  • useTransition 및 useDeferredValue와 같은 API를 사용하여 기본 우선 순위 할당을 재정의할 수 있다.
  • useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 재정의하여 UI의 업데이트가 반응하지 않는 것을 방지하기 위한 훅이다.
  • 새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다.


useDefferedValue


  • useDeferredValue은 특정 UI 업데이트를 나중에 처리할 수 있게 해주는 React 훅이다.
  • 특히 무거운 작업을 처리하는 경우에 유용하며, 이를 통해 업데이트 우선 순위를 관리할 수 있다.
  • useDeferredValue는 계산이 무거운 작업에서, 이전 value를 새 value로 업데이트하기 전까지 더 오래 유지하여 부드러운 사용자 경험을 제공한다.


useTransition vs useDefferedValue


useTransition

  • 코드 내부의 있는 상태 업데이트(setter)를 지연시키기 위해 사용.
  • 배열을 반환한다. [isPending, startTransition]
  • startTransition 함수 내부에 다른 상태 업데이트 로직을 넣어 '전환’으로 변경한다.
  • startTransition 내부에 들어간 상태 업데이트는 낮은 우선순위로 처리된다.
  • stratTransition 내부에서 상태 업데이트가 되는 동안, isPending은 true가 된다.


useDefferedValue

  • 코드 내부의 특정 상태(state)를 더 오래 유지하기 위해 사용.
  • 상태가 빈번히 업데이트 되는 경우, useDefferedValue훅에 인자로 상태를 사용하여, 최종 업데이트가 끝난 후의 상태값을 사용할 수 있다.
  • ex: input text에 따라 렌더링되는 리스트 컴포넌트. 사용자가 입력을 끝마쳐 더이상 업데이트가 없는 최종의 상태를 사용한다.


동시Concurrent 렌더링의 문제: 티어링 Tearing


  • 업데이트가 순서대로 처리되지 않아 UI가 불일치하게 되는 버그.
  • 컴포넌트가 렌더링 중에도 업데이트되는 상태에 의존할 때 발생할 수 있음.
  • 이로 인해 같은 상태임에도 일관되지 않은 데이터로 렌더링될 수 있음.
  • useSyncExternalStore나 전역 상태 툴을 사용하여 동시성 렌더링으로 중단된 렌더링이 상태가 업데이트되면 리렌더링하게 하여 티어링을 방지할 수 있다.