useTransition과useDeferredValue는 무거운 작업을 빠르게 만들지 않는다. 대신 입력이 끊기지 않게 반응성을 지킨다. 그 원리를 React 렌더링 모델부터 짚는다.
1. 문제 — 렌더링은 "시작하면 못 멈췄다"
React 17까지 렌더링은 동기적이고 중단 불가능했다. state가 바뀌면 화면을 다시 그리는데, 이 작업이 메인 스레드를 독점하고 중간에 멈출 수 없었다.
function Search() {
const [query, setQuery] = useState("");
const results = hugeList.filter((item) => item.includes(query)); // 무거운 렌더
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={results} />
</>
);
}
타이핑을 하면 매 키마다 ① 리렌더 시작 → ② 1만 개 필터링 + <List> 렌더(예: 100ms) → ③ 그동안 메인 스레드가 막혀 다음 키 입력이 씹힌다. 급한 일(타이핑 반영)과 안 급한 일(목록 갱신)이 같은 우선순위로 묶여 있는 게 원인이다.
2. 해결 — 우선순위 + 중단 가능한 렌더링
Concurrent 렌더링은 두 가지를 가능하게 한다.
- 우선순위 구분 — 어떤 업데이트는 "급함(urgent)", 어떤 건 "안 급함(transition)"으로 표시
- 중단 가능(interruptible) — 낮은 우선순위 렌더 도중 급한 업데이트(새 키)가 오면, 하던 렌더를 버리고 급한 것부터 처리한 뒤 다시 시작
즉 무거운 목록 렌더를 잠깐 멈추고 입력에 양보할 수 있다. useTransition과 useDeferredValue가 이걸 쓰는 두 API다.
3. useTransition — "이 업데이트는 안 급해"
startTransition으로 상태 업데이트를 감싸면 그 업데이트가 낮은 우선순위가 된다.
function Search() {
const [query, setQuery] = useState("");
const [list, setList] = useState(hugeList);
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
setQuery(e.target.value); // ① 급함 — 즉시 반영 (입력창이 안 끊김)
startTransition(() => { // ② 안 급함 — 낮은 우선순위
setList(hugeList.filter((item) => item.includes(e.target.value)));
});
};
return (
<>
<input value={query} onChange={onChange} />
{isPending && <Spinner />}
<List items={list} />
</>
);
}
setQuery(급함)는 즉시 처리 → 입력창은 항상 부드럽다setList(transition)는 무거운 렌더 도중 새 키가 오면 버려지고 다시 시작isPending으로 "갱신 중" UI를 보여줄 수 있다
핵심 조건 — startTransition 안에 넣을 수 있는 건 내가 setState를 직접 호출하는 경우다.
4. useDeferredValue — 값의 반영을 한 박자 늦춤
setState를 내가 제어할 수 없을 때(예: 값이 props로 내려옴) 쓴다. 값의 지연된 복사본을 만든다.
function Search({ query }) { // query는 부모가 내려줌 (제어 불가)
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => hugeList.filter((item) => item.includes(deferredQuery)),
[deferredQuery],
);
return <List items={results} />;
}
동작:
query가 바뀌면 먼저 옛deferredQuery로 빠르게 렌더(급한 부분만 반영)- 그다음 낮은 우선순위로 새 값을 반영한 재렌더를 예약
- 그 사이 또 바뀌면 진행 중 렌더를 버리고 최신 값으로 다시
→ 입력창은 query(최신)로 즉시 반응하고, 무거운 목록만 한 박자 늦게 따라온다.
5. 둘의 차이 — 언제 무엇을?
useTransition | useDeferredValue | |
|---|---|---|
| 다루는 대상 | 상태 업데이트(setState) | 값(value) |
| 쓰는 상황 | setState를 내가 호출할 때 | 값이 props로 오는 등 제어 못 할 때 |
| 부가 기능 | isPending 제공 | 없음 (value !== deferred로 직접 비교 가능) |
같은 문제를 "업데이트를 미루느냐 / 값을 미루느냐"라는 두 각도에서 푼다.
6. 핵심 오해 — "빨라지는 게 아니다"
면접에서 중요한 포인트다.
- Concurrent 기능은 무거운 작업을 더 빠르게 만들지 않는다. 1만 개 필터링은 여전히 100ms 걸린다.
- 대신 그 작업을 중단 가능하게, 낮은 우선순위로 돌려 급한 입력이 끼어들 틈을 준다. 즉 총 작업량이 아니라 체감 반응성(responsiveness) 을 개선한다.
또 하나 — useDeferredValue가 효과를 보려면 무거운 자식이 memo로 감싸져 있어야 한다. 그래야 지연된 값이 안 바뀐 렌더에서 자식이 재렌더되지 않고 넘어간다.
const List = memo(function List({ items }) {
/* 무거운 렌더 */
});
7. 정리
React 17까지는 모든 렌더가 같은 우선순위라 무거운 렌더가 입력 반응성을 잡아먹었다. Concurrent 렌더링은 업데이트에 우선순위를 주고 낮은 우선순위 렌더를 중단 가능하게 만든다.
useTransition은 내가 호출하는 setState 를,useDeferredValue는 제어 못 하는 값 을 낮은 우선순위로 미뤄, 무거운 작업 중에도 타이핑이 끊기지 않게 한다. 작업을 빠르게 하는 게 아니라 반응성을 지키는 도구다.
리렌더 자체를 줄이는 메모이제이션과는 결이 다르다. 상태 갱신의 정확성 문제는 클로저와 stale closure, 리렌더 최소화는 불변성과 구조적 공유 글에서 다룬다.