- Published on
INP 개선 - React 렌더링 병목 찾는 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
INP(Interaction to Next Paint)는 사용자가 클릭, 탭, 키입력 같은 상호작용을 한 뒤 다음 화면 갱신(Next Paint) 이 일어날 때까지 걸린 시간을 봅니다. React 앱에서 INP가 나빠지는 전형적인 패턴은 크게 두 가지입니다.
- 입력 이벤트 핸들러가 무겁다(동기 작업, 과도한 상태 업데이트)
- 이벤트 이후 렌더링과 커밋이 길다(불필요한 리렌더, 큰 트리, 비싼 계산)
중요한 점은, INP는 단순히 렌더 시간만이 아니라 입력 처리부터 페인트까지의 전체 경로를 포함한다는 것입니다. 그래서 "렌더 최적화"만 해도 좋아지지 않는 경우가 있고, 반대로 렌더 병목만 제대로 잡아도 급격히 좋아지는 경우가 많습니다.
먼저 Long Task를 잡아 원인을 좁히는 접근이 유효합니다. 관련해서는 Chrome INP 급락 원인 찾기 - Long Task 추적 글의 흐름(롱태스크 식별 → 호출 스택 확인 → 원인 코드로 좁히기)을 함께 참고하면 좋습니다.
아래 7가지는 React 렌더링 병목을 찾을 때 제가 가장 자주 쓰는 체크리스트입니다. 각 항목은 "어떻게 찾고" "어떻게 고치는지"를 코드 중심으로 정리합니다.
1) React DevTools Profiler로 "누가" 리렌더를 유발하는지 먼저 고정
INP가 나쁠 때 제일 먼저 해야 할 일은 추측이 아니라 증거 기반으로 병목 컴포넌트를 고정하는 것입니다.
찾는 법
- Chrome DevTools Performance에서 상호작용(클릭 등) 직후 구간을 녹화
- React DevTools
Profiler에서 해당 상호작용을Record로 재현 Commit duration이 큰 커밋을 선택Ranked탭에서 렌더 시간이 큰 컴포넌트 확인Why did this render?(설정에서 활성화)로 props/state 변화 원인 확인
실전 팁
- "렌더 시간이 큰 컴포넌트"와 "렌더 횟수가 많은 컴포넌트"는 다를 수 있습니다.
- INP는 입력 이후의 단일 상호작용에서 최악값에 민감하므로, 한 번의 커밋이 길어지는 케이스를 우선 보세요.
2) 상태 업데이트 범위를 줄여 리렌더 전파를 차단
React는 상태가 바뀐 컴포넌트부터 하위 트리로 렌더를 전파합니다. 따라서 상태의 위치가 너무 상위면, 작은 UI 변경이 큰 트리를 흔들어 INP가 나빠집니다.
안 좋은 예
function Page() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<HugeList query={query} />
<Footer />
</>
);
}
키 입력마다 Page 전체가 리렌더되고, HugeList가 매번 새로 계산되면 입력 INP가 쉽게 악화됩니다.
개선: 상태를 더 아래로 내리거나, 파생값을 분리
function Page() {
return (
<>
<SearchSection />
<Footer />
</>
);
}
function SearchSection() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<HugeList query={query} />
</>
);
}
또는 리스트 필터링 같은 비싼 계산은 useMemo로 "계산"과 "렌더"를 분리합니다.
const filtered = useMemo(() => {
return items.filter((x) => x.title.includes(query));
}, [items, query]);
핵심은 "상호작용과 무관한 영역"이 리렌더에 휘말리지 않게 트리를 쪼개는 것입니다.
3) memo/useCallback/useMemo는 "어디에" 쓰는지가 전부
최적화 훅은 만능이 아닙니다. 잘못 쓰면 오히려 비용만 늘고, 제대로 쓰면 렌더 병목을 크게 줄입니다.
대표적인 병목 패턴: props 참조가 매번 바뀌는 경우
function Parent({ items }: { items: Item[] }) {
return (
<Child
items={items}
onSelect={(id) => {
// 매 렌더마다 새 함수
console.log(id);
}}
/>
);
}
const Child = memo(function Child({ items, onSelect }: Props) {
return items.map((x) => (
<button key={x.id} onClick={() => onSelect(x.id)}>
{x.title}
</button>
));
});
Child가 memo여도 onSelect가 매번 새로 만들어져 props가 바뀌면 리렌더됩니다.
개선
function Parent({ items }: { items: Item[] }) {
const onSelect = useCallback((id: string) => {
console.log(id);
}, []);
return <Child items={items} onSelect={onSelect} />;
}
추가로, items 자체가 부모에서 매번 새 배열로 만들어지는 경우도 흔합니다.
const items = useMemo(() => rawItems.slice().sort(sortFn), [rawItems]);
판단 기준
- 자식 컴포넌트가 무겁고, props 안정화로 렌더를 "확실히" 줄일 수 있을 때만 적용
- 가벼운 컴포넌트에 무분별한
memo는 비교 비용만 늘릴 수 있음
4) "리스트"는 거의 항상 가상화가 답 (특히 입력과 결합될 때)
검색 입력과 리스트 렌더가 결합되면, 키 한 번에 수백~수천 DOM 노드를 재계산/재배치하게 됩니다. 이때 INP는 급격히 악화됩니다.
안 좋은 예
function HugeList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((x) => (
<li key={x.id}>{x.title}</li>
))}
</ul>
);
}
개선: 가상화(예: react-window)
import { FixedSizeList as List } from "react-window";
function VirtualizedList({ items }: { items: Item[] }) {
return (
<List height={500} itemCount={items.length} itemSize={36} width={"100%"}>
{({ index, style }) => (
<div style={style}>{items[index].title}</div>
)}
</List>
);
}
가상화는 렌더링 병목을 "최적화"가 아니라 "제거"에 가깝게 만듭니다. 특히 입력과 결합된 화면에서는 가장 효과가 큽니다.
5) 동기 계산을 이벤트 핸들러에서 떼어내고 startTransition으로 우선순위 분리
React 18 이후에는 사용자 입력처럼 긴급한 업데이트와, 필터 결과 렌더처럼 덜 긴급한 업데이트를 분리할 수 있습니다. 이 분리만으로도 INP가 좋아지는 케이스가 많습니다.
예: 입력은 즉시, 리스트 갱신은 전환으로
import { useMemo, useState, useTransition } from "react";
function SearchPage({ items }: { items: Item[] }) {
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() => {
return items.filter((x) => x.title.toLowerCase().includes(query));
}, [items, query]);
return (
<>
<input
value={text}
onChange={(e) => {
const v = e.target.value;
setText(v); // 긴급 업데이트
startTransition(() => {
setQuery(v); // 덜 긴급한 업데이트
});
}}
/>
{isPending ? <p>Updating…</p> : null}
<VirtualizedList items={filtered} />
</>
);
}
주의할 점은 startTransition이 "연산 자체"를 빠르게 만들지는 않는다는 것입니다. 다만 입력의 응답성을 지키면서 무거운 렌더를 뒤로 미룰 수 있어 INP에 유리합니다.
6) Suspense 경계와 코드 스플리팅으로 "상호작용 직후" 렌더 부담을 쪼개기
상호작용 후에 라우팅이나 모달 오픈이 일어나면서 큰 번들이 로드되고, 동시에 큰 트리가 마운트되면 INP가 나빠집니다. 이때는 "한 번에 많이"를 "조금씩"으로 나누는 전략이 필요합니다.
예: 모달을 지연 로딩하고 Suspense로 감싸기
import { lazy, Suspense, useState } from "react";
const HeavyModal = lazy(() => import("./HeavyModal"));
function Page() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
{open ? (
<Suspense fallback={<div>Loading…</div>}>
<HeavyModal onClose={() => setOpen(false)} />
</Suspense>
) : null}
</>
);
}
이 방식은 "클릭 후 즉시 전체 렌더"를 피하고, 최소 UI를 먼저 페인트한 뒤 나머지를 로드하게 만들어 INP를 개선하는 데 도움이 됩니다.
7) 레이아웃 스래싱과 DOM 측정 루프를 끊어 "렌더 이후" 비용을 줄이기
React 렌더링이 끝나도, 브라우저는 스타일 계산, 레이아웃, 페인트를 해야 합니다. 특히 getBoundingClientRect() 같은 DOM 측정과 스타일 변경이 섞이면 레이아웃 스래싱이 발생해 INP 경로를 길게 만듭니다.
안 좋은 예: 측정과 변경이 한 프레임에 반복
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
// 측정 후 바로 스타일 변경
ref.current.style.height = rect.width + "px";
});
개선 1: requestAnimationFrame으로 페인트 사이클에 맞추기
useEffect(() => {
const el = ref.current;
if (!el) return;
const id = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
el.style.height = rect.width + "px";
});
return () => cancelAnimationFrame(id);
}, []);
개선 2: 가능하면 CSS로 해결
DOM 측정 기반 UI는 비용이 큽니다. 예를 들어 정사각형 박스는 CSS aspect-ratio로 끝낼 수 있습니다.
.square {
aspect-ratio: 1 / 1;
}
레이아웃 변동은 CLS에도 영향을 줄 수 있으니, 폰트/이미지/광고로 레이아웃이 흔들리는 케이스가 있다면 Chrome CLS 급증 - 폰트·이미지·광고 시프트 해결도 함께 점검하면 좋습니다. INP와 CLS는 다른 지표지만, 실제 UX에서는 종종 같은 원인(레이아웃 불안정, 무거운 렌더)이 함께 나타납니다.
디버깅을 더 빠르게 만드는 체크리스트
위 7가지를 적용하기 전에, 아래 질문에 답하면 원인 수렴이 빨라집니다.
- 상호작용은 클릭인가, 입력인가, 스크롤인가
- 상호작용 직후 커밋이 몇 번 발생하는가(불필요한 연쇄 업데이트가 있는가)
- 리렌더가 가장 많은 컴포넌트는 무엇인가
- 렌더 시간이 긴 컴포넌트는 무엇인가
- 리스트/테이블/차트처럼 DOM이 큰 영역이 포함되는가
- 이벤트 핸들러에서 동기 계산을 수행하는가(JSON 파싱, 정렬, 필터, 정규식 등)
- DOM 측정과 스타일 변경이 섞여 있는가
마무리: INP는 "한 번의 최악"을 줄이는 게임
INP 개선은 평균을 조금씩 낮추는 작업이라기보다, 사용자가 실제로 체감하는 "딱 한 번의 버벅임"을 없애는 작업에 가깝습니다. React에서는 그 최악의 순간이 대개 다음 중 하나로 귀결됩니다.
- 불필요한 리렌더 전파(상태 위치, props 불안정)
- 큰 리스트/큰 트리 렌더(가상화, 분할)
- 입력과 무거운 렌더가 같은 우선순위로 묶임(
startTransition) - 렌더 이후 레이아웃/페인트 비용(DOM 측정 루프)
이 글의 7가지를 순서대로 적용하면, "어디가 병목인지"를 빠르게 특정하고, "왜 INP가 나쁜지"를 코드 수준에서 설명 가능한 상태로 만들 수 있습니다. 그 다음 최적화는 생각보다 단순해집니다.