- Published on
Chrome INP 점수 급락? Long Task 추적·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표는 멀쩡한데, 어느 날부터 Chrome에서 INP(Interaction to Next Paint) 점수가 급락하는 경우가 있습니다. 특히 배포 직후가 아니라 기능 추가/분석 스크립트 도입/프론트 번들 변경 이후에 발생한다면, 네트워크나 TTFB보다 메인 스레드가 막히는 Long Task(50ms 이상) 가 원인일 확률이 큽니다.
INP는 “사용자 입력(클릭/탭/키 입력 등) → 다음 페인트까지 걸린 시간”의 상위 백분위(대개 p75)를 보는 지표입니다. 즉, 입력 직후에 JS가 길게 실행되거나, 레이아웃/스타일 계산이 폭발하거나, 렌더링이 막히면 INP가 크게 악화됩니다.
이 글에서는 Long Task를 추적해 ‘누가’ 메인 스레드를 점유하는지 찾아내고, 코드/구조 레벨에서 INP를 회복하는 방법을 단계별로 정리합니다. (캐시/재검증 이슈로 체감 성능이 흔들리는 Next.js 환경이라면 Next.js App Router 캐시 꼬임·재검증 버그 해결도 함께 점검해보는 게 좋습니다.)
INP 급락의 전형적인 패턴: “입력 직후 200~800ms 멈춤”
INP가 나빠질 때 DevTools에서 자주 보이는 패턴은 다음 중 하나입니다.
- 클릭 이벤트 핸들러가 동기적으로 무거운 계산/정렬/필터링 수행
- 입력 직후 대량 DOM 업데이트로 style/layout/paint가 연쇄 폭발
- 라우팅/모달 오픈 시 대형 번들 동적 import + 초기화 코드가 한 번에 실행
- 3rd-party(태그/광고/분석) 스크립트가 메인 스레드를 점유
- 프레임워크/상태관리로 인해 불필요한 리렌더가 입력과 같은 타이밍에 겹침
핵심은 “느리다”가 아니라 입력과 같은 타이밍에 메인 스레드가 바쁘다는 점입니다.
1) 현상 재현: ‘필드 데이터’와 ‘실험실 데이터’를 분리
INP는 현장(Real User Monitoring, RUM)에서 더 중요합니다. 먼저 아래를 분리하세요.
- 필드 데이터(크롬 UX 리포트/GA4/자체 RUM): 실제 사용자 p75 INP가 나빠졌는지
- 실험실 데이터(DevTools/Lighthouse): 어떤 상호작용에서 Long Task가 발생하는지
실무에서는 “특정 페이지/특정 컴포넌트/특정 브라우저 버전에서만” 악화되는 케이스가 많습니다. 그래서 어떤 interaction(버튼/탭/검색/스크롤) 이 p75를 끌어올리는지부터 좁혀야 합니다.
2) DevTools로 Long Task 범인 찾기 (Performance 패널)
절차
- Chrome DevTools → Performance
- 톱니바퀴에서 Screenshots, Web Vitals(있다면) 활성화
- “Record” 시작 후 문제 상호작용(예: 버튼 클릭) 수행
- 정지 후 Main 트랙에서 빨간 삼각형/긴 막대(긴 task)를 확인
무엇을 봐야 하나
- Long Task(>50ms): INP 악화의 1차 후보
- Task 내부의 Event Handler / Function Call / Evaluate Script
- Recalculate Style / Layout / Update Layer Tree / Paint가 연속되는지
특히 Long Task를 클릭하면 하단에 Call Tree / Bottom-Up이 나오는데, 여기서 “가장 시간을 많이 쓴 함수”를 찾습니다.
흔한 함정: “내 코드가 아니라 라이브러리/3rd-party”
Bottom-Up에서 상위에 gtag, tagmanager, analytics, heatmap류가 보이면, 기능 자체보다 측정 스크립트가 입력과 같은 타이밍에 실행되는 것이 문제일 수 있습니다. 이 경우는 “제거”보다 로드/실행 타이밍을 늦추거나, 입력 직후에는 실행하지 않도록” 설계하는 편이 현실적입니다.
3) Long Task를 수치로 잡기: web-vitals + PerformanceObserver
DevTools로 범인을 찾았다면, 이제는 배포 후에도 재발을 감지할 수 있게 계측이 필요합니다. 최소한 아래 두 가지를 권장합니다.
web-vitals로 INP를 RUM으로 수집PerformanceObserver로 Long Task를 수집(원인 추적용)
INP 수집 (web-vitals)
// npm i web-vitals
import { onINP } from 'web-vitals';
function sendToAnalytics(metric: any) {
// metric.name === 'INP'
// metric.value: ms
// metric.id: unique id
// metric.attribution: 이벤트/타겟 힌트(브라우저 지원 범위 내)
navigator.sendBeacon('/rum', JSON.stringify(metric));
}
onINP(sendToAnalytics, { reportAllChanges: true });
reportAllChanges를 켜면 상호작용이 누적되며 값이 업데이트될 때마다 보고되어, “어떤 릴리즈 이후 상호작용이 나빠졌는지”를 비교하기 좋습니다.
Long Task 수집 (PerformanceObserver)
// Long Task: entryType = 'longtask'
// Chrome 계열에서 지원
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: ms
// entry.startTime: navigationStart 기준 ms
// entry.attribution: 어떤 작업이 원인인지 힌트(브라우저별 상이)
const payload = {
type: 'longtask',
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
attribution: entry.attribution || [],
url: location.href,
};
// 너무 자주 보내면 비용이 커지므로 샘플링 권장
if (entry.duration > 200) {
navigator.sendBeacon('/rum/longtask', JSON.stringify(payload));
}
}
});
longTaskObserver.observe({ entryTypes: ['longtask'] });
이렇게 모은 Long Task를 INP 이벤트와 같은 세션/페이지/릴리즈로 묶으면, “INP 급락 시점에 어떤 Long Task가 늘었는지”를 빠르게 역추적할 수 있습니다. (운영에서 원인 추적을 체계화한다는 점에서는 장애 원인 파악 방법론과 유사합니다. 예를 들어 리눅스에서 리소스 이슈를 파고드는 방식은 리눅스 OOM Killer로 프로세스 죽음 원인 추적과도 결이 같습니다.)
4) 해결 전략 1: “쪼개기” — 한 번에 300ms 하던 일을 3번에 20ms로
INP 관점에서 가장 강력한 처방은 긴 동기 작업을 쪼개서 메인 스레드에 숨 쉴 틈을 주는 것입니다.
requestIdleCallback(가능할 때만) + setTimeout 분할
function chunkedWork(items, handler, chunkSize = 200) {
let i = 0;
function runChunk(deadline) {
// requestIdleCallback은 idle time 기반이라 환경에 따라 호출이 늦어질 수 있음
// deadline.timeRemaining()이 충분하지 않으면 다음 idle로 넘김
while (i < items.length && (!deadline || deadline.timeRemaining() > 5)) {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) handler(items[i]);
}
if (i < items.length) {
schedule();
}
}
function schedule() {
if ('requestIdleCallback' in window) {
requestIdleCallback(runChunk, { timeout: 500 });
} else {
setTimeout(() => runChunk(null), 0);
}
}
schedule();
}
// 클릭 핸들러에서 무거운 작업을 즉시 수행하지 말고 분할
button.addEventListener('click', () => {
chunkedWork(bigArray, (x) => {
// 가벼운 단위 작업
doSomething(x);
});
});
핵심은 “총 시간”이 비슷하더라도, 입력 직후 첫 페인트를 막지 않게 만드는 것입니다.
React/Next.js라면: 긴 상태 업데이트를 transition으로
import { useTransition } from 'react';
function Search() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function onChange(e) {
const next = e.target.value;
setQuery(next); // 입력 반영은 즉시
startTransition(() => {
// 무거운 필터링/렌더링은 transition으로 밀기
setResults(expensiveFilter(next));
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending ? <span>Updating…</span> : <ResultList items={results} />}
</>
);
}
이렇게 하면 입력 반응성이 좋아져 INP 악화를 줄이는 데 도움이 됩니다.
5) 해결 전략 2: “오프로딩” — CPU 작업은 Web Worker로
정렬/압축/파싱/대량 데이터 가공처럼 DOM이 필요 없는 CPU 작업은 Web Worker로 보내는 게 정석입니다.
Worker 예시
// worker.js
self.onmessage = (e) => {
const { items } = e.data;
// 무거운 계산
const result = items
.map(x => ({ ...x, score: heavyScore(x) }))
.sort((a, b) => b.score - a.score);
self.postMessage({ result });
};
function heavyScore(x) {
let s = 0;
for (let i = 0; i < 20000; i++) s = (s + x.id * i) % 1000003;
return s;
}
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.onmessage = (e) => {
render(e.data.result); // DOM 업데이트는 메인에서
};
button.addEventListener('click', () => {
// 입력 직후 메인 스레드 점유를 피함
worker.postMessage({ items: bigItems });
});
Worker로 옮기면 Long Task가 크게 줄고, INP가 눈에 띄게 회복되는 경우가 많습니다.
6) 해결 전략 3: “DOM/레이아웃 폭발” 줄이기
Long Task가 JS가 아니라 Layout/Recalculate Style에 쏠려 있다면, 아래를 우선 점검하세요.
- 한 번의 입력에 수백~수천 개 노드를 추가/삭제하지 않는가
offsetHeight,getBoundingClientRect()같은 레이아웃 스래싱이 반복되는가- 애니메이션을
top/left로 돌려 layout을 유발하지 않는가 (transform권장) - 리스트는 가상화(virtualization) 적용이 가능한가
레이아웃 스래싱 방지 예시
// 나쁜 예: 읽기-쓰기-읽기-쓰기 반복
for (const el of items) {
const h = el.getBoundingClientRect().height; // read
el.style.height = (h + 10) + 'px'; // write
}
// 개선: 읽기 먼저 모으고, 쓰기를 나중에 일괄 처리
const heights = items.map(el => el.getBoundingClientRect().height);
items.forEach((el, idx) => {
el.style.height = (heights[idx] + 10) + 'px';
});
7) 해결 전략 4: 3rd-party 스크립트 실행 타이밍 재설계
INP 급락의 숨은 주범은 “기능 코드”가 아니라 태그/광고/세션리플레이인 경우가 많습니다.
- 초기 로드에 꼭 필요 없다면
defer/async+ 사용자 첫 상호작용 이후 로드 - 라우트 전환/모달 오픈 같은 상호작용 직후에는 추적 이벤트를 배치로 모아서 idle에 전송
입력 직후 추적을 idle로 미루기
function track(event) {
const send = () => {
navigator.sendBeacon('/track', JSON.stringify(event));
};
if ('requestIdleCallback' in window) {
requestIdleCallback(send, { timeout: 1000 });
} else {
setTimeout(send, 200);
}
}
button.addEventListener('click', () => {
openModal(); // UX 우선
track({ type: 'open' }); // 추적은 뒤로
});
8) 배포 후 검증: “INP는 평균이 아니라 p75 싸움”
수정 후에는 단순히 Lighthouse 점수만 보지 말고, 아래로 검증하세요.
- RUM에서 p75 INP가 회복되는지(릴리즈 전/후 비교)
- Long Task(>200ms 등) 발생 빈도가 줄었는지
- 특정 페이지/상호작용에만 남아있는지(세그먼트)
또한 성능 회귀는 기능 회귀처럼 CI에서 잡기 어렵기 때문에, 릴리즈 단위로 성능 예산(performance budget) 을 두고 “Long Task가 일정 횟수 이상이면 경고” 같은 운영 가드레일을 만드는 것이 좋습니다. (워크플로우/배포 파이프라인을 체계화하는 관점에서는 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리처럼 자동화 전략을 참고해도 좋습니다.)
체크리스트: INP 급락 시 30분 안에 보는 순서
- 어떤 상호작용에서 INP가 튀는가? (페이지/버튼/입력)
- DevTools Performance에서 Main Long Task를 클릭 → Bottom-Up 상위 함수 확인
- Long Task가
- JS라면: 분할/transition/worker
- Layout이라면: 스래싱 제거/DOM 업데이트 최소화/가상화
- 3rd-party라면: 로드/실행 타이밍 조정
- web-vitals + longtask observer로 재발 감시
마무리
INP는 “자바스크립트가 빠르냐”보다 “사용자 입력 순간에 메인 스레드가 비어 있냐”에 더 가깝습니다. 그래서 해결도 최적화(빠르게) 보다 스케줄링(적절한 타이밍에) 이 더 큰 효과를 내는 경우가 많습니다.
INP가 급락했다면, 먼저 Long Task를 잡아 “누가 입력 직후를 점유하는지”를 명확히 한 뒤, 쪼개기·오프로딩·DOM 폭발 방지·3rd-party 지연을 적용해 보세요. 대부분의 케이스는 이 네 가지 안에서 깔끔하게 정리됩니다.