- Published on
Chrome INP 급등 원인 - Long Task 분해 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
INP(Interaction to Next Paint)는 “사용자 입력(클릭/탭/키 입력)에 앱이 다음 화면을 그려주기까지 걸린 시간”을 대표하는 Core Web Vitals 지표입니다. 현업에서 흔한 상황은 이렇습니다.
- 어느 날부터 Chrome에서 INP가 200ms 이하(좋음)에서 500~1000ms(나쁨)로 급등
- 서버 응답은 빠른데 “클릭이 먹히는 느낌”이 늦고, 스크롤/탭 전환이 버벅임
- Lighthouse는 애매하고, 실제 사용자(RUM)에서만 심각하게 튐
대부분의 경우 원인은 단순합니다. **메인 스레드(Main thread)를 오래 점유하는 Long Task(보통 50ms 초과)**가 입력 이벤트 처리와 렌더링을 막아 INP를 폭발시킵니다. 이 글에서는 “Long Task를 실제로 찾아서, 쪼개고, 다시 측정하는” 실전 흐름을 다룹니다.
> 참고: INP는 단일 상호작용이 아니라 페이지 수명 동안의 상호작용 중 **대표값(대개 98th percentile 근처)**을 쓰기 때문에, “가끔 발생하는 큰 멈춤”이 전체 지표를 망가뜨립니다.
INP 급등의 전형적인 메커니즘
INP가 커지는 경로는 대체로 다음 중 하나입니다.
- Input delay: 사용자가 클릭했지만 메인 스레드가 바빠 이벤트 루프가 입력을 꺼내지 못함
- Processing time: 이벤트 핸들러가 무거움(동기 JS, 큰 루프, JSON 파싱, 레이아웃 스래싱)
- Presentation delay: 핸들러는 끝났는데 다음 paint까지 DOM/스타일/레이아웃/페인트가 밀림
Long Task는 이 3가지를 한 번에 악화시킵니다. 특히 “클릭 → 핸들러에서 대량 연산 → setState/DOM 변경 → 강제 레이아웃 → 페인트 지연” 조합이 자주 나옵니다.
DevTools로 Long Task 범인 찾기(실전 체크리스트)
1) Performance 패널로 재현과 캡처
- Chrome DevTools → Performance
- CPU Throttling(예: 4x) 켜고, Network는 실제와 비슷하게
- Record 후, 문제가 되는 상호작용(클릭/입력/탭 전환)을 수행
- Stop
이때 확인 포인트:
- Main 트랙에서 긴 노란색(JavaScript) 블록이 있는지
- Long Task 표시(빨간 삼각형/경고) 또는 50ms 이상 task
- Event Log에서 클릭/포인터/키 이벤트가 언제 처리되었는지
2) “왜 길었는지”를 콜스택으로 파고들기
Long Task를 클릭하면 Summary/Bottom-up/Call tree에서 시간이 어디에 쓰였는지 보입니다.
- Scripting이 길면: JS 연산/파싱/프레임워크 렌더
- Rendering이 길면: style/layout/paint, 레이아웃 스래싱 가능
- System이 길면: GC, 이미지 디코드, 폰트 등도 의심
3) Long Task가 한 번만 뜨는지, 반복되는지 구분
- “특정 버튼 클릭 시 1회 크게 멈춤” → 초기화/대량 데이터 처리/동기 렌더
- “스크롤/타이핑마다 계속 멈춤” → 이벤트 핸들러/리렌더/레이아웃 스래싱
이 구분이 해결책을 결정합니다.
Long Task 분해 전략 1: 작업을 ‘프레임 단위’로 쪼개기
가장 효과적인 첫 수는 한 번에 200500ms 먹는 일을 1020ms 조각으로 나눠 브라우저가 입력 처리와 렌더링을 끼워 넣게 만드는 것입니다.
setTimeout(0)만으로는 부족한 이유
setTimeout(fn, 0)은 매크로태스크로 밀어내지만, 연속 호출 시 여전히 메인 스레드를 바쁘게 만들 수 있고, 페인트 타이밍을 보장하지 않습니다.
requestAnimationFrame + chunking(권장)
렌더 프레임 경계에 맞춰 조금씩 처리하면 체감이 좋아집니다.
// 무거운 리스트 처리(예: 50,000개)를 프레임마다 조금씩 수행
function processInChunks(items, handler, chunkSize = 200) {
let index = 0;
return new Promise((resolve) => {
function frame() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
handler(items[index], index);
}
if (index < items.length) {
requestAnimationFrame(frame);
} else {
resolve();
}
}
requestAnimationFrame(frame);
});
}
// 사용 예
await processInChunks(bigArray, (item) => {
// DOM 조작은 여기서 최소화하고, 데이터 계산 위주로
compute(item);
});
핵심은 “한 프레임에서 너무 오래 일하지 않기”입니다. chunkSize는 정답이 없고, DevTools에서 Main thread task가 50ms를 넘지 않도록 조절합니다.
Long Task 분해 전략 2: Idle time 활용(requestIdleCallback)
사용자 입력 반응이 중요한 화면에서는 “지금 당장 필요 없는 일”을 idle로 미루는 게 INP에 특히 잘 먹힙니다.
function runWhenIdle(work, timeout = 1000) {
if ('requestIdleCallback' in window) {
return requestIdleCallback((deadline) => {
// 남은 idle 시간이 충분할 때만 실행
while (deadline.timeRemaining() > 5 && work.hasMore()) {
work.step();
}
if (work.hasMore()) runWhenIdle(work, timeout);
}, { timeout });
}
// 폴백: 다음 tick으로
return setTimeout(() => {
while (work.hasMore()) work.step();
}, 0);
}
// 사용 예: 검색 인덱스 구성, 프리페치, 로그 정리 등
const work = {
i: 0,
hasMore() { return this.i < 50000; },
step() { this.i++; heavyCompute(this.i); }
};
runWhenIdle(work);
주의할 점:
- idle은 “언제든 끊길 수 있는” 실행입니다. **중단/재개 가능한 형태(step)**로 설계하세요.
- timeout을 걸어 무한히 밀리지 않게 합니다.
Long Task 분해 전략 3: Web Worker로 메인 스레드에서 제거
Long Task가 “순수 계산/파싱/정렬”이라면 Worker로 옮기는 것이 가장 깔끔합니다.
Worker로 JSON 파싱/정렬 오프로드
// worker.js
self.onmessage = (e) => {
const { raw } = e.data;
const data = JSON.parse(raw);
data.sort((a, b) => a.score - b.score);
self.postMessage({ data });
};
// main.js
const worker = new Worker('/worker.js');
function parseAndSortInWorker(raw) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.data);
worker.postMessage({ raw });
});
}
// 사용 예
const sorted = await parseAndSortInWorker(hugeJSONString);
render(sorted);
Worker로 옮기면 메인 스레드의 Input delay가 크게 줄어 INP가 안정화되는 경우가 많습니다.
단, Worker도 만능은 아닙니다.
- DOM 접근 불가(계산 전용)
- 메시지 직렬화 비용(큰 객체는
structuredClone비용 발생) - 가능하면 전송 데이터는 최소화(원시 타입/TypedArray 활용)
Long Task 분해 전략 4: 렌더링 비용 줄이기(레이아웃 스래싱 제거)
INP가 나쁜데 JS 자체는 그리 길지 않다면, “Rendering” 시간이 문제일 수 있습니다.
전형적인 레이아웃 스래싱 패턴
// 나쁜 예: 읽기/쓰기가 교차되며 레이아웃 강제 계산이 반복
for (const el of elements) {
const h = el.offsetHeight; // layout read
el.style.height = (h + 10) + 'px'; // layout write
}
읽기-쓰기 분리로 개선
// 좋은 예: 먼저 읽기만 모으고, 그 다음 쓰기만 수행
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});
추가 팁:
- 애니메이션은
top/left보다transform사용 - 큰 DOM 업데이트는 DocumentFragment/배치 업데이트
- 가상 스크롤(virtualization)로 렌더 대상 자체를 줄이기
“INP는 가끔만 튄다”를 잡는 관측 코드
현상 재현이 어렵거나, 특정 사용자 환경에서만 튄다면 “상호작용이 느린 순간에 어떤 Long Task가 있었는지”를 현장에서 수집해야 합니다.
Long Task 관측(PerformanceObserver)
// Long Task(50ms+)를 관측해 콘솔/로그로 남김
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: ms
// entry.attribution: 어떤 컨테이너/프레임에서 발생했는지 힌트
console.log('[LongTask]', {
startTime: entry.startTime,
duration: entry.duration,
name: entry.name,
attribution: entry.attribution
});
}
});
longTaskObserver.observe({ entryTypes: ['longtask'] });
여기에 “사용자 상호작용 직전/직후”의 Long Task를 묶어서 전송하면, RUM에서 INP 급등 원인을 좁히는 데 도움이 됩니다.
React/Vue 등 프레임워크에서 자주 터지는 케이스
1) 큰 상태 변경 한 번에 몰아치기
- 필터 변경 시 리스트 1만 개를 한 번에 리렌더
- 입력창 타이핑마다 비싼 파생 계산 수행
해결:
- 파생 계산 메모이제이션
- 입력은 debounce(단, UX에 맞게)
- 리스트는 virtualization
- “즉시 반응이 필요한 UI”와 “나중에 그려도 되는 UI”를 분리
2) 이벤트 핸들러 안에서 동기 작업 과다
예: 클릭 핸들러에서 analytics payload 생성/압축/대량 stringify
해결:
- 클릭 핸들러에서는 상태 업데이트만 하고, 로깅/전송은 idle/worker로
실전 디버깅 플로우(요약)
- RUM/DevTools에서 INP가 튀는 상호작용을 특정
- Performance 기록에서 해당 타이밍의 Main thread Long Task 확인
- Long Task가 Scripting이면: chunking/idle/worker로 분해
- Long Task가 Rendering이면: 레이아웃 스래싱 제거/DOM 업데이트 배치/virtualization
- 수정 후 다시 측정: CPU throttling + 동일 시나리오 반복
배포 파이프라인에서 성능 회귀 막기
INP는 기능 추가보다 “사소한 코드 한 줄”로도 회귀합니다. 그래서 성능 회귀를 배포 단계에서 최대한 빨리 잡는 게 중요합니다. 특히 모노레포/다중 워크플로 환경에서는 성능 측정 잡이 동시에 돌아가거나 취소되면서 데이터가 꼬이기도 합니다. CI 안정화는 성능 관리의 기본 체력입니다.
- GitHub Actions에서 워크플로 동시 실행을 제어해 측정 결과를 안정화하는 방법은 GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress도 함께 참고하세요.
또한, 타입스크립트/번들 설정 변화가 런타임 동작을 바꿔 예상치 못한 렌더 폭발을 만들기도 합니다. 예를 들어 클래스 필드 초기화 방식 변경은 이벤트 핸들러 바인딩/참조를 꼬이게 해 불필요한 리렌더를 유발할 수 있습니다.
- 관련 이슈는 TS 5.5+ useDefineForClassFields로 깨진 this 해결 참고.
결론
Chrome에서 INP가 급등할 때 “대부분”의 출발점은 Long Task입니다. 해결은 거창한 최적화보다, 아래 3가지를 정확히 실행하는 데서 시작합니다.
- DevTools Performance로 문제 상호작용 시점의 Long Task를 잡는다
- Long Task를 쪼개거나(chunking/idle), 아예 메인 스레드 밖으로 옮긴다(worker)
- Rendering 병목이면 레이아웃 스래싱/DOM 폭발을 제거한다
INP는 사용자 체감과 가장 직결되는 지표입니다. “가끔 1초 멈추는 순간”을 없애는 것만으로도, 평균 성능보다 훨씬 큰 만족도를 만들 수 있습니다.