리액트 동시성

가은·2025년 3월 24일
0

동기식 렌더링의 문제

메인 스레드를 가로막아서 사용자 경험을 저하시킨다.

설계 측면에서 동기식 렌더링에는 우선순위라는 개념이 없다. 동기식 렌더링은 모든 업데이트를 동일하게 취급하고, 업데이트가 사용자에게 보이는지 여부를 따지지 않는다. 사용자와 상호 작용이 가능한 항목이 우선적으로 렌더링되는 것이 좋겠지만, 리액트에 동시성 기능이 도입되기 전까지는 중요한 업데이트를 가로막아 사용자 경험이 저하되는 현상이 종종 나타났다.

동기식 렌더링의 문제를 완화하기 위해 여러 업데이트를 일괄 처리하여 메인 스레드에서 수행되는 작업을 최소화하게 할 수 있지만 일괄 처리에도 한계가 존재한다.

리액트의 동시성 렌더링은 업데이트 작업의 중요도와 긴급도에 따라 우선순위를 정하여 UI의 응답성을 유지하고, 더 나은 사용자 경험을 제공한다.
동시성 렌더링 기능으로 타임 슬라이싱이 가능하게 되었다.

  • 타임 슬라이싱? 렌더링 프로세스를 더 작은 덩어리로 분할해 점진적으로 처리하는 기법

파이버 다시 보기

4장에서 본 파이버 재조정자는 동시성 렌더링을 가능하게 하는 리액트의 핵심 매커니즘이다.
렌더링 프로세스를 파이버라고 하는 더 작고 관리하기 쉬운 작업 단위로 분할하여 처리한다.
리액트는 파이버를 활용해서 렌더링 작업을 일시적으로 중지, 재개 또는 우선순위를 설정해 중요도에 따라 업데이트를 지연하거나 예약한다.

업데이트 예약과 지연

파이버 재조정자는 업데이트를 예약하고 지연하는 기능을 스케줄러와 여러 효율적인 API에 의존해 구현한다.

리액트는 렌더 레인[참고]을 통해 렌더링을 하는데 이는 동기식으로 DOM을 업데이트하기 때문에 다른 업데이트를 가로막게 된다. 다른 렌더링 작업의 우선순위를 낮추려면, 관련 상태 업데이트를useTransition 훅의 startTransition 함수로 감싸면 된다.

const [isPending, startTransition] = useTransition();

useEffect(() => {
  const socket = new WebSocket("wss://~~.com");
  socket.onmessage = (event) => {
    startTransition(() => {
      setMessages((prev) => [...prev, event.data])'
    });
  };
  
  return () => {
    socket.close();
  };
}, []);

스케줄러

스케줄러는 타이밍 관련 유틸리티를 제공하는 독립형 패키지로, 파이버 재조정자와는 별개로 동작한다. 스케줄러와 재조정자는 렌더 레인을 통해 작업의 긴급도에 따라 우선순위를 설정하고 정리하게 된다.

스케줄러의 주된 기능은 마이크로태스크를 예약하여 메인 스레드의 제어를 관리하고 원활한 실행을 보장하는 것이다.
마이크로태스크는 마이크로태스트 큐에 의해 관리되는 작업의 일종이다.

  • 이벤트 루프
    자바스크립트 엔진은 이벤트 루프를 통해 비동기 작업을 관리한다.
    이벤트 루프는 매크로태스크 큐와 마이크로태스크 큐라는 두 가지 작업 대기열에서 기본으로 동작한다.
  • 매크로태스크 큐
    이벤트 처리, setTimeout 콜백 실행, setInterval 콜백 실행, I/O 등의 작업이 저장된다.
    이 대기열의 작업은 한 번에 하나씩 처리되며, 현재 작업이 완료되어야 다음 작업을 진행한다.
  • 마이크로태스크 큐
    더 작고 즉각적인 작업을 하며 promise, Object.MutationObserver 같은 연산에서 발생한다.

마이크로태스크는 다른 작업보다 우선순위가 높기 때문에 다음 매크로태스크로 넘어가기 전에 실행된다. 마이크로태스크가 계속해서 마이크로태스크를 대기열에 추가하면 작업 대기열이 처리되지 않는 상황이 발생하는데, 이를 기아 상태라고 한다.

마이크로태스크는 현재 스크립트 실행 직후 브라우저가 렌더링이나 이벤트 처리 같은 작업을 수행하기 전에 루트 스케줄의 처리가 높은 우선순위로 이루어지도록 보장하기 위해 사용된다.

(더 깊은 내용은 책을 읽도록 하자)

렌더 레인

렌더 레인은 작업의 렌더링과 우선순위 관리를 효율화한다.
레인은 우선순위 수준을 나타내는 단위로, 리액트가 렌더링 주기에서 처리할 수 있는 작업을 의미한다.

랜더 레인은 리액트가 렌더링 과정에서 필요한 업데이트를 구성하고 우선순위를 설정하는 데 사용하는 가벼운 추상화이다.

ex) setState가 클릭 핸들러 내부에서 호출

  • 해당 업데이트는 Sync 레인(1순위)에 배치, 마이크로태스크로 예약
  • startTransition의 트랜지션 내에서 호출되면 트랜지션 레인(우선순위 낮음)에 배치, 마이크로태스크로 예약

각 레인은 각기 다른 우선순위에 대응하며, 우선순위가 높은 순서대로 처리된다.

컴포넌트가 업데이트되거나 새 컴포넌트가 렌더 트리에 추가되면, 리액트는 해당 업데이트의 우선순위에 따라 할당된다.

렌더 레인 작동 방식

  1. 업데이트 수집: 마지막 렌더링 이후에 예약된 모든 업데이트를 수집해서 우선순위에 따라 각 레인에 할당
  2. 레인 처리: 우선 순위가 가장 높은 레인부터 시작해 각 레인에 있는 업데이트 처리(같은 레인은 일괄 처리)
  3. 커밋 단계: 모든 업데이트 처리 후 변경 사항을 DOM에 적용, 효과 실행, 마무리 작업 수행
  4. 반복: 렌더링을 할 때마다 항상 우선순위대로 처리되도록 보장

업데이트가 발생하면 리액트는 다음 단계를 수행하여 우선순위를 결정하고 레인에 할당한다.

  1. 업데이트의 콘텍스트 확인
  2. 콘텍스트에 따라 우선순위 추정
  3. 우선순위 재정의가 있는지 확인(명시적으로 설정한 우선순위가 있다면 설정된 우선순위를 사용)
  4. 올바른 레인에 업데이트 할당

useTransition

useTransition 은 컴포넌트에서 상태 업데이트의 우선순위를 관리하고 우선순위가 높은 업데이트로 인해 UI가 응답하지 않는 것을 방지하는 리액트 훅이다.

새로운 데이터를 로드하거나 여러 페이지를 이동하는 등 시각적으로 혼란을 줄 수 있는 업데이트를 처리할 때 유용하다.

useTransition에서 반환된 startTransition 함수로 감싸진 모든 업데이트는 트랜지션 레인에 들어간다.
트랜지션 레인이 Sync 레인보다 우선순위가 낮은 특성을 활용해 업데이트 타이밍을 제어한다.

useTransition의 실행 단계는 다음과 같이 요약된다.

  1. 함수 컴포넌트 내에서 useTransition 훅을 가져와 호출
  2. isPending 상태와 startTransition 이 있는 배열 반환
  3. startTransition 함수로 타이밍을 제어하려는 상태 업데이트나 컴포넌트 렌더링을 감쌈
  4. isPending 상태는 전환이 진행 중인지, 완료되었는지 알려줌
  5. 리액트는 트랜지션으로 감싸진 업데이트가 적절한 우선순위 수준으로 처리되도록 보장 →
    이 과정에서 스케줄러와 렌더 레인 매커니즘을 사용해 업데이트를 할당 및 관리

useDeferredValue

useDeferredValue는 특정 UI 업데이트를 나중으로 미루는데 사용되는 리액트 훅으로, 애플리케이션이 과부하 작업이나 연산 집약적 작업을 처리할 때 유용하다.
이를 통해 업데이트의 우선순위를 관리하고, 부드러운 전환과 개선된 사용자 경험을 제공하는 데 일조한다.

초기 렌더링 중에 반환되는 지연된 값은 인수로 전달된 값과 동일하며 이후 업데이트에서는 useDeferredValue 가 오래된 값을 더 유지하고, 새 값으로 업데이트할 시점을 제어한다.
이전 값과 새 값 사이에 렌더링이 여러 번 발생하지 않도록 동작하기 때문에 값이 바뀌어도 UI가 매번 리렌더링되지 않는다. 대신 새 값으로 업데이트될 시점을 제어해 한 번에 새 값으로 업데이트한다.

useDeferredValue는 리액트 동시성 기능의 일부이며 특정 상태의 지연 및 중단을 가능하게 한다.

주요 목적은 덜 중요한 업데이트의 렌더링을 지연하는 것이며, 사용할 땐 지연할 값을 인수로 전달하고 반환된 값을 컴포넌트에서 사용하면 된다.

const [searchValue, setSearchValue] = useState('');
const deferredSearchValue = useDeferredValue(searchValue);

디바운싱과 스로틀링은 특정 상황에서 효과적이지만, 개발자가 임의로 지연 시간을 설정해야 하며 렌더링 도중 사용자의 입력 등을 가로막을 수 있다. 렌더링 최적화에 특화된 useDeferredValue 는 지연 시간을 사용자 기기 성능에 맞춰 조정하기 때문에 임의로 시간을 설정할 필요가 없으며 렌더링을 일시 중지하고 새 입력에 응답한 후 백그라운드에서 렌더링을 다시 진행하게 된다.

useDeferredValue 는 애플리케이션이 특정 업데이트의 우선순위를 다른 업데이트보다 우선시해야 하는 상황에 유용하다.
이 외에도 대규모 데이터를 검색하거나 필터링할 때, 복잡한 시각화나 애니메이션을 렌더링할 때, 백그라운드에서 서버의 데이터를 업데이트할 때, 사용자 상호 작용에 영향을 미칠 수 있는 연산 집약적 작업을 처리할 때 사용을 고려할만 하다.

useDeferredValue 를 사용하여 업데이트를 지연하면 사용자에게 표시되는 데이터가 최신 데이터와 다른, 오래된 데이터일 수 있기 때문에 상황을 잘 고려하여 사용해야한다.

동시성 렌더링 관련 문제

동시성 렌더링은 성능과 응답성을 높이지만, 업데이트 처리 순서를 예측하기 어려울 수 있다.

티어링

티어링 현상은 업데이트 순서가 어긋나면서 UI가 일관성을 잃게 되는 버그의 일종을 말한다.
애플리케이션을 렌더링하는 컴포넌트가 의존하고 있는 상태가 업데이트될 때 발생한다.

이런 티어링 문제를 해결하기 위해 useSyncExternalStore 훅이 제공된다.

useSyncExternalStore는 애플리케이션의 내부 상태와 외부 상태를 동기화한다.
적절히 처리하지 않으면 티어링이 발생할 수 있는 고비용 계산 작업 시에 유용하다.

const value = useSyncExternalStore(store.subscribe, store.getSnapshot);

store.subscribe 는 콜백 함수를 받는 함수이다. 이 함수 내부에서는 외부 저장소의 변경 사항을 구독하고 저장소에 변화가 생길 때마다 콜백 함수를 호출할 수 있다.
리액트는 이 함수의 호출을 새 값을 사용해 컴포넌트를 리렌더링하라는 신호로 간주할 수 있다. 함수가 실행되면 정리 함수를 반환하는데 반환된 함수를 실행하면 외부 저장소 구독을 취소한다.

store.getSnapshot 은 외부 저장소의 현재값을 반환하는 함수이다. 컴포넌트가 렌더링될 때마다 호출되며, 반환된 값은 컴포넌트의 내부 상태를 업데이트하는 데 사용된다.
동기적으로 호출되므로 비동기 연산을 수행하거나 부작용이 있으면 안된다.
렌더링 시점에 상태의 일관성을 보장해서 여러 인스턴스의 컴포넌트가 동일한 상태를 갖는다.

profile
일이 재밌게 진행 되겠는걸?

0개의 댓글