- Published on
Chrome INP 급락 - Long Task 잡는 8단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답이 빠른데도 Chrome UX Report나 RUM에서 INP가 급락하는 경우가 있습니다. 이때 가장 흔한 패턴은 메인 스레드가 50ms 이상 점유되는 Long Task가 상호작용 처리 경로에 끼어드는 것입니다. INP는 사용자 입력부터 다음 페인트까지의 지연을 보므로, 클릭 직후의 자바스크립트 실행, 스타일 계산, 레이아웃, 페인트, 그리고 프레임 드랍까지 모두 영향을 줍니다.
이 글은 "INP가 떨어졌다"를 "어떤 Long Task가, 어떤 입력에서, 어떤 코드로 발생했는지"로 쪼개서 해결하는 8단계 절차를 제공합니다. Long Task를 추적하는 기본 방법은 아래 글에서 더 자세히 다뤘으니, 본문에서는 "잡는" 쪽에 집중합니다.
0) 전제: INP를 망치는 대표 원인 3가지
Long Task는 결과이고, 원인은 보통 아래 세 갈래로 수렴합니다.
- 입력 직후 동기 작업 폭주: 클릭 핸들러에서 대량 연산, JSON 파싱, 큰 DOM 업데이트
- 렌더링 비용 폭주: 레이아웃 스래싱, 무거운 CSS, 큰 이미지 디코드, 과도한 페인트
- 하이드레이션 및 초기 부트 비용: Next.js 같은 프레임워크에서 초기 상호작용 시점에 JS가 몰림
특히 Next.js 앱이라면 하이드레이션 이슈가 Long Task처럼 보이는 경우가 많습니다. 필요하면 아래 글도 함께 보세요.
1단계: INP가 나빠진 "입력"을 먼저 고정한다
INP는 페이지의 여러 상호작용 중 "최악" 또는 "상위 구간"을 대표값으로 잡습니다. 따라서 먼저 어떤 입력에서 문제가 터지는지 고정해야 합니다.
- RUM에서 이벤트 타입을 나눕니다:
click,pointerdown,keydown,submit - 문제 화면에서 재현 시나리오를 문장으로 고정합니다
- 예: "상품 리스트에서 필터 클릭 후 첫 렌더가 늦다"
- 예: "검색창에 첫 글자 입력이 끊긴다"
가능하면 PerformanceObserver로 실제 사용자에서 INP 후보 이벤트를 수집해, 어떤 컴포넌트/라우트에서 터지는지 태깅합니다.
// 브라우저에서 INP 후보 이벤트를 관측 (실서비스에서는 샘플링/전송 필요)
// web-vitals 라이브러리를 써도 되지만, 원리 이해를 위해 간단 예시
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// event는 Event Timing API 엔트리
// entry.duration은 처리 지연(입력~다음 페인트) 관점의 값으로 해석 가능
console.log('[event]', entry.name, 'duration(ms)=', entry.duration);
}
});
try {
po.observe({ type: 'event', buffered: true, durationThreshold: 16 });
} catch (e) {
// 일부 환경에서는 지원이 제한될 수 있음
}
핵심은 "느린 입력"을 단 하나라도 고정하고, 그 입력의 처리 경로를 끝까지 따라가는 것입니다.
2단계: DevTools Performance에서 Long Task를 "입력 직후"로 좁힌다
Performance 패널에서 전체를 보면 잡음이 많습니다. 아래처럼 범위를 줄입니다.
Performance녹화 시작- 문제 입력을 딱 한 번 수행
- 녹화 종료
- 타임라인에서 입력 이벤트(
click등) 직후 구간만 드래그로 확대
여기서 확인 포인트는 2가지입니다.
- 메인 스레드에 노란색(스크립트) 덩어리가 길게 이어지는가
- 그 덩어리가 입력 이벤트 핸들러와 시간적으로 맞물리는가
Long Task는 보통 Main 트랙에서 Task 또는 긴 Evaluate Script로 보이며, 하나가 50ms를 넘으면 사용자 체감이 급격히 나빠집니다.
3단계: Long Task를 "함수"가 아니라 "작업 묶음"으로 분해한다
많은 팀이 여기서 실수합니다. 프로파일에서 최상단에 React나 Zone 같은 프레임워크 함수가 보이면 "프레임워크가 느리다"고 결론내리는데, 실제로는 그 아래에 있는 사용자 코드가 원인인 경우가 대부분입니다.
분해 방법:
- Call Tree만 보지 말고
Bottom-Up도 같이 봅니다 Self Time이 큰 항목을 우선합니다Recalculate Style,Layout,Update Layer Tree,Paint가 길면 JS 최적화만으로는 해결이 안 됩니다
결론적으로 Long Task는 아래 3조각으로 나눠야 합니다.
- JS 실행 비용
- 렌더링 비용(스타일/레이아웃/페인트)
- 입력 처리 타이밍(핸들러가 언제 실행되는지)
4단계: "입력 핸들러"에서 동기 작업을 잘라낸다
클릭 핸들러에서 무거운 일을 하면 INP가 직격으로 나빠집니다. 1차 목표는 "핸들러를 즉시 반환"시키는 것입니다.
패턴 A: 큰 작업을 프레임 뒤로 미루기
- UI 반응(상태 토글, 로딩 표시)을 먼저
- 무거운 작업은
requestAnimationFrame또는setTimeout으로 뒤로
button.addEventListener('click', () => {
// 1) 먼저 즉각적인 UI 업데이트
document.body.classList.add('loading');
// 2) 다음 프레임 이후 무거운 작업
requestAnimationFrame(() => {
// 예: 큰 배열 정렬, 대량 DOM 생성 등
heavyWork();
document.body.classList.remove('loading');
});
});
패턴 B: 긴 루프를 쪼개서 양보하기
yield 포인트를 만들어 메인 스레드 독점을 피합니다.
function chunked(items, fn, chunkSize = 200) {
let i = 0;
return new Promise((resolve) => {
function run() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) fn(items[i], i);
if (i < items.length) {
// 다음 틱으로 양보
setTimeout(run, 0);
} else {
resolve();
}
}
run();
});
}
이 단계의 목표는 "Long Task를 여러 개의 짧은 Task"로 바꾸는 것입니다.
5단계: 렌더링 병목이면 "레이아웃 스래싱"부터 제거한다
JS 최적화를 했는데도 INP가 안 오르면, 대부분 렌더링 비용입니다. 대표 원인은 레이아웃 스래싱입니다.
- 잘못된 예: 읽기(
offsetWidth)와 쓰기(style.width)를 번갈아 수행
// 안 좋은 예: 반복마다 레이아웃 강제
for (const el of cards) {
const w = el.offsetWidth;
el.style.width = (w + 10) + 'px';
}
- 개선: 읽기와 쓰기를 분리
const widths = cards.map((el) => el.offsetWidth);
for (let i = 0; i < cards.length; i++) {
cards[i].style.width = (widths[i] + 10) + 'px';
}
또한 애니메이션/전환은 가능하면 transform과 opacity로 제한해 레이아웃과 페인트를 피합니다.
6단계: 프레임워크 앱은 "초기 상호작용"에 몰린 JS를 분산한다
INP 급락이 특정 릴리즈 이후 발생했다면, 번들 증가나 하이드레이션 타이밍 변화가 원인일 수 있습니다.
체크리스트:
- 특정 컴포넌트가 클라이언트로 내려오면서 번들이 커졌는가
- 초기 렌더 직후
useEffect에서 무거운 일을 하는가 - 첫 클릭에 동적 import가 한꺼번에 터지는가
Next.js에서 흔한 처방
- 무거운 위젯은 동적 로드 + SSR 비활성화
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
ssr: false,
loading: () => null,
});
export default function Page() {
return (
<section>
<h1>Dashboard</h1>
<HeavyChart />
</section>
);
}
- 이벤트 직후 필요한 코드만 먼저 로드되게 분리(라우트/컴포넌트 단위 쪼개기)
이 단계는 "첫 입력"에서만 INP가 나쁜 케이스에 특히 효과가 큽니다.
7단계: Third-party 스크립트를 격리하거나 늦춘다
광고/태그/AB 테스트/챗 위젯은 Long Task의 단골입니다. 문제는 내 코드가 아니라서 최적화가 어렵다는 점입니다.
실전 우선순위:
- 정말 필요한가(제거)
- 초기 상호작용 전에 필요한가(지연)
- 메인 스레드 점유가 큰가(대체/격리)
로딩 전략 예시
<!-- 초기 렌더에 꼭 필요 없다면 defer 또는 지연 로드 고려 -->
<script defer src="/vendor/third-party.js"></script>
또는 사용자 상호작용 이후에만 로드:
let loaded = false;
window.addEventListener('pointerdown', () => {
if (loaded) return;
loaded = true;
const s = document.createElement('script');
s.src = '/vendor/third-party.js';
s.defer = true;
document.head.appendChild(s);
}, { once: true });
주의: 지연 로드는 측정/전환 누락 같은 제품 요구사항과 충돌할 수 있으니, 데이터팀과 합의 후 적용합니다.
8단계: "고친 뒤" INP가 유지되는지 회귀 방지를 건다
INP는 릴리즈 한 번으로 다시 무너집니다. Long Task는 코드리뷰만으로 잡기 어렵기 때문에, 자동화된 가드레일이 필요합니다.
권장 조합:
- CI에서 Lighthouse만으로는 부족할 수 있으니, 실제 상호작용을 포함한 시나리오 기반 측정을 추가
- 번들 크기/청크 수 변화 감시
- RUM에서 INP 상위 이벤트의
route,component,release태깅
간단한 "Long Task 감지"도 운영에서 도움됩니다.
// Long Task API로 50ms 이상 작업을 수집
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration이 길면 메인 스레드 점유가 길었다는 뜻
if (entry.duration >= 50) {
console.log('[longtask]', Math.round(entry.duration), 'ms');
}
}
});
try {
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch (e) {
// 지원하지 않는 브라우저도 있음
}
여기서 더 나아가면, Long Task 발생 시점의 사용자 행동(최근 클릭한 버튼 id 등)을 함께 로그로 남겨 "재현 가능한" 형태로 만드는 것이 핵심입니다.
실전 체크리스트 요약
- 느린 입력을 하나로 고정했는가
- 입력 직후 구간에서 Long Task를 확인했는가
- JS vs 렌더링 비용을 분리했는가
- 핸들러 동기 작업을 잘라냈는가
- 레이아웃 스래싱을 제거했는가
- 초기 상호작용에 몰린 번들을 분산했는가
- Third-party를 지연/격리했는가
- RUM과 CI로 회귀 방지를 걸었는가
INP는 "최적화"라기보다 "메인 스레드 스케줄링" 문제로 보는 게 빠릅니다. Long Task를 없애는 것보다, Long Task가 입력 처리 경로를 가로막지 못하게 만드는 것이 더 현실적인 해법인 경우도 많습니다. 위 8단계를 순서대로 적용하면, 원인 파악부터 재발 방지까지 한 번에 정리할 수 있습니다.