- Published on
Chrome INP 폭증 원인 추적 - Long Task·TBT 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표는 멀쩡한데 사용자 체감만 갑자기 나빠지는 경우가 있습니다. 특히 Chrome에서 INP가 폭증하면 대개 클릭, 키 입력 같은 인터랙션이 늦게 처리되거나, 이벤트는 처리됐는데 다음 프레임 렌더가 늦어 사용자에게 "먹통"처럼 보이는 상황이 발생합니다.
INP는 단순히 이벤트 핸들러 실행 시간만 보는 지표가 아닙니다. 입력이 발생한 시점부터 브라우저가 다음 페인트를 완료할 때까지의 지연을 보므로, 메인 스레드가 다른 작업으로 바쁘면(대표적으로 Long Task) 인터랙션이 통째로 밀립니다. 이 글에서는 Chrome DevTools로 Long Task와 TBT를 추적해 INP 폭증의 원인을 좁히고, 코드 레벨에서 재현 가능하게 고쳐나가는 과정을 다룹니다.
INP, Long Task, TBT 관계 정리
INP가 느려지는 전형적인 패턴
- 입력 이벤트가 발생했지만 메인 스레드가 바빠서 이벤트 루프가 돌지 못함
- 이벤트는 처리했지만 상태 업데이트 후 레이아웃, 스타일 계산, 페인트가 밀림
- 이벤트 핸들러 자체가 무거워서 다음 프레임이 늦어짐
INP는 위 상황을 모두 포함합니다. 그래서 "이벤트 핸들러를 최적화했는데도 INP가 안 내려간다"가 흔합니다. 실제 병목은 렌더링 파이프라인(스타일, 레이아웃, 페인트)이나 다른 스크립트 태스크에 있을 수 있습니다.
Long Task와 TBT
- Long Task: 메인 스레드에서 50ms를 초과해 실행된 태스크
- TBT: 각 Long Task에서 50ms를 초과한 구간을 합산한 값
- 예: 120ms 태스크 1개면 TBT는 70ms
TBT는 Lighthouse가 주로 쓰는 진단 지표이고, INP는 실제 사용자 경험 지표입니다. 하지만 현장에서 INP 폭증의 원인 분석은 대부분 Long Task 추적으로 수렴합니다. Long Task가 많고 길수록 입력이 큐에 쌓여 INP가 악화됩니다.
1단계: DevTools에서 INP 폭증 구간을 재현하고 증거 확보
Performance 패널로 인터랙션 중심 캡처
- Chrome DevTools
Performance탭 Screenshots와Web Vitals(가능한 경우) 체크Record후 문제가 되는 클릭, 스크롤, 입력을 2~3회 수행- 정지 후 타임라인에서 긴 노란색(스크립트) 블록과 빨간 삼각형(긴 작업 경고)을 확인
여기서 핵심은 "입력 시점"과 "다음 페인트" 사이에 무엇이 끼어드는지 보는 것입니다. 타임라인에서 다음 요소를 함께 봅니다.
- Main 트랙의
Task길이(50ms 초과 여부) Event(click, pointerdown, keydown 등) 처리 시점Rendering(Recalculate Style, Layout, Paint) 구간
Long Task를 코드 라인으로 내려가기
Performance 캡처에서 Long Task를 클릭하면 하단에 Bottom-Up, Call Tree가 나옵니다. 여기서 다음을 찾습니다.
- 특정 함수가 반복적으로 상위 비용을 차지
- 프레임워크 런타임(예: React commit) 아래에서 특정 컴포넌트가 과도한 렌더를 유발
- JSON 파싱, 정렬, 필터링 같은 순수 연산이 메인을 점유
특히 번들된 코드라면 소스맵이 반드시 필요합니다. 소스맵이 없으면 "어딘지 모르겠는데 느리다"에서 멈춥니다.
2단계: 현장에서 자주 나오는 INP 폭증 원인 7가지
1) 입력 핸들러에서 동기 대량 연산
예: 검색 입력마다 정렬, 필터, 하이라이트를 동기 수행.
- 해결 방향: 디바운스, 작업 쪼개기, Web Worker 오프로딩
2) 상태 업데이트 폭발로 인한 과도한 렌더
한 번의 클릭이 여러 전역 상태 변경을 유발
리스트 전체 re-render
해결 방향: 배치 업데이트, 메모이제이션, 렌더 범위 축소
3) 레이아웃 스래싱(layout thrashing)
DOM 읽기(getBoundingClientRect)와 쓰기(style.width = ...)가 교차하면 레이아웃이 반복 강제됩니다.
- 해결 방향: 읽기/쓰기를 분리,
requestAnimationFrame로 쓰기 모으기
4) 큰 DOM과 비가시 영역 렌더
모달 하나 열었는데 배경 리스트가 수천 개라서 레이아웃 비용이 폭증
해결 방향: 가상 스크롤, 보이지 않는 영역은 렌더 지연
5) 3rd-party 스크립트가 메인을 점유
태그 매니저, A/B, 광고, 챗 위젯이 Long Task를 생성
해결 방향: 로딩 전략 변경, 필요 시점까지 지연, 샌드박싱
6) 폰트/이미지 로딩과 결합된 렌더 지연
입력 직후 리플로우와 페인트가 겹치면 체감이 악화됩니다.
- 해결 방향: 프리로드, 적절한
font-display, 이미지 크기 고정
7) 라우트 전환 시 동기 데이터 가공
SPA 라우팅 직후 데이터 정규화, 대량 파싱을 메인에서 수행하면 INP가 튑니다.
- 해결 방향: 스트리밍/점진 렌더, Worker, 서버에서 전처리
3단계: 코드로 Long Task를 쪼개서 INP를 내리는 패턴
아래는 "메인 스레드 점유"를 줄이는 대표 패턴입니다. 핵심은 한 번에 200ms를 쓰지 말고, 10~20ms 단위로 쪼개고, 가능하면 메인을 떠나게 만드는 것입니다.
패턴 A: 작업을 청크로 쪼개고 yield 하기
브라우저가 입력과 렌더를 처리할 기회를 주는 방식입니다.
// 긴 연산을 청크로 쪼개 메인 스레드 독점을 줄인다.
async function chunkedProcess(items, chunkSize = 200) {
const result = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// CPU 연산
for (const item of chunk) {
result.push(expensiveTransform(item));
}
// 이벤트 루프에 양보 (입력/렌더 처리 기회 제공)
await new Promise((r) => setTimeout(r, 0));
}
return result;
}
setTimeout(0)는 간단하지만 타이밍이 거칠 수 있습니다. 지원 브라우저 범위가 허용된다면 scheduler.postTask 같은 스케줄링 API를 고려할 수 있습니다(도입 전 폴리필/지원성 검토 필요).
패턴 B: requestAnimationFrame로 DOM 쓰기 모으기
레이아웃 스래싱을 줄이는 데 효과적입니다.
let pending = false;
let nextWidth = 0;
function onDragMove(x) {
// 읽기(측정)는 필요하면 여기서 하되, 쓰기는 모아서 한다.
nextWidth = Math.max(0, x);
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const el = document.querySelector('#panel');
el.style.width = `${nextWidth}px`;
});
}
패턴 C: Web Worker로 순수 연산 오프로딩
정렬, 파싱, 압축/해제, 큰 데이터 가공은 Worker가 정석입니다.
worker.js
self.onmessage = (e) => {
const { items } = e.data;
// 메인 스레드가 아닌 Worker에서 CPU 연산 수행
const processed = items.map(expensiveTransform);
self.postMessage({ processed });
};
메인 코드
const worker = new Worker(new URL('./worker.js', import.meta.url));
function processInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.processed);
worker.postMessage({ items });
});
}
Worker로 옮기면 Long Task가 줄어 INP 개선에 직결되는 경우가 많습니다. 단, DOM 접근은 Worker에서 불가하므로 "순수 연산"만 분리해야 합니다.
패턴 D: 입력 이벤트는 가볍게, 무거운 건 지연 실행
클릭 직후 바로 모든 일을 하지 말고, UX에 영향이 적은 후처리는 뒤로 미룹니다.
button.addEventListener('click', () => {
// 1) 사용자 피드백을 먼저
showSpinner();
// 2) 네트워크 요청 시작
fetchData();
// 3) 무거운 분석/로그는 지연
setTimeout(() => {
heavyAnalytics();
}, 0);
});
분석/로그가 3rd-party SDK 호출과 결합돼 Long Task를 만들면, 입력 경로에서 분리하는 것만으로도 INP가 크게 내려갑니다.
4단계: INP 폭증을 "관측" 가능하게 만들기 (RUM)
실험실(Lighthouse)에서만 보면 재현이 어렵습니다. 실제 사용자 환경에서 어떤 페이지, 어떤 인터랙션에서 INP가 튀는지 수집해야 합니다.
web-vitals로 INP 수집
import { onINP } from 'web-vitals';
onINP((metric) => {
// metric.value: INP(ms)
// metric.rating: good/needs-improvement/poor
// metric.attribution: 원인 추적에 도움되는 데이터(환경에 따라 다름)
navigator.sendBeacon('/rum', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
}));
});
수집 시 권장하는 태그
- 라우트/페이지 식별자
- 사용자 디바이스 성능(대략적인
hardwareConcurrency, 메모리 등) - 배포 버전(커밋 SHA)
- 실험 플래그(AB 테스트)
이렇게 모아두면 "특정 배포 이후", "특정 기기군에서만" INP가 폭증하는 패턴을 빠르게 찾을 수 있습니다.
5단계: 3rd-party로 인한 Long Task를 다루는 현실적인 방법
3rd-party 스크립트는 INP를 가장 자주 망가뜨리지만, 조직적으로 제거가 어렵습니다. 대신 다음 순서로 접근하면 합의가 쉬워집니다.
- DevTools Performance에서 Long Task의 호출 스택에 3rd-party가 보이는지 확인
- 해당 스크립트를 "입력 경로"에서 분리
- 초기 로딩에서 제거하고 사용자 동작 후 로드
- 페이지 idle 시점에 로드
- 로드하되 실행을 늦춤
- 불가피하면 페이지별로 조건부 로드
이 과정은 "원인 추적"이 중요합니다. INP 문제를 막연히 프론트 최적화로만 보면 끝이 없습니다. 병목을 수치와 스택으로 제시해야 제거 또는 지연에 대한 의사결정이 가능합니다.
6단계: 체크리스트로 마무리 (재발 방지)
배포 후 INP가 다시 튀지 않게 하려면, 아래를 릴리즈 게이트로 두는 것이 효과적입니다.
- Performance 캡처에서 주요 플로우(클릭, 검색, 라우팅)에 200ms 이상의 단일 Task가 없는지
- 입력 직후
Recalculate Style과Layout이 연쇄적으로 길게 발생하지 않는지 - 리스트/테이블은 가상 스크롤 또는 점진 렌더를 적용했는지
- Worker로 옮길 수 있는 순수 연산이 메인에 남아있지 않은지
- 3rd-party 스크립트가 초기 로딩과 입력 경로에 끼어들지 않는지
- RUM에서 배포 버전별 INP 분포를 비교하는 대시보드가 있는지
비슷한 "원인 추적" 접근이 통하는 사례
INP 폭증을 잡는 과정은 결국 "증상"이 아니라 "병목의 근거"를 모으는 일입니다. 이런 접근은 다른 분야의 성능/장애 분석에도 그대로 적용됩니다.
- 메모리 폭증의 원인을 누수 단위로 쪼개 추적하는 방식은 Rust Tokio task 누수로 RAM 폭증 진단법과 유사합니다.
- 리소스가 늘지 않는 원인을 메트릭의 신뢰성부터 검증하는 흐름은 Kubernetes HPA가 안 늘 때 metrics-server 0값 해결과도 닮아 있습니다.
결론: INP 폭증은 대부분 "메인 스레드 독점" 문제다
Chrome에서 INP가 갑자기 나빠지면, 우선 Long Task를 찾고 그 Long Task가 어디서 왔는지(자사 코드인지, 렌더링인지, 3rd-party인지)를 스택으로 증명하는 것이 가장 빠른 길입니다. 그 다음은 단순합니다.
- 한 번에 오래 실행되는 일을 쪼개고
- DOM 읽기/쓰기를 정리하고
- 순수 연산은 Worker로 보내고
- 3rd-party는 입력 경로에서 제거하거나 지연
이 네 가지를 체계적으로 적용하면, INP는 대개 "눈에 띄게" 내려가고 재발도 줄어듭니다.