
이전에 업데이트 큐에 대해 이야기하면서 Lanes 모델을 언급드렸는데요. 리액트 18에서는 Lanes 모델의 도입과 함께 다양한 동시성 관련 훅들이 등장했습니다. 그중에서도 useDeferredValue와 useTransition은 렌더링 우선순위를 세분화하고, 보다 부드러운 사용자 경험을 제공하는 데 핵심적인 역할을 합니다.
오늘은 이 두 훅에 대해 이야기해보겠습니다.
리액트 18 이전에는 모든 렌더링이 동기적으로 처리되었습니다. 렌더링 작업이 시작되면 완료될 때까지 중간에 멈추거나 우선순위를 조정할 수가 없었고, 결과적으로 무거운 작업이 있는 경우 사용자 입력이나 애니메이션이 끊기거나 느려지는 문제가 있었습니다.
이러한 문제를 해결하기 위해 리액트 18에서는 동시성 모드가 도입되었고 그 핵심에는 Lanes모델이 있습니다.
리액트 16과 17에서는 ExpirationTime이라는 개념을 사용하여 업데이트 우선순위를 관리했습니다.
export const NoWork = 0; // 아무 작업도 없음
export const Never = 1; // 거의 처리되지 않아도 되는 매우 낮은 우선순위
export const Idle = 2; // 유휴 상태에서 처리
let ContinuousHydration = 3; // 서버사이드 렌더링 hydration 관련
export const Sync = MAX_SIGNED_31_BIT_INT; // 즉시 처리해야 하는 동기 작업
export const Batched = Sync - 1; // 배치로 처리할 작업
하지만 이 모델에는 몇가지 한계가 존재합니다.
예를 들어, 사용자가 입력을 하면서 동시에 다크모드를 켰다고 가정해봅시다. 다크모드는 즉시 반영되어야 하지만, 검색어 입력에 따른 결과는 약간 늦어도 괜찮습니다. 그러나 이 두 업데이트가 같은 시점에 발생하면 동일한
ExpirationTime을 가지게 되어, 리액트는 이 둘을 구분하지 못하고 동일하게 처리합니다.
function addItem(item) {
setItems(prevItems => [...prevItems, item]); // 아이템 추가
setTotalPrice(prevTotal => prevTotal + item.price); // 가격 업데이트
}
```
- 애니메이션, 드래그, 키 입력과 같은 상호작용은 높은 우선순위를 필요로 합니다.
- 데이터 페칭 이후의 렌더링은 중간 우선순위로 처리될 수 있습니다.
- 화면에 보이지 않는 콘텐츠 업데이트는 낮은 우선순위로 처리될 수 있습니다.
export function computeExpirationForFiber(
currentTime: ExpirationTime,
fiber: Fiber,
suspenseConfig: null | SuspenseConfig,
): ExpirationTime {
const mode = fiber.mode;
if ((mode & BlockingMode) === NoMode) {
return Sync;
}
const priorityLevel = getCurrentPriorityLevel();
if ((mode & ConcurrentMode) === NoMode) {
return priorityLevel === ImmediatePriority ? Sync : Batched;
}
// ..
}리액트의 Lanes 모델은 서로 다른 업데이트에 우선순위를 부여하기 위한 내부 메커니즘입니다. Lanes은 비트 필드로 표현되며, 각 비트는 특정 우선순위의 업데이트 집합을 나타냅니다. 비트 마스크 기반 시스템으로 각각의 우선순위를 서로 다른 비트 위치로 표현합니다.
Lanes는 흔히 고속도로의 차선에 비유됩니다. 이 비유가 적절한 이유는 다음과 같습니다.
Lanes의 주요 우선순위
export const SyncLane: Lane =/* */ 0b0000000000000000000000000000001;
export const InputContinuousLane: Lane =/* */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane =/* */ 0b0000000000000000000000000100000;
export const TransitionLane1: Lane =/* */ 0b0000000000000000000000100000000;
export const IdleLane: Lane =/* */ 0b0100000000000000000000000000000;
각 Lane은 다음과 같은 우선순위를 가집니다.
useDeferredValue , useTransition 에서 활용됩니다.Lanes 모델은 리액트 내부의 업데이트 우선순위 처리를 훨씬 유연하게 만들어주며, useDeferredValue와 useTransition 같은 동시성 훅들과 함께 강력한 사용자 경험 개선을 가능하게 합니다. 다음은 이 두 훅을 각각 살펴보며, 실제 렌더링 흐름에서 어떻게 동작하는지 예제를 통해 알아보겠습니다.
useDeferredValue는 값의 업데이트를 지연시켜 UI의 응답성을 유지하는 훅입니다.
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value)
useDeferredValue는 값의 지연된 버전을 반환합니다.리액트로 무거운 데이터를 사용해 화면을 조작하다보면 다음과 같은 상황을 마주할 수 있습니다.
이럴 때 useDeferredValue가 등장합니다. 개인적인 견해로는 자주 바뀌는 값으로 인해 무거운 렌더링이 자주 일어나는 문제를 해결하기 위해 이 훅을 만들지 않았을까 생각합니다. 무거운 렌더링은 나중으로 미루고, 우선 사용자 입력부터 처리하자는 사용자 중심의 UI 설계 철학 아닐까요?.
useTransition보다 더 간단한 API로 우선순위를 관리합니다.)이런 useDeferredValue 도 도입시 고려해야 할 점이 존재합니다.
useDeferredValue는 만능 해결책이 아닙니다. 따라서 다음과 같은 상황에서 고려해보면 좋을 것 같습니다.
자주 비교되는 개념이 바로 debounce입니다. 둘 다 지연을 활용하는 기법이지만, 방식과 목적이 다릅니다.
debounce는 입력 이벤트를 일정 시간 동안 지연시켜 불필요한 호출을 막는 데 초점이 있습니다. 마치 엘리베이터 문이 닫히기 전에 누군가 또 탈지 기다리는 것처럼 일정시간 기다리는 시간이 존재합니다.useDeferredValue는 입력은 즉시 반영하되, 그에 따른 무거운 렌더링 작업만 늦추는 방식입니다. UI 응답성을 지키는 게 핵심입니다.
useDeferredValue

단순히 텍스트를 표시하는 것이 무거운 작업이 아니기에 지연이 발생하지 않습니다. 따라서 100ms 동안 CPU를 점유(while (performance.now() - startTime < 100))하여 무거운 작업임을 나타내고 입력에 따라 500개의 결과를 렌더링하여 부하를 증가시켜봤습니다.
결과적으로 텍스트를 입력하면 입력 필드는 빠르게 업데이트되지만, 검색 결과는 deferredQuery를 사용하므로 지연되어 나타납니다. 이는 리액트가 더 중요한 UI 업데이트(입력 필드)에 우선순위를 두고, 덜 중요한 업데이트(결과 목록)는 나중에 처리하기 때문입니다.
debounce

디바운스는 자바스크립트의 타이머 기능을 활용하여 사용자 입력에 반응하는 방식을 제어합니다. 입력 속도와 관계없이 설정된 시간동안 실행 자체를 지연시키므로 그 기간 동안은 리렌더링이 전혀 발생하지 않습니다.
사용자가 빠르게 연속해서 입력할 경우 타이머가 지속적으로 초기화되어 최종 입력 이후에만 실제 업데이트가 진행됩니다. 이러한 특성 때문에 API 호출과 같은 서버 요청을 효과적으로 줄일 수 있으며, 불필요한 연산 자체를 원천적으로 차단하기 때문에 브라우저의 부담이 크게 감소합니다.
어느 부분에서 useDeferredValue 와 차이가 나는지 느껴지시나요?
useTransition은 UI 업데이트의 우선순위를 관리하는 훅으로, 사용자 경험을 개선하기 위해 지금 당장 처리해야 하는 업데이트와 덜 중요한 업데이트를 구분하기 위해 사용됩니다.

function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value);
startTransition(() => {
setResults(filterItems(allItems, e.target.value)); // 무거운 연산
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending ? <p>검색 중...</p> : null}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
여기서 눈여겨볼 점은 사용자가 타이핑하는 동안 입력창은 지연 없이 즉시 반응하고, 무거운 검색 결과 계산은 startTransition 안에 넣어 우선순위를 낮춘다는 것입니다. 이로 인해 사용자는 타이핑이 막힌다는 느낌 없이 자연스럽게 입력할 수 있습니다.
useTransition은 [isPending, startTransition] 튜플을 반환합니다.startTransition 함수는 콜백으로 전달된 상태 업데이트에 낮은 우선순위(TransitionLane)를 부여합니다.isPending 값은 지연된 상태 업데이트가 완료될 때까지 true를 유지합니다.대규모 상태 업데이트가 UI의 반응성을 저하시키는 문제를 해결하기 위해 만들어졌습니다. 다들 이런 경험 있지 않으신가요?
이런 문제를 해결하기 위해 동시성 모드라는 새로운 패러다임이 등장하고, 핵심 아이디어인 렌더링 작업을 중단 가능하고 우선순위를 부여할 수 있게 만든 것 아닐까요? 저는 동시성 모드라는 것이 단순히 API가 추가됐다라기 보다는 리액트가 사용자 경험을 보는 철학적인 관점이 변화한 것은 아닐까 하는 생각도 들었습니다.
isPending 상태를 통해 전환 과정에서의 피드백을 제공할 수 있어, 사용자는 무슨 일이 일어나고 있는지 알 수 있습니다. 불확실함이 줄어들면 사용자 만족도가 올라갑니다.useTransition에도 주의할 점들이 있습니다.
기본적으로 리액트는 UI 업데이트를 위한 렌더 트리를 하나씩 처리합니다. 그런데 useTransition을 사용하면 다음과 같은 일이 벌어집니다.
1. 사용자가 타이핑을 함 → 즉시 반영 (높은 우선순위 → setQuery)
2. 동시에, 검색 결과를 준비 (낮은 우선순위 → startTransition 안의 setResults, 이 두 개의 상태 변화는 동시에 진행 중입니다.)
3. 따라서 리액트는 이를 위해 다음 두 개의 렌더 트리를 내부적으로 유지합니다.
현재 화면에 보여지고 있는 렌더 트리 → 사용자 입력을 빠르게 반영
Transition 중인 렌더 트리 → 아직 보여주지 않았지만 백그라운드에서 계산 중인 UI
두 훅 모두 내부적으로 React의 Lanes 모델을 활용하여 렌더링 우선순위를 관리하지만, 사용 방식과 목적에 차이가 있습니다.
| 특성 | useTransition | useDeferredValue |
|---|---|---|
| 주요 목적 | 상태 업데이트의 우선순위 낮추기 | 값의 지연된 버전 생성 |
| 제어 방식 | 명시적 - 개발자가 어떤 업데이트를 지연시킬지 결정 | 암시적 - React가 값 업데이트 시점 결정 |
| API 구조 | 함수 기반(startTransition) | 값 기반(반환 값) |
| 상태 표시 | isPending 제공 | 별도 상태 필요 |
| 적합한 사용 사례 | 상태 업데이트 제어가 필요한 경우 | Props나 외부 값에 대응하는 경우 |
| 코드 복잡성 | 약간 높음(상태 업데이트 래핑 필요) | 낮음(값만 전달) |
| Lanes 활용 | TransitionLane 직접 할당 | 내부적으로 TransitionLane 사용 |