리액트 톺아보기 ➃ - Concurrent Mode

나주엽·2025년 4월 6일

리액트 톺아보기

목록 보기
4/4
post-thumbnail

앞서 React Fiber 아키텍처에 대해 정리했었다.
이 아키텍처를 활용해 React 18에서는 Concurrent Mode가 도입되었다.
이 글에서는 Concurrent Mode에 대해 조금 더 알아본다.

Concurrent Mode

Concurrent Mode는 React 18에 도입된 새로운 렌더링 모델이다.
기존에는 한 번에 하나의 업데이트만 처리해 렌더링을 중단할 수 없었다.
하지만, 이 새로운 모드에서는 여러 버전의 UI를 동시에 준비하고 필요에 따라 작업을 일시 중단하거나 건너뛰는 것이 가능하다.

It’s a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time.

이 부분은 React Fiber에 내용과 매우 유사하다.
물론이다. React Fiber를 기반으로 하기 때문이다.

Concurrent Mode에서는 전체 작업이 완료되기 전까지 실제 DOM 업데이트(Commit 단계)를 지연시킨다.
이를 통해 무거운 연산을 백그라운드에서 처리하면서도 사용자 입력에는 즉시 반응할 수 있게 된다.

즉, React가 사용자 기기 성능에 맞춰 일정 간격으로 브라우저에 제어권을 넘겨주고, UI 작업을 자동으로 스케줄링해 더 나은 응답성을 제공하는 것이다.

이제 이런 기능이 실제 코드에서 활용되는 방법을 살펴보자.

1. Automatic Batching

기존에도 상태가 변경되면 React는 컴포넌트를 리렌더링하고, 성능을 위해 상태 변경을 일괄 처리(batch)한다.
하지만 기존 React(17 이하)는 이벤트 핸들러 내부의 상태 변경만 자동으로 일괄 처리 가능했다.

그 이유는 React가 SyntheticEvent라는 자체 이벤트 시스템으로 브라우저의 이벤트를 감싸서 처리하면서 이벤트 핸들러의 종료 시점(동기적 종료)을 명확히 알 수 있었기 때문이다.

반면, 비동기 작업(setTimeout, Promise 등)의 경우 React가 이를 일괄 처리할 적절한 타이밍을 잡아줄 스케줄링 기능을 갖추지 않았기 때문에 각각의 상태 업데이트 호출 시마다 렌더링을 즉시 수행했다.

React 18에서는 이를 Fiber 기반의 아키텍처와 Concurrent 렌더링을 통해 개선했다.

  1. 상태 업데이트가 발생하면 React는 이를 바로 처리하지 않고 업데이트 큐에 담아둔다.
  2. React의 Concurrent Scheduler는 브라우저의 이벤트 루프가 현재 수행 중인 모든 마이크로태스크를 마쳤음을 알려주는 순간(적절한 타이밍)을 기다렸다가 모아둔 상태 업데이트를 일괄 처리한 후 한 번의 렌더링을 수행한다.

즉, React 18에서는 렌더링을 즉시 처리하지 않고, Concurrent Scheduler를 통해 상태 업데이트의 렌더링 시점을 적절히 지연시키고 최적화된 타이밍에 처리하도록 개선된 것이다.

이 덕분에 React 18부터는 이벤트 핸들러 내부뿐 아니라 비동기 함수 내부에서도 상태 업데이트의 일괄 처리가 가능해져, 성능이 크게 개선되었다.

2. Transition, useDeferredValue

다음은 useTransitionuseDeferredValue이다.

Transition (useTransition)

Transition이란 React에서 긴급하지 않은 상태 업데이트의 우선순위를 낮춰 처리하는 기능이다.
사용자가 그 즉시 변경을 확인할 필요가 없는 업데이트를 더 낮은 우선순위로 처리해 UI가 부드럽게 동작하도록 만든다.

기존 React는 상태 변경 시 즉시 렌더링을 수행했기 때문에 입력이 지연되는 문제가 있다.

이제 useTransition 훅을 사용해 이 문제를 해결할 수 있다.

import { useState, useTransition } from 'react';

function App() {
  const [input, setInput] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    startTransition(() => {
      setInput(e.target.value);
    });
  };

  return (
    <>
      <input onChange={handleChange} />
      {isPending && <div>로딩 중...</div>}
      <HeavyList filter={input} />
    </>
  );
}

startTransition 으로 감싼 상태 업데이트는 즉시 렌더링 되지 않고, 업데이트 큐에 담겨 대기한다.
그 후, 메인 스레드가 여유가 생기면 이 작업을 렌더링하게 된다.
그 결과로, 입력 지연 문제를 해결해 UI에 반응성을 유지할 수 있다.

useDeferredValue

useDeferredValueuseTransition과 유사하게 낮은 우선순위를 지정한다.

하지만, 함수 실행의 우선순위를 낮추는 useTransition과 달리, useDeferredValue는 값 자체의 업데이트 우선순위를 낮춘다.

즉, 빠르게 변경되는 상태 값의 업데이트를 지연시켜 렌더링을 줄이는 것이다.
이 역시, 렌더링을 즉시 수행하지 않고, 메인 스레드가 여유 있을 때만 렌더링을 수행하는 것이다.

아래와 같이 useMemo와 함께 사용하면 효과가 더 좋다.

import { useState, useDeferredValue, useMemo } from 'react';

function SearchList({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const filteredItems = useMemo(() => {
    return items.filter(item => item.includes(deferredQuery));
  }, [items, deferredQuery]);

  return (
    <>
      <input onChange={(e) => setQuery(e.target.value)} />
      <ItemList items={filteredItems} />
    </>
  );
}

3. Suspense

Suspense는 React 16에서 처음 등장했다.
이때의 Suspense는 React.lazy 를 이용한 코드 스플리팅에서만 사용 가능했다.
심지어, SSR 환경에서는 사용할 수 없었습니다.

React 18 - Suspense의 변경점은 다음과 같다.

1. 항상 일관된 트리가 커밋된다.

아래 예시를 보자.

import { lazy, Suspense } from 'react';
import Panel from './Panel';

const LazyComponent = lazy(() => import('./LazyComponent'));

export default function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <A>
        <LazyComponent />
      </A>
    </Suspense>);
}

LazyComponent 가 렌더링 할 준비가 되지 않았다면, 화면에 fallback UI를 표시해야 한다.

우선, React 17 버전을 살펴보자.

  1. 우선, DOM에 A를 배치한 후, LazyComponent가 준비되지 않았기 때문에 Adisplay: none 속성을 부여한다.
    즉, 보이지는 않지만 A는 마운트가 되어있고, effect가 실행된다.
  2. 그리고, fallback UI를 표시한다.
  3. LazyComponent가 준비되면, 렌더링을 시도한다.
  4. fallback UI를 지우고, A의 하위에 LazyComponent를 배치한다.
  5. A의 display: none 속성을 제거한다.

React 18의 동작은 다음과 같다.

  1. DOM 에 A를 배치하지 않는다.
  2. fallback UI를 보여준다.
  3. LazyComponent가 준비되면, 렌더링을 시도한다.
  4. fallback UI를 지우고, A 그리고 LazyComponent를 배치한다.
  5. A의 effect가 실행된다.

즉, 컴포넌트가 완전히 준비되었을 때에만 커밋되고, 항상 동일한 트리가 커밋된다.

2. 컨텐츠가 다시 나타날 때 layout effects 가 재실행된다.

React 17의 Suspense는 LazyComponent가 아직 준비되지 않은 상태에서도 부모 컴포넌트를 미리 마운트했다.
즉, 부모 컴포넌트가 DOM에 마운트된 상태였기 때문에 부모 컴포넌트 내부의 layout effect (useLayoutEffect)가 먼저 실행되었다.

이러한 동작으로 인해 LazyComponent가 아직 준비되지 않은 시점에 layout effect가 실행되면서,
DOM 요소의 실제 크기나 위치 등 정확한 레이아웃 정보를 얻을 수 없었다.

나중에 LazyComponent가 로드되어 실제로 렌더링된 이후에도,
이미 실행된 layout effect는 재실행되지 않아 여전히 올바른 레이아웃을 계산할 수 없는 문제가 있었다.

하지만 React 18에서는 Concurrent Rendering과 개선된 Suspense의 도입으로 이 문제가 크게 개선되었다.

React 18의 Suspense는 LazyComponent가 준비될 때까지 부모 컴포넌트의 마운트를 미루고,
모든 하위 컴포넌트가 준비된 이후에 한꺼번에 DOM에 커밋(commit)된다.

이로 인해 React 18에서는 부모 컴포넌트의 layout effect가 실제로 DOM이 완전히 준비된 이후에 실행된다.
즉, LazyComponent를 포함한 하위 컨텐츠의 크기나 레이아웃 정보가 정확하게 확보된 상태에서 layout effect가 실행되므로,
정확한 DOM 정보에 기반하여 레이아웃을 계산할 수 있게 되었다.

3. 스트리밍을 사용한 SSR이 지원된다.

React 17에서는 SSR에서 Suspense를 사용할 수 없었다.
하지만, React 18에서는 HTML 스트리밍을 지원하는 서버 렌더러가 추가되어서 스트림을 생성할 수 있게 되었다.

4. Transition을 이용해 fallback UI를 방지한다.

앞서 다룬 useTransition을 사용해 fallback UI가 이전 컨텐츠를 가리는 경우를 막을 수 있게 되었다.

0개의 댓글