React useTransition과 Concurrent 렌더링 실전: Suspense, Deferring, Scheduler 우선순위를 코드로 이해하기

메인 스레드가 막히는 순간, UI는 사용자를 배신한다
React 애플리케이션에서 "왜 필터를 클릭했는데 선택이 늦게 반영되지?"라는 질문을 받아본 적이 있을 것입니다. 우리 팀은 수십만 건의 거래 내역을 보여주는 어드민 대시보드를 운영하면서 정확히 이 문제를 마주했습니다. 날짜 범위 피커를 선택할 때마다 선택 UI 자체가 200ms 이상 굳어버렸고, 사용자들은 자신의 클릭이 먹혔는지조차 알 수 없었습니다.
React 18 이전 세계에서 해결책은 제한적이었습니다. debounce로 무거운 계산을 늦추거나, setTimeout(0)으로 렌더링을 다음 태스크로 미루거나, 전체 목록을 가상화하는 방법들이 대표적이었습니다. 이 방법들은 모두 증상을 완화하는 방어적 전략이지, React의 렌더링 스케줄러가 우선순위를 직접 이해하고 조정하는 방식이 아니었습니다.
React 18과 함께 도입된 Concurrent 렌더링 모델은 다른 방향으로 문제를 풉니다. 렌더링 자체를 중단 가능하게 만들고, 긴급도에 따라 어떤 업데이트를 먼저 커밋할지 React가 결정하게 합니다. useTransition, useDeferredValue, Suspense와의 연동, 그리고 내부의 Scheduler 패키지까지, Concurrent 렌더링의 실제 동작 방식을 코드 수준에서 파악합니다.
1. Concurrent 렌더링이란 무엇인가: Blocking vs Interruptible
React 17까지의 렌더링은 동기(Synchronous) 방식이었습니다. setState가 호출되면 React는 컴포넌트 트리를 재귀적으로 순회하며 렌더링을 완료할 때까지 메인 스레드를 점유했습니다. 10ms가 걸리든 300ms가 걸리든 그 작업은 중단될 수 없었습니다. 사용자의 클릭, 키 입력, 스크롤 이벤트는 렌더링이 완료될 때까지 큐에서 대기해야 했습니다. 이것이 "Blocking 렌더링"입니다.
Concurrent 모드는 렌더링 과정을 잘게 쪼개고, 각 조각 사이에 브라우저가 다른 작업을 처리할 기회를 줍니다. React는 5ms 단위로 작업을 처리하다가 더 긴급한 업데이트가 들어오면 현재 진행 중인 렌더링을 일시 중단하고 긴급한 작업을 먼저 처리합니다. 이 개념을 "Interruptible 렌더링"이라고 합니다.
공식 React 블로그 React v18 릴리즈 노트에서는 이를 다음과 같이 설명합니다. "Concurrent React can interrupt, pause, resume, or abandon a render." 렌더링이 더 이상 단순한 함수 호출 스택이 아니라, 우선순위가 부여된 작업 단위의 집합으로 바뀐 것입니다.
핵심적인 차이는 "커밋(Commit)"의 타이밍에 있습니다. 동기 렌더링에서는 렌더와 커밋이 항상 붙어서 일어납니다. Concurrent 렌더링에서는 렌더 단계가 여러 번 나뉘어 실행될 수 있고, 더 높은 우선순위의 업데이트가 들어오면 이전 렌더 단계의 작업이 버려지고 처음부터 다시 계산됩니다. 이 때문에 Concurrent 모드에서는 사이드 이펙트를 렌더 단계(useMemo, useReducer의 계산 함수 등)에 두면 안 된다는 원칙이 생겼습니다.
2. React 18 Lane 모델: 우선순위 비트마스크 동작 원리
React 18 내부적으로 업데이트의 우선순위는 "Lane"이라는 개념으로 표현됩니다. Lane은 32비트 정수를 비트마스크로 사용하는 시스템으로, 각 비트 또는 비트 그룹이 특정 우선순위 수준을 나타냅니다.
React 소스 코드(ReactFiberLane.js)에서 실제로 정의된 Lane 상수의 일부를 살펴보면 다음과 같은 구조를 확인할 수 있습니다.
// React 내부의 Lane 우선순위 상수 (개념 설명용으로 재구성)
const SyncLane = 0b0000000000000000000000000000001; // 1: 동기, 최우선
const InputContinuousLane = 0b0000000000000000000000000000100; // 4: 연속 입력 (스크롤 등)
const DefaultLane = 0b0000000000000000000000000010000; // 16: 기본 업데이트
const TransitionLanes = 0b0000000001111111111111111000000; // Transition 업데이트 묶음
const IdleLane = 0b0100000000000000000000000000000; // Idle: 최저 우선순위
setState를 직접 호출하면 기본적으로 DefaultLane에 배치됩니다. 클릭 이벤트 핸들러 안에서 호출되면 SyncLane에 가깝게 처리됩니다. startTransition 안에서 호출되면 TransitionLanes 중 하나에 배치됩니다.
비트마스크를 사용하는 이유는 연산 효율성 때문입니다. 여러 Lane의 조합을 OR 연산으로 표현하고, 특정 Lane이 포함됐는지는 AND 연산으로 확인합니다. React의 스케줄러는 현재 작업 큐에 쌓인 Lane들의 비트마스크를 보고 가장 낮은 비트(가장 높은 우선순위)부터 처리합니다.
이 모델의 실용적 의미는 하나입니다. 업데이트를 Transition으로 표시하는 순간, 해당 업데이트는 더 높은 우선순위 업데이트가 들어왔을 때 언제든 뒤로 밀릴 수 있는 "선점 가능(preemptible)"한 작업이 됩니다. 사용자의 키 입력이나 클릭은 항상 Transition보다 높은 우선순위 Lane에 배치되므로, 무거운 UI 업데이트는 브라우저가 여유 있을 때 처리하는 것.
3. startTransition과 useTransition의 관계: 내부 흐름 추적
useTransition과 startTransition은 같은 메커니즘의 두 가지 인터페이스입니다. startTransition은 컴포넌트 외부에서도 사용할 수 있는 함수이고, useTransition은 훅으로서 isPending 상태를 함께 제공합니다.
import { useTransition, useState } from 'react';
interface FilterState {
category: string;
dateRange: [Date, Date] | null;
status: 'all' | 'active' | 'completed';
}
function TransactionFilter({ onFilterChange }: { onFilterChange: (f: FilterState) => void }) {
const [isPending, startTransition] = useTransition();
const [localFilter, setLocalFilter] = useState<FilterState>({
category: 'all',
dateRange: null,
status: 'all',
});
const handleCategoryChange = (category: string) => {
// 1단계: 로컬 UI 상태는 즉시 업데이트 (SyncLane)
setLocalFilter(prev => ({ ...prev, category }));
// 2단계: 비싼 목록 재렌더링은 Transition으로 표시 (TransitionLane)
startTransition(() => {
onFilterChange({ ...localFilter, category });
});
};
return (
<div>
<select
onChange={(e) => handleCategoryChange(e.target.value)}
style={{ opacity: isPending ? 0.6 : 1 }}
>
<option value="all">전체</option>
<option value="income">수입</option>
<option value="expense">지출</option>
</select>
{isPending && <span className="loading-indicator">필터 적용 중...</span>}
</div>
);
}
isPending의 동작 방식이 중요합니다. startTransition 콜백 내부의 setState가 최종적으로 커밋(DOM 반영)될 때까지 isPending은 true입니다. Transition 업데이트가 시작된 순간부터 완료될 때까지 로딩 상태를 자동으로 추적해줍니다. 이를 통해 스피너를 수동으로 관리하거나 별도의 로딩 상태를 useState로 만들 필요가 없어집니다.
내부 흐름을 추적하면: startTransition 호출 → React가 현재 실행 컨텍스트를 "transition" 모드로 표시 → 내부 콜백의 모든 setState 호출이 TransitionLane에 배치 → 스케줄러가 현재 렌더링 큐에 추가 → 더 높은 우선순위 업데이트가 없으면 처리, 있으면 대기.
주의할 점은 startTransition 내부의 콜백이 동기여야 한다는 것입니다. async/await나 setTimeout 안에서 호출한 setState는 Transition으로 인식되지 않습니다. 비동기 로직 완료 후 상태를 업데이트하려면 비동기 처리를 startTransition 밖에서 하고, 결과를 적용하는 setState 호출만 startTransition 안에 넣어야 합니다.
4. useDeferredValue와 useTransition: 언제 무엇을 선택하는가
두 API는 비슷한 목적을 가지지만 제어권의 위치가 다릅니다. 이 차이를 이해하는 것이 올바른 선택의 핵심입니다.
useDeferredValue는 값을 지연시킵니다. 상태 업데이트의 출처에는 관여할 수 없고, 이미 결정된 상태 값이 자식 컴포넌트에 반영되는 것을 지연시킵니다. 서드파티 컴포넌트나 제어할 수 없는 상태 소스에서 오는 값을 다룰 때 유리합니다.
| 구분 | useTransition | useDeferredValue |
|---|---|---|
| 제어 위치 | 상태 업데이트 발생 시점 | 상태 값이 자식에 전달되는 시점 |
| 사용 조건 | setState 호출 코드를 직접 제어할 수 있을 때 | 외부에서 오는 prop/state 값일 때 |
| 로딩 상태 | isPending 플래그 제공 | 이전 값과 현재 값이 다른지로 직접 추론 |
| 취소 가능성 | Transition 업데이트는 새 업데이트로 대체 가능 | 지연된 값은 새 값이 오면 이전 렌더를 폐기 |
| 전형적 사용처 | 필터/탭 전환, 페이지 이동 | 검색 입력값으로 무거운 목록 필터링 |
useDeferredValue의 전형적인 패턴은 다음과 같습니다.
import { useDeferredValue, memo, useState } from 'react';
// 무거운 목록 컴포넌트는 반드시 memo로 감싸야 한다
const HeavyTransactionList = memo(function HeavyTransactionList({
query,
}: {
query: string;
}) {
// 실제 필터링 로직: query가 변경될 때만 재계산
const filtered = useExpensiveFilter(query);
return (
<ul>
{filtered.map((tx) => (
<li key={tx.id}>{tx.description}</li>
))}
</ul>
);
});
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// query !== deferredQuery 인 동안 이전 목록이 보이면서 스탬프 처리 가능
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="거래 내역 검색..."
/>
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<HeavyTransactionList query={deferredQuery} />
</div>
</div>
);
}
useDeferredValue를 사용할 때 memo가 필수인 이유가 있습니다. React는 지연된 값으로 컴포넌트를 재렌더링할 때, 이전 값으로 렌더링한 결과를 재사용할 수 있어야 합니다. memo가 없으면 deferredQuery가 실제로 바뀌지 않아도 부모가 렌더링될 때마다 자식이 함께 렌더링됩니다.
5. Suspense와 useTransition 연동: 로딩 깜박임 없애는 전환 패턴
useTransition과 Suspense를 함께 사용하면 데이터 로딩 중 발생하는 로딩 UI 깜박임을 제거할 수 있습니다. 이 패턴이 React 18의 가장 강력한 UX 개선 중 하나입니다.
Suspense 없이 탭을 전환할 때의 흐름은 다음과 같습니다. 탭 클릭 → 즉시 빈 상태 또는 스피너 표시 → 데이터 로드 완료 → 콘텐츠 표시. 이 흐름에서 사용자는 전환할 때마다 레이아웃이 무너지는 경험을 합니다.
startTransition으로 탭 전환을 Transition으로 표시하면 React는 다른 방식으로 동작합니다. 탭 클릭 → React가 새 탭 렌더링을 시작하지만 커밋하지 않음 → 데이터 준비 중에는 이전 탭 콘텐츠를 유지 → 새 탭의 Suspense가 해소되면 한 번에 전환. 사용자는 "이전 콘텐츠 → 새 콘텐츠"로 자연스럽게 이동합니다.
이 패턴은 RSC(React Server Components) 환경에서도 동일하게 작동합니다. Next.js App Router에서의 router.push()도 내부적으로 Transition으로 처리되어 페이지 전환 중 이전 페이지가 유지됩니다. waylog에서 다룬 React Server Components 아키텍처와 결합하면 서버 데이터 패칭 중에도 현재 UI를 유지하는 패턴을 구현할 수 있습니다.
Suspense의 fallback이 표시되는 경우와 표시되지 않는 경우를 명확히 구분해야 합니다. 초기 마운트 시에는 항상 fallback이 표시됩니다. 그러나 startTransition 안에서 상태가 변경되어 이미 마운트된 Suspense 경계가 다시 suspend되는 경우에는, React는 fallback으로 전환하지 않고 이전 콘텐츠를 유지한 채 isPending을 true로 표시합니다. 이 동작이 로딩 깜박임을 방지하는 핵심 메커니즘입니다.
6. Scheduler 패키지 직접 들여다보기: 태스크 큐와 Time Slicing
React의 Concurrent 렌더링을 실질적으로 가능하게 하는 것은 별도 패키지인 scheduler입니다. github.com/facebook/react의 Scheduler.js 소스를 보면 React와 독립적으로 동작하는 태스크 스케줄러임을 확인할 수 있습니다.
Scheduler는 MessageChannel을 사용해 태스크를 다음 태스크 큐(macrotask)로 미룹니다. setTimeout(0)을 쓰지 않는 이유는 MessageChannel이 브라우저에서 더 빠른 콜백 실행을 보장하기 때문입니다. Scheduler는 각 작업에 만료 시간(expirationTime)을 부여하고, 현재 처리 중인 작업이 5ms를 초과하면 제어권을 브라우저로 반환합니다. 이것이 "Time Slicing"입니다.
Scheduler 내부에는 두 개의 힙(heap)이 있습니다. taskQueue는 즉시 실행 가능한 작업들을 만료 시간 오름차순으로 정렬하는 최소 힙입니다. timerQueue는 아직 시작 시간이 되지 않은 지연된 작업들을 보관합니다. React는 렌더링 작업을 이 스케줄러에 등록하고, 스케줄러가 적절한 타이밍에 React에게 "지금 렌더링해도 된다"는 신호를 보냅니다.
우선순위 수준은 다섯 가지로 정의되어 있습니다.
| 우선순위 | 만료 시간 | 사용 사례 |
|---|---|---|
| Immediate | -1 (즉시) | 사용자 입력, 포커스 이벤트 |
| UserBlocking | 250ms | 클릭, 드래그 |
| Normal | 5,000ms | 기본 업데이트, 데이터 패칭 |
| Low | 10,000ms | 분석 이벤트 |
| Idle | maxInt | 사전 렌더링 |
startTransition으로 표시한 업데이트는 기본적으로 Normal 우선순위로 처리됩니다. 즉각적인 사용자 입력(Immediate, UserBlocking)이 들어오면 Scheduler가 현재 진행 중인 Normal 우선순위 작업을 중단하고 높은 우선순위 작업을 먼저 실행합니다.
7. React DevTools Profiler로 Concurrent 효과 측정하기
Concurrent 렌더링의 효과를 눈으로 확인하려면 React DevTools의 Profiler 탭을 활용해야 합니다. Profiler는 각 렌더링 사이클의 시작 시간, 소요 시간, 어떤 컴포넌트가 렌더링됐는지를 기록합니다.
Concurrent 렌더링을 적용하기 전과 후를 Profiler로 비교하면 두 가지 차이가 두드러집니다. 첫째, Transition이 적용된 렌더링은 Profiler 타임라인에서 여러 개의 짧은 청크로 나뉘어 보입니다. startTransition 없이 동기 렌더링할 때는 하나의 긴 청크로 표시됩니다. 둘째, Transition 렌더링 중에 사용자 입력이 들어오면 해당 입력에 대한 새 렌더링 기록이 중간에 삽입되어 있는 것을 볼 수 있습니다.
Profiler를 활용할 때 "왜 이 컴포넌트가 렌더링됐는가"를 확인하는 것이 중요합니다. Profiler에서 컴포넌트를 클릭하면 렌더링 원인(props changed, hooks changed, parent rendered)을 보여줍니다. useDeferredValue를 잘못 적용한 경우 deferredQuery가 변경되지 않았는데도 자식 컴포넌트가 매번 렌더링되는 패턴을 발견할 수 있습니다. 이는 memo 누락 또는 콜백 함수 참조 불안정 문제로 이어집니다.
INP(Interaction to Next Paint) 관점에서 Concurrent 렌더링 효과를 측정하려면 Chrome DevTools의 Performance 패널을 Profiler와 함께 사용해야 합니다. 이 측정 방법에 대한 더 구체적인 내용은 INP 최적화 실전 가이드에서 다루고 있습니다.
8. 실무 사례: 대형 목록 필터링에 useTransition 적용기
우리 팀이 운영하는 어드민 대시보드는 거래 내역 20만 건 이상을 클라이언트에서 필터링합니다. 서버 사이드 필터링으로 전환하면 근본적으로 해결되지만, 오프라인 모드 지원과 즉각적인 멀티 필터 조합 때문에 클라이언트 필터링을 유지해야 했습니다.
초기에는 필터 변경 시마다 전체 20만 건을 동기 필터링하여 UI 렌더링까지 연결했습니다. 필터 클릭에서 결과 표시까지 800ms 이상 걸리는 경우도 있었고, 그 동안 체크박스 선택 표시조차 지연됐습니다.
적용한 패턴은 크게 세 단계로 정리됩니다.
import { useTransition, useState, useMemo, memo } from 'react';
// 1. 가상화된 목록 컴포넌트는 memo로 안정화
const VirtualizedTransactionList = memo(function VirtualizedTransactionList({
transactions,
}: {
transactions: Transaction[];
}) {
// react-window 또는 @tanstack/react-virtual 기반 가상 목록
return <div className="list-container">{/* 가상화 렌더링 */}</div>;
});
// 2. 필터 상태를 두 레이어로 분리
function TransactionDashboard({ allTransactions }: { allTransactions: Transaction[] }) {
// UI 레이어: 즉시 반영 (SyncLane)
const [filterUI, setFilterUI] = useState({
category: 'all',
status: 'all',
searchQuery: '',
});
// 데이터 레이어: Transition으로 지연 (TransitionLane)
const [filterData, setFilterData] = useState(filterUI);
const [isPending, startTransition] = useTransition();
const handleFilterChange = (key: string, value: string) => {
// 즉시: 필터 UI 상태 업데이트 (체크박스, 드롭다운 선택 표시)
const next = { ...filterUI, [key]: value };
setFilterUI(next);
// 지연: 실제 데이터 필터링 트리거
startTransition(() => {
setFilterData(next);
});
};
// 3. 필터링 계산은 filterData 기준으로만 수행
const filteredTransactions = useMemo(() => {
return allTransactions.filter((tx) => {
if (filterData.category !== 'all' && tx.category !== filterData.category) return false;
if (filterData.status !== 'all' && tx.status !== filterData.status) return false;
if (filterData.searchQuery && !tx.description.includes(filterData.searchQuery)) return false;
return true;
});
}, [allTransactions, filterData]);
return (
<div>
<FilterPanel
filter={filterUI}
onFilterChange={handleFilterChange}
isPending={isPending}
/>
<div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 0.15s' }}>
<VirtualizedTransactionList transactions={filteredTransactions} />
</div>
</div>
);
}
이 패턴에서 filterUI와 filterData를 분리하는 이유가 핵심입니다. 체크박스 선택 표시는 filterUI를 보므로 즉시 반영됩니다. 실제 20만 건 필터링은 filterData가 바뀔 때만 일어나고, 이는 Transition으로 표시되어 더 높은 우선순위 작업에 의해 언제든 중단될 수 있습니다. 사용자가 여러 필터를 빠르게 변경하면 중간 filterData 업데이트들은 취소되고 최종 값으로만 필터링됩니다. 이런 상태 분리 패턴은 모던 React 상태 관리 글에서도 다룬 "UI 상태와 서버 상태의 분리"와 같은 사고방식의 연장선입니다.
9. 함정과 주의사항: useTransition 내부에서 setState를 잘못 섞으면 생기는 일
useTransition을 처음 사용할 때 가장 흔히 마주치는 실수는 Transition 콜백 안에 긴급 업데이트를 함께 넣는 것입니다.
// 잘못된 패턴: 두 setState가 모두 Transition으로 표시됨
startTransition(() => {
setIsSelected(true); // UI 피드백이 지연되어서는 안 되는 업데이트
setFilteredList(heavyFilter(items)); // 무거운 계산 결과
});
// 올바른 패턴: 긴급 업데이트와 지연 업데이트를 명확히 분리
setIsSelected(true); // Transition 밖: SyncLane으로 즉시 처리
startTransition(() => {
setFilteredList(heavyFilter(items)); // TransitionLane: 지연 가능
});
두 번째 주의사항은 startTransition 안에서 외부 (React 외부) 뮤테이션을 수행하는 것입니다. React는 Transition 렌더링을 중단하고 재시작할 수 있습니다. 콜백 안에서 ref.current를 변경하거나 전역 변수를 업데이트하면, 렌더링이 재시작될 때 해당 뮤테이션이 중복 실행됩니다.
세 번째 함정은 useTransition을 비동기 로직의 대체제로 오해하는 것입니다. startTransition은 상태 업데이트의 우선순위를 낮출 뿐, 비동기 처리를 하지는 않습니다. 무거운 동기 계산(예: 20만 건을 한 번에 filter)을 startTransition 안에 넣어도 해당 JavaScript 실행 자체는 여전히 메인 스레드를 점유합니다. Time Slicing은 React의 렌더 트리 순회를 쪼개는 것이지, 일반 JavaScript 코드 실행을 중단하지는 않습니다.
매우 무거운 동기 계산이 병목이라면 startTransition만으로는 부족합니다. Web Worker로 계산을 오프로드하거나, 청크 단위로 나누어 scheduler.postTask를 활용해야 합니다. useTransition은 React 렌더링 우선순위를 조정하는 도구이고, 계산 자체를 분산하는 도구가 아닙니다.
또한 useTransition은 'use client' 컴포넌트에서만 사용 가능합니다. React Server Components 안에서는 훅 자체를 사용할 수 없으며, 서버-클라이언트 경계를 통과하는 Transition은 Next.js의 라우터 레벨에서 처리됩니다.
10. Next.js App Router에서의 Concurrent 렌더링 호환성
Next.js App Router는 React 18의 Concurrent 기능을 전제로 설계되었습니다. createRoot를 사용해 React를 초기화하므로, 모든 클라이언트 컴포넌트에서 useTransition과 useDeferredValue를 바로 사용할 수 있습니다.
App Router에서 중요한 동작 방식은 네비게이션입니다. router.push()나 <Link>로 페이지를 이동할 때 Next.js는 내부적으로 startTransition을 사용해 라우트 전환을 처리합니다. 덕분에 새 페이지 데이터를 fetching하는 동안 이전 페이지 UI가 유지되고, 페이지 이동 중에도 현재 페이지의 인터랙션이 차단되지 않습니다.
Server Actions와의 연동에서는 주의가 필요합니다. Server Action 호출 자체는 Transition이 아닙니다. Server Action을 실행하고 그 결과로 상태를 업데이트할 때, 해당 업데이트를 Transition으로 표시하려면 startTransition 안에서 Server Action을 호출해야 합니다.
'use client';
import { useTransition } from 'react';
import { updateTransactionStatus } from '@/app/actions/transaction';
function StatusToggle({ transactionId, currentStatus }: {
transactionId: string;
currentStatus: 'active' | 'archived';
}) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
const nextStatus = currentStatus === 'active' ? 'archived' : 'active';
startTransition(async () => {
// Server Action 호출을 Transition으로 래핑
// Next.js App Router에서 Server Action은 async 함수이므로
// startTransition이 async 콜백을 지원하는 React 18.3+ 기준으로 동작
await updateTransactionStatus(transactionId, nextStatus);
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
>
{isPending ? '처리 중...' : currentStatus === 'active' ? '보관' : '활성화'}
</button>
);
}
Streaming SSR 환경에서 Concurrent 렌더링은 더 큰 의미를 가집니다. 서버에서 스트리밍으로 HTML을 내려보내는 동안, 클라이언트에서 hydration이 완료된 컴포넌트부터 인터랙션이 가능해집니다. 이 "선택적 hydration(Selective Hydration)"은 Concurrent 기능이 없으면 구현할 수 없는 패턴입니다. 사용자가 hydration이 완료되지 않은 컴포넌트를 클릭하면, React는 해당 컴포넌트를 우선 hydrate한 뒤 클릭 이벤트를 처리합니다. 이 흐름 전체가 Concurrent 렌더링과 Lane 모델 위에서 동작합니다.
결론
Concurrent 렌더링은 React가 "얼마나 빨리 렌더링하는가"에서 "어떤 렌더링을 먼저 할 것인가"로 질문 자체를 바꾼 모델입니다. Lane 비트마스크, Scheduler의 Time Slicing, Transition 우선순위는 모두 하나의 목표를 향합니다. 사용자의 입력에 대한 즉각적인 반응을 보장하면서, 무거운 UI 업데이트는 브라우저가 여유 있을 때 처리하는 것.
useTransition과 useDeferredValue는 그 목표를 달성하는 두 가지 접근로입니다. 상태 업데이트의 발생 지점을 제어할 수 있다면 useTransition, 외부에서 오는 값의 전파 속도를 조절해야 한다면 useDeferredValue를 선택합니다.
실무 체크리스트:
startTransition콜백 안에는 지연 가능한setState만 넣고, 즉각 반응이 필요한 UI 업데이트는 반드시 밖에 배치한다.useDeferredValue를 사용하는 자식 컴포넌트는memo로 감싸지 않으면 최적화 효과가 없다.- Transition은 React 렌더링 우선순위 조정 도구이므로, 무거운 동기 JavaScript 계산은 별도로 Web Worker 또는 청크 분할로 처리해야 한다.
startTransition콜백은 동기 함수여야 한다. 비동기setState는 Transition으로 분류되지 않는다(React 18.3+에서 async 콜백 지원이 일부 추가되었으나, Server Action 연동 패턴에서만 활용한다).- DevTools Profiler와 Chrome Performance 패널을 함께 사용해 Transition 전후 렌더링 청크 분할 여부와 사용자 입력 처리 타이밍을 실측으로 검증한다.