이전 버전의 React(17 이하)에서 렌더링은 ‘동기적’이고 ‘중단 불가능한’ 작업이었다. 한 번 렌더링이 시작되면, 그 작업이 끝날 때까지 메인 스레드는 블로킹된다. 이는 복잡한 UI를 계산하는 동안 사용자가 버튼을 클릭하거나 타이핑을 해도 애플리케이션이 즉각 반응하지 못하는 멈춤 현상의 주원인이었다.
React 18은 이러한 문제를 해결하기 위해 동시성 렌더링을 도입했다. 핵심은 렌더링 자체를 빠르게 하는 것이 아니라, 렌더링을 중단하고 중요한 작업을 먼저 처리할 수 있는 ‘반응성’을 확보하는 데 있다.
매 상태 변경 마다 지나치게 무거운 렌더링 작업이 실행되는게 아니라면 크게 사용할 일은 없을 것이다. 다만 그런 상황에 대한 해결 방안이 생겼다는 것이 중요한 부분이다.
React 18은 상태 업데이트를 우선순위에 따라 두 가지로 분류한다.
Urgent updates (긴급 업데이트): 사용자의 직관적인 행동과 관련된 업데이트 (예: 타이핑, 클릭, 드래그). 즉각적인 반응이 없으면 사용자는 앱이 고장 났다고 느낀다.
Transition updates (전환 업데이트): 화면의 전환이나 데이터 시각화처럼 즉각적이지 않아도 되는 업데이트 (예: 검색 결과 목록 렌더링, 탭 전환).
대표적인 예로 검색 UI를 들 수 있다. 검색창에 글자를 입력하는 행위는 Urgent하게 처리되어야 하고, 입력값에 따라 아래에 추천 검색어를 띄워주는 행위는 Transition으로 처리되어야 한다. 추천 검색어 렌더링 때문에 타이핑이 끊기는 경험은 치명적인 UX 저하를 야기하기 때문이다.
React 18은 무거운 작업을 Transition으로 분류하여, 긴급한 상호작용이 발생하면 진행 중이던 렌더링 작업을 일시 중단하거나 폐기하고 급한 작업부터 처리한다. 이를 제어하기 위해 3가지 주요 API가 도입되었다.
startTransition
useTransition
useDefferedValue
startTransition & useTransition
startTransition은 특정 상태 업데이트를 Transition(낮은 우선순위)으로 마킹하는 API다. 함수형 컴포넌트에서는 주로 isPending 상태값과 함께 useTransition 훅을 사용한다.
import { useState, useTransition } from 'react';
const TabContainer = () => {
const [tab, setTab] = useState('about');
const [isPending, startTransition] = useTransition();
const selectTab = (nextTab) => {
// 탭 변경 상태 업데이트를 낮은 우선순위로 감싼다.
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
{/* isPending을 통해 구형 데이터가 유지되는 동안 로딩 상태를 보여줄 수 있다 */}
{isPending && <Spinner />}
<TabContent tab={tab} />
</div>
);
}이 코드가 적용되면, 사용자가 탭을 전환하는 도중 다시 다른 탭을 클릭할 경우 React는 이전 탭의 렌더링을 즉시 중단하고 최신 요청을 처리한다. 결과적으로 앱은 항상 반응하는 상태를 유지한다.
useDefferedValue
useTransition이 상태 업데이트를 감싸는 방식이라면, useDeferredValue는 값 자체의 업데이트를 지연시킨다. 주로 props로 전달받은 데이터나, 직접 제어할 수 없는 상태를 기반으로 파생된 값을 다룰 때 유용하다.
import { useState, useDeferredValue } from 'react';
const SearchPage = () => {
const [query, setQuery] = useState('');
// query는 즉시 업데이트되지만, deferredQuery는 여유가 있을 때 업데이트된다.
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* 무거운 리스트 컴포넌트에는 지연된 값을 전달한다 */}
<HeavyResultList query={deferredQuery} />
</>
);
}Debounce/Throttle과의 차이
useDeferredValue는 흔히 Debounce(일정 시간 대기)와 비교되지만, 동작 원리가 다르다.
Debounce: 무조건 정해진 시간(예: 300ms)을 기다린다. 고사양 기기에서도 불필요한 딜레이가 발생한다.
useDeferredValue: 사용자의 기기 성능에 따라 적응형으로 동작한다. 기기 성능이 좋아 메인 스레드가 여유로우면 지연 없이 즉시 렌더링하고, 부하가 크면 프레임을 드랍하지 않도록 렌더링을 미룬다.
올바른 Pending 처리
useDeferredValue를 사용할 때 데이터가 최신화되었는지 확인하려면, 원본 값과 지연된 값을 비교해야 한다.
// 두 값이 다르다면, 현재 React가 백그라운드에서 deferredQuery를 업데이트하는 중이다.
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<HeavyResultList query={deferredQuery} />
</div>
);주의사항
동시성 렌더링은 렌더링을 포기하거나 지연시키는 것이므로 사이드 이펙트 관리에 주의해야 한다. 특히 useDeferredValue로 전달된 값이 API 호출의 useEffect 의존성 배열에 들어갈 경우, 사용자의 입력마다 API가 호출될 위험이 있다.
따라서 네트워크 요청과 같이 비용이 발생하는 작업에는 여전히 Debounce를 병행하거나, React Query와 같은 데이터 페칭 라이브러리의 캐싱 전략을 함께 사용하는 것이 안전하다.

