렌더 레인과 스케줄러를 통한 우선순위 기반 렌더링 이해
이전에 [ 리액트 파이버를 통한 리랜더링 이해 ] 포스트를 통해 리액트가 어떻게 랜더링을 최적화하는지 살펴보았다. 이때 개인적으로는 아쉬움이 살짝 남았는데, 리액트가 랜더링을 효율적으로 처리하는 구조는 이해했지만, 언제 어떤 작업을 우선해서 처리하는지는 전혀 확인할 수 없었기 때문이다. 이번 포스트에서는 그 궁금증을 이어가며, React 18부터 도입된 Concurrent Rendering 환경에서 핵심적인 역할을 수행하는 RenderLane과 React Scheduler에 대해 알아보려 한다. 내부 구현에 가까운 개념이지만, 이를 이해하면 React의 렌더링 스케줄링이 어떤 기준으로 동작하는지, 그리고 실제 사용자 경험에 어떤 영향을 미치는지를 보다 명확하게 파악할 수 있다.
RenderLane
RenderLane은 React의 예약(scheduling) 시스템에서 중요한 요소로, 작업의 렌더링과 우선순위 관리를 효율화하는 데 사용된다. React는 동시에 여러 업데이트가 발생할 수 있는 환경에서도 성능 저하 없이 렌더링을 처리하기 위해 각 작업에 고유한 Lane(레인)을 부여한다. 이 Lane은 각각의 작업이 어떤 우선순위를 가지는지, 언제 처리되어야 하는지를 추적하는 메타데이터 역할을 하며, 같은 Lane에 속한 작업은 함께 병합되고, 우선순위가 더 높은 Lane은 먼저 처리된다. 이러한 구조 덕분에 React는 다양한 작업을 동시에 처리하면서도 사용자에게는 자연스럽고 끊김 없는 인터페이스를 제공할 수 있다.
React 19에서 사용되는 주요한 Lane 들은 아래와 같다.
Lane 이름 | 설명 |
SyncLane | 동기 작업용 Lane. React가 즉시 처리해야 하는 가장 높은 우선순위 작업에 사용됨 (예: 초기 렌더링). |
InputContinuousLane | 연속적인 사용자 입력 처리에 사용 (예: 드래그, 스크롤 중 처리되는 상태 업데이트). |
DefaultLane | 기본 우선순위의 작업 (예: 일반적인 상태 업데이트나 UI 변경). |
TransitionLane | startTransition()로 감싸진 비동기 UI 업데이트에 사용. 낮은 우선순위. |
IdleLane | 유휴 시간에 처리되는 가장 낮은 우선순위 작업에 사용 (예: 백그라운드 작업). |
OffscreenLane | 화면에 보이지 않는 컴포넌트의 렌더링에 사용 (예: Suspense를 통한 비동기 프리패칭 등). |
RetryLane | 실패한 렌더링을 다시 시도할 때 사용되는 Lane. |
ErrorLane | 오류 발생 시의 처리에 사용되는 Lane. |
BlockingLane | 레이아웃에 영향을 주는 업데이트로, 사용자 경험에 중요한 일부 블로킹 작업. |
HydrationLane | 서버 사이드 렌더링 후 클라이언트에서 수화(hydration)할 때 사용됨. |
React에서 상태 업데이트가 발생하면, 먼저 해당 작업이 얼마나 급한지에 따라 적절한 Lane이 결정된다. 예를 들어 사용자 입력처럼 즉각적인 반응이 필요한 작업은 SyncLane이나 InputContinuousLane 같은 높은 우선순위의 Lane에 할당되고, startTransition으로 감싼 비동기 업데이트는 TransitionLane, 화면에 보이지 않는 작업은 IdleLane 등으로 분류된다. 이렇게 정해진 Lane 정보는 업데이트 객체에 포함되어 React 내부의 스케줄링 시스템에 전달된다.
React Scheduler는 이 Lane 정보를 기반으로 우선순위가 높은 작업부터 처리하며, 동시에 여러 작업이 있을 경우 병합하여 실행 순서를 조정한다. 이후 스케줄링된 작업은 렌더링 → 커밋 과정을 거치면서 실제로 DOM에 반영된다. 이처럼 React는 Lane 시스템을 활용해 여러 종류의 작업을 동시에 다루되, 사용자 경험에 중요한 작업을 우선 처리함으로써 더 부드럽고 끊김 없는 UI 인터페이스를 제공할 수 있다.
React Scheduler
RenderLane이 업데이트 식별하고 병합한다면, React Scheduler는 그렇게 정해진 Lane들을 기반으로 어떤 작업을 언제 실행할지를 결정하는 조율자 역할을 한다. Scheduler는 현재 실행 가능한 가장 높은 우선순위의 Lane을 선택하고, 해당 작업을 적절한 시점에 실행되도록 큐에 등록한다. 이때 MessageChannel, setTimeout, requestIdleCallback 등을 활용해 브라우저의 이벤트 루프에 맞춰 비동기적으로 작업을 예약하고, 필요한 경우 이전 작업을 중단(preempt)하거나 나중으로 미루는 등의 동작도 수행한다.
우선순위 | 설명 |
ImmediatePriority | 가장 높은 우선순위. 즉시 실행되어야 하는 작업. 예: 동기 상태 업데이트, 에러 처리 등 |
UserBlockingPriority | 사용자 인터랙션과 직접 관련된 작업. 클릭, 키보드 입력 등과 관련된 업데이트. |
NormalPriority | 기본 우선순위. 일반적인 상태 업데이트나 네트워크 응답 처리 등. |
LowPriority | 급하지 않은 작업. 예: 비동기 로딩 중 애니메이션, 레이아웃 측정 등. |
IdlePriority | 유휴 시간에만 실행. 예: 백그라운드 캐싱, 로그 전송 등. |
Scheduler는 이렇게 다양한 우선순위를 활용해 작업을 세밀하게 조절한다. 예를 들어, 사용자의 클릭 이벤트처럼 즉시 반응이 필요한 작업은 UserBlockingPriority로 지정되어 빠르게 실행되며, 렌더링에 직접 영향을 주는 setState 같은 동기 작업은 ImmediatePriority로 즉시 처리된다. 반면, 화면에 직접적인 영향을 주지 않는 백그라운드 작업이나 데이터 로깅 등은 IdlePriority로 지정되어 브라우저가 유휴 상태일 때 실행되므로, 사용자 경험을 방해하지 않는다. 이러한 구조 덕분에 React는 다양한 종류의 작업을 우선순위에 따라 유연하게 처리할 수 있으며, 과도한 렌더링으로 인한 프레임 드랍이나 UI 지연을 방지할 수 있다.
또한, React는 Lane과 Scheduler Priority를 내부적으로 연결하여 업데이트 흐름을 최적화한다. 예를 들어 TransitionLane은 NormalPriority로, IdleLane은 IdlePriority로 매핑되며, 이를 통해 React는 Lane에 맞는 Scheduler 우선순위를 자동으로 계산해준다. 이 매핑 시스템 덕분에 개발자는 직접 우선순위를 지정하지 않아도, React는 상황에 맞게 작업을 스케줄링하고 적절한 타이밍에 실행할 수 있다. 결국 React의 렌더링 최적화 전략은 Lane 시스템을 통해 무엇을 해야 할지 판단하고, Scheduler Priority를 통해 언제 그것을 해야 할지 결정함으로써 이루어진다.
useTransition과 useDeferredValue
이런 스케줄링 체계를 개발자가 직접 활용할 수 있도록 React는 useTransition과 useDeferredValue 같은 훅을 제공한다. useTransition은 사용자 인터랙션과 직접 연결되지 않은 UI 업데이트를 낮은 우선순위(Normal Priority)로 처리할 수 있게 해주며, 내부적으로는 startTransition을 통해 해당 업데이트를 TransitionLane에 할당한다. 이 Lane은 일반적으로 사용자와의 직접적인 인터랙션보다 뒤늦게 처리되도록 스케줄되며, 내부적으로는 Scheduler의 NormalPriority로 실행된다. 덕분에 입력 필드 같은 빠른 피드백이 필요한 UI는 즉시 반응하고, 그에 따라 변화하는 무거운 렌더링 작업은 자연스럽게 뒤로 밀려 실행된다. 예를 들어 검색어 입력 시, 입력 자체는 실시간으로 반영되고, 필터링된 목록은 천천히 업데이트되어 UX를 개선할 수 있다.
반면, useDeferredValue는 이미 발생한 상태 업데이트의 값을 렌더링 시점만 지연(defer)시키는 방식이다. 내부적으로 전달받은 값의 변경을 감지하되, 해당 값의 반영은 우선순위가 낮은 시점, 즉 React가 유휴 상태일 때 일어나도록 설계된다. 이를 위해 React는 이 작업을 일반적으로 IdleLane 혹은 낮은 우선순위의 TransitionLane에 할당하며, Scheduler Priority는 보통 IdlePriority 또는 LowPriority 수준이다. 이로 인해 불필요한 즉각 렌더링을 방지하고, 중요하지 않은 UI 변경 사항을 사용자 경험에 방해되지 않게 미뤄서 처리할 수 있다. 두 훅 모두 React의 Lane 및 Scheduler 시스템과 긴밀하게 연결되어 있어, 복잡한 비동기 UI에서도 부드러운 인터랙션과 안정적인 성능을 동시에 확보할 수 있게 해준다.
간단하게 정리하면 useTransition가 업데이트 자체를 지연시키는 데 반해, useDeferredValue는 이미 변경된 값의 반영을 지연시킨다.
// useTransition 예시
import { useState, useTransition, type ChangeEvent } from 'react';
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
export default function SearchWithTransition() {
const [input, setInput] = useState('');
const [list, setList] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInput(value);
// 무거운 작업을 Transition으로 감쌈
startTransition(() => {
const filtered = items.filter((item) => item.includes(value));
setList(filtered);
});
};
return (
<div>
<input value={input} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Loading...</p>}
<ul>
{list.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
// useDeferredValue 예시
import { useState, useDeferredValue } from 'react';
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
export default function SearchWithDeferredValue() {
const [input, setInput] = useState('');
const deferredInput = useDeferredValue(input);
const filtered = items.filter((item) => item.includes(deferredInput));
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search..."
/>
<ul>
{filtered.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
블로그의 정보
Ayden's journal
Beard Weard Ayden