- Published on
Chrome INP 급락 - Long Task 찾고 쪼개기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표는 멀쩡한데, 어느 날부터 Chrome 사용자 경험 지표에서 INP(Interaction to Next Paint) 가 급락하는 경우가 있습니다. 보통 “클릭은 즉시 먹는데 화면 반응이 늦다”, “스크롤/탭 전환이 버벅인다” 같은 체감으로 먼저 나타납니다. 이때 가장 흔한 범인은 메인 스레드(Main thread)를 길게 점유하는 Long Task(일반적으로 50ms 초과 작업) 입니다.
이 글은 다음 순서로 문제를 해결합니다.
- INP가 무엇을 측정하고, 왜 Long Task에 취약한지
- Chrome DevTools에서 Long Task를 정확히 찾는 방법
- Long Task를 쪼개서 입력(Interaction) 우선 처리를 보장하는 패턴
- 프레임 드랍/레이아웃 스래싱/동기 JS 등 자주 터지는 원인별 처방
INP가 급락하는 전형적인 시나리오
INP는 사용자의 상호작용(클릭/탭/키 입력 등) 이후 다음 페인트(Next Paint) 가 일어나기까지의 지연을 측정합니다. 단순히 이벤트 핸들러 실행 시간만 보는 게 아니라, 다음 요소까지 모두 영향을 줍니다.
- 이벤트 핸들러에서 실행된 JS
- 해당 JS가 유발한 스타일 계산/레이아웃/페인트
- 메인 스레드를 막는 다른 작업(타이머, GC, 프레임워크 렌더, 서드파티 스크립트)
즉, 클릭 핸들러가 5ms여도 그 직후에 300ms짜리 Long Task가 끼어 있으면 INP는 나빠질 수 있습니다.
“갑자기” 나빠지는 이유
릴리즈 이후 INP가 급락했다면 다음 변경이 흔합니다.
- 리스트/테이블 렌더링이 커짐(페이지당 아이템 수 증가)
- 입력 직후 동기적으로 무거운 계산/정렬/필터 수행
- 이미지/폰트 로딩 후 레이아웃 변화가 커짐(CLS도 함께 악화)
- 서드파티 태그(AB 테스트, 분석 SDK) 추가
- React/Next.js에서 특정 상태 업데이트가 대규모 리렌더를 유발
(Next.js 기반이라면 런타임/에지 이슈도 함께 점검해야 합니다. 관련해서는 Next.js 14 Edge 런타임 crypto is not defined 해결법도 참고할 만합니다. 직접적으로 INP와 동일 주제는 아니지만, “환경 차이로 특정 코드가 예상 못한 경로로 실행되며 비용이 커지는” 케이스를 빠르게 배제하는 데 도움이 됩니다.)
1) DevTools로 Long Task를 “증거”로 잡기
감으로 고치면 끝이 없습니다. INP는 사용자 상호작용 타이밍과 엮이므로, 상호작용 → Long Task → 페인트 지연의 연결고리를 DevTools에서 확인해야 합니다.
Performance 패널: Interaction과 Long Task를 함께 보기
- Chrome DevTools → Performance
- Record(녹화) 시작
- INP가 나쁜 동작(예: 버튼 클릭, 탭 전환)을 2~3회 재현
- Stop
이후 다음을 확인합니다.
- Main 트랙에서 50ms 이상 길게 이어진 작업(노란색 JS가 길게 늘어짐)
- Interactions(또는 Event Log)에서 클릭/탭 이벤트가 발생한 시점
- 이벤트 직후 Long Task가 끼어 페인트가 밀리는지
빠른 판별 팁
- Long Task가 이벤트 이전에도 있으면: 백그라운드에서 메인 스레드를 계속 잡아먹는 작업(폴링, 무거운 애니메이션, 서드파티)
- 이벤트 직후에 길면: 핸들러/상태 업데이트/렌더/측정 코드가 원인일 확률이 높음
Web Vitals 확장/필드 데이터와의 연결
실제 INP 급락은 대개 “필드 데이터(실사용자)”에서 먼저 보입니다. 원인을 찾을 때는 랩 측정(DevTools) 로 재현하고, 수정 후에는 필드 지표로 개선을 확인해야 합니다.
- CrUX / RUM(자체 수집)에서 INP 분포(P75) 확인
- 특정 페이지/경로/디바이스에서만 악화되는지 세그먼트
2) Long Task를 쪼개는 핵심 원칙
Long Task를 줄이는 방법은 다양하지만, 목표는 하나입니다.
- 입력 직후엔 메인 스레드를 비워 두고
- 무거운 작업은 쪼개서
- 가능하면 백그라운드(Worker) 로 보내며
- DOM 측정/변경은 배치(batch) 한다
아래부터는 현장에서 자주 쓰는 “쪼개기 패턴”을 코드로 정리합니다.
3) 패턴 A: requestAnimationFrame + setTimeout(0)로 프레임 양보
입력 직후 UI 피드백(로딩 스피너, 버튼 disabled, optimistic UI)을 먼저 그리고, 무거운 작업은 다음 프레임 이후로 미룹니다.
button.addEventListener('click', () => {
// 1) 즉시 UI 피드백
button.disabled = true;
button.textContent = '처리 중...';
// 2) 다음 페인트 기회를 확보
requestAnimationFrame(() => {
// 3) 메인 스레드 큐 뒤로 보내기
setTimeout(() => {
heavyWork();
button.disabled = false;
button.textContent = '완료';
}, 0);
});
});
function heavyWork() {
// 예: 큰 배열 정렬, 필터링, 마크다운 파싱 등
}
requestAnimationFrame은 “다음 프레임에 맞춰 실행”이라 페인트 타이밍과 궁합이 좋습니다.setTimeout(0)은 같은 프레임에서 이어지는 긴 JS를 약간 뒤로 미뤄 입력-페인트 경로를 정리하는 데 도움이 됩니다.
주의: 이건 “근본적인 비용을 줄이기”가 아니라 “입력 우선순위를 높이기”입니다. 근본 비용은 아래 패턴으로 줄여야 합니다.
4) 패턴 B: 작업을 청크(chunk)로 쪼개기 (Cooperative Scheduling)
큰 루프를 한 번에 돌리면 Long Task가 됩니다. 1~5ms 단위로 끊고, 중간중간 브라우저에 제어권을 돌려줍니다.
requestIdleCallback 기반(지원/폴백 필요)
function processInChunks(items, handler, chunkSize = 200) {
let index = 0;
return new Promise((resolve) => {
function run(deadline) {
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && index < items.length) {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) handler(items[index], index);
}
if (index < items.length) {
requestIdleCallback(run, { timeout: 500 });
} else {
resolve();
}
}
requestIdleCallback(run, { timeout: 500 });
});
}
// 사용 예
await processInChunks(bigArray, (x) => {
// 가벼운 처리 단위
});
- 유휴 시간에 돌리므로 입력과 경쟁을 덜 합니다.
- 단, 모든 환경에서 완벽히 신뢰하기 어렵고(백그라운드 탭/저전력 모드), 폴백이 필요합니다.
scheduler.postTask(가능하면)로 우선순위 제어
Chrome 계열에서 점진적으로 지원되는 Scheduler API는 작업 우선순위를 명시할 수 있습니다.
const postTask = globalThis.scheduler?.postTask;
async function runNonUrgent(fn) {
if (postTask) {
return postTask(fn, { priority: 'background' });
}
// 폴백
return new Promise((r) => setTimeout(() => r(fn()), 0));
}
button.addEventListener('click', async () => {
// 입력에 가까운 작업은 먼저
showSpinner();
// 덜 급한 작업은 background로
await runNonUrgent(() => rebuildIndex());
hideSpinner();
});
5) 패턴 C: Web Worker로 메인 스레드에서 계산 분리
정렬/검색 인덱싱/파싱 같은 순수 계산은 Worker로 보내는 게 가장 확실합니다.
메인 스레드
const worker = new Worker('/workers/search-worker.js');
function buildIndexAsync(docs) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data);
worker.onerror = reject;
worker.postMessage({ type: 'BUILD_INDEX', docs });
});
}
button.addEventListener('click', async () => {
showSpinner();
const index = await buildIndexAsync(hugeDocs);
renderIndexReady(index);
hideSpinner();
});
Worker(/workers/search-worker.js)
self.onmessage = (e) => {
const { type, docs } = e.data;
if (type !== 'BUILD_INDEX') return;
// 무거운 계산을 여기서 수행
const index = docs.map((d) => ({ id: d.id, tokens: tokenize(d.text) }));
self.postMessage(index);
};
function tokenize(text) {
return text.toLowerCase().split(/\W+/).filter(Boolean);
}
- INP 급락의 가장 강력한 해결책은 “메인 스레드에서 계산을 치우는 것”입니다.
- 단, DOM 접근은 Worker에서 불가하므로 “계산은 Worker, DOM 반영은 메인”으로 역할을 나눕니다.
6) 패턴 D: DOM 측정/변경 분리(레이아웃 스래싱 방지)
INP가 나쁜 페이지를 보면, 클릭 핸들러 내부에서 getBoundingClientRect() 같은 측정과 style 변경이 섞여 레이아웃이 반복 계산되는 경우가 많습니다.
나쁜 예(측정-변경-측정 반복)
function bad() {
const h1 = el.getBoundingClientRect().height;
el.style.height = (h1 + 10) + 'px';
const h2 = el.getBoundingClientRect().height; // 강제 리플로우 가능
el.style.width = (h2 * 2) + 'px';
}
좋은 예(읽기→쓰기 배치)
function good() {
// 1) 읽기(측정)만 모으기
const rect = el.getBoundingClientRect();
const nextHeight = rect.height + 10;
const nextWidth = (rect.height * 2);
// 2) 쓰기(변경)만 모으기
el.style.height = nextHeight + 'px';
el.style.width = nextWidth + 'px';
}
추가로, 애니메이션은 top/left 대신 transform을 사용하고, 필요 시 will-change를 제한적으로 적용해 페인트 비용을 줄입니다.
7) 프레임워크(React/Next.js)에서 자주 만드는 Long Task
1) 입력과 동시에 대규모 상태 업데이트
- 검색창 입력마다 전체 리스트를 필터링/정렬
- 입력 디바운스 없이 API/렌더를 연쇄 호출
대응:
- 입력 이벤트는 가볍게(상태 최소화)
- 무거운 연산은
useMemo/Worker/청크 처리 - React 18+라면
startTransition으로 우선순위 낮추기
import { startTransition, useState } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');
return (
<input
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v); // 입력 반영은 즉시
startTransition(() => {
setDeferredQuery(v); // 무거운 렌더/필터는 낮은 우선순위
});
}}
/>
);
}
2) 리스트 렌더링 폭증(가상화 미적용)
- 1,000개 이상의 DOM을 한 번에 렌더
- 탭 전환 시 숨겨진 영역까지 전부 렌더
대응:
- windowing(react-window 등) 적용
- “접힌 섹션은 렌더 지연”
8) “원인 찾기” 체크리스트: 어디부터 의심할까
INP 급락을 빠르게 좁히는 체크리스트입니다.
A. 입력 직후 실행되는 코드
- 클릭 핸들러에서 동기 루프/정렬/JSON stringify 등
- 상태 업데이트가 연쇄 렌더를 유발
B. 렌더 이후 발생하는 비용
- 레이아웃 스래싱(측정/변경 혼합)
- 큰 이미지/폰트로 인한 스타일/레이아웃 비용
C. 백그라운드 Long Task
- setInterval 폴링
- analytics/ads/AB 테스트 스크립트
- 과도한 MutationObserver
D. “데이터가 커졌을 때만” 터지는 문제
예를 들어 데이터 조인/머지 후 행이 폭증해 프론트에 더 많은 아이템이 내려오면, 렌더링이 Long Task가 됩니다. 데이터 파이프라인 이슈가 의심된다면 Pandas merge 후 NaN 폭증·행수 증가 원인 7가지처럼 상류에서 데이터가 비정상적으로 커지는 원인도 함께 점검하는 게 좋습니다.
9) 수정 후 검증: “INP가 좋아졌다”를 어떻게 확인하나
- DevTools Performance로 동일 시나리오 재측정
- Long Task(>50ms)가 줄었는지
- Interaction 이후 다음 페인트까지 빈 구간이 생겼는지
- Lighthouse/PSI는 참고(랩 환경)
- 가장 중요: RUM/CrUX에서 P75 INP 추세 확인
- 릴리즈 전후 비교
- 디바이스(저사양 Android)에서 더 크게 개선되는지
결론: INP 급락은 “입력 경로를 청소”하면 회복된다
INP는 사용자의 입력이 “다음 화면 변화”로 이어지는 경로 전체를 봅니다. 그래서 단순 최적화보다 Long Task를 찾아 쪼개고, 입력 직후 페인트를 보장하는 게 효과가 큽니다.
실무적으로는 다음 우선순위를 추천합니다.
- Performance 트레이스로 Long Task의 정확한 위치를 찾는다.
- 입력 직후엔 UI 피드백을 먼저 그리고(
rAF), 무거운 작업은 뒤로 미룬다. - 루프는 청크로 쪼개고(
requestIdleCallback/Scheduler), 가능하면 Worker로 보낸다. - DOM 측정/변경을 배치해 레이아웃 스래싱을 제거한다.
이 흐름대로 접근하면 “어느 날 갑자기 INP가 급락한” 상황에서도 원인을 재현하고, 재발을 막는 구조로 개선할 수 있습니다.