dev.syw

useTransition·useDeferredValue·Suspense로 끊김 없는 UX를 설계하는 동시성 모델.

동시성 렌더링과 전환 우선순위

입문편에서 useStateuseEffect로 상태를 다루는 법을 익혔다면, 이번 레슨에서는 React 18이 도입한 동시성 렌더링(Concurrent Rendering) 모델을 깊이 파고듭니다. 기존 동기 렌더링은 한 번 시작하면 브라우저가 다른 일을 할 수 없었습니다. 반면 동시성 모드는 렌더링을 중단·재개·폐기할 수 있어, 무거운 UI 갱신 중에도 입력 응답성을 유지할 수 있습니다.

1강에서 Fiber 아키텍처와 재조정 알고리즘을 살펴봤습니다. 이 레슨은 그 위에 얹힌 스케줄러 우선순위 시스템과, 개발자가 우선순위를 직접 제어할 수 있는 useTransition, useDeferredValue, Suspense를 다룹니다.

학습 목표

  • 동시성 렌더링이 기존 동기 렌더링의 어떤 문제를 해결하는지 설명할 수 있다.
  • 긴급 업데이트비긴급 업데이트를 구분하고 useTransition으로 분리할 수 있다.
  • useDeferredValue로 파생 값의 렌더링을 지연해 입력 응답성을 유지할 수 있다.
  • Suspense 경계로 비동기 데이터 로딩 상태를 선언적으로 처리할 수 있다.
  • tearing(찢어짐) 문제를 이해하고 useSyncExternalStore로 외부 스토어를 안전하게 구독할 수 있다.

동시성 렌더링이 해결하는 문제

동기 렌더링의 한계

React 17 이전의 렌더링은 완전히 동기적(synchronous)입니다. 컴포넌트 트리 렌더링이 시작되면 메인 스레드를 독점하며, 완료될 때까지 브라우저는 사용자 입력이나 화면 갱신을 처리하지 못합니다.

// 예: 10,000개 항목을 필터링하는 무거운 컴포넌트
function HeavyList({ filter }) {
  // 매 렌더마다 동기적으로 10,000개를 걸러냄 → 메인 스레드 블로킹
  const filtered = heavyItems.filter((item) =>
    item.name.includes(filter)
  );

  return (
    <ul>
      {filtered.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

사용자가 검색창에 글자를 입력할 때마다 이 렌더링이 동기 실행되면, 입력값이 화면에 반영되기까지 수백 ms의 지연이 발생합니다. 타이핑이 뚝뚝 끊기는 느낌이 나는 이유입니다.

동시성 모드의 동작 방식

React 18의 동시성 렌더러는 렌더링 작업을 작은 청크(chunk) 로 나누어, 프레임 경계마다 브라우저에 제어권을 돌려줍니다. 더 높은 우선순위 작업(사용자 입력)이 들어오면 진행 중인 렌더링을 중단(interrupt) 하고, 입력을 먼저 처리한 뒤 렌더링을 재개하거나 폐기합니다.

[동기 렌더링]
입력 → ████████████ 렌더(블로킹) ████████████ → 화면 갱신
                   ↑ 이 사이 입력 이벤트 무시됨

[동시성 렌더링]
입력 → ██ 렌더 청크 → 입력 처리 → ██ 렌더 청크 → 입력 처리 → 화면 갱신

💡 TIP — 동시성 기능은 ReactDOM.createRoot를 써야 활성화됩니다. ReactDOM.render(레거시 모드)를 사용하면 모든 동시성 훅은 폴백 동작으로 돌아갑니다.

// ✅ React 18 동시성 모드
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);

// ❌ 레거시 모드 — 동시성 기능 비활성화
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

긴급 업데이트와 비긴급 업데이트

React의 스케줄러는 업데이트를 크게 두 범주로 나눕니다.

분류예시지연 허용
긴급(Urgent)타이핑, 클릭, 드래그불가 — 즉각 반영 필요
비긴급(Transition)검색 결과 목록, 탭 전환, 페이지 이동가능 — 잠깐 지연해도 무방

사용자는 키를 눌렀을 때 입력창이 즉시 반응하기를 기대합니다. 반면 검색 결과는 0.1초쯤 늦게 나타나도 자연스럽게 느껴집니다. 이 구분을 코드로 표현하는 것이 useTransition입니다.

useTransition으로 무거운 상태 전환을 논블로킹으로 처리

useTransition은 상태 업데이트를 비긴급(transition) 으로 표시합니다. 긴급 업데이트가 들어오면 해당 전환은 중단되고, 긴급 업데이트가 끝난 후 재개됩니다.

import { useState, useTransition } from 'react';

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

  function handleChange(e) {
    // ✅ 입력값 갱신은 긴급 업데이트 — 즉시 반영
    setQuery(e.target.value);

    // ✅ 목록 필터링은 비긴급 전환 — 중단 가능
    startTransition(() => {
      setResults(heavyItems.filter((item) =>
        item.name.includes(e.target.value)
      ));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="검색..." />
      {isPending && <span>결과 갱신 중...</span>}
      <HeavyList items={results} />
    </div>
  );
}
  • isPending: 전환이 진행 중일 때 true. 로딩 인디케이터 표시에 활용합니다.
  • startTransition 콜백 안의 setState는 모두 비긴급으로 처리됩니다.
  • 전환 중 사용자가 새 입력을 하면, 진행 중 렌더가 중단되고 최신 입력 기준으로 재시작됩니다(인터럽트 기반 자동 무효화).

⚠️ 주의startTransition 콜백은 동기적으로 실행되어야 합니다. setTimeout 이나 Promise 내부에서 setState를 호출하면 전환으로 인식되지 않습니다.

// ❌ 비동기 콜백 내부는 transition으로 표시되지 않음
startTransition(() => {
  setTimeout(() => setResults(filtered), 100);
});

// ✅ 콜백 안에서 바로 setState
startTransition(() => {
  setResults(filtered); // 동기 호출
});

useTransition과 Suspense 연동

startTransition 안에서 Suspense를 유발하는 상태 전환이 일어나면, React는 이미 보이는 콘텐츠를 유지한 채 전환이 완료될 때까지 기다립니다. 완료되면 새 콘텐츠로 한 번에 교체합니다.

import { useState, useTransition, Suspense } from 'react';
import { fetchUserProfile } from './api';

function ProfilePage() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  function switchUser(id) {
    startTransition(() => setUserId(id)); // ✅ 기존 UI 유지하며 전환
  }

  return (
    <div style={{ opacity: isPending ? 0.6 : 1 }}>
      <button onClick={() => switchUser(2)}>사용자 2로 전환</button>
      <Suspense fallback={<p>로딩 중...</p>}>
        <UserProfile userId={userId} />
      </Suspense>
    </div>
  );
}

isPending 중에는 투명도를 낮춰 사용자에게 갱신 중임을 암시하는 것이 일반적인 패턴입니다.

useDeferredValue로 입력 응답성 유지하기

useDeferredValue는 값의 "지연된 사본"을 반환합니다. 긴급 업데이트가 완료된 후 한 박자 늦게 갱신됩니다. useTransition이 상태를 업데이트하는 쪽에서 우선순위를 조정한다면, useDeferredValue값을 소비하는 쪽에서 렌더링 우선순위를 낮춥니다.

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

// memo로 감싸야 deferredQuery가 바뀌지 않으면 리렌더를 건너뜀
const HeavyList = memo(function HeavyList({ query }) {
  const filtered = heavyItems.filter((item) => item.name.includes(query));
  return (
    <ul>
      {filtered.map((item) => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

function SearchBox() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query); // 한 박자 느린 사본

  const isStale = query !== deferredQuery; // 오래된 결과임을 알 수 있음

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색..."
      />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <HeavyList query={deferredQuery} />
      </div>
    </div>
  );
}

useTransition vs useDeferredValue

useTransitionuseDeferredValue
제어 위치상태를 업데이트하는 쪽값을 소비하는 쪽
주요 사용 시나리오상태 업데이트 코드에 접근 가능할 때props나 외부 값처럼 직접 제어 불가일 때
isPending 제공❌ (값 비교로 직접 판단)
메모이제이션 필요선택memo 와 함께 써야 효과적

💡 TIP — 제3자 라이브러리에서 넘어오는 value prop처럼 상태 업데이트 코드에 접근할 수 없을 때는 useDeferredValue가 유일한 선택지입니다.

Suspense 경계로 로딩 상태를 선언적으로 다루기

Suspense는 자식 컴포넌트가 준비 중일 때 fallback을 보여주는 선언적 로딩 경계입니다. React 18에서는 데이터 패칭 라이브러리(React Query, SWR, Relay 등)와 함께 쓸 때 강력한 패턴이 됩니다.

import { Suspense } from 'react';

// 데이터 패칭 라이브러리가 Suspense를 지원하면
// 컴포넌트는 로딩 분기 없이 데이터를 바로 사용
function UserCard({ userId }) {
  // React Query v5의 useSuspenseQuery 예시
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  return <div>{user.name}</div>;
}

function App() {
  return (
    // ✅ 로딩 UI를 컴포넌트 밖 경계에서 선언
    <Suspense fallback={<Skeleton />}>
      <UserCard userId={1} />
    </Suspense>
  );
}

Suspense 경계 설계 원칙

Suspense 경계는 여러 겹으로 중첩할 수 있습니다. 세밀하게 나눌수록 부분적으로 표시할 수 있지만, 너무 잘게 나누면 로딩 인디케이터가 산발적으로 나타나 UX가 오히려 나빠집니다.

function Dashboard() {
  return (
    // 바깥 경계: 전체 대시보드 로딩
    <Suspense fallback={<FullPageSpinner />}>
      <Header />
      {/* 안쪽 경계: 차트만 별도 로딩 — 사이드바는 먼저 표시 가능 */}
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
      <Sidebar />
    </Suspense>
  );
}

⚠️ 주의useEffect 안에서 데이터를 패칭하는 전통적인 방식은 Suspense와 연동되지 않습니다. Suspense와 함께 쓰려면 해당 라이브러리가 Promise를 throw 하는 Suspense 프로토콜을 구현해야 합니다. 직접 구현보다는 React Query, SWR 같은 검증된 라이브러리를 사용하세요.

useId와 useSyncExternalStore — 동시성 모드의 안전한 동반자

useId — 하이드레이션 안전 ID 생성

동시성 렌더링에서는 서버·클라이언트 렌더링 순서가 달라질 수 있어, Math.random()이나 전역 카운터로 생성한 ID가 불일치를 일으킵니다. useId는 서버·클라이언트에서 항상 동일한 ID를 보장합니다.

import { useId } from 'react';

function EmailField() {
  const id = useId(); // ":r0:", ":r1:" 형태의 안정적 ID

  return (
    <div>
      <label htmlFor={id}>이메일</label>
      <input id={id} type="email" />
    </div>
  );
}
// ❌ 동시성 모드에서 불안정
const id = Math.random().toString(36).slice(2);

// ✅ 서버·클라이언트 일치 보장
const id = useId();

tearing 문제와 useSyncExternalStore

tearing(찢어짐) 은 동시성 렌더링에서 발생하는 미묘한 버그입니다. 렌더링이 중단·재개되는 사이에 외부 스토어의 값이 바뀌면, 같은 렌더 트리 내에서 서로 다른 스냅숏을 읽는 상황이 생깁니다.

[tearing 시나리오]
렌더링 시작 → ComponentA가 store.value = 1 읽음
         ↓ 렌더링 중단 (긴급 이벤트 처리)
         → store.value 가 2 로 변경됨
         ↓ 렌더링 재개
         → ComponentB가 store.value = 2 읽음
결과: ComponentA는 1, ComponentB는 2 → 화면이 "찢어짐"

useSyncExternalStore는 외부 스토어를 구독할 때 이 문제를 방지하는 공식 API입니다. 렌더링 도중 스토어가 변경되면 React가 해당 렌더를 폐기하고 최신 값으로 다시 시작합니다.

import { useSyncExternalStore } from 'react';

// 외부 스토어 예시 (Redux, Zustand 같은 라이브러리 내부 동작과 유사)
const store = {
  state: { count: 0 },
  listeners: new Set(),

  getState() {
    return this.state;
  },

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener); // 구독 해제 함수 반환
  },

  increment() {
    this.state = { count: this.state.count + 1 };
    this.listeners.forEach((l) => l());
  },
};

function Counter() {
  // ✅ tearing 없는 안전한 외부 스토어 구독
  const { count } = useSyncExternalStore(
    (listener) => store.subscribe(listener), // subscribe
    () => store.getState(),                  // getSnapshot (클라이언트)
    () => ({ count: 0 })                    // getServerSnapshot (SSR)
  );

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => store.increment()}>+1</button>
    </div>
  );
}

useSyncExternalStore의 세 매개변수:

매개변수역할
subscribe스토어 변경을 구독하고 해제 함수를 반환
getSnapshot현재 스냅숏 반환 — 렌더마다 안정적인 참조를 돌려줘야 함
getServerSnapshotSSR 시 서버에서 사용할 스냅숏 (선택, 생략하면 SSR 불가)

⚠️ 주의getSnapshot이 매번 새 객체를 반환하면(() => ({ ...state })) React가 무한 재렌더를 유발합니다. 스토어 상태 참조 자체가 바뀔 때만 새 참조를 반환해야 합니다. Redux나 Zustand는 이미 이 규칙을 준수하므로, 직접 구현할 때만 주의하면 됩니다.

요약

  • 동시성 렌더링은 렌더를 중단·재개·폐기 할 수 있어, 무거운 UI 갱신 중에도 사용자 입력에 즉시 반응한다.
  • useTransition은 상태 업데이트를 비긴급 전환으로 표시해 긴급 업데이트에 양보한다. isPending으로 진행 상태를 알 수 있다.
  • useDeferredValue는 값을 소비하는 쪽에서 렌더 우선순위를 낮추며, memo와 함께 써야 효과적이다.
  • Suspense 경계는 비동기 데이터의 로딩 상태를 선언적으로 처리하며, useTransition과 함께 쓰면 기존 UI를 유지한 채 부드럽게 전환할 수 있다.
  • tearing 문제는 동시성 렌더링에서 외부 스토어가 일관성을 잃는 현상이며, useSyncExternalStore로 방지한다.
  • useId는 서버·클라이언트 하이드레이션에 안전한 고유 ID를 생성한다.

연습문제

  1. 10,000개의 항목을 필터링하는 리스트 컴포넌트가 있습니다. 사용자가 검색 입력창에 타이핑할 때 입력창 자체는 즉시 반응하고, 목록 필터링은 비긴급으로 처리되도록 useTransition을 적용하세요. isPending 동안에는 목록 영역에 반투명 효과를 주세요.

  2. 위 문제와 동일한 시나리오를 useDeferredValue로 구현하세요. query !== deferredQuery 일 때 목록 영역을 반투명하게 표시하고, HeavyList 컴포넌트가 불필요하게 리렌더되지 않도록 memo로 감싸세요.

  3. 다음 구조의 외부 스토어를 useSyncExternalStore로 구독하는 useThemeStore 커스텀 훅을 작성하세요. 스토어는 { theme: 'light' | 'dark' } 를 가지며, toggleTheme() 메서드로 변경됩니다.

  4. 사용자 탭(A, B, C)을 클릭하면 각 탭의 데이터를 Suspense로 로딩하는 컴포넌트를 작성하세요. 탭 전환 시 이전 탭 콘텐츠가 사라지지 않고 유지되다가 새 탭 데이터가 준비되면 교체되도록 useTransition을 적용하세요.

힌트 — 1번: startTransition 안에서 필터 결과를 setState. 2번: useDeferredValue(query) + memo(HeavyList). 3번: subscribeSet으로 구독자 관리, getSnapshot은 동일 참조 반환. 4번: useTransition + SuspenseisPending 동안 opacity 조절.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

React.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.