- Published on
Chrome INP 폭증? Long Task 원인·해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표(TTFB, 응답시간)는 멀쩡한데, Chrome에서만 INP(Interaction to Next Paint)가 갑자기 튀어 오르는 경우가 있습니다. 대개 “사용자 클릭/입력은 들어왔는데 화면이 다음 프레임으로 갱신되기까지” 메인 스레드가 바빠서 못 그리는 상황입니다. 이때 범인으로 가장 자주 잡히는 게 Long Task(50ms 이상 메인 스레드 작업) 입니다.
이 글에서는 INP가 왜 Long Task에 민감한지, Chrome DevTools/PerformanceObserver로 원인을 어떻게 좁히는지, 그리고 **코드로 어떻게 쪼개고(Chunking), 양보하고(Yield), 옮기고(Worker), 줄일지(렌더링/JS/서드파티)**를 단계별로 정리합니다.
> 참고로 INP가 아니라 TTFB가 갑자기 늘어나는 문제라면 프론트보다 서버/캐시 쪽이 병목일 수 있습니다. Next.js 환경이라면 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기도 함께 확인해보세요.
INP와 Long Task의 관계: “입력 이벤트”가 아니라 “다음 페인트”가 문제
INP는 대략 아래 흐름에서 가장 느린 상호작용(또는 대표 상호작용)을 잡아냅니다.
- 사용자가 클릭/탭/키입력
- 브라우저가 이벤트 핸들러 실행
- 스타일 계산/레이아웃/페인트/컴포지팅
- 다음 화면 업데이트(Next Paint)
여기서 2~3 구간에 메인 스레드가 점유되면 입력은 받았는데 화면이 멈춘 것처럼 보입니다. Long Task는 보통 다음 원인으로 생깁니다.
- 무거운 JS 실행(파싱/컴파일/실행)
- 큰 DOM 업데이트로 인한 스타일/레이아웃 폭발
- 동기식 작업(대량 JSON 처리, 정렬/필터링, 마크다운 파싱 등)
- 서드파티 스크립트(태그 매니저, A/B 테스트, 광고)
- 렌더링 파이프라인을 강제로 깨는 코드(레이아웃 스래싱)
핵심은 “이벤트 핸들러가 길어서”만이 아닙니다. 이벤트 후에 이어지는 렌더링 단계까지 포함해 메인 스레드가 막히면 INP가 커집니다.
증상 패턴으로 보는 원인 가설
1) 특정 페이지/컴포넌트에서만 INP 폭증
- 목록/테이블/차트처럼 DOM이 큰 화면
- 스크롤/필터/정렬/검색 입력 시 느려짐
- 원인 후보: 대량 렌더링, 가상화 미적용, 비효율적 상태 업데이트
2) 첫 상호작용만 유난히 느림
- 첫 클릭에서만 뚝 끊김
- 원인 후보: 코드 스플리팅 미흡, 첫 상호작용 시 lazy 로딩/초기화가 몰림
3) 모바일/저사양에서만 심각
- 데스크톱은 괜찮고 모바일에서 INP 급증
- 원인 후보: JS 실행 비용이 절대적으로 큼(번들 과대), 메인 스레드 여유 부족
진단 1: DevTools Performance로 Long Task를 “증거”로 만들기
- Chrome DevTools → Performance
- CPU Throttling(예: 4x)로 재현성 확보
- 녹화(Record) 후 문제 상호작용(클릭/입력) 수행
- Main 트랙에서 긴 노란 블록(스크립팅), 보라색(렌더링) 구간 확인
특히 아래를 보세요.
- Long Task 표시(빨간 삼각형 경고)
- Call Tree / Bottom-Up에서 시간이 큰 함수
- “Recalculate Style / Layout / Update Layer Tree / Paint”가 몰리는지
이 단계에서 목표는 “느리다”가 아니라, 어떤 작업이 몇 ms를 먹는지를 특정하는 것입니다.
진단 2: PerformanceObserver로 Long Task를 운영 환경에서 수집
실제 사용자 환경(RUM)에서는 DevTools만으로 부족합니다. Long Task는 PerformanceObserver로 관측 가능합니다.
// longtask 관측 (Chrome 등 지원)
// 주의: 샘플링/전송량 관리 필요
export function observeLongTasks({ onEntry } = {}) {
if (!('PerformanceObserver' in window)) return;
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: Long Task 지속시간(ms)
// entry.attribution: 어떤 작업이 원인인지(브라우저/버전에 따라 제한)
onEntry?.({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
attribution: entry.attribution,
});
}
});
obs.observe({ type: 'longtask', buffered: true });
return () => obs.disconnect();
} catch {
// Safari 등 미지원/예외
}
}
운영에서는 다음처럼 묶어서 보내면 좋습니다.
- 페이지/라우트
- 상호작용 직전/직후 N초 동안의 Long Task 합/최대
- 디바이스 메모리/하드웨어 동시성(navigator.deviceMemory, hardwareConcurrency)
- 번들 버전/릴리즈 태그
이렇게 하면 “특정 릴리즈 이후 모바일에서만 INP가 튄다” 같은 회귀를 빠르게 잡습니다.
해결 1: Long Task를 쪼개기(Chunking) + 양보하기(Yield)
가장 흔하고 효과적인 처방은 큰 동기 루프를 잘게 쪼개고 브라우저에 제어권을 돌려주는 것입니다.
requestIdleCallback로 유휴 시간 활용(가능하면)
function chunkedProcess(items, fn, { chunkSize = 200 } = {}) {
let i = 0;
return new Promise((resolve) => {
function run(deadline) {
while (i < items.length && (deadline.timeRemaining() > 0)) {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) fn(items[i], i);
}
if (i < items.length) {
requestIdleCallback(run);
} else {
resolve();
}
}
requestIdleCallback(run);
});
}
- 장점: 렌더링을 방해하지 않으면서 처리
- 단점: 유휴 시간이 부족하면 완료가 늦어질 수 있음(중요 작업에는 부적합)
setTimeout(0) / scheduler.yield로 프레임을 살리기
requestIdleCallback이 애매하거나 “빠르게 끝나야 하는데 끊김만 줄이고 싶다”면 프레임 사이에 양보하세요.
const yieldToMain = () => new Promise((r) => setTimeout(r, 0));
async function processInChunks(items, fn, chunkSize = 500) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const item of chunk) fn(item);
// 메인 스레드에 양보 → 입력/페인트 처리 기회 제공
await yieldToMain();
}
}
Chrome에서는 scheduler.yield()(실험/지원 상황 확인)가 더 의도에 맞을 수 있습니다.
해결 2: 무거운 계산은 Web Worker로 옮기기
메인 스레드에서 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), {
type: 'module',
});
export function sortInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.items);
worker.postMessage({ items: structuredClone(items) });
});
}
포인트:
- Worker로 보낼 때 복사 비용이 생깁니다. 큰 데이터는
Transferable(ArrayBuffer 등)로 전송 고려 - UI 업데이트는 메인에서 하되, 계산만 Worker로
해결 3: 렌더링 비용 줄이기(레이아웃 스래싱/대량 DOM)
Long Task는 JS만이 아니라 스타일/레이아웃에서도 발생합니다.
레이아웃 스래싱 방지: 읽기/쓰기를 분리
나쁜 예(읽기-쓰기-읽기 반복):
for (const el of elements) {
el.style.height = (el.getBoundingClientRect().height + 10) + 'px';
}
개선(읽기 먼저, 쓰기 나중):
const heights = elements.map((el) => el.getBoundingClientRect().height);
heights.forEach((h, idx) => {
elements[idx].style.height = (h + 10) + 'px';
});
대량 리스트는 가상화(virtualization)
- 화면에 보이는 만큼만 DOM을 유지
- React/Vue/Svelte 모두 가상화 라이브러리나 자체 구현 가능
가상화가 어려우면 최소한:
- DOM 노드 수 줄이기(불필요한 wrapper 제거)
content-visibility: auto;로 오프스크린 렌더 비용 절감
.list-item {
content-visibility: auto;
contain-intrinsic-size: 1px 80px;
}
해결 4: “첫 상호작용에 몰리는 초기화” 분리
INP가 첫 클릭에서만 튀면, 흔히 아래가 숨어 있습니다.
- 첫 클릭 시점에만 로딩되는 대형 모듈 import
- 첫 클릭 때 분석 SDK 초기화
- 첫 입력 때만 생성되는 거대한 데이터 구조
해결책:
- 사용자 상호작용 전에(단, 초기 로딩을 망치지 않는 선에서)
requestIdleCallback로 준비 - 또는 진짜 필요한 기능만 클릭 시 로딩하고, 나머지는 지연
// 페이지 로드 후 유휴 시간에 미리 준비
requestIdleCallback(async () => {
const { warmUp } = await import('./heavy-init.js');
warmUp();
});
해결 5: 서드파티 스크립트가 Long Task를 만들 때
광고/태그/AB 테스트/챗 위젯은 Long Task의 단골입니다.
체크리스트:
async/defer적용 여부- 초기 렌더/상호작용 이후로 로딩 지연
- 동일한 기능 중복 삽입 여부(태그 매니저에서 중복 태그)
- 특정 페이지에서만 필요한지(전 페이지 공통 삽입 금지)
가능하면 “사용자 입력 직후”가 아니라 “첫 페인트 이후/유휴 시간”로 미루세요.
해결 6: 프레임워크/라우팅 전환에서 INP가 튈 때
SPA에서 라우팅 전환 시 다음이 겹치면 Long Task가 커집니다.
- 라우트 전환 + 대형 컴포넌트 마운트
- 상태 관리 스토어 업데이트 폭발
- 데이터 페치 결과로 대량 렌더
대응:
- 라우트 단위 코드 스플리팅
- 상태 업데이트 배치(batch) 및 불필요한 리렌더 제거
- 큰 UI는 “스켈레톤 + 점진적 렌더”
서버 렌더/스트리밍이 섞여 TTFB/렌더링이 요동친다면, 앞서 언급한 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기처럼 캐시/재검증 전략도 함께 보정해야 체감이 안정됩니다.
실전 디버깅 루틴(10분 안에 원인 좁히기)
- 재현 조건 고정: 특정 디바이스/CPU throttling/네트워크
- Performance 기록 후 문제 상호작용 1회만 수행
- Main에서 가장 긴 Task 클릭 → Bottom-Up에서 Top offender 확인
- offender가 JS면:
- 큰 루프/파싱/정렬/직렬화/마운트 로직 찾기
- Chunking/Yield/Worker 중 하나 적용
- offender가 Render/Layout면:
- 레이아웃 스래싱 여부(읽기/쓰기 섞임)
- DOM 크기, 가상화, content-visibility 검토
- 서드파티가 보이면:
- 로딩 시점 지연/조건부 로딩
- 수정 후 같은 조건으로 재측정(전/후 비교)
체크리스트: INP를 안정적으로 낮추는 우선순위
- 50ms+ Long Task를 “없애기”보다 쪼개서 10~20ms 단위로 만들기
- 입력 직후(클릭/키입력)에 무거운 작업 실행 금지
- 큰 계산은 Worker로
- 대량 DOM은 가상화 또는 content-visibility
- 레이아웃 스래싱 제거(읽기/쓰기 분리)
- 서드파티 스크립트는 늦게/필요한 곳에만
- 운영에서 Long Task를 샘플링 수집해 회귀 감지
마무리: INP 폭증은 “한 방짜리 큰 작업”을 찾는 게임
INP가 나빠졌다는 건 사용자의 입력이 UI 업데이트로 이어지는 경로 어딘가에서 메인 스레드가 길게 붙잡혔다는 뜻입니다. DevTools로 Long Task를 특정하고, 그 작업을 쪼개거나(Chunk/Yield), 옮기거나(Worker), 줄이거나(렌더/서드파티 최적화) 하면 대부분 눈에 띄게 개선됩니다.
다음 단계로는 “어떤 상호작용에서 INP가 나쁜지”를 RUM으로 수집해, 특정 컴포넌트/릴리즈/디바이스에 대해 자동으로 경보를 걸어두면 재발을 크게 줄일 수 있습니다.