- Published on
Chrome INP 폭증 원인 찾기 - Long Task 분해
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답은 빠른데도 사용자 체감이 “버튼을 눌러도 늦게 반응한다”로 바뀌는 순간이 있습니다. Core Web Vitals 중 INP(Interaction to Next Paint) 가 급격히 튀는 케이스가 대표적입니다. 특히 Chrome 필드 데이터(크롬 UX Report, RUM)에서 INP가 나빠지면, 실제로는 네트워크가 아니라 메인 스레드(Main Thread)에서 발생한 Long Task(50ms 초과 작업) 가 상호작용 처리를 막고 있는 경우가 많습니다.
이 글은 “INP 폭증”을 Long Task 관점에서 원인 규명 → 재현 → 분해(쪼개기) → 검증까지 연결하는 방법을 다룹니다. 목표는 단순히 점수를 올리는 것이 아니라, 사용자 입력(클릭/탭/키입력) 이후 다음 페인트가 늦어지는 구조적 병목을 제거하는 것입니다.
INP와 Long Task의 관계를 짧게 정리
INP는 사용자의 상호작용(예: 클릭)부터 브라우저가 다음 프레임을 그리기까지 걸린 시간을 측정합니다. 이 과정에서 가장 흔한 지연 원인은 다음 중 하나입니다.
- 이벤트 핸들러 자체가 오래 걸림(동기 JS, 무거운 계산)
- 이벤트 핸들러는 짧지만, 직후에 실행되는 렌더링/레이아웃/스타일 계산이 무거움
- 이벤트 핸들러가 큐에 들어갔는데, 앞에서 실행 중인 Long Task가 메인 스레드를 점유해 시작 자체가 늦음
여기서 Long Task는 “어떤 함수가 50ms 넘게 실행”되는 것만 의미하지 않습니다. GC, 레이아웃 스래싱, 대량 DOM 업데이트, 번들 초기화, JSON 파싱/정렬/필터링 등 메인 스레드에서 일어나는 모든 작업이 후보입니다.
핵심은 이겁니다.
- INP가 나쁘다 → 상호작용이 “처리되기까지” 또는 “다음 페인트까지” 막힌다
- 막는 주범은 대개 메인 스레드 점유(= Long Task)
- 그러면 해결은 Long Task를 쪼개거나(분해), 메인 스레드에서 치우거나(Worker), 우선순위를 조정하는 방향으로 간다
1) DevTools로 INP 폭증을 “상호작용 단위”로 잡기
Performance 패널: Interaction을 기준으로 본다
- Chrome DevTools → Performance
- “Web Vitals” 또는 “Enable advanced paint instrumentation”을 켠 뒤(버전에 따라 옵션 위치가 다름)
- 기록(Record) 시작 → 문제가 되는 클릭/입력 수행 → 정지
확인 포인트:
- Timings/Experience 섹션에서 Interaction 마커가 보이는지
- Interaction 이후 Main 트랙에 빨간 삼각형(롱 태스크) 또는 긴 스크립트 실행이 붙는지
- “Summary”에서 Scripting / Rendering / Painting 중 어디가 비중이 큰지
여기서 중요한 건 “페이지 로드”가 아니라 사용자 상호작용 직후 구간을 확대해서 보는 것입니다.
Long Task의 범인을 빠르게 좁히는 단서
- Evaluate Script / Function Call이 길다 → 순수 JS 계산/루프/파싱 가능성
- Recalculate Style / Layout이 길다 → DOM 변경 방식 문제(레이아웃 스래싱)
- GC가 튄다 → 객체 할당 폭증, 큰 배열/문자열, 반복 렌더링
2) Long Task를 “원인 코드”로 연결하는 실전 트릭
(1) Performance에서 Call Tree / Bottom-Up을 본다
- Bottom-Up: 가장 “시간을 많이 쓴 함수”를 찾기 좋음
- Call Tree: 어떤 호출 경로로 그 함수가 실행되었는지 추적
여기서 “상호작용 핸들러”와 직접 연결되지 않아도 됩니다. 예를 들어 클릭 이후 상태 변경 → 리렌더 → 대량 계산이 발생하면, 실제 범인은 렌더 경로에 있을 수 있습니다.
(2) Long Task를 쪼개기 전에 “측정 코드”를 붙인다
RUM(실사용자 모니터링)이 있다면 좋지만, 없더라도 최소한 다음을 붙이면 재현이 쉬워집니다.
web-vitals로 INP 수집PerformanceObserver로 longtask 수집
아래 예시는 브라우저에서 INP와 Long Task를 함께 로깅하는 최소 구성입니다.
// npm i web-vitals
import { onINP } from 'web-vitals';
// 1) INP 측정
onINP((metric) => {
// metric.value: ms
// metric.attribution: 어떤 interaction인지 힌트
console.log('[INP]', metric.value, metric);
});
// 2) Long Task 측정 (PerformanceObserver)
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration >= 50ms
console.log('[LongTask]', {
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
attribution: entry.attribution,
});
}
});
try {
po.observe({ type: 'longtask', buffered: true });
} catch (e) {
// 일부 환경에서 미지원
}
이렇게 하면 “INP가 튄 시점”과 “Long Task가 발생한 시점”을 같은 타임라인으로 맞춰볼 수 있습니다.
3) Long Task 분해 전략 5가지 (실전 우선순위)
Long Task를 줄이는 방법은 많지만, INP 관점에서는 “상호작용 직후”의 메인 스레드 점유를 끊는 게 최우선입니다.
전략 A: 작업을 chunk로 쪼개고, 중간에 yield한다
가장 범용적이고 효과가 빠른 방법입니다. 긴 루프/필터/정렬/가공을 작은 조각으로 나눠서, 브라우저가 이벤트 처리/렌더링을 할 틈을 줍니다.
핵심은 setTimeout(0)보다 scheduler.yield()(지원 시) 또는 requestAnimationFrame/requestIdleCallback을 상황에 맞게 쓰는 것입니다.
// 긴 작업을 조각내 실행: 메인 스레드 독점을 피한다
async function processInChunks(items, chunkSize = 200) {
const result = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// 실제 작업
for (const item of chunk) {
result.push(expensiveTransform(item));
}
// 브라우저에 양보
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
await new Promise((r) => requestAnimationFrame(() => r()));
}
}
return result;
}
적용 대상 예:
- 검색 결과 5,000개를 한 번에 가공/정렬
- 대량 JSON을 파싱 후 즉시 맵핑
- 클릭 후 차트 데이터 계산을 동기로 처리
전략 B: 상호작용 직후에는 “최소 업데이트”만 하고, 나머지는 지연한다
INP는 “다음 페인트”까지이므로, 클릭 후 즉시 필요한 UI(로딩 표시, 버튼 비활성화 등)만 먼저 그리고, 무거운 작업은 뒤로 미룹니다.
button.addEventListener('click', async () => {
// 1) 즉시 반응: 사용자에게 피드백을 먼저 제공
button.disabled = true;
button.textContent = 'Processing...';
// 2) 다음 프레임 이후 무거운 작업 시작
await new Promise((r) => requestAnimationFrame(() => r()));
// 3) 무거운 작업
const data = await heavyComputeOrFetch();
renderResult(data);
button.disabled = false;
button.textContent = 'Done';
});
이 패턴만으로도 “클릭했는데 멈춘 느낌”이 크게 줄어듭니다.
전략 C: 레이아웃 스래싱을 제거한다 (읽기/쓰기 분리)
INP 폭증의 숨은 원인은 Layout입니다. DOM을 바꾼 직후 offsetHeight 같은 레이아웃 정보를 읽으면 강제 동기 레이아웃이 발생합니다. 이를 반복하면 Long Task가 됩니다.
나쁜 예:
for (const el of items) {
el.style.width = (el.offsetWidth + 10) + 'px'; // 읽기+쓰기 반복
}
개선(읽기 단계/쓰기 단계 분리):
const widths = items.map((el) => el.offsetWidth); // 읽기만
items.forEach((el, idx) => {
el.style.width = (widths[idx] + 10) + 'px'; // 쓰기만
});
프레임워크(React/Vue)에서도 비슷합니다. 측정이 필요하면 useLayoutEffect 남발을 줄이고, 측정/렌더 경로를 분리해야 합니다.
전략 D: 무거운 계산은 Web Worker로 보낸다
정렬/압축/파싱/암호화처럼 DOM이 필요 없는 계산은 Worker로 보내면 메인 스레드 Long Task를 크게 줄일 수 있습니다.
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
function computeInWorker(payload) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data);
worker.onerror = reject;
worker.postMessage(payload);
});
}
// 클릭 핸들러
button.addEventListener('click', async () => {
button.textContent = 'Working...';
await new Promise((r) => requestAnimationFrame(r));
const result = await computeInWorker({ items: bigList });
renderResult(result);
});
// worker.js
self.onmessage = (e) => {
const { items } = e.data;
// DOM 접근 없이 계산
const result = items.map(expensiveTransform).sort(compareFn);
self.postMessage(result);
};
전략 E: 번들 초기화/동적 import로 “상호작용 경로”를 가볍게 만든다
INP가 특정 버튼/페이지에서만 튄다면, 그 경로에서 처음 로드되는 모듈이 무거운 경우가 많습니다(차트 라이브러리, 에디터, 마크다운 렌더러 등). 클릭 시점에 한꺼번에 import + 초기화가 일어나면 Long Task로 이어집니다.
- 미리 prefetch/preload
- 또는 초기화 비용이 큰 부분을 더 잘게 쪼개서 lazy-init
let chartLibPromise;
function prewarmChartLib() {
if (!chartLibPromise) {
chartLibPromise = import('./chart-lib.js');
}
}
// 사용자가 해당 섹션에 가까워졌을 때 미리 로드
window.addEventListener('pointerover', (e) => {
if (e.target.closest('[data-chart-button]')) prewarmChartLib();
}, { passive: true });
document.querySelector('[data-chart-button]').addEventListener('click', async () => {
const chartLib = await (chartLibPromise ?? import('./chart-lib.js'));
chartLib.render();
});
4) “어디서부터 쪼갤지” 결정하는 체크리스트
Long Task를 무작정 쪼개면 코드만 복잡해집니다. 아래 순서로 우선순위를 정하면 효율적입니다.
- 상호작용 직후 0~500ms 구간에 있는 Long Task부터
- Scripting이 길면: 루프/정렬/파싱/초기화 → chunk/yield 또는 Worker
- Rendering이 길면: DOM 업데이트 방식/레이아웃 스래싱/대량 컴포넌트 렌더 → 배치 업데이트, 가상화
- GC가 길면: 객체 생성 패턴/대형 배열/문자열 → 재사용, 스트리밍, chunk
- “첫 상호작용만 느림”이면: 동적 import/초기화 prewarm
5) 개선 검증: INP는 “평균”이 아니라 “나쁜 꼬리”를 줄여야 한다
INP는 사용자 경험의 최악 구간(고지연 상호작용)에 민감합니다. 개발자 로컬에서 한 번 빨라졌다고 끝내면, 필드에서는 여전히 튈 수 있습니다.
권장 검증 루틴:
- DevTools Performance에서 동일 상호작용을 5~10회 반복
- CPU 4x/6x throttling으로도 Long Task가 다시 생기지 않는지 확인
- RUM에서 INP p75/p90을 함께 본다(가능하면)
또한 배포 파이프라인에서 성능 회귀를 잡는 체계를 갖추면 운영이 편해집니다. 성능 이슈가 “간헐적”일수록 자동화가 중요합니다. CI에서 캐시/환경이 꼬이면 측정 자체가 흔들릴 수 있는데, 이런 류의 문제는 배포 자동화 관점에서 함께 점검해두는 게 좋습니다. 예를 들어 GitHub Actions 캐시가 불안정하면 빌드 산출물이 매번 달라져 성능 비교가 어려워질 수 있습니다. 관련해서는 GitHub Actions 캐시가 안 먹을 때 키·경로·권한도 같이 참고해볼 만합니다.
운영 중에는 “사용자만 느린” 케이스가 네트워크/인프라 문제처럼 보이기도 합니다. 하지만 INP는 대부분 프론트 메인 스레드 병목이어서, 서버 5xx 진단과는 접근이 다릅니다. 다만 장애 상황에서 프론트가 재시도/폴링을 과도하게 돌려 메인 스레드를 압박하는 케이스도 있으니, 인프라 이슈와 함께 보려면 EKS에서 503 Service Unavailable 원인 10분 진단처럼 “서버 측 원인”을 빠르게 배제하는 루틴을 갖춰두는 것도 도움이 됩니다.
6) 자주 나오는 INP 폭증 패턴과 처방
패턴 1: 클릭 시 대량 리스트 렌더 + 정렬/필터
- 증상: Scripting + Rendering 동시 폭증
- 처방:
- 계산은 Worker 또는 chunk/yield
- 렌더는 가상 스크롤(virtualization)
- “즉시 반응 UI” 먼저, 리스트는 다음 프레임에
패턴 2: 입력창 타이핑마다 동기 검증/포맷팅
- 증상: keydown/input 이벤트마다 Long Task
- 처방:
- debounce(단, 반응성 요구에 맞게)
- incremental validation(바뀐 부분만)
- 포맷팅은 blur 시점으로 미루기
패턴 3: 특정 버튼 첫 클릭만 느림
- 증상: Evaluate Script가 길고 모듈 로딩/초기화가 큼
- 처방:
- dynamic import prewarm
- 초기화 lazy 분해
결론: Long Task는 “없애기”보다 “상호작용 경로에서 치우기”
INP 폭증을 잡는 가장 현실적인 방법은, 상호작용 직후에 메인 스레드를 오래 점유하는 작업을 찾아 (1) 쪼개서 양보하거나 (2) 뒤로 미루거나 (3) Worker로 옮기는 것입니다. DevTools에서 Interaction을 기준으로 타임라인을 좁히고, Bottom-Up으로 범인을 특정한 뒤, chunk/yield 패턴을 먼저 적용해보면 대부분의 케이스에서 빠르게 개선을 확인할 수 있습니다.
다음 단계로는 “왜 그 작업이 그 시점에 필요한가?”를 다시 설계하는 것입니다. INP는 결국 사용자에게 ‘즉시 반응하는 느낌’을 주는 지표이므로, 기술적으로는 Long Task 분해가 핵심이고, 제품적으로는 상호작용 직후에 필요한 최소 피드백을 먼저 제공하는 UX가 핵심입니다.