Ray Book
React 18에서 19까지

Concurrent Rendering의 본질, useTransition과 useDeferredValue

React 18이 도입한 Concurrent Rendering이 정확히 무엇을 바꿨는지, 그리고 useTransition과 useDeferredValue를 언제 어떻게 써야 하는지를 정리합니다.

reactreact18concurrentuseTransitionuseDeferredValuesuspense

동기 렌더링의 한계

React 17까지의 렌더링은 동기적 이었습니다. 한번 렌더링이 시작되면 전체 컴포넌트 트리를 끝까지 그릴 때까지 메인 스레드를 점유했습니다. 작은 앱에서는 문제가 없지만, 컴포넌트가 수백 개가 되면 한 번의 상태 업데이트가 수십 ms 이상 걸리게 됩니다.

이 시간 동안 사용자는 멈춘 화면을 봐야 합니다. 키보드 입력은 큐에 쌓이고, 클릭은 반응이 없습니다. 렌더링이 끝나야 입력이 처리됩니다. 이것이 동기 렌더링의 한계입니다.

function SearchPage() {
  const [query, setQuery] = useState('');
  const results = expensiveSearch(query); // 무거운 계산

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList results={results} /> {/* 5000개 아이템 */}
    </>
  );
}

사용자가 한 글자를 입력할 때마다 5000개 결과를 다시 렌더링합니다. 입력 응답성이 무너집니다. 이전에는 디바운싱이나 throttling으로 우회했지만, 그것은 본질적인 해결이 아니었습니다.

Concurrent Rendering, 멈출 수 있는 렌더링

React 18은 이 모델을 근본적으로 바꿨습니다. 이제 렌더링은 중간에 멈출 수 있고, 다시 시작할 수 있고, 우선순위를 조정할 수 있습니다. 이를 가능하게 하는 내부 메커니즘이 FiberLane 입니다.

Fiber는 React 16부터 도입된 새로운 렌더링 아키텍처입니다. 컴포넌트 트리를 작은 단위 (Fiber 노드) 로 쪼개고, 각 단위를 처리한 후 메인 스레드에 제어권을 양보할 수 있습니다. 다음 단위를 처리할지, 아니면 더 긴급한 작업을 먼저 할지는 React가 결정합니다.

Lane은 React 18에서 정교화된 우선순위 시스템입니다. 각 상태 업데이트에 우선순위 (lane) 를 할당하고, 더 높은 우선순위가 들어오면 진행 중인 낮은 우선순위 렌더링을 중단합니다.

핵심은 이것입니다, React가 "이 업데이트는 급하지 않다"는 정보를 알아야 한다. Concurrent Rendering의 능력을 활용하려면, 개발자가 어떤 업데이트가 긴급하지 않은지 알려줘야 합니다. 그래서 등장한 것이 useTransitionuseDeferredValue입니다.

useTransition, "이 업데이트는 급하지 않다"

useTransition은 상태 업데이트를 트랜지션 으로 표시합니다. 트랜지션 안에서 일어난 업데이트는 React가 백그라운드에서 처리하며, 그 사이에 더 긴급한 업데이트가 들어오면 중단됩니다.

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    setQuery(e.target.value); // 긴급한 업데이트 (입력)

    startTransition(() => {
      setResults(expensiveSearch(e.target.value)); // 트랜지션으로 표시
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <p>검색 중...</p>}
      <ResultList results={results} />
    </>
  );
}

setQuery는 즉시 적용되어 입력이 부드럽게 동작합니다. setResults는 트랜지션 안에 있으므로, 사용자가 빠르게 타이핑하는 동안 React는 이전 트랜지션을 버리고 새 트랜지션을 시작합니다. 결과 렌더링이 입력을 막지 않습니다.

isPending은 트랜지션이 진행 중인지를 알려줍니다. 로딩 UI를 보여주는 데 사용합니다.

언제 사용하는가

useTransition은 다음과 같은 경우에 적합합니다.

  • 무거운 렌더링이 입력을 막을 때 , 검색, 필터, 정렬
  • 탭 전환 , 새 탭의 콘텐츠가 무거워서 전환이 끊길 때
  • 라우트 전환 , 새 페이지 렌더링 동안 이전 페이지를 유지하고 싶을 때

베스트 프랙티스

상태 업데이트가 컴포넌트 안에 있을 때 사용합니다. 외부 라이브러리에서 상태가 업데이트되면 useTransition으로 감쌀 수 없습니다. 그런 경우에는 useDeferredValue가 더 적합합니다.

useDeferredValue, "이 값의 업데이트를 미뤄라"

useDeferredValue는 값의 업데이트를 지연 시킵니다. 새 값이 들어와도, React는 이전 값으로 렌더링을 계속하다가 여유가 생겼을 때 새 값으로 다시 렌더링합니다.

function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(
    () => expensiveSearch(deferredQuery),
    [deferredQuery]
  );

  return <ResultList results={results} />;
}

부모 컴포넌트가 query를 props로 내려줍니다. useDeferredValue(query)는 이 값의 변경을 지연시킨 deferredQuery를 반환합니다. 무거운 검색은 deferredQuery를 사용하므로, 새 query가 들어와도 즉시 재렌더링되지 않습니다.

useTransition과의 차이

둘은 비슷해 보이지만 다릅니다.

useTransitionuseDeferredValue
대상상태 업데이트 함수이미 존재하는 값
사용 위치업데이트를 일으키는 곳값을 사용하는 곳
통제권누가 업데이트를 일으키는지 알아야 함props로 받은 값에도 적용 가능
isPending제공됨제공되지 않음 (직접 비교해야 함)

useTransition은 "내가 일으키는 업데이트를 트랜지션으로 표시"하는 것이고, useDeferredValue는 "내가 받은 값의 변경을 지연"하는 것입니다.

언제 useDeferredValue를 사용하는가

  • 외부 라이브러리에서 받은 값 , 직접 setState를 통제할 수 없는 경우
  • props로 받은 값 , 부모 컴포넌트의 코드를 수정할 수 없을 때
  • 상태가 다른 컴포넌트에서 관리될 때

베스트 프랙티스는 useDeferredValue를 사용한 컴포넌트는 가능하면 메모이제이션 하는 것입니다. 그렇지 않으면 부모가 새 값으로 렌더링될 때마다 자식이 두 번 렌더링됩니다 (이전 값으로 한 번, 새 값으로 한 번).

const ResultList = memo(function ResultList({ results }) {
  // ...
});

Suspense, 비동기 렌더링의 통합

Concurrent Rendering의 또 다른 핵심은 Suspense 입니다. Suspense는 컴포넌트가 데이터를 기다리는 동안 fallback UI를 보여주는 메커니즘입니다.

<Suspense fallback={<Loading />}>
  <UserProfile userId={123} />
</Suspense>

UserProfile이 데이터를 기다리는 동안 <Loading />이 표시됩니다. 데이터가 도착하면 자동으로 교체됩니다.

Suspense는 어떻게 동작하는가

Suspense의 동작 원리는 의외로 단순합니다. 컴포넌트가 데이터를 기다려야 할 때 Promise를 throw 합니다. React는 이 throw를 catch하고, 가장 가까운 <Suspense>의 fallback을 렌더링합니다. Promise가 resolve되면 컴포넌트를 다시 렌더링합니다.

이전에는 이 메커니즘을 직접 사용하는 것이 권장되지 않았고, Relay나 Next.js 같은 프레임워크 내부에서만 활용되었습니다. React 19에서 use() Hook이 등장하면서, 이제 누구나 Suspense를 직접 활용할 수 있습니다 (이 부분은 5편에서 다룹니다).

Suspense for SSR

React 18은 서버 사이드 렌더링에서도 Suspense를 사용할 수 있게 했습니다. 이전에는 SSR이 모든 데이터가 준비되어야 시작할 수 있었고, 가장 느린 데이터에 전체 페이지가 막혔습니다.

이제는 Streaming SSR 이 가능합니다. 빠른 부분을 먼저 보내고, Suspense 경계 안의 느린 부분은 준비되는 대로 추가로 보냅니다.

<>
  <Header /> {/* 즉시 전송 */}
  <Suspense fallback={<Skeleton />}>
    <SlowComments /> {/* 준비되면 추가 전송 */}
  </Suspense>
  <Footer /> {/* 즉시 전송 */}
</>

사용자는 빠른 부분을 먼저 보고, 느린 부분은 점진적으로 채워지는 것을 봅니다. 첫 콘텐츠 표시 시간 (FCP) 이 크게 개선됩니다.

Automatic Batching, 조용한 큰 변화

React 18에는 잘 안 보이지만 중요한 변화가 하나 더 있습니다. Automatic Batching 입니다.

React 17에서는 이런 코드가 두 번 렌더링되었습니다.

function handleClick() {
  setTimeout(() => {
    setCount(c => c + 1); // 1번째 렌더링
    setFlag(f => !f);     // 2번째 렌더링
  }, 0);
}

React 17의 배치는 React 이벤트 핸들러 안에서만 동작했습니다. setTimeout, Promise, 네이티브 이벤트 핸들러 안에서는 각 setState마다 렌더링이 일어났습니다.

React 18부터는 모든 곳에서 자동으로 배치 됩니다. 위 코드는 한 번만 렌더링됩니다. 성능이 자연스럽게 개선되며, 별도로 신경 쓸 것이 없습니다.

만약 의도적으로 배치를 피하고 싶다면 (드물지만), flushSync를 사용합니다.

import { flushSync } from 'react-dom';

flushSync(() => setCount(c => c + 1));
flushSync(() => setFlag(f => !f));

베스트 프랙티스 정리

Concurrent Rendering 관련 API를 잘 쓰기 위한 핵심 원칙은 다음과 같습니다.

1. 입력과 결과를 분리하라. 사용자 입력 (긴급) 과 그 결과로 일어나는 무거운 렌더링 (트랜지션) 을 분리합니다. 입력은 즉시 반영하고, 결과는 트랜지션으로 처리합니다.

2. 외부 값에는 useDeferredValue, 내가 통제하는 업데이트에는 useTransition. 둘은 같은 목적이지만 통제권의 위치가 다릅니다.

3. useDeferredValue를 쓰는 자식은 메모이제이션하라. 그렇지 않으면 두 번 렌더링됩니다.

4. Suspense 경계는 가능한 한 작게. 큰 영역을 하나의 Suspense로 묶으면 작은 데이터 하나가 늦어도 전체가 fallback을 보여줍니다. 작은 영역으로 나누면 빠른 부분부터 보여줄 수 있습니다.

5. flushSync는 정말 필요할 때만. Automatic Batching의 이점을 잃습니다. 보통 외부 라이브러리와의 동기화 같은 특수한 경우에만 사용합니다.

다음 단계

다음 글에서는 외부 상태 라이브러리 (Zustand, Redux 등) 와 Concurrent Rendering이 어떻게 충돌했는지, 그리고 그 해결책으로 등장한 useSyncExternalStore의 동작 원리를 살펴봅니다. tearing이라는 이름의 미묘한 버그가 어떻게 발생하는지, 그리고 React가 이를 어떻게 해결했는지를 다룹니다.