- Published on
Chrome INP 튀는 원인 찾기 - Long Task 추적법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답이 빠른데도 INP(Interaction to Next Paint)가 특정 순간에만 크게 튀는 경우가 있습니다. 이런 패턴은 대부분 메인 스레드가 잠깐이라도 ‘바빠져서’ 입력 처리(이벤트)와 다음 페인트가 밀리는 상황에서 발생합니다. 핵심은 그 바쁨의 실체를 Long Task(50ms 이상 메인 스레드 작업) 로 잡아내는 것입니다.
이 글은 “INP가 왜 튀는지”를 감으로 추측하지 않고, Long Task를 증거로 수집하고 범인을 특정하는 추적 절차를 다룹니다.
- INP와 Long Task의 관계를 정리하고
- DevTools Performance로 재현·캡처·분석하는 방법
PerformanceObserver로 현장에서 로그를 남겨 “간헐적 스파이크”를 잡는 방법- 흔한 원인 패턴(동기 JS, 레이아웃 스래싱, 서드파티)과 해결 방향
관련해서 Long Task/TBT를 줄여 INP를 개선하는 접근은 아래 글도 함께 보면 좋습니다.
- Chrome INP 느림? Long Task·TBT 7분 개선
- 스크롤/렌더링 끊김 원인 분석 관점은 Safari iOS 스크롤 끊김 - compositing 원인·해결도 유사한 디버깅 사고방식을 제공합니다.
INP가 ‘튀는’ 전형적인 메커니즘
INP는 사용자 상호작용(클릭/탭/키 입력 등) 이후 다음 화면 반영(페인트) 까지 걸린 시간을 봅니다. 즉, 다음 중 하나라도 막히면 INP가 커집니다.
- 이벤트가 큐에 쌓여서 늦게 실행됨(메인 스레드 점유)
- 이벤트 핸들러가 오래 걸림(동기 JS, 무거운 로직)
- 핸들러 이후 렌더링 경로가 막힘(스타일 계산, 레이아웃, 페인트)
여기서 Long Task는 “1번과 2번”을 강하게 의심하게 만드는 신호입니다. 특히 입력 직전/직후 타임라인에 50ms 이상 Task가 있으면, 그 Task가 INP 스파이크의 직접 원인일 가능성이 큽니다.
1단계: DevTools Performance로 스파이크를 ‘증거화’하기
간헐적으로 튀는 INP는 “재현이 어렵다”가 가장 큰 난관입니다. 하지만 다음 체크리스트로 성공률을 올릴 수 있습니다.
캡처 전 준비
- Chrome DevTools
Performance탭 Screenshots체크(시각적으로 어느 시점인지 확인)Web Vitals또는Event Log관련 옵션이 보이면 활성화(버전에 따라 UI가 다름)- CPU 스로틀링
4x정도로 걸고 재현해보면(개발 PC가 너무 빠른 경우) Long Task가 더 잘 드러납니다.
캡처 요령
Record시작- 문제 상호작용을 수행(예: 특정 버튼 클릭, 입력, 드롭다운 열기)
- INP가 튀는 순간을 포함해 3~5초 더 기록
Stop
분석 포인트(타임라인에서 무엇을 봐야 하나)
Performance 타임라인에서 아래 순서로 좁혀가면 빠릅니다.
- Main 트랙에서 빨간 삼각형/경고 표시가 있는 긴 막대(롱 태스크) 찾기
- 그 Long Task를 클릭해서 Bottom-up / Call Tree에서 시간을 먹는 함수 확인
- 해당 Task의 앞뒤로 Input(클릭/포인터/키) 이벤트가 있는지 확인
- Task 내부에
Recalculate Style,Layout,Update Layer Tree,Paint같은 렌더링 작업이 길게 붙어 있는지 확인
중요한 감각은 이겁니다.
- “클릭했는데 반응이 늦다”는 것은 대부분 클릭 이벤트 자체가 늦게 실행되었거나, 실행은 됐지만 다음 페인트가 늦은 것입니다.
- 타임라인에서 “입력 이벤트”와 “Long Task”의 상대 위치를 보면 어떤 케이스인지 분류할 수 있습니다.
2단계: Long Task를 코드 레벨 범인으로 연결하기
DevTools에서 Call Tree를 보면 대략적인 함수 이름이 보이지만, 번들링/미니파이로 인해 애매할 수 있습니다. 이때는 소스맵과 함께 다음을 확인합니다.
- 번들러 소스맵이 프로덕션에서도 적용되는지(최소한 스테이징)
- 프레임워크 런타임 내부가 아니라 “우리 코드” 호출 지점이 어디인지
- 특정 컴포넌트 렌더/상태 업데이트가 연쇄적으로 발생하는지
흔한 패턴 1: 동기 루프/JSON 파싱/정렬 같은 CPU 작업
예를 들어 클릭 시 다음 같은 작업이 동기로 돌면 바로 Long Task가 됩니다.
button.addEventListener('click', () => {
// 예: 큰 배열 정렬/필터링을 동기 수행
const sorted = bigList
.filter(x => x.enabled)
.sort((a, b) => a.score - b.score);
render(sorted);
});
이 경우 해결 방향은 보통 다음 중 하나입니다.
- 작업을 쪼개서 프레임 사이에 양보(
setTimeout,requestAnimationFrame,scheduler.postTask) - Web Worker로 오프로딩
- 데이터 크기 자체를 줄이거나 캐시
흔한 패턴 2: 렌더링 폭발(상태 업데이트 연쇄)
React/Vue 등에서 “클릭 한 번”이 수십 개 컴포넌트 리렌더로 이어지면 Long Task가 됩니다.
- 메모이제이션(
memo,useMemo,computed)이 무력화되는 의존성 - 리스트 렌더에서 key 불안정
- 상태를 상위로 끌어올려 불필요한 하위 트리까지 갱신
이 경우 DevTools Performance에서 Scripting이 길게 나오면서 함수 호출이 프레임워크 렌더 쪽으로 몰리는 형태가 자주 보입니다.
흔한 패턴 3: 레이아웃 스래싱(강제 동기 레이아웃)
다음처럼 DOM 읽기(getBoundingClientRect)와 쓰기(style.width = ...)가 섞이면 브라우저가 레이아웃을 반복 강제할 수 있습니다.
function badLayoutLoop(items) {
for (const el of items) {
const rect = el.getBoundingClientRect(); // read
el.style.width = (rect.width + 1) + 'px'; // write
}
}
해결은 “읽기-쓰기 분리”가 기본입니다.
function goodLayoutLoop(items) {
const widths = items.map(el => el.getBoundingClientRect().width); // read phase
items.forEach((el, i) => {
el.style.width = (widths[i] + 1) + 'px'; // write phase
});
}
INP가 튀는 케이스에서 이 패턴이 무서운 이유는, 이벤트 핸들러는 짧아 보이는데도 Layout/Recalculate Style이 길게 붙어 “렌더링 때문에 늦는 것처럼” 보이기 때문입니다.
3단계: “간헐적” 스파이크를 잡는 현장 계측(PerformanceObserver)
DevTools로 재현이 잘 되면 좋지만, 실제로는 “특정 사용자/특정 데이터/특정 기기에서만” 튀는 경우가 많습니다. 이때는 Long Task와 INP 후보 이벤트를 현장에서 로깅해야 합니다.
아래 코드는 Long Task를 수집해 합계/최대값을 기록합니다. (브라우저 지원을 고려해 방어 코드를 포함하세요.)
export function observeLongTasks({ onEntry } = {}) {
if (typeof PerformanceObserver === 'undefined') return () => {};
let observer;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: ms
onEntry?.({
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
});
}
});
observer.observe({ entryTypes: ['longtask'] });
} catch {
return () => {};
}
return () => observer.disconnect();
}
이걸 “사용자 상호작용 직후 N초” 구간에만 켜서, 로그 볼륨을 줄이고 원인 상관관계를 만들 수 있습니다.
import { observeLongTasks } from './observeLongTasks.js';
function withInteractionLongTaskProbe(fn) {
return (...args) => {
const longTasks = [];
const stop = observeLongTasks({
onEntry: (e) => longTasks.push(e),
});
const t0 = performance.now();
try {
return fn(...args);
} finally {
// 상호작용 후 2초만 관찰
setTimeout(() => {
stop();
const total = longTasks.reduce((s, e) => s + e.duration, 0);
const max = longTasks.reduce((m, e) => Math.max(m, e.duration), 0);
console.log('interaction longtask stats', {
since: t0,
count: longTasks.length,
total,
max,
sample: longTasks.slice(0, 5),
});
// TODO: 필요하면 서버로 전송(샘플링 권장)
}, 2000);
}
};
}
document.querySelector('#buy').addEventListener(
'click',
withInteractionLongTaskProbe(() => {
// 실제 클릭 로직
})
);
이 방식의 장점은 “INP가 튄 사용자 세션”에서 Long Task의 존재 여부와 크기를 바로 확인할 수 있다는 점입니다. 이후엔 그 시점의 추가 힌트(라우트, 화면 상태, 데이터 크기, 활성화된 기능 플래그, 로드된 서드파티 등)를 같이 보내 원인을 좁힙니다.
4단계: Long Task의 ‘정체’를 분류하는 체크리스트
Long Task가 발견되면, 아래 질문에 답하면서 원인을 빠르게 분류할 수 있습니다.
A. Long Task가 Scripting 비중이 큰가?
- 우리 코드의 무거운 계산
- 프레임워크 렌더/리컨실리에이션 폭발
- 서드파티 스크립트(태그 매니저, A/B 테스트, 광고, 분석)
대응:
- 계산 오프로딩(Worker)
- 렌더 범위 축소(virtualization, memo)
- 서드파티 지연 로드/조건부 로드
B. Rendering(Style/Layout/Paint) 비중이 큰가?
- 레이아웃 스래싱
- 큰 DOM 트리 변경
- 폰트 로딩/이미지 리사이즈로 레이아웃 변화
대응:
- DOM 읽기/쓰기 분리
- 애니메이션을
transform,opacity중심으로 - 레이아웃 비용 큰 컴포넌트 격리
C. 특정 상호작용에서만 발생하는가?
- “장바구니 담기”처럼 데이터가 커지는 동작
- “필터 적용”처럼 리스트 재계산/재렌더
대응:
- 데이터 크기 기반 샘플링 로그
- 입력 직후 무거운 작업을 지연시키고 먼저 UI 피드백(로딩/스켈레톤) 표시
5단계: 개선을 검증하는 방법(숫자로 끝내기)
원인 후보를 고쳤다면, 다시 Performance 캡처로 다음을 확인합니다.
- 문제 상호작용 근처 Long Task가 사라졌는지(또는 50ms 미만으로 줄었는지)
- 이벤트 핸들러 이후 첫 페인트가 앞당겨졌는지
- CPU 스로틀링 환경에서도 INP 스파이크가 재현되지 않는지
가능하면 필드 데이터(실사용자)로도 확인해야 합니다.
- 동일 페이지/동일 상호작용에서 Long Task 로그(개수/최대/합계)가 줄었는지
- INP 분포의 꼬리(특히 p95, p99)가 줄었는지
자주 놓치는 함정 4가지
“클릭 핸들러는 짧은데 INP가 큼”
- 핸들러 이후에 발생하는 스타일/레이아웃/페인트가 길 수 있습니다. Performance에서 Rendering 구간을 꼭 확인하세요.
서드파티가 원인인데 우리 코드만 보고 끝냄
- Long Task의 호출 스택에 외부 도메인 스크립트가 보이면, 로드 타이밍/조건부 로드를 검토해야 합니다.
개발 환경에서만 측정
- 개발 모드 오버헤드(소스맵, HMR, devtools hook)로 패턴이 달라질 수 있습니다. 스테이징/프로덕션에 가까운 빌드로 확인하세요.
한 번의 캡처로 결론
- INP 스파이크는 분산이 큽니다. 최소 5회 이상 반복 캡처하고, 공통적으로 등장하는 Long Task를 우선순위로 잡는 게 안전합니다.
마무리: INP 스파이크 추적의 핵심은 “상호작용 근처 Long Task”
INP가 튄다는 건 “입력 처리부터 다음 페인트까지” 어딘가가 막힌 것입니다. 그 막힘을 가장 효율적으로 드러내는 증거가 Long Task이고, DevTools Performance와 PerformanceObserver를 조합하면 재현이 어려운 간헐적 스파이크도 데이터로 잡아낼 수 있습니다.
정리하면 다음 순서가 가장 실전적입니다.
- Performance로 문제 상호작용 구간을 캡처
- Main 트랙에서 Long Task를 찾고 Call Tree로 범인 함수 식별
- 재현이 어렵다면
PerformanceObserver로 현장 Long Task 로그 수집 - Scripting vs Rendering vs 서드파티로 분류해 해결
- p95/p99 INP와 Long Task 통계를 다시 확인
이 흐름만 습관화해도 “INP 튀는 이유를 모르겠다”에서 “어떤 Task가 몇 ms를 먹는다”로 대화가 바뀌고, 해결 속도가 크게 빨라집니다.