- Published on
React 19 useTransition로 입력렉·렌더 지연 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
사용자가 타이핑할 때 글자가 "늦게" 따라오거나, 입력 중 UI가 잠깐 멈추는 현상은 대개 메인 스레드에서 무거운 렌더링 작업이 입력 이벤트 처리와 경쟁하기 때문에 발생합니다. React 19에서는 useTransition을 통해 "지금 당장 필요한 업데이트"(입력 반영) 와 "조금 늦어도 되는 업데이트"(검색 결과 렌더, 필터링, 비싼 계산) 를 분리해, 입력 반응성을 지키면서도 UI를 자연스럽게 갱신할 수 있습니다.
이 글에서는 React 19 기준으로 useTransition을 실전에서 어떻게 써야 입력렉과 렌더링 지연을 줄일 수 있는지, 그리고 흔한 함정을 어떻게 피하는지 정리합니다. 성능 병목을 체계적으로 진단하는 방법은 Next.js INP 폭증? React 렌더 병목 7단계 진단도 함께 참고하면 좋습니다.
입력렉이 생기는 구조: 입력 이벤트와 렌더링의 경쟁
브라우저에서 키 입력이 들어오면 이벤트 핸들러가 실행되고, 상태 업데이트가 발생하면 React는 렌더링을 예약합니다. 문제는 다음과 같은 작업이 입력과 같은 우선순위로 묶이는 경우입니다.
- 입력값 변경과 동시에 대량 리스트 필터링
- 입력값 변경과 동시에 복잡한 정렬, 그룹핑, 하이라이팅
- 입력값 변경과 동시에 무거운 컴포넌트 트리 전체가 리렌더
- 입력값 변경과 동시에 라우팅 전환 또는 데이터 재요청
이때 메인 스레드가 렌더링과 계산에 오래 묶이면, 다음 키 입력 이벤트가 제때 처리되지 못해 "입력렉" 으로 체감됩니다.
핵심은 간단합니다.
- 입력 UI는 항상 즉시 업데이트
- 결과 UI는 transition으로 낮은 우선순위 업데이트
React 19 useTransition 핵심 개념
useTransition은 다음을 제공합니다.
startTransition(fn):fn내부의 상태 업데이트를 Transition(낮은 우선순위) 로 표시isPending: transition 작업이 진행 중인지 나타내는 플래그
Transition으로 표시된 업데이트는 React가 "사용자 입력 같은 급한 업데이트"를 먼저 처리하도록 양보합니다. 즉, 결과 렌더가 다소 늦어질 수 있지만 입력 반응성은 유지됩니다.
안티패턴 예시: 입력 상태와 결과 상태를 한 번에 갱신
아래 패턴은 흔하지만 입력렉을 유발하기 쉽습니다.
import { useMemo, useState } from "react";
type Item = { id: number; name: string };
export function SearchBad({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
// items가 크거나, 필터 로직이 비싸면 입력이 버벅일 수 있음
const q = query.trim().toLowerCase();
return items.filter((it) => it.name.toLowerCase().includes(q));
}, [items, query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="type to search"
/>
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
</div>
);
}
useMemo가 있다고 해서 입력렉이 자동으로 해결되지는 않습니다. query가 바뀌는 순간 필터링 계산과 리스트 렌더가 바로 따라오면, 여전히 메인 스레드를 잡아먹습니다.
해결 패턴 1: query와 deferredQuery를 분리 (Transition으로 결과만 갱신)
가장 실용적인 패턴은 상태를 두 개로 나누는 것입니다.
query: 입력창에 즉시 반영되는 상태 (긴급)deferredQuery: 결과 계산/렌더에 쓰이는 상태 (transition)
import { useMemo, useState, useTransition } from "react";
type Item = { id: number; name: string };
export function SearchGood({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [deferredQuery, setDeferredQuery] = useState("");
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() => {
const q = deferredQuery.trim().toLowerCase();
if (!q) return items;
return items.filter((it) => it.name.toLowerCase().includes(q));
}, [items, deferredQuery]);
return (
<div>
<label>
Search
<input
value={query}
onChange={(e) => {
const next = e.target.value;
// 1) 입력은 즉시
setQuery(next);
// 2) 결과 갱신은 transition
startTransition(() => {
setDeferredQuery(next);
});
}}
/>
</label>
{isPending && <div>Updating results…</div>}
<div>Result: {filtered.length}</div>
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
</div>
);
}
이렇게 하면 타이핑은 즉시 반응하고, 결과 리스트는 React가 여유 있을 때 업데이트합니다. isPending으로 로딩 스피너나 "업데이트 중" 힌트를 주면 UX가 더 좋아집니다.
포인트
- 입력창
value는 반드시 긴급 상태(query)를 바라보게 합니다. - transition 상태(
deferredQuery)는 "결과"에만 사용합니다. - 결과 렌더가 오래 걸릴수록 효과가 커집니다.
해결 패턴 2: 서버 요청을 Transition으로 감싸 "입력은 즉시, 요청은 천천히"
검색어 변경 시 API를 호출하는 경우에도 동일한 원칙을 적용할 수 있습니다.
아래 예시는 fetch 자체를 중단시키는 것이 아니라, 요청 트리거와 결과 반영을 낮은 우선순위로 두는 접근입니다.
import { useEffect, useState, useTransition } from "react";
type User = { id: number; name: string };
async function searchUsers(q: string, signal: AbortSignal): Promise<User[]> {
const res = await fetch(`/api/users?q=${encodeURIComponent(q)}`, { signal });
if (!res.ok) throw new Error("request failed");
return res.json();
}
export function RemoteSearch() {
const [query, setQuery] = useState("");
const [deferredQuery, setDeferredQuery] = useState("");
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!deferredQuery.trim()) {
setUsers([]);
setError(null);
return;
}
const ac = new AbortController();
setError(null);
searchUsers(deferredQuery, ac.signal)
.then((data) => setUsers(data))
.catch((e: unknown) => {
if (ac.signal.aborted) return;
setError(e instanceof Error ? e.message : "unknown error");
});
return () => ac.abort();
}, [deferredQuery]);
return (
<div>
<input
value={query}
onChange={(e) => {
const next = e.target.value;
setQuery(next);
startTransition(() => setDeferredQuery(next));
}}
placeholder="Search users"
/>
{isPending && <div>Searching…</div>}
{error && <div role="alert">{error}</div>}
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</div>
);
}
여기서 주의할 점
useTransition은 네트워크 자체를 빠르게 만들지 않습니다. "UI 우선순위"를 조절합니다.- 검색 요청 폭주를 줄이려면
AbortController외에도 디바운스가 필요할 수 있습니다. - 디바운스와 transition은 경쟁 관계가 아니라 보완 관계입니다. 디바운스로 호출 횟수를 줄이고, transition으로 렌더 우선순위를 조절합니다.
useTransition이 잘 듣는 케이스 vs 잘 안 듣는 케이스
잘 듣는 케이스
- 결과 리스트가 크고 렌더가 무거움
- 입력과 동시에 여러 컴포넌트가 연쇄적으로 리렌더됨
- 필터링/정렬/하이라이트 등 CPU 작업이 큼
- 라우팅 전환처럼 화면 갱신이 큰 업데이트
잘 안 듣는 케이스
- 입력 이벤트 핸들러 자체가 무거운 작업을 수행함
- transition 밖에서 이미 동기적으로 큰 계산을 실행함
- 이미지 디코딩, 서드파티 스크립트 등 React 외부가 메인 스레드를 점유함
즉, startTransition(() => setState(...))로 감싼다고 해서, 그 이전에 실행한 무거운 계산이 사라지지는 않습니다. 무거운 계산은 transition 안으로 옮기거나(가능한 경우), 워커로 빼거나, 렌더 구조를 줄여야 합니다.
흔한 함정 5가지
1) 입력 상태까지 Transition으로 보내버리기
입력창 value를 transition 상태로 묶으면 오히려 타이핑이 늦게 반영됩니다. 입력은 긴급 상태로 유지하세요.
2) "결과"가 아닌 "원천 데이터"를 transition으로 감싸기
예를 들어, 대형 items 자체를 transition으로 바꾸는 것은 상태 일관성을 복잡하게 만들 수 있습니다. 가능한 한 UI에 가까운 "파생 상태"(검색어, 필터 조건, 페이지 인덱스)만 transition으로 두는 편이 안전합니다.
3) isPending을 로딩 스피너로만 쓰고 UX를 망치기
isPending 동안 결과 영역을 통째로 비우면 화면이 깜빡입니다. 보통은 기존 결과를 유지한 채 "업데이트 중" 뱃지 정도만 보여주는 것이 더 자연스럽습니다.
4) Transition 업데이트가 누적되는 것을 고려하지 않기
사용자가 빠르게 타이핑하면 transition 업데이트가 연속으로 발생합니다. 이때는
- 마지막 입력만 반영되도록 상태 구조를 단순화하고
- 원격 요청이라면
AbortController로 이전 요청을 취소하고 - 필요하면 디바운스까지 적용
같은 정책이 필요합니다.
5) 성능 문제를 useTransition 하나로 끝내려 하기
useTransition은 "우선순위" 도구입니다. 렌더 비용 자체가 너무 크면, 결국 어디선가 버벅입니다. 컴포넌트 분리, 메모이제이션, 가상 스크롤, 불필요한 컨텍스트 구독 제거 같은 기본 최적화가 선행되어야 합니다. 진단 루틴은 Next.js INP 폭증? React 렌더 병목 7단계 진단이 실전에서 도움이 됩니다.
실전 체크리스트: 입력렉을 없애는 적용 순서
- 문제 재현: "빠르게 타이핑"하면서 프레임 드랍/입력 지연을 확인
- 입력 상태와 결과 상태를 분리
- 결과 상태 업데이트를
startTransition으로 감싸기 isPending을 활용해 기존 결과 유지 + 업데이트 힌트 제공- 여전히 느리면
- 결과 렌더 트리 축소
- 리스트 가상화(예:
react-window계열) - 무거운 계산을 워커로 분리
- 데이터 구조/렌더링 경로 재설계
useTransition을 "우선순위 설계"로 바라보기
React 19에서 useTransition은 단순한 최적화 트릭이 아니라, UI를 "긴급한 것"과 "덜 긴급한 것"으로 설계하는 도구에 가깝습니다.
- 사용자가 타이핑하는 동안에는 입력 커서와 글자가 최우선
- 결과는 조금 늦어도 괜찮지만, 끊기지 않고 자연스럽게 갱신
이 원칙대로 상태를 나누고 transition을 적용하면, 입력렉 같은 체감 성능 이슈를 코드 구조 수준에서 안정적으로 줄일 수 있습니다.
추가로, 프론트 성능 문제는 종종 백엔드 병목이나 타임아웃 설계와도 연결됩니다. 분산 환경에서 지연을 다루는 패턴은 gRPC MSA에서 Deadline Exceeded 원인과 패턴도 함께 보면 전체 시스템 관점에서 도움이 됩니다.