useTransition (Async Support) vs useDeferredValue: 동시성 렌더링의 완성

크리스가 만든 검색 기능에는 치명적인 문제가 있다.
사용자가 검색어(query)를 입력할 때마다 10,000개의 아이템을 필터링해서 리스트를 다시 그린다.
// ❌ 렉 걸리는 검색창
function SearchApp() {
const [query, setQuery] = useState('');
const handleChange = (e) => {
// 입력값 업데이트 (긴급)
setQuery(e.target.value);
// 리스트 필터링 로직이 실행되면서 메인 스레드를 점유함 -> 입력창이 버벅거림
};
return (
<div>
<input value={query} onChange={handleChange} />
<HeavyList query={query} />
</div>
);
}타자를 칠 때마다 화면이 뚝뚝 끊긴다. 크리스는 "디바운스(Debounce)를 써야 하나?"라고 생각했지만, 디바운스는 입력이 끝난 후에 반응하므로 검색 경험이 굼뜨게 느껴진다.
리액트 18부터 도입된 동시성(Concurrency) 훅들은 이 문제를 근본적으로 해결한다. 바로 "중요한 업데이트는 즉시 처리하고, 무거운 업데이트는 나중에(Interruptible) 처리하는 것"이다.
오늘은 이 마법을 부리는 두 가지 도구, useTransition과 useDeferredValue를 비교하고, React 19에서 추가된 비동기 지원 기능까지 알아본다.
1. useTransition: "급한 것 먼저 하세요"
useTransition은 상태 업데이트를 "긴급하지 않음(Non-urgent)"으로 표시하는 훅이다.
우리의 상황에서:
크리스는 리스트 업데이트를 startTransition으로 감싸기로 했다.
// ✅ useTransition 적용
import { useState, useTransition } from 'react';
function SearchApp() {
const [query, setQuery] = useState('');
const [listQuery, setListQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
// 1. 긴급 업데이트: 입력창은 즉시 반응
setQuery(value);
// 2. 전환 업데이트: 리스트 필터링은 뒷전으로 미룸
startTransition(() => {
setListQuery(value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{/* isPending으로 로딩 상태 표시 가능 */}
{isPending && <span className="spinner">필터링 중...</span>}
{/* 리스트는 listQuery를 바라본다 */}
<HeavyList query={listQuery} />
</div>
);
}이제 사용자가 타자를 빠르게 치면, 리액트는 setQuery만 부지런히 처리하고 startTransition 내부의 작업은 건너뛴다. 타자가 멈추면 그제야 최신 값으로 리스트를 한 번만 그린다. 입력창이 절대 버벅거리지 않는다.
React 19의 진화: 비동기 지원 (Async Transitions)
React 19부터는 startTransition 안에 비동기 함수(Async/Await)를 넣을 수 있게 되었다!
이것은 앞서 배운 useActionState의 기본 원리이기도 하다.
// React 19: 비동기 작업도 Transition으로 처리
const handleClick = () => {
startTransition(async () => {
// API 호출이 끝날 때까지 자동으로 isPending이 true가 된다.
await updateProfile(name);
});
};이제 별도의 isLoading state를 만들지 않아도, 비동기 작업 중 UI를 제어할 수 있다.
2. useDeferredValue: "이 값은 나중에 쓸게요"
useDeferredValue는 useTransition과 목적은 같지만 사용법이 다르다.
useTransition은 상태를 업데이트하는 함수(Setter)를 감싸는 반면, useDeferredValue는 이미 바뀐 값(Value) 자체를 감싼다.
주로 Props로 전달받은 값을 지연시키고 싶을 때 유용하다.
// ✅ useDeferredValue 적용
import { useState, useDeferredValue } from 'react';
function SearchApp() {
const [query, setQuery] = useState('');
// query는 즉시 바뀌지만, deferredQuery는 리액트가 여유 있을 때 바뀐다.
const deferredQuery = useDeferredValue(query);
return (
<div>
{/* 입력창은 원본 query를 사용하여 즉각 반응 */}
<input value={query} onChange={e => setQuery(e.target.value)} />
{/* 리스트는 지연된 값을 사용 */}
{/* deferredQuery가 업데이트되기 전까지는 이전 리스트를 계속 보여줌 */}
<HeavyList query={deferredQuery} />
</div>
);
}코드가 훨씬 간단하다. startTransition을 쓸 수 없는 상황(예: 라이브러리가 주는 값을 그대로 쓸 때)에서 매우 유용하다.
3. 무엇을 써야 할까? (Comparison)
두 훅 모두 "렌더링 우선순위 조절"이라는 같은 목표를 가진다.
| 특징 | useTransition | useDeferredValue |
| 제어 대상 | State 업데이트 함수 (setState) | 값 (props, 변수) |
| 사용 위치 | 상태를 변경하는 곳 (이벤트 핸들러) | 값을 사용하는 곳 (자식 컴포넌트, Props) |
| 장점 | isPending 상태를 제공하여 로딩 UI 구현 가능 | 코드가 간결함, useEffect 의존성 배열에 넣기 좋음 |
| 비유 | "이 작업은 천천히 처리해" (명령) | "이 값은 덜 신선해도 돼" (데이터) |
크리스의 선택 가이드:
4. 디바운스(Debounce)와는 무엇이 다른가?
따라서 렌더링 최적화에는 동시성 훅이 디바운스보다 훨씬 사용자 친화적(UX)이다. (단, API 요청을 줄이는 게 목적이라면 여전히 디바운스가 필요하다.)
핵심 정리
이제 렌더링 성능까지 잡았다.
내장 훅 마스터리의 마지막 단계는 "외부 세계와의 동기화"다.
useEffect로 window.resize 이벤트를 구독하거나, Redux/Zustand 같은 외부 스토어를 만들 때 발생하는 티어링(Tearing, 화면 찢어짐) 문제를 아는가?
"useSyncExternalStore & useId: 라이브러리 개발자와 SSR을 위한 필수 훅" 편에서 계속된다.