- Published on
Chrome INP 급락? Long Task 추적·분해 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표는 멀쩡한데 사용자 체감이 갑자기 나빠지고, CrUX나 RUM에서 INP가 급락하는 케이스가 자주 있습니다. 이때 많은 팀이 네트워크나 백엔드부터 의심하지만, INP는 본질적으로 “사용자 입력에 대한 다음 페인트까지”의 지연을 재는 지표라서, 프론트엔드 메인 스레드가 막히는 순간에 크게 흔들립니다.
특히 Chrome에서 INP가 나빠졌다는 리포트가 뜨면, 가장 먼저 확인할 대상은 Long Task입니다. Long Task는 메인 스레드를 긴 시간 점유해 입력 처리와 렌더링을 지연시키고, 그 결과 INP가 악화됩니다. 이 글에서는 Long Task를 실제로 “추적하고, 어떤 코드가 막는지 찾고, 어떻게 분해해서 개선하는지”를 단계별로 정리합니다.
INP와 Long Task의 관계를 빠르게 정리
INP는 사용자의 클릭, 탭, 키 입력 같은 상호작용이 발생한 시점부터, 브라우저가 그 상호작용의 결과를 화면에 그려주는 다음 페인트 시점까지의 지연을 측정합니다. 즉 아래 경로 중 어디가 막혀도 INP가 튑니다.
- 입력 이벤트가 큐에 쌓여서 핸들러가 늦게 실행됨
- 핸들러가 실행되지만 자바스크립트가 오래 걸려 다음 단계로 못 감
- 상태 업데이트 이후 레이아웃, 스타일 계산, 페인트가 지연됨
Long Task는 보통 “50ms를 초과하는 메인 스레드 작업”을 의미하며, 다음을 포함합니다.
- 무거운 이벤트 핸들러
- 대량 DOM 업데이트로 인한 레이아웃 스래싱
- 큰 JSON 파싱, 동기식 데이터 가공
- 번들 초기화 비용, 폴리필, 서드파티 스크립트
INP 급락이 보이면, 먼저 Long Task가 어느 상호작용에서 발생하는지 특정하고, 그 작업을 쪼개거나 메인 스레드 밖으로 빼는 전략을 세우는 게 가장 빠른 길입니다.
0단계: “재현 가능한 케이스”부터 만들기
실전에서 가장 흔한 실패는 “INP가 나쁘다”는 숫자만 보고 개선을 시작하는 것입니다. 먼저 아래를 확보하세요.
- 어떤 페이지에서 INP가 나쁜지
- 어떤 상호작용에서 나쁜지: 클릭, 입력, 스크롤 등
- 어떤 기기에서 심한지: 저사양 안드로이드, iOS, 데스크톱
- 배포 시점과 상관관계: 특정 릴리즈 이후인지
RUM을 쓰고 있다면 “worst interaction” 샘플(상호작용 타입, 타겟 셀렉터, 타임라인)을 뽑아 DevTools에서 재현하는 게 가장 좋습니다.
Next.js 앱이라면 캐시나 RSC 관련 변화가 체감 성능에 영향을 줄 때도 있으니, 릴리즈 직후에 특정 페이지에서만 튄다면 캐시/재검증 흐름도 같이 의심해볼 만합니다. 관련해서는 Next.js 14 RSC 캐시 꼬임, revalidateTag로 푸는 법도 함께 참고하면 좋습니다.
1단계: DevTools Performance로 Long Task를 “상호작용 단위로” 찾기
권장 측정 세팅
- Chrome DevTools
Performance탭 - 톱니바퀴 설정에서
Web Vitals또는Event Log가 보이면 활성화 - CPU 스로틀링을
4x또는6x로 걸어 저사양을 근사 - 레코딩 시작 후 “문제 상호작용”을 딱 1번만 수행
- 레코딩 종료 후, 타임라인에서 긴 노란색(스크립트) 구간과 빨간 경고를 확인
여기서 핵심은 “상호작용을 1번만” 수행하는 것입니다. 여러 번 클릭하면 원인 이벤트가 섞여 분석이 어려워집니다.
Long Task에서 봐야 하는 3가지
- 어떤 이벤트에서 시작했는가:
click,pointerdown,input등 - Long Task 내부에서 시간이 어디에 쓰였는가: 스크립트, 스타일, 레이아웃, 페인트
- 호출 스택이 어디로 이어지는가: 내 코드인지, 라이브러리인지, 서드파티인지
DevTools에서 Long Task 구간을 클릭하면 하단에 Call Tree, Bottom-Up, Event Log가 보입니다. 보통 Bottom-Up이 “시간을 가장 많이 먹는 함수”를 찾기 쉽습니다.
2단계: Long Task 원인을 4가지 유형으로 분류
실전에서 Long Task는 대개 아래 중 하나로 귀결됩니다.
유형 A: 이벤트 핸들러가 무거움
클릭 핸들러에서 동기식으로 큰 연산을 하거나, 상태 업데이트와 DOM 변경이 한 번에 몰리는 경우입니다.
- 대량 데이터 정렬/필터링
- 큰 문자열 처리
- 여러 setState가 연쇄적으로 발생
유형 B: 렌더링 비용이 폭발
JS 자체는 짧아도, DOM 변경 이후 스타일 계산과 레이아웃이 길어질 수 있습니다.
- 테이블, 리스트, 카드가 한 번에 수백 개 렌더
- 측정 API를 반복 호출하면서 레이아웃 스래싱 발생
유형 C: 서드파티 스크립트가 메인 스레드를 점유
광고, 태그 매니저, A/B 테스트, 분석 도구가 클릭 타이밍에 동기 작업을 끼워 넣는 케이스가 있습니다.
유형 D: 초기 로딩 이후 “첫 상호작용”이 무거움
페이지는 떠 있지만, 첫 클릭에서 lazy 모듈 로딩, 초기화가 한 번에 터지는 패턴입니다.
- 첫 클릭에서 에디터 로딩
- 첫 입력에서 formatter 초기화
이 분류를 먼저 해두면 개선 전략이 곧바로 정해집니다.
3단계: Long Task를 쪼개는 1순위 전략들
3-1. 작업을 프레임 사이로 쪼개기: scheduler.yield() 또는 setTimeout
큰 루프를 한 번에 돌리면 메인 스레드를 오래 점유합니다. 가능한 경우 “조각내서” 프레임 사이에 양보하세요.
아래는 scheduler.yield()를 이용해 루프를 분할하는 예시입니다. 지원이 애매한 브라우저가 있다면 폴백을 둡니다.
const yieldToMain = async () => {
if (typeof scheduler !== 'undefined' && scheduler.yield) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
};
export async function chunkedProcess(items, onProgress) {
const result = [];
for (let i = 0; i < items.length; i++) {
// 무거운 계산
result.push(expensiveTransform(items[i]));
// 200개마다 메인 스레드에 양보
if (i % 200 === 0) {
onProgress?.(i);
await yieldToMain();
}
}
return result;
}
포인트는 “입력 이벤트 처리 직후 가능한 빨리 반환”하도록 만드는 것입니다. 핸들러에서 모든 일을 끝내려 하지 말고, UI 업데이트를 먼저 하고 나머지를 뒤로 미루면 INP가 눈에 띄게 좋아집니다.
3-2. CPU 작업은 Web Worker로 이동
파싱, 압축, 대량 연산은 Worker로 보내는 게 정석입니다. 메인 스레드에서 해야 할 일은 “UI 반응”과 “최소한의 상태 변경”만 남깁니다.
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
export function runHeavyJob(payload) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data);
worker.onerror = (e) => reject(e);
worker.postMessage(payload);
});
}
// worker.js
self.onmessage = (e) => {
const data = e.data;
const out = heavyCompute(data);
self.postMessage(out);
};
Worker로 옮긴 뒤에는 “메시지 직렬화 비용”도 확인해야 합니다. 큰 객체를 자주 주고받으면 오히려 손해일 수 있으니, 구조를 단순화하거나 Transferable을 검토하세요.
3-3. 렌더 폭발은 “가시 영역만” 렌더: 리스트 가상화
INP가 클릭 후 렌더링 때문에 튄다면, 대량 DOM이 원인일 확률이 높습니다. 이 경우 가장 효과적인 처방은 가상화입니다.
- React라면
react-window,react-virtualized계열 - 자체 구현이라면 스크롤 컨테이너에서 보이는 구간만 렌더
가상화는 단순 최적화가 아니라, 렌더 비용을 상수 수준으로 제한하는 구조적 해결입니다.
3-4. 레이아웃 스래싱 제거: 측정과 쓰기를 분리
아래처럼 DOM을 읽고(getBoundingClientRect) 바로 쓰고(style)를 반복하면 레이아웃이 강제 동기화되어 Long Task가 발생합니다.
// 안 좋은 예: read/write 반복
for (const el of items) {
const rect = el.getBoundingClientRect();
el.style.transform = `translateY(${rect.top}px)`;
}
개선은 “읽기 단계”와 “쓰기 단계”를 분리하는 것입니다.
// 좋은 예: read phase -> write phase
const rects = items.map((el) => el.getBoundingClientRect());
for (let i = 0; i < items.length; i++) {
items[i].style.transform = `translateY(${rects[i].top}px)`;
}
또는 쓰기 자체를 requestAnimationFrame에 모아 프레임 경계에서 처리하는 방식도 효과가 큽니다.
4단계: “상호작용 경로”를 짧게 만드는 패턴
4-1. 입력 즉시 반응이 필요한 UI를 분리
예를 들어 검색 입력에서 매 타이핑마다 무거운 필터링을 하면 INP가 나빠집니다. 입력 반응과 결과 갱신을 분리하세요.
- 입력값은 즉시 state에 반영
- 결과 필터링은 디바운스 또는 idle 타이밍에 실행
let t;
input.addEventListener('input', (e) => {
const q = e.target.value;
renderQuery(q); // 즉시 반응
clearTimeout(t);
t = setTimeout(() => {
const result = heavyFilter(allItems, q);
renderResult(result);
}, 150);
});
React를 사용 중이고 특정 훅/서스펜스 조합에서 리렌더가 폭증한다면, 상호작용 지연으로 이어질 수 있습니다. 비슷한 성격의 문제를 다룬 글로 React 19 use() 무한 Suspense·리렌더 해결법도 함께 보면 원인 파악에 도움이 됩니다.
4-2. 이벤트 위임과 핸들러 최소화
리스트 아이템 수백 개에 각각 핸들러를 붙이면 메모리와 이벤트 처리 비용이 증가합니다. 상위 컨테이너에서 이벤트 위임을 쓰고, 핸들러는 가능한 가볍게 유지하세요.
list.addEventListener('click', (e) => {
const item = e.target.closest('[data-id]');
if (!item) return;
selectItem(item.dataset.id);
});
4-3. 서드파티는 상호작용 경로에서 분리
태그 매니저나 분석 SDK가 클릭 시점에 동기 전송이나 무거운 작업을 하면 INP를 망칩니다.
- 클릭 핸들러에서는 큐에만 적재
- 전송은
requestIdleCallback또는 백그라운드 타이밍에 처리
const queue = [];
function track(event) {
queue.push({ event, ts: Date.now() });
const flush = () => {
const batch = queue.splice(0, queue.length);
// sendBeacon 사용 가능하면 메인 스레드 영향이 줄어듦
navigator.sendBeacon?.('/analytics', JSON.stringify(batch));
};
if ('requestIdleCallback' in window) {
requestIdleCallback(flush, { timeout: 1000 });
} else {
setTimeout(flush, 500);
}
}
5단계: Long Task를 “측정 가능하게” 만들기
DevTools에서 한 번 보고 끝내면 재발합니다. 배포 후에도 Long Task를 잡아내려면 런타임 계측이 필요합니다.
5-1. Long Task PerformanceObserver로 수집
브라우저는 Long Task 엔트리를 제공합니다. 이를 이용해 “어느 페이지에서, 어떤 타이밍에 Long Task가 많은지”를 RUM으로 보낼 수 있습니다.
export function observeLongTasks(onEntry) {
if (!('PerformanceObserver' in window)) return;
try {
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration, entry.startTime
onEntry(entry);
}
});
po.observe({ entryTypes: ['longtask'] });
return () => po.disconnect();
} catch {
// 일부 환경에서 실패할 수 있으니 무시
}
}
실전에서는 아래 정보를 함께 보내면 디버깅이 쉬워집니다.
- 라우트, 빌드 버전, 디바이스 힌트
- Long Task 발생 시점의 최근 상호작용 타입
- Long Task 직전의 사용자 액션(가능하면)
5-2. INP와 Long Task를 같이 보자
INP가 나쁜 세션에서 Long Task가 동시에 증가하는지 보면 “원인 후보”를 빠르게 좁힐 수 있습니다. 단, INP는 입력 이벤트와 연결되므로 “Long Task가 언제 발생했는지”를 상호작용과 엮어 보는 게 중요합니다.
6단계: 자주 놓치는 원인 체크리스트
- 클릭 시 모달을 열면서 백드롭, 스크롤 락, 포커스 트랩이 동시에 동작해 레이아웃이 튐
- 코드 스플리팅이 과도해서 첫 상호작용마다 동적 import 초기화가 발생
- 폰트 로딩, 이미지 디코딩이 상호작용 직후 페인트를 지연
- 개발 모드에서만 재현되는 리렌더 폭증을 운영에서 착각
- 특정 브라우저에서만 페인트 비용이 큰 CSS 효과 사용
스크롤이나 페인트 쪽이 의심된다면 iOS Safari 사례지만 레이어와 페인트 최적화 관점이 유사하므로 Safari iOS 스크롤 끊김 - 레이어·페인트 최적화도 참고할 만합니다.
7단계: 개선 적용 순서(실전 추천)
- Performance 레코딩으로 “문제 상호작용 1개”를 확정
- Long Task의 주범이 내 코드인지, 렌더링인지, 서드파티인지 분류
- 내 코드면 즉시 쪼개기: yield, 디바운스, 이벤트 경량화
- CPU 연산이면 Worker로 이동
- 렌더 폭발이면 가상화, 불필요한 리렌더 제거
- 레이아웃 스래싱 제거: read/write 분리,
requestAnimationFrame배치 - RUM으로 Long Task를 계측해 재발 감시
마무리: INP 급락은 “한 번의 긴 점유”에서 시작된다
INP는 평균적인 성능이 아니라 “사용자가 실제로 클릭했을 때의 최악 지연”에 민감합니다. 그래서 평소엔 티가 안 나다가도, 특정 상호작용에서 단 한 번 Long Task가 터지면 지표가 급격히 나빠질 수 있습니다.
가장 중요한 태도는 두 가지입니다.
- 상호작용 단위로 추적하고, 그 순간의 메인 스레드 점유를 줄이는 것
- 한 번 고친 뒤에도 Long Task를 계측해 재발을 막는 것
위 절차대로 원인을 특정하고 Long Task를 분해하면, INP는 생각보다 빠르게 회복되는 경우가 많습니다.