- Published on
Chrome INP 급락? Long Task 10분 추적·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 지표가 멀쩡한데 유독 **Chrome INP(Interaction to Next Paint)**만 갑자기 나빠지는 순간이 있습니다. 특히 릴리즈 직후나 특정 페이지/플로우에서만 재현되면, 대부분은 메인 스레드(Main Thread)에서 50ms를 초과하는 Long Task가 사용자 입력을 막아 INP를 끌어내립니다.
이 글은 “원인 추정”이 아니라 DevTools에서 Long Task를 바로 잡아내고, 그 자리에서 코드 레벨로 줄이는 흐름을 10분 루틴으로 정리합니다. (React/Next.js, 바닐라 JS 모두 적용 가능)
INP 급락(악화)의 전형적인 패턴
INP는 “사용자 입력(클릭/탭/키 입력) → 다음 페인트까지 걸린 시간”의 최악 또는 상위 퍼센타일을 대표값으로 잡습니다. 그래서 아래 패턴이 있으면 INP가 급격히 악화됩니다.
- 클릭 직후 동기 JS가 길게 실행되어 렌더링이 막힘
- 입력 이벤트 핸들러에서 레이아웃 강제 계산(reflow) 유발
- 대량 DOM 업데이트/리스트 렌더링이 한 번에 발생
- JSON 파싱/정렬/압축/암호화 등 CPU 작업을 메인 스레드에서 처리
- 서드파티 스크립트가 특정 타이밍에 긴 작업을 수행
핵심은 “내 코드가 빠르다”가 아니라, 사용자 입력 직후 200~800ms짜리 블로킹 작업이 한 번이라도 있으면 INP가 무너질 수 있다는 점입니다.
10분 추적 루틴: DevTools로 Long Task를 ‘입력 기준’으로 잡기
1) 재현 플로우를 고정하고 시크릿/캐시 클린
- 시크릿 창(확장 프로그램 영향 최소화)
- CPU/네트워크 환경을 고정(가능하면 CPU 4x slowdown도 한 번)
- 문제 페이지에서 “INP가 나빠지는 클릭/입력”을 딱 하나로 고정
2) Performance 패널에서 “입력 → 페인트” 구간을 캡처
- Chrome DevTools → Performance
- Record 시작
- 문제 입력(클릭/키 입력) 1회 수행
- Record 종료
3) Main 트랙에서 Long Task를 찾고, 이벤트 원인을 역추적
- 타임라인에서 입력 시점 주변을 확대
- Main 트랙의 긴 막대(노란색 Script, 보라색 Rendering 등)를 클릭
- 우측/하단의 Call Tree / Bottom-Up에서 가장 무거운 함수를 확인
여기서 중요한 건 “전체 페이지 로드”가 아니라 입력 직후 0~2초 구간만 집중하는 것입니다. INP는 상호작용 지표라서, 로드 타임 최적화와 디버깅 관점이 다릅니다.
4) Long Task가 ‘내 코드’인지 ‘서드파티’인지 먼저 분리
- Call stack에 내 번들 파일(예:
app.js,main.tsx)이 보이면 내 코드 gtm.js,analytics.js,ads,tagmanager등이면 서드파티
서드파티가 원인이라면 “제거”가 최선이지만, 현실적으로는 지연 로드/상호작용 이후 로드/샌드박스(iframe)/샘플링 같은 완화 전략이 필요합니다.
원인별 해결: Long Task를 줄이는 7가지 실전 처방
아래는 Performance 캡처에서 가장 자주 나오는 원인과, 바로 적용 가능한 해결책입니다.
1) 입력 핸들러에서 무거운 동기 작업(파싱/정렬/필터)
증상: 클릭 이벤트 핸들러 안에서 JSON 파싱 + 정렬 + 렌더 트리거를 한 번에 수행.
해결: 입력 직후에는 UI를 먼저 그리게 하고(페인트), 무거운 작업은 다음 틱/idle로 미룹니다.
// 나쁜 예: 클릭 직후 동기 작업으로 메인 스레드 블로킹
button.addEventListener('click', () => {
const data = JSON.parse(bigJsonString);
data.sort((a, b) => b.score - a.score);
render(data);
});
// 개선 1: UI 업데이트를 먼저 확정하고, 무거운 작업은 다음 프레임 이후로
button.addEventListener('click', () => {
showLoadingUI();
requestAnimationFrame(() => {
// 다음 페인트 이후 실행
const data = JSON.parse(bigJsonString);
data.sort((a, b) => b.score - a.score);
render(data);
hideLoadingUI();
});
});
추가로 requestIdleCallback(지원 범위 주의)로 “사용자 입력 직후”를 피하는 것도 효과적입니다.
const ric = window.requestIdleCallback || ((cb) => setTimeout(() => cb({ timeRemaining: () => 0 }), 1));
button.addEventListener('click', () => {
showLoadingUI();
ric(() => {
const data = JSON.parse(bigJsonString);
render(data);
hideLoadingUI();
});
});
2) 대량 DOM 업데이트(리스트/테이블)로 렌더링 폭발
증상: 클릭 한 번에 1,000개 DOM 노드를 생성/갱신 → Rendering/Style/Layout가 길어짐.
해결: 가상화(virtualization) 또는 incremental rendering(조각 렌더링).
// 조각 렌더링: 한 번에 다 붙이지 말고 50개씩 나눠서 붙이기
function renderChunked(items, container, chunkSize = 50) {
let i = 0;
function appendChunk() {
const frag = document.createDocumentFragment();
for (let c = 0; c < chunkSize && i < items.length; c++, i++) {
const el = document.createElement('div');
el.textContent = items[i].title;
frag.appendChild(el);
}
container.appendChild(frag);
if (i < items.length) {
requestAnimationFrame(appendChunk); // 프레임마다 조금씩
}
}
requestAnimationFrame(appendChunk);
}
React/Next.js라면 react-window, react-virtualized 계열로 리스트 가상화가 정석입니다.
3) 레이아웃 스래싱(Layout Thrashing)
증상: DOM 읽기(offsetHeight, getBoundingClientRect)와 쓰기(style.width=...)가 반복되며 강제 reflow.
해결: 읽기/쓰기를 분리해서 배치.
// 나쁜 예: 읽기-쓰기-읽기-쓰기 반복
for (const el of elements) {
const h = el.getBoundingClientRect().height;
el.style.height = (h + 10) + 'px';
}
// 개선: 먼저 읽기만 모으고, 그 다음 쓰기
const heights = elements.map(el => el.getBoundingClientRect().height);
requestAnimationFrame(() => {
elements.forEach((el, idx) => {
el.style.height = (heights[idx] + 10) + 'px';
});
});
4) 메인 스레드에서 CPU 작업(압축/암호화/이미지 처리)
증상: 입력 직후 Script Long Task가 200ms~수 초.
해결: Web Worker로 오프로딩.
// main.js
const worker = new Worker('/worker.js');
button.addEventListener('click', () => {
showLoadingUI();
worker.postMessage({ type: 'heavy', payload: bigArray });
});
worker.onmessage = (e) => {
const { result } = e.data;
render(result);
hideLoadingUI();
};
// worker.js
self.onmessage = (e) => {
if (e.data.type === 'heavy') {
const payload = e.data.payload;
// 예: 무거운 정렬/집계
payload.sort((a, b) => a - b);
self.postMessage({ result: payload });
}
};
Worker로 보내기 어려운 객체는 structuredClone 비용도 고려해야 합니다. 대용량은 Transferable(ArrayBuffer)로 전송하는 게 좋습니다.
5) 서드파티 스크립트가 입력 타이밍에 끼어듦
증상: Call Tree에 gtm.js, analytics, heatmap 등이 보이고, 입력 직후 긴 작업.
해결:
- 상호작용 이후 로드(첫 입력 전에는 로드하지 않기)
defer/async로 로드 순서 조정- 특정 페이지에서만 로드(전역 삽입 금지)
<!-- 예: 최초 사용자 상호작용 이후에만 로드 -->
<script>
let loaded = false;
function loadThirdParty() {
if (loaded) return;
loaded = true;
const s = document.createElement('script');
s.src = 'https://example.com/third-party.js';
s.async = true;
document.head.appendChild(s);
}
window.addEventListener('pointerdown', loadThirdParty, { once: true });
window.addEventListener('keydown', loadThirdParty, { once: true });
</script>
6) 이벤트 핸들러가 너무 자주 실행(스크롤/포인터 이동)
증상: mousemove, scroll에서 매 프레임 무거운 작업 → 입력 시점에도 메인이 바쁨.
해결: passive + requestAnimationFrame 스로틀.
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
// 여기서만 계산/DOM 업데이트
updateStickyHeader();
ticking = false;
});
}, { passive: true });
7) “나중에 하려다” 더 나빠지는 케이스: 마이크로태스크 폭주
증상: Promise.then() 체인이 길게 이어져 메인 스레드가 계속 점유(렌더링 기회 박탈).
해결: 적절히 macrotask로 끊기(setTimeout(0)) 또는 프레임 단위로 분할.
async function processInBatches(items, batchSize = 100) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
doWork(batch);
// 렌더링 기회를 주기 위해 macrotask로 양보
await new Promise(r => setTimeout(r, 0));
}
}
“잡았는지” 확인: INP 관점의 재측정 체크리스트
Long Task를 줄였다고 끝이 아닙니다. INP는 사용자 입력-페인트이므로, 다음을 확인해야 합니다.
- 문제 입력을 3~5회 반복해도 긴 작업이 다시 튀지 않는가?
- 같은 입력이라도 캐시 유무/데이터 크기에 따라 Long Task가 커지지 않는가?
- 스로틀 CPU(4x)에서 최악값이 과도하게 튀지 않는가?
- 모바일 실기기(특히 중저가)에서 메인 스레드가 더 쉽게 막히지 않는가?
가능하면 RUM(Real User Monitoring)에서 INP 분포를 같이 보세요. “일부 유저만” 겪는 경우는 데이터 크기(계정별), 서드파티, 확장 프로그램, 기기 성능 편차가 원인인 경우가 많습니다.
Next.js/React에서 특히 자주 터지는 포인트
- 상태 업데이트 한 번에 큰 컴포넌트 트리가 리렌더
- 리스트 키(key) 불안정으로 DOM 재생성
useEffect에서 동기 연산 + 연쇄 setState- RSC/캐시로 인한 stale UI를 해결하려다 클라이언트에서 보정 로직이 커짐
Next.js 캐시/렌더링 전략 때문에 UI가 꼬여서 “클라이언트에서 후처리”를 덧대다 보면 Long Task가 생기기도 합니다. 관련해서는 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 참고하면, 불필요한 클라이언트 보정 로직을 줄이는 데 도움이 됩니다.
운영 관점: ‘급락’은 보통 배포/외부요인과 같이 온다
INP가 갑자기 나빠졌다면, 코드 변경 외에도 환경/의존성 변화가 트리거였을 수 있습니다.
- 서드파티 태그 버전 변경(GTM 컨테이너 업데이트)
- A/B 실험 스크립트 추가
- 특정 API 응답이 커져서(예: payload 증가) 파싱/렌더 비용 증가
- 에러 재시도 루프/폴링 증가로 메인 스레드가 상시 바쁨
이런 “외부요인”은 백엔드/인프라 이슈처럼 원인을 분해하는 접근이 유효합니다. 예를 들어 네트워크 egress 문제가 생기면 프론트에서 재시도/타임아웃 처리로 메인 스레드가 바빠지는 식의 2차 효과도 생깁니다. 비슷한 진단 관점은 GCP Cloud NAT 포트 고갈로 egress 실패 진단법 같은 글의 체크리스트 접근이 참고가 됩니다.
마무리: 10분 안에 끝내는 핵심 요약
- INP 급락(악화)의 1순위는 입력 직후 Main Thread Long Task
- Performance 캡처는 “전체”가 아니라 입력 후 0~2초만 확대해서 본다
- Call Tree/Bottom-Up으로 가장 무거운 함수를 특정한다
- 해결은 대체로 3가지로 수렴
- 작업을 쪼개서 프레임에 분산(rAF/배치)
- 메인 스레드 밖으로 이동(Web Worker)
- 불필요한 렌더/레이아웃을 줄임(가상화/스래싱 제거)
이 루틴으로도 Long Task가 안 보이는데 INP만 나쁘다면, 다음 단계로는 Event Timing API 기반 RUM, 특정 입력의 핸들러 체인(캡처/버블), 프레임 드랍(GPU/애니메이션)까지 확장해서 보는 게 좋습니다.