React 18: useTransition을 이용한 디바운스/스레드 락 최적화
- 원인: 디바운스는 상태 업데이트 "시점"만 늦출 뿐, 렌더링 비용이 큰 연산이 한꺼번에 몰리면 그 순간 메인 스레드가 그대로 잠김
- 해결:
useTransition으로 입력값 업데이트는 긴급 처리, 무거운 리스트 재계산은startTransition안에 넣어 비긴급으로 분류 - 주의: 총 연산량 자체가 줄어드는 건 아니라서, 렌더링 비용이 진짜로 비싼 CPU 바운드 작업에만 효과적임
setQuery(e.target.value); // 긴급: 즉시
startTransition(() => setDeferredQuery(e.target.value)); // 비긴급
↓ 왜 이런 문제가 나는지 원리 보기
실시간 대용량 트래픽을 관제하는 모니터링 대시보드처럼, 수천 개의 액티브 커넥션 노드를 필터링하는 검색창을 만들다 보면 입력하자마자 브라우저가 순간적으로 굳어버리며 키보드 타자가 수 초 뒤에나 한꺼번에 밀려 입력되는 현상을 마주치게 됩니다. 검색 쿼리가 바뀔 때마다 무거운 차트 컴포넌트와 노드 그리드가 동기적으로 리렌더링되며 싱글 스레드인 브라우저의 메인 스레드를 완전히 독점해 버리는 것이 원인입니다.
대표적인 최적화 기법인 디바운스(Debounce)를 먼저 떠올리기 쉽지만, 이는 렌더링 횟수만 지연시킬 뿐 한 번 렌더링이 시작될 때 발생하는 메인 스레드 프리징(Thread Lock) 자체는 해결해 주지 못합니다. React 18의 Concurrency(동시성) Feature인 useTransition을 쓰면 렌더 큐의 우선순위를 재조정해서 입력 응답성을 확보할 수 있습니다. 디바운스의 한계와 이를 동시성 렌더링으로 해결하는 방법, 그리고 트레이드오프를 정리합니다.
lodash.debounce가 메인 스레드 락을 풀지 못한 이유
전통적인 웹 성능 최적화에서 입력 이벤트 제어에는 주로 debounce가 호출되곤 합니다. 입력이 멈춘 후 일정 시간(예: 300ms) 뒤에 상태를 업데이트하게 만드는 것이죠. 하지만 이런 대시보드처럼 한 번의 리렌더링 비용이 극도로 큰 CPU 바운드 작업에서는 디바운스가 오히려 사용자 경험을 망치게 됩니다.
키보드를 빠르게 입력할 때는 렉이 안 걸리는 듯하다가, 입력을 멈추는 순간 300ms 뒤에 메인 스레드가 락(Lock)에 걸리며 화면 전체가 뚝뚝 끊겼습니다. 즉, 디바운스는 단지 렌더링의 '시점'을 뒤로 미루는 임시방편일 뿐이며, 브라우저가 화면을 그리는 동기적(Synchronous) 렌더링 연산 그 자체를 쪼개거나 멈추지는 못한다는 한계를 지닙니다.
lodash.debounce는 상태 업데이트 시점만 뒤로 미룰 뿐이라, 미뤄둔 시점에 무거운 렌더링이 한꺼번에 몰리면 그때 프레임이 멈춥니다. 반면 useTransition은 무거운 연산을 비긴급 작업으로 분류해서, 타이핑 같은 긴급한 작업에 먼저 렌더링 주도권을 양보하고 나머지는 백그라운드에서 나눠 처리합니다.
startTransition을 통한 렌더링 우선순위 강제 조정
React 18의 useTransition 훅은 isPending과 startTransition 두 개의 값을 반환합니다. 가장 중요한 동작 방식은 "타이핑에 따른 입력값 업데이트(긴급)"와 "필터링된 노드 그리드 다시 그리기(비긴급)"로 렌더링 작업 단위를 이원화하는 것입니다.
import { useState, useTransition } from 'react';
function DashboardSearch() {
const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
// 1. 긴급한 업데이트: 입력창 반응 속도를 유지하기 위해 즉시 반영
setQuery(e.target.value);
// 2. 비긴급한 업데이트: 백그라운드에서 천천히 처리해도 좋은 작업으로 위임
startTransition(() => {
setDeferredQuery(e.target.value);
});
};
return (
<div>
<input type="text" value={query} onChange={handleSearch} placeholder="서버 노드 검색..." />
{isPending && <p style={{ color: '#ff5f2e' }}>실시간 트래픽 연산 데이터 갱신 중...</p>}
<HeavyNodeList query={deferredQuery} />
</div>
);
}
이렇게 분류하면 유저가 타이핑을 계속 이어나갈 때 React는 setDeferredQuery의 연산을 임시로 중단하고(Interrupt) 키보드 이벤트 응답과 입력창 렌더링을 최우선으로 처리합니다. 타이핑이 멈춰야만 비로소 백그라운드에서 HeavyNodeList를 재연산하게 되므로 렉이 크게 줄어듭니다.
Debounce vs useTransition 결정적 차이
성능 최적화 기법의 목적과 실무 적용 트레이드오프 비교입니다.
| 최적화 기법 | 핵심 제어 방식 | 적용 추천 시나리오 |
|---|---|---|
| Debounce | 요청/이벤트 빈도 지연 (시간 지연 처리) | 디스크 I/O가 동반되는 API 호출 (네트워킹 절약) |
| useTransition | UI 렌더링 연산 우선순위 쪼개기 및 양보 (동시성 처리) | 렌더링 연산 비용이 매우 비싼 CPU 바운드 작업 |
다만 useTransition에도 트레이드오프는 명확히 존재합니다. 리렌더링에 소요되는 총 CPU 자원의 양 자체가 줄어드는 것은 아니며, 화면 갱신이 백그라운드에서 진행되는 동안 이전 렌더링 상태를 다소 길게 쥐고 있어야 하므로 메모리 오버헤드가 동반될 수 있습니다. 따라서 신중하게 컴포넌트를 쪼개고 프로파일링하며 활용해야 합니다.
처음 이 훅을 도입했을 때는 debounce를 걷어내면 만사 해결될 줄 알고 검색 입력 전체를 startTransition으로 감쌌는데, 오히려 타이핑 자체가 살짝 밀리는 느낌이 들었습니다. 알고 보니 입력값 자체(query)까지 트랜지션 안에 넣어버려서 정작 즉시 반영돼야 할 텍스트 입력마저 우선순위가 밀리고 있었습니다. 입력값과 무거운 렌더링 대상을 분리하고 나서야 원래 의도대로 동작했습니다.
React 18 동시성 렌더링과 useTransition의 스케줄링 원리는 공식 문서에서 더 자세히 확인할 수 있습니다.
React가 렌더링 우선순위를 스케줄링하는 과정은 결국 자바스크립트 이벤트 루프와 마이크로태스크 큐의 실행 순서 제어 방식과 맞닿아 있습니다. 이 로우레벨 구조가 궁금하다면 [호마다의 IT 개발 입문 블로그] 의 이벤트 루프 글도 함께 참고해보세요.
댓글
댓글 쓰기