- Published on
Chrome INP 급등? Long Task 추적·해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 빠른데도 “클릭이 늦게 먹는다”, “입력 후 UI가 멈춘다” 같은 체감이 생기면 대개 프론트의 메인 스레드(Main Thread) 가 막힌 상황입니다. 최근 Chrome/CrUX에서 INP(Interaction to Next Paint) 가 핵심 지표로 자리 잡으면서, 예전엔 TBT/TTI로 뭉뚱그려 보던 문제가 INP 급등으로 더 또렷하게 드러납니다.
이 글은 “INP가 왜 튀는지”를 감으로 추측하는 대신, Long Task를 근거로 추적하고 재현→원인 규명→해결→재검증까지 이어지는 실전 흐름을 정리합니다.
> 참고: 프론트 이슈도 결국 배포/캐시/서버 액션 등 백엔드 상태와 얽힐 때가 많습니다. Next.js 환경에서 캐시/CSRF/500이 섞여 증상을 왜곡하는 케이스는 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결도 함께 보시면 원인 분리가 쉬워집니다.
INP와 Long Task의 관계를 정확히 잡기
INP가 측정하는 것
INP는 사용자의 상호작용(클릭/탭/키 입력 등) 부터 다음 페인트(Next Paint) 까지 걸린 시간을 봅니다. 즉,
- 이벤트가 큐에 들어왔는데 메인 스레드가 바빠서 처리 시작이 늦거나
- 핸들러가 무겁거나(동기 JS, 레이아웃 스래싱 등) 처리 자체가 오래 걸리거나
- 처리 후 UI 업데이트가 렌더링/페인트에서 막히는
모든 케이스가 INP를 악화시킵니다.
Long Task란?
브라우저 관점에서 50ms를 넘는 메인 스레드 작업을 Long Task로 봅니다. Long Task가 길고 자주 발생하면 입력 이벤트가 처리되지 못하고 대기합니다. 결과적으로 INP가 뛸 확률이 급격히 올라갑니다.
특히 다음 상황이 흔한 트리거입니다.
- 대용량 리스트 렌더링/정렬/필터링을 메인 스레드에서 동기 처리
- JSON 큰 덩어리 파싱, 마크다운/문서 변환, 이미지 처리(캔버스) 등 CPU 집약 작업
- 복잡한 상태 업데이트로 인한 연쇄 렌더링(React/Vue)
- 스크롤/리사이즈에 무거운 핸들러 + passive 미설정
- 서드파티 스크립트(태그 매니저, A/B, 광고) 로딩 후 초기화가 메인 스레드를 점유
1단계: DevTools로 “INP가 튀는 상호작용”을 재현하기
Performance 패널에서 상호작용 기반으로 보기
- Chrome DevTools → Performance
- “Web Vitals” 또는 “Screenshots” 옵션을 켜고(버전에 따라 다름)
- 문제 되는 클릭/키 입력을 실제로 수행한 뒤 Record 종료
여기서 핵심은 다음입니다.
- Main 트랙에서 50ms 이상 막대(롱 태스크)가 있는지
- 이벤트(Click/Pointer/Key) 이후 다음 Paint까지 어떤 작업이 길게 이어지는지
- “Recalculate Style / Layout / Update Layer Tree / Paint”가 연쇄로 터지는지
Long Task의 “원인 함수”까지 내려가기
Performance 프로파일에서 Long Task를 클릭하면 하단에 Call Tree / Bottom-Up가 나옵니다.
- Bottom-Up: “시간을 제일 많이 쓴 함수”를 빠르게 찾기 좋음
- Call Tree: 어떤 호출 경로로 들어왔는지(프레임워크/번들/서드파티 포함) 보기 좋음
여기서 (anonymous) 나 minified 로만 보이면, 소스맵이 제대로 잡히는지 확인하세요. 프로덕션에서 재현이 어렵다면, 동일 번들을 스테이징에서 재현하거나 “production sourcemap 업로드(에러/성능 관측 도구 연동)”를 고려합니다.
2단계: Long Task를 “계측”해서 지속적으로 잡기
재현이 어렵거나 특정 사용자/특정 기기에서만 튀는 INP는 DevTools만으로 부족합니다. 이때는 런타임에서 Long Task를 계측해 “언제/어디서/얼마나” 발생하는지 로그로 남겨야 합니다.
PerformanceObserver로 Long Task 수집
브라우저는 longtask 엔트리를 제공합니다(지원 범위는 브라우저별 상이). 아래 코드는 Long Task를 감지해 콘솔/로그로 남기는 기본 형태입니다.
// longtask-observer.js
export function observeLongTasks({ threshold = 50 } = {}) {
if (typeof PerformanceObserver === 'undefined') return;
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const dur = entry.duration;
if (dur < threshold) continue;
// entry.attribution에는 원인 프레임/스크립트 힌트가 들어갈 수 있음(브라우저별)
const attribution = entry.attribution?.map((a) => ({
name: a.name,
containerType: a.containerType,
containerSrc: a.containerSrc,
containerId: a.containerId,
}));
console.log('[LongTask]', {
duration: dur,
startTime: entry.startTime,
name: entry.name,
attribution,
});
}
});
obs.observe({ entryTypes: ['longtask'] });
return () => obs.disconnect();
} catch {
// 일부 환경에서 longtask 미지원
}
}
이 데이터를 Sentry/Datadog/RUM 같은 곳으로 보내면, “특정 라우트에서만 Long Task 폭증” 같은 패턴이 보입니다.
web-vitals로 INP 자체도 같이 수집
INP 급등은 Long Task의 결과인 경우가 많지만, “어떤 상호작용에서 INP가 높았는지”도 같이 저장해야 디버깅이 빨라집니다.
import { onINP } from 'web-vitals';
export function reportINP(send) {
onINP((metric) => {
send({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
// metric.attribution에 이벤트 타입/타겟 셀렉터 등 유용한 힌트가 포함될 수 있음
attribution: metric.attribution,
});
});
}
metric.attribution에는 “어떤 이벤트가 문제였는지(클릭/키다운 등)”와 “처리/프레젠테이션 지연” 힌트가 포함될 수 있어, Long Task 로그와 매칭하면 원인 좁히기가 매우 쉬워집니다.
3단계: Long Task의 대표 원인별 해결 전략
아래는 실제로 INP를 튀게 만드는 빈도가 높은 원인과, 가장 효과가 큰 처방을 우선순위로 정리한 것입니다.
3.1 무거운 동기 JS(파싱/정렬/변환)를 쪼개기
증상
- 클릭 후 200~800ms 동안 Main thread가 노란색(스크립트)로 꽉 참
- Bottom-Up에
JSON.parse, 큰 배열 정렬, 마크다운 렌더러, 날짜 포맷터 등이 상위
해결 1) 작업을 청크로 쪼개서 이벤트 루프에 양보
requestIdleCallback(지원 주의) 또는 setTimeout(0)/scheduler.postTask 등을 활용해 큰 작업을 나눕니다.
export async function chunkedMap(items, fn, chunkSize = 200) {
const out = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const it of chunk) out.push(fn(it));
// 메인 스레드에 숨 쉴 틈 제공
await new Promise((r) => setTimeout(r, 0));
}
return out;
}
해결 2) Web Worker로 CPU 작업을 분리
정렬/검색 인덱싱/문서 변환 같은 CPU 작업은 Worker로 보내는 게 INP 개선에 직빵입니다.
// worker.js
self.onmessage = (e) => {
const { items } = e.data;
// 예: 무거운 정렬
items.sort((a, b) => a.score - b.score);
self.postMessage({ items });
};
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));
export function sortInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.items);
worker.postMessage({ items: [...items] });
});
}
3.2 렌더링 폭발(연쇄 리렌더/대량 DOM) 줄이기
증상
- 스크립트뿐 아니라 Layout/Paint가 길게 이어짐
- 클릭 한 번에 React commit이 크고, DOM 노드가 급증
해결 1) 가상화(virtualization)
대량 리스트는 “모두 렌더” 대신 “보이는 것만 렌더”가 기본입니다(react-window 등).
해결 2) 상태 업데이트 배치/불필요한 렌더 제거
- React:
memo,useMemo,useCallback을 “측정 기반”으로 적용 - 파생 상태는 계산 비용이 크면 캐시하거나 Worker로 이동
해결 3) 입력 직후 UI 피드백을 먼저, 무거운 작업은 뒤로
사용자 인풋에 즉시 반응하는 작은 상태(버튼 로딩, 하이라이트)만 먼저 반영하고, 데이터 처리/정렬/렌더는 뒤로 미룹니다.
button.addEventListener('click', () => {
button.disabled = true;
button.textContent = '처리 중...';
// 무거운 작업은 다음 틱으로
setTimeout(() => {
heavyWork();
button.textContent = '완료';
}, 0);
});
3.3 Layout Thrashing(레이아웃 스래싱) 제거
증상
- Performance에서 “Recalculate Style / Layout”이 반복
- 코드에서
getBoundingClientRect()같은 측정과style변경이 교차
해결: 읽기(Read)와 쓰기(Write) 분리
function updatePositions(nodes) {
// 1) 읽기 단계
const rects = nodes.map((el) => el.getBoundingClientRect());
// 2) 쓰기 단계
rects.forEach((r, i) => {
const el = nodes[i];
el.style.transform = `translate(${Math.round(r.left)}px, 0)`;
});
}
추가로 애니메이션은 top/left보다 transform을 우선하고, 필요하면 will-change를 제한적으로 사용합니다.
3.4 이벤트 핸들러/리스너 최적화(스크롤/포인터)
증상
- 스크롤/드래그 중 INP가 튐
- 이벤트 리스너가 너무 자주 실행되거나, passive가 아니라서 스크롤을 막음
해결 1) passive 옵션
document.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('wheel', onWheel, { passive: true });
해결 2) throttle/debounce + requestAnimationFrame
function rafThrottle(fn) {
let scheduled = false;
return (...args) => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
fn(...args);
});
};
}
window.addEventListener('scroll', rafThrottle(() => {
// 가벼운 작업만
}));
3.5 서드파티 스크립트가 메인 스레드를 점유하는 경우
증상
- Long Task attribution에 외부 도메인 스크립트가 보이거나
- 특정 태그(광고/AB/분석) 활성화 시점부터 INP가 악화
해결 체크리스트
- 가능하면
defer/async적용 + 초기화 타이밍을 늦추기 - 라우트별로 필요한 페이지에서만 로드(전 페이지 공통 삽입 지양)
- Tag Manager에서 태그 발화 조건을 보수적으로
- “사용자 상호작용 후 로드” 전략(첫 입력 전까지 로드 지연)
4단계: “원인이 서버 지연처럼 보이는” 함정 분리
INP는 본질적으로 클라이언트 지표지만, 실무에서는 서버/캐시 문제로 UX가 꼬이면서 “클릭 후 멈춤”처럼 보이는 경우가 있습니다.
- 클릭 → 서버 응답 지연 → UI가 스피너도 못 띄운 채 멈춘 느낌
- 서버액션/라우터 전환에서 캐시 꼬임 → 반복 리렌더/에러 처리 루프
이때는 네트워크 워터폴과 메인 스레드 Long Task를 동시에 봐야 합니다. Next.js를 쓰는 팀이라면 위에서 언급한 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결처럼 “서버가 불안정해서 프론트가 과도한 재시도/상태 갱신을 하는” 패턴도 함께 점검하세요.
5단계: 개선 효과를 검증하는 방법(재현성 있게)
로컬에서만 빠르게 끝내면 안 되는 이유
개발 PC는 빠르고, 프로덕션 사용자는 느립니다. INP는 특히 저사양/모바일에서 악화가 잘 드러납니다.
- DevTools Performance에서 CPU 4x/6x slowdown
- Network throttling(단, INP는 네트워크보다 메인 스레드 영향을 더 받는 경우가 많음)
- 실제 모바일 기기 원격 디버깅
“전/후 비교”를 숫자로 남기기
- 동일 상호작용(예: 검색 버튼 클릭, 필터 토글) 시나리오 고정
- Performance trace 3~5회 측정 후 중앙값 비교
- Long Task 총합/최대값, INP(또는 Interaction latency) 비교
가능하면 배포 후 RUM으로 INP p75(CrUX 기준의 대표 지표)를 추적해 회귀(regression)를 막습니다.
실전 체크리스트 요약
- Performance에서 문제 상호작용 직후 Main thread Long Task 확인
- Bottom-Up으로 “시간 상위 함수” 1~3개를 먼저 제거
- 큰 동기 작업은 청크 분할 또는 Web Worker로 이동
- 대량 DOM은 가상화, 불필요 렌더는 측정 기반 최적화
- Layout thrashing 제거(읽기/쓰기 분리)
- 스크롤/휠/터치는 passive + rafThrottle
- 서드파티 스크립트는 로드/초기화 타이밍을 늦추고 페이지별로 제한
- RUM으로 INP + Long Task를 함께 수집해 “특정 라우트/기기/릴리즈” 상관관계 확보
마무리
INP 급등은 대부분 “이벤트 핸들러가 느려서”라기보다, 그 순간 메인 스레드가 다른 일을 너무 오래 하고 있어서 발생합니다. 그래서 해결도 “코드 한 줄 최적화”보다 Long Task를 찾아 쪼개고(청크/Worker), 렌더링 폭발을 줄이고(가상화/불필요 렌더 제거), 레이아웃 스래싱을 끊는 것이 정공법입니다.
다음 단계로는, 실제 서비스에서 Long Task/INP를 RUM으로 수집해 릴리즈별 회귀를 감시하고, “특정 기능 토글/서드파티 태그”가 INP를 망가뜨리는 지점을 자동으로 찾는 체계를 만드는 것을 권합니다.