
소개
리액트 개발자 대부분은 startTransition에 대해 들어본 적이 있습니다. 하지만 이것이 실제로 어떤 역할을 하는지 제대로 이해하는 사람은 드뭅니다. 언제 사용해야 하는지 아는 사람은 더욱 적습니다. 이제 기초부터 차근차근 설명해 드리겠습니다.
startTransition 없이 리액트가 렌더링하는 방식
startTransition을 이해하려면, 먼저 리액트가 일반적으로 상태 업데이트를 어떻게 처리하는지 알아야 합니다.
setState를 호출하면 리액트는 렌더링을 예약합니다. 렌더링이란 리액트가 컴포넌트 함수를 실행하고, 새로운 요소 트리를 생성한 뒤, 이를 이전 트리와 비교하여 차이점을 DOM에 적용하는 과정을 의미합니다. 이를 "재조정(reconciliation)"이라고 합니다. 재조정이란 단순히 리액트가 무엇이 변경되었는지 파악하는 것을 의미합니다.
여기서 중요한 점이 있습니다. 기본적으로 모든 상태 업데이트는 긴급한(urgent) 작업으로 간주됩니다. 리액트는 모든 업데이트를 동일하게 취급합니다. 업데이트는 순서대로 처리되며, 각 업데이트가 완료되어야만 다음 업데이트가 시작됩니다.
사용자가 검색창에 텍스트를 입력한다고 가정해 봅시다. 키를 누를 때마다 새로운 텍스트를 전달받아 setState가 호출됩니다. 리액트는 컴포넌트를 다시 렌더링합니다. 만약 해당 컴포넌트가 10,000개의 항목으로 구성된 목록을 필터링하는 중이라면, 키를 누를 때마다 10,000개 항목 전체에 대한 전체 렌더링이 트리거됩니다. 리액트는 다음 키 입력을 처리하기 전에 10,000개 항목의 렌더링을 모두 완료해야 합니다.
사용자가 "hello"라고 입력합니다. 키 입력 5회. 렌더링 5회. 각 렌더링은 10,000개의 항목을 처리합니다. 리액트가 목록을 렌더링하느라 바쁘기 때문에 키 입력 사이에 입력 필드가 멈춥니다. 사용자는 텍스트가 한 글자씩 표시되는 대신 덩어리 단위로 나타나는 것을 보게 됩니다.
이것이 바로 쟁크(jank)입니다. "쟁크"란 인터페이스가 끊기거나 반응이 느리게 느껴지는 현상을 말합니다. 이는 메인 스레드가 너무 오래 걸리는 작업으로 인해 차단될 때 발생합니다.
startTransition의 실제 역할
startTransition은 리액트에 특정 상태 업데이트가 긴급하지 않음을 알립니다. 리액트는 이를 백그라운드에서 처리할 수 있으며, 더 중요한 작업이 들어오면 이를 중단할 수 있습니다.
다음은 이에 대한 개념적 모델입니다. 리액트에는 이제 두 개의 레인이 있습니다.
긴급 레인(urgent lane). 입력, 클릭, 키 입력 같은 작업들입니다. 이러한 작업들은 화면을 즉시 업데이트해야 하며, 그렇지 않으면 앱이 제대로 작동하지 않는 것처럼 느껴집니다.
전환 레인(transition lane). 필터링된 목록 업데이트, 큰 차트 렌더링, 검색 결과 처리 같은 작업들입니다. 이러한 작업들도 중요하지만, 사용자가 눈치채지 못할 정도로 몇 프레임 정도는 기다릴 수 있습니다.
setState 호출을 startTransition으로 감싸면, 리액트에게 해당 업데이트를 전환 레인으로 보내라고 지시하는 것입니다.
import { useState, useTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
// 긴급. 입력을 즉시 업데이트합니다.
setQuery(e.target.value);
// 긴급하지 않음. 리액트가 시간이 될 때 필터링된 목록을 업데이트합니다.
startTransition(() => {
setResults(filterItems(e.target.value));
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <span>Filtering...</span>}
<ItemList items={results} />
</div>
);
}
이제 사용자가 "hello"를 입력하면 다음과 같은 일이 발생합니다.
"h" 키를 입력합니다. 리액트는 즉시 입력 필드에 "h"가 표시되도록 업데이트합니다. 그런 다음 전환 레인에서 목록 필터링 작업을 시작합니다.
리액트가 아직 필터링 중인 동안 "e" 키 입력이 도착합니다. 리액트는 "h"에 대한 필터링을 중단합니다. 즉시 입력 필드에 "he"가 표시되도록 업데이트한 다음, 대신 "he"에 대한 필터링을 시작합니다.
사용자는 입력한 키가 즉시 화면에 나타나는 것을 볼 수 있습니다. 리액트가 잠시 숨을 돌릴 틈이 생기면 목록이 업데이트됩니다. 끊김 현상 없이 매끄럽게 진행됩니다.
핵심 단어는 "중단(abandons)"입니다. 리액트는 새로운 업데이트가 들어오면 진행 중인 트랜지션 렌더링을 버릴 수 있습니다. 반드시 완료할 필요는 없습니다. 이를 중단 가능한 렌더링(interruptible rendering)이라고 합니다. startTransition을 사용하지 않으면 리액트는 이를 수행할 수 없습니다. 시작한 작업을 반드시 완료해야만 합니다.
리액트가 렌더링을 중단해야 할 시점을 어떻게 알까
리액트가 렌더링 도중에 작업을 중단할 수 있는 이유가 궁금할 수 있습니다. 그 이유는 리액트 18에서 내부적인 렌더링 방식이 변경되었기 때문입니다.
리액트 18 이전에는 렌더링이 동기식으로 이루어졌습니다. 리액트는 렌더링을 시작하면 완료될 때까지 중단하지 않았습니다. 이 기간 동안 메인 스레드는 계속 차단된 상태였습니다. 렌더링 중에는 어떤 사용자 이벤트도 처리할 수 없었습니다.
리액트 18에서는 동시 렌더링 기능이 도입되었습니다. 리액트는 렌더링 작업을 작은 단위로 나눕니다. 각 단위가 처리된 후에는 더 시급한 작업이 있는지 확인합니다. 시급한 작업이 있다면 현재 작업을 일시 중지하고 시급한 작업을 먼저 처리합니다.
startTransition은 리액트에 “이 업데이트는 일시 중지해도 안전하다”고 알리는 방법입니다. 리액트는 시급한 업데이트를 일시 중지하지 않습니다. 오직 트랜지션만 일시 중지합니다.
useTransition의 isPending 값은 현재 전환이 진행 중인지 여부를 알려줍니다. 이 값을 사용하여 백그라운드에서 대용량 작업이 진행되는 동안 로딩을 표시할 수 있습니다.
실제로 필요한 사용 사례
AI 스트리밍
AI가 토큰을 빠르게 전송합니다. 초당 10~30개 정도의 토큰이 전송될 수 있습니다. 각 토큰이 도착할 때마다 마크다운 문자열이 늘어납니다. 이를 블록 단위로 나누어 다시 렌더링합니다.
startTransition을 사용하지 않으면, 각 토큰이 동기식 렌더링을 트리거합니다. 렌더링에 30ms가 걸리고 토큰이 33ms마다 도착한다면, 렌더링 작업이 쌓이게 됩니다. 브라우저는 화면을 그릴 기회를 전혀 얻지 못하게 되고, 페이지가 멈춰 버립니다.
startTransition을 사용하면 블록 상태 업데이트를 감싸게 됩니다.
useEffect(() => {
const newBlocks = splitIntoBlocks(content);
startTransition(() => {
setBlocks(newBlocks);
});
}, [content]);
토큰 1이 도착합니다. 리액트는 전환 레인에서 새로운 블록을 렌더링하기 시작합니다. 33ms 후에 토큰 2가 도착합니다. 리액트는 토큰 1에 대한 렌더링을 중단하고 토큰 2로 새로 시작합니다. 토큰 3이 도착합니다. 다시 중단합니다. 토큰 전송이 잠시 멈춘 시점에 리액트는 가장 최근의 렌더링을 완료합니다.
사용자는 가장 최근의 상태를 보게 됩니다. 중간 상태는 모두 볼 수 없습니다. UI는 이 모든 과정에서 반응성을 유지합니다.
대규모 리스트 필터링 또는 검색
가장 대표적인 예시입니다. 수천 개의 항목이 있는 상황에서 사용자가 검색어를 입력한다고 가정해 봅시다. startTransition을 사용하지 않으면, 리액트가 수천 개의 항목을 다시 렌더링하는 동안 키 입력 하나하나가 메인 스레드를 차단하게 됩니다.
startTransition을 사용하면 입력 반응이 빠릅니다. 리액트에 여유가 생길 때 목록이 업데이트됩니다.
대용량 콘텐츠가 포함된 탭 전환
사용자가 탭을 클릭합니다. 새로운 탭에는 복잡한 컴포넌트 트리가 있습니다. 차트, 표, 방대한 양의 데이터 등이 포함되어 있죠.
startTransition을 사용하지 않으면, 새로운 콘텐츠 전체가 렌더링될 때까지 클릭한 탭이 시각적으로 강조 표시되지 않습니다. 마치 클릭이 아무런 반응도 일으키지 않은 것처럼 느껴집니다.
startTransition을 사용하면, 활성 탭을 긴급 업데이트로 즉시 업데이트합니다. 그런 다음 탭 콘텐츠를 전환 효과와 함께 로드합니다. 탭은 즉시 강조 표시되고, 콘텐츠는 잠시 후에 나타납니다.
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const [content, setContent] = useState(tabs[0].content);
const [isPending, startTransition] = useTransition();
function handleClick(index) {
setActiveTab(index); // 긴급. 탭을 즉시 강조 표시합니다.
startTransition(() => {
setContent(tabs[index].content); // 전환. 콘텐츠를 준비될 때 렌더링합니다.
});
}
return (
<div>
<div className="tabs">
{tabs.map((tab, i) => (
<button key={i} onClick={() => handleClick(i)} className={i === activeTab ? "active" : ""}>
{tab.label}
</button>
))}
</div>
{isPending ? <Spinner /> : <TabContent data={content} />}
</div>
);
}
단일 페이지 애플리케이션(SPA)에서의 탐색
사용자가 링크를 클릭했을 때, 탐색이 즉각적으로 이루어지는 것처럼 느껴지도록 해야 합니다. 하지만 새 페이지가 로딩에 시간이 걸릴 수 있습니다. 이 경우 경로 업데이트를 startTransition 메서드 안에 감싸세요. 그러면 기존 페이지는 그대로 표시된 채로, 새 페이지는 백그라운드에서 렌더링됩니다. 화면이 텅 비는 현상이 발생하지 않습니다.
React Router와 Next.js는 이미 내부적으로 이러한 방식을 사용하고 있습니다.
startTransition이 필요하지 않은 경우
모든 상태 업데이트에 startTransition이 필요한 것은 아닙니다. 대부분은 필요하지 않습니다.
setState로 트리거된 렌더링이 16ms 미만으로 빠르다면 startTransition이 필요하지 않습니다. 브라우저는 프레임 드롭 없이 이를 처리할 수 있습니다. 빠른 업데이트에 startTransition을 추가하면 이득 없이 복잡성만 가중됩니다.
상태 업데이트가 사용자 입력을 직접 제어하는 경우, startTransition으로 감싸지 마십시오. 입력은 즉시 업데이트되어야 합니다. 업데이트를 분리할 수 있습니다. 입력 값은 시급합니다. 그 값의 하류 효과는 전환입니다.
상태 업데이트가 단 하나뿐이고 그 규모가 작다면, startTransition은 오버헤드를 발생시킵니다. 스케줄링 메커니즘은 무료가 아닙니다. 사소한 업데이트의 경우, 단순히 동기식으로 렌더링하는 것보다 더 느립니다.
startTransition vs 디바운싱
사람들은 때때로 디바운싱을 통해 같은 문제를 해결하기도 합니다. “사용자가 300ms 동안 키 입력을 멈출 때까지 기다린 다음, 목록을 업데이트한다.” 이 방법은 작동하지만 인위적인 지연을 유발합니다. 사용자는 마지막 키 입력 후 300ms를 기다려야 결과를 볼 수 있습니다. 리액트가 더 빨리 렌더링할 수 있다 하더라도 말이죠.
startTransition은 지연을 추가하지 않기 때문에 더 낫습니다. 리액트는 즉시 작업을 시작합니다. 단지 중단될 수 있는 방식으로 작업을 수행할 뿐입니다. 다음 키 입력 전에 리액트가 렌더링을 완료하면, 사용자는 결과를 즉시 볼 수 있습니다. 300ms를 기다릴 필요가 없습니다.
디바운싱은 작업 시작을 지연시킵니다. startTransition은 작업을 즉시 수행하지만 중단 가능하게 만듭니다.
이 두 가지를 결합할 수 있습니다. 키 입력마다 API 호출을 실행하고 싶지 않은 네트워크 요청에는 디바운싱을 사용하세요. 그 뒤를 잇는 렌더링에는 startTransition을 사용하세요.
startTransition vs requestIdleCallback
사람들이 자주 비교하는 또 다른 개념입니다. requestIdleCallback은 “브라우저가 유휴 상태일 때 이 함수를 실행하라”는 브라우저 API입니다. 이름은 비슷해 보이지만 실제로는 매우 다릅니다.
requestIdleCallback은 리액트 외부에서 작업을 스케줄링하기 위한 것입니다. 리액트의 렌더링과는 전혀 연동되지 않습니다. 리액트는 이 기능을 인식하지 못하며, 리액트의 렌더링을 중단시킬 수도 없습니다.
startTransition은 리액트의 자체적인 기능입니다. 이는 reconciler와 연동됩니다. "Reconciler"는 무엇을 다시 렌더링할지 결정하는 리액트의 구성 요소입니다. 리액트는 전체 프로세스를 제어하므로 트랜지션을 중단하고 재개할 수 있습니다.
분석이나 로깅과 같은 리액트와 무관한 작업에는 requestIdleCallback을 사용하세요. 비용이 많이 드는 렌더링을 유발하는 리액트 상태 업데이트에는 startTransition을 사용하세요.
한 문장으로 요약하면
startTransition은 리액트에 "이 업데이트는 중요하지만 잠시 미뤄도 된다"고 알립니다. 리액트는 긴급한 업데이트가 들어올 때 전환 렌더링을 중단함으로써 UI의 반응성을 유지합니다. 상태 업데이트로 인해 비용이 많이 드는 렌더링이 발생하지만, 사용자가 렌더링이 끝날 때까지 기다리며 작업이 차단되어서는 안 되는 경우에 이 메서드를 사용하세요.
'개발 > 번역' 카테고리의 다른 글
| [번역] 코드 리뷰를 잘한다면, AI 에이전트를 잘 활용하게 될 것입니다 (1) | 2026.03.08 |
|---|---|
| [번역] 장애허용성 (1) | 2026.02.09 |
| [번역] 모든 것을 배열로 바꾸는 것을 그만두세요 (대신 일을 덜 하세요) (1) | 2026.01.24 |
| [번역] HTTP 범위 요청(Range Requests)을 통한 동영상 제공하기 (1) | 2025.12.31 |
| [번역] 왜 타입스크립트는 당신을 구해주지 못하는가 (1) | 2025.12.03 |