- Published on
React 18 useTransition로 INP 지표 개선 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/네트워크가 빠른데도 체감이 느린 UI는 대부분 “입력 이벤트 이후 메인 스레드가 오래 점유되는” 문제로 귀결됩니다. 2024년부터 Core Web Vitals에 본격 포함된 INP(Interaction to Next Paint)는 바로 이 구간을 정조준합니다. 특히 React 앱에서는 검색, 필터, 대량 리스트 렌더링, 차트 업데이트 같은 작업이 입력 직후 동기 렌더로 이어지면서 INP가 급락하는 경우가 많습니다.
React 18의 useTransition은 이런 상황에서 “사용자 입력(긴급 업데이트)”과 “무거운 UI 갱신(비긴급 업데이트)”을 분리해, 입력 반응을 먼저 처리하고 나머지는 뒤로 미루는 도구입니다. 이 글에서는 INP 관점에서 useTransition을 어떻게 적용해야 효과가 나는지, 그리고 적용해도 개선이 안 되는 케이스를 어떻게 진단하는지까지 실전 흐름으로 정리합니다.
참고로 INP 자체의 원인 분석(롱 태스크, TBT, DevTools로 병목 찾기)은 아래 글이 함께 도움이 됩니다.
INP 관점에서 useTransition이 해결하는 것
INP는 사용자가 클릭/탭/키입력 같은 상호작용을 한 뒤, 다음 페인트가 일어날 때까지 걸린 시간을 대표값(대개 최악에 가까운 구간)으로 잡습니다. React 앱에서 흔한 패턴은 다음과 같습니다.
onChange혹은onClick에서 상태 업데이트 발생- 상태 변경으로 대량 렌더, expensive 계산, 정렬/필터링, 가상 DOM diff 증가
- 메인 스레드가 길게 점유되어 다음 페인트가 늦어짐
useTransition은 2번을 “비긴급”으로 표시해서, React가 가능한 한 입력 반응을 먼저 처리하고(긴급), 무거운 렌더는 뒤로 미루게 합니다. 즉, “총 작업량을 줄이는” 도구가 아니라 “입력 직후의 우선순위를 조정해 INP를 개선하는” 도구에 가깝습니다.
핵심은 다음 한 줄입니다.
- 입력 UX에 직접 연결된 상태는 즉시 업데이트
- 무거운 렌더를 유발하는 상태는
startTransition으로 감싸서 업데이트
언제 useTransition을 쓰면 효과가 큰가
다음 조건 중 2개 이상이면 성공 확률이 높습니다.
- 입력 이벤트(키 입력, 탭, 체크박스) 직후 렌더 비용이 큼
- 결과 UI가 즉시 정확할 필요는 없고 “잠깐 늦어도 되는” 성격(검색 결과, 추천 목록, 필터 결과)
- 렌더 비용이 큰 컴포넌트 트리가 입력 상태와 강하게 결합되어 있음
- 입력 중 타이핑이 버벅이거나, 클릭 후 스피너도 못 그리고 멈춘 뒤 한 번에 갱신됨
반대로, 다음은 useTransition만으로는 한계가 큽니다.
- 이벤트 핸들러 자체가 무거움(동기 계산, JSON 파싱, 암호화, 큰 루프)
- 네이티브 레이아웃 스래싱, 스타일 계산, 이미지 디코딩 등 브라우저 작업이 병목
- 렌더 이전에 이미 메인 스레드를 막는 작업이 있음
이 경우는 useTransition 이전에 “핸들러에서 무거운 일을 빼거나”, Web Worker, 분할 계산, 메모이제이션, 가상화 등을 먼저 검토해야 합니다.
실전 1: 검색 입력 + 대량 리스트 필터링
가장 흔한 케이스입니다. 입력이 들어올 때마다 query가 바뀌고, 그에 따라 filteredItems가 계산되고, 리스트가 다시 렌더링됩니다.
문제 코드(입력과 무거운 렌더가 한 덩어리)
import React, { useMemo, useState } from "react";
type Item = { id: string; title: string; tags: string[] };
export function SearchPage({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
// items가 수만 건이거나, 조건이 복잡하면 여기서 렌더 지연이 커짐
return items.filter((it) => {
return (
it.title.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
);
});
}, [items, query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
<div>Results: {filtered.length}</div>
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
</div>
);
}
이 코드는 “입력 상태 업데이트”가 곧바로 “무거운 필터링 + 대량 렌더”로 이어집니다. 타이핑 순간마다 메인 스레드가 길게 잡히면 INP가 악화됩니다.
개선 코드(useTransition으로 비긴급 업데이트 분리)
패턴은 간단합니다.
- 입력 박스에 바인딩되는 상태는
inputValue로 즉시 업데이트 - 실제 필터링에 쓰는
query는startTransition으로 늦게 업데이트
import React, { useMemo, useState, useTransition } from "react";
type Item = { id: string; title: string; tags: string[] };
export function SearchPage({ items }: { items: Item[] }) {
const [inputValue, setInputValue] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((it) => {
return (
it.title.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
);
});
}, [items, query]);
return (
<div>
<label>
Search
<input
value={inputValue}
onChange={(e) => {
const next = e.target.value;
setInputValue(next); // 긴급: 입력 즉시 반영
startTransition(() => {
setQuery(next); // 비긴급: 무거운 렌더를 유발하는 상태
});
}}
placeholder="Search"
/>
</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div>Results: {filtered.length}</div>
{isPending ? <span>Updating…</span> : null}
</div>
<ul aria-busy={isPending}>
{filtered.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
</div>
);
}
이렇게 하면 타이핑은 즉시 반응하고, 결과 리스트 갱신은 약간 늦게 따라오게 됩니다. INP 관점에서는 “입력 이후 다음 페인트”가 빨라질 가능성이 큽니다.
포인트: isPending UI는 INP에 간접적으로 중요
isPending은 단순 장식이 아니라, 전환 중임을 사용자에게 알려 “멈췄다”는 인상을 줄이는 장치입니다. 또한 aria-busy 같은 접근성 속성으로 보조기기에도 상태를 전달할 수 있습니다.
실전 2: 탭 전환 시 무거운 차트/리스트 렌더
탭 클릭은 즉각 반응해야 합니다. 하지만 탭 콘텐츠가 무거우면 클릭 후 화면이 멈춘 뒤 한 번에 바뀌어 INP가 나빠질 수 있습니다.
개선 패턴: 탭 선택은 긴급, 콘텐츠 교체는 전환
import React, { useTransition, useState } from "react";
type TabKey = "overview" | "analytics" | "logs";
function HeavyAnalytics() {
// 무거운 차트/테이블 렌더가 있다고 가정
return <div>Heavy analytics content</div>;
}
export function DashboardTabs() {
const [activeTab, setActiveTab] = useState<TabKey>("overview");
const [isPending, startTransition] = useTransition();
function onClickTab(next: TabKey) {
startTransition(() => {
setActiveTab(next);
});
}
return (
<div>
<div role="tablist" aria-label="Dashboard">
<button role="tab" aria-selected={activeTab === "overview"} onClick={() => onClickTab("overview")}>
Overview
</button>
<button role="tab" aria-selected={activeTab === "analytics"} onClick={() => onClickTab("analytics")}>
Analytics
</button>
<button role="tab" aria-selected={activeTab === "logs"} onClick={() => onClickTab("logs")}>
Logs
</button>
{isPending ? <span style={{ marginLeft: 8 }}>Loading view…</span> : null}
</div>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{activeTab === "overview" ? <div>Overview</div> : null}
{activeTab === "analytics" ? <HeavyAnalytics /> : null}
{activeTab === "logs" ? <div>Logs</div> : null}
</div>
</div>
);
}
여기서 탭 버튼 자체의 “클릭 반응”이 빨라지고, 무거운 콘텐츠 렌더는 전환으로 처리되어 INP가 개선될 여지가 큽니다.
useTransition 적용 전 체크리스트(효과가 없을 때의 흔한 이유)
1) 이벤트 핸들러가 이미 무겁다
아래처럼 startTransition 밖에서 무거운 계산을 해버리면, 전환 여부와 관계없이 입력 직후 메인 스레드를 막습니다.
onChange={(e) => {
const next = e.target.value;
// 나쁜 예: 여기서 큰 계산을 해버리면 INP는 그대로 나쁨
const tokens = next.split(" ").map((s) => s.toLowerCase());
startTransition(() => {
setQuery(tokens.join(" "));
});
}}
무거운 계산이 필요하다면 전환 내부로 넣거나, 더 나아가 Web Worker로 보내는 걸 고려해야 합니다.
2) “렌더 비용”이 아니라 “커밋 이후 작업”이 병목
React 렌더 자체는 빨라도, DOM 업데이트 후 브라우저가 레이아웃/페인트에 오래 걸리면 INP가 나쁠 수 있습니다. 이 경우에는
- DOM 노드 수 줄이기(리스트 가상화)
- 이미지/폰트 로딩 전략
- CSS 복잡도 감소
같은 접근이 필요합니다.
3) 상태 구조가 잘못되어 불필요한 리렌더가 폭발
useTransition은 우선순위를 조절할 뿐, 잘못된 상태 설계로 인한 “광범위 리렌더”를 자동으로 해결하지 않습니다.
- 리스트 아이템 컴포넌트에
React.memo적용 useMemo로 파생 데이터 계산 캐싱- 상태를 더 가까운 곳으로 내리기(불필요한 상위 리렌더 차단)
같은 기본기가 함께 가야 합니다.
INP 개선을 “측정 가능하게” 만드는 방법
useTransition은 체감만으로는 평가가 어렵습니다. 최소한 아래 2가지는 같이 하길 권합니다.
- Chrome DevTools Performance에서 상호작용 직후 Long Task 확인
- RUM(Real User Monitoring)으로 INP 수집
web-vitals로 INP 로깅(간단 RUM)
Next.js/React 앱이라면 web-vitals로 INP를 수집해 전후 비교를 할 수 있습니다.
// src/reportWebVitals.ts
import { onINP, type Metric } from "web-vitals";
export function reportWebVitals(send: (m: Metric) => void) {
onINP((metric) => {
send(metric);
});
}
// 예: Next.js에서 클라이언트 진입 시 호출
import { reportWebVitals } from "./reportWebVitals";
reportWebVitals((m) => {
// 실제로는 analytics endpoint로 전송
console.log("INP", {
id: m.id,
value: m.value,
rating: m.rating,
navigationType: m.navigationType,
});
});
이렇게 전환 적용 전후로 동일 시나리오(예: 검색어 10자 입력, 탭 5회 전환)를 비교하면, “개선이 있었는지”를 숫자로 확인할 수 있습니다.
useDeferredValue와의 차이: 언제 무엇을 쓰나
비슷한 문제를 푸는 도구로 useDeferredValue도 있습니다.
useTransition: “업데이트를 발생시키는 쪽”에서 전환으로 감쌈useDeferredValue: “값을 소비하는 쪽”에서 늦춰진 값을 사용
실전에서는 다음처럼 같이 쓰기도 합니다.
- 입력값은 즉시, 검색 결과는
useDeferredValue로 늦춤 - 혹은 필터 상태 업데이트는
useTransition, 결과 렌더는useDeferredValue
다만 우선은 useTransition으로 “어떤 업데이트가 비긴급인지”를 명확히 표현하는 편이 디버깅과 팀 합의에 유리합니다.
적용 전략 요약(실전 가이드)
- INP가 나쁜 상호작용을 하나 고른다(검색, 필터, 탭 등)
- 해당 상호작용 직후에 “무거운 렌더를 유발하는 상태”를 찾는다
- 입력에 직접 필요한 상태와 분리한다
- 무거운 상태 업데이트를
startTransition으로 감싼다 isPending으로 진행 중 UI를 제공한다- DevTools Performance와 RUM으로 전후 INP를 비교한다
useTransition은 만능 최적화가 아니라 “사용자 입력을 먼저 살리는 우선순위 제어”입니다. 렌더 비용이 큰 화면에서 이 원칙을 제대로 적용하면, 같은 기능을 유지하면서도 INP를 눈에 띄게 개선할 수 있습니다.