- Published on
Chrome INP 튐? long task 200ms 줄이는 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 빠른데도 클릭/타이핑이 버벅이고 INP가 갑자기 튀는 경우, 대부분은 메인 스레드(Main Thread)가 200ms급 Long Task로 막히는 순간이 존재하기 때문입니다. INP(Interaction to Next Paint)는 “사용자 입력 → 다음 페인트”까지의 시간을 보므로, 네트워크보다 CPU·렌더링·JS 실행의 영향을 훨씬 크게 받습니다.
이 글은 “Long Task를 어떻게 찾아서 200ms를 어떻게 줄일 것인가”에 초점을 맞춥니다. 단순히 ‘코드 최적화하세요’가 아니라, Chrome DevTools에서 범인을 특정하고, 작업을 쪼개고, 렌더링을 덜 하게 만드는 순서로 진행합니다.
INP와 Long Task의 관계 (왜 200ms가 문제인가)
- Long Task: 메인 스레드에서 50ms 이상 연속으로 실행되는 작업
- INP: 입력 이벤트 처리(핸들러 실행) + 스타일/레이아웃/페인트 + 다음 프레임까지의 대기 포함
200ms짜리 작업이 있으면 어떤 일이 생길까요?
- 사용자가 클릭
- 이벤트가 큐에 쌓임(이미 메인 스레드가 바쁨)
- 작업이 끝난 뒤에야 이벤트 핸들러 실행
- 그 후 렌더링 파이프라인(스타일→레이아웃→페인트)
즉, 입력 자체가 늦게 처리되고, 처리 후에도 렌더링이 밀리면 INP가 더 커집니다.
1) Chrome DevTools로 “200ms Long Task”를 정확히 찾기
Performance 패널: Long Task 위치와 원인 함수 파악
- DevTools → Performance
- “Web Vitals” 체크(가능하면)
- Record 후 문제 동작(클릭/스크롤/입력)
- 타임라인에서 빨간 삼각형(긴 작업 경고) 또는 Main 트랙의 두꺼운 블록 확인
- 해당 블록 클릭 → Bottom-up / Call tree로 시간을 먹는 함수 확인
여기서 중요한 건 “총 실행 시간”보다 연속 점유 시간입니다. 200ms를 4개로 쪼개 50ms 아래로 만들면, 체감과 INP가 급격히 좋아지는 경우가 많습니다.
Event Log로 입력 이벤트 지연 확인
Performance에서 Event Log를 보면 클릭/keydown 등 입력 이벤트가 언제 발생했고, 언제 처리됐는지 흐름을 볼 수 있습니다. 입력 이벤트와 그 직후의 작업이 Main에서 한 덩어리로 200ms 이상 잡혀 있다면 전형적인 INP 튐 패턴입니다.
Long Animation Frame(LoAF)도 같이 보기
최근 Chrome은 Long Animation Frame(긴 프레임) 정보를 제공해 “어떤 프레임이 왜 길어졌는지”를 더 잘 보여줍니다. Performance 트레이스에서 긴 프레임이 보이면, 그 프레임의 원인(스크립트/레이아웃/페인트)을 같이 확인하세요.
2) 200ms Long Task를 줄이는 1순위: 작업 쪼개기(Chunking)
가장 확실한 방법은 한 번에 200ms 하는 일을 여러 번에 나눠 메인 스레드가 숨 쉴 틈을 주는 겁니다.
나쁜 예: 대량 루프/파싱/정렬을 클릭 핸들러에서 한 번에
button.addEventListener('click', () => {
// 200ms+ 걸리는 작업이 한 번에 실행되면 INP가 튄다
const result = heavyCompute(bigArray);
render(result);
});
개선: requestIdleCallback 또는 setTimeout으로 분할
function chunkedProcess(items, chunkSize, onChunk, onDone) {
let i = 0;
function runChunk(deadline) {
// requestIdleCallback이 없거나 deadline이 부족하면 최소한의 양만 처리
const start = performance.now();
while (i < items.length) {
onChunk(items[i]);
i++;
// 8~12ms 정도로 제한(60fps 기준 프레임 예산 고려)
if (performance.now() - start > 10) break;
// requestIdleCallback의 남은 시간도 참고 가능
if (deadline && deadline.timeRemaining() < 2) break;
}
if (i < items.length) {
schedule(runChunk);
} else {
onDone?.();
}
}
const schedule = (fn) => {
if ('requestIdleCallback' in window) {
requestIdleCallback(fn, { timeout: 200 });
} else {
setTimeout(() => fn(null), 0);
}
};
schedule(runChunk);
}
핵심은 “총 시간”이 아니라 연속 점유 시간을 50ms 아래로 줄이는 것입니다. 보통 200ms Long Task는 10ms 전후로 쪼개면 입력·스크롤이 즉시 부드러워집니다.
3) INP에 직결되는 이벤트 핸들러 최적화
INP는 “입력 → 다음 페인트”이므로, 입력 핸들러 안에서 다음을 피하세요.
- 동기 DOM 읽기/쓰기 반복(레이아웃 스래싱)
- 대량 상태 업데이트(특히 렌더 트리 전체 변경)
- JSON 파싱/정렬/필터링 같은 CPU 작업
패턴: DOM 읽기와 쓰기를 분리해 레이아웃 스래싱 제거
// 나쁜 예: 읽고 쓰고 읽고 쓰면 레이아웃이 반복 발생
for (const el of items) {
const h = el.getBoundingClientRect().height;
el.style.height = (h + 10) + 'px';
}
// 좋은 예: 읽기(측정) -> 쓰기(적용) 단계 분리
const heights = items.map(el => el.getBoundingClientRect().height);
items.forEach((el, idx) => {
el.style.height = (heights[idx] + 10) + 'px';
});
입력 직후 UI 반응을 먼저, 무거운 작업은 뒤로
input.addEventListener('input', (e) => {
// 1) 즉시 UI 반응(가벼운 작업)
status.textContent = '검색 중...';
// 2) 무거운 필터/정렬은 다음 틱으로 미루기
setTimeout(() => {
const result = expensiveFilter(e.target.value);
render(result);
status.textContent = '완료';
}, 0);
});
이렇게만 해도 INP가 크게 개선되는 경우가 많습니다(사용자 입장에선 “입력이 먹히는 느낌”이 중요).
4) 스케줄링: requestAnimationFrame과 scheduler.yield 활용
렌더링과 맞추려면 requestAnimationFrame
DOM 업데이트가 페인트와 맞물리면, 작업 타이밍이 꼬여 긴 프레임을 만들 수 있습니다.
function updateUI(nextState) {
requestAnimationFrame(() => {
// 프레임 시작에 UI 변경을 모아 적용
applyStateToDOM(nextState);
});
}
가능하면 Scheduler API로 양보(yield)
Chrome 계열에서 scheduler.yield()(지원 여부 확인 필요)를 쓰면 긴 작업 중간에 메인 스레드에 양보하기 좋습니다.
async function processInBatches(items) {
for (let i = 0; i < items.length; i++) {
doWork(items[i]);
if (i % 200 === 0) {
// 긴 작업이 될 것 같으면 잠깐 양보
if (window.scheduler?.yield) {
await scheduler.yield();
} else {
await new Promise(r => setTimeout(r, 0));
}
}
}
}
5) Web Worker로 메인 스레드에서 CPU 작업 빼기
정렬/파싱/검색 인덱싱/압축 같은 순수 계산은 Worker로 보내는 게 정답인 경우가 많습니다.
main.js
const worker = new Worker('/worker.js');
function heavyComputeAsync(payload) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMessage = (e) => {
if (e.data.id !== id) return;
worker.removeEventListener('message', onMessage);
resolve(e.data.result);
};
worker.addEventListener('message', onMessage);
worker.postMessage({ id, payload });
});
}
button.addEventListener('click', async () => {
status.textContent = '처리 중...';
const result = await heavyComputeAsync(bigArray);
render(result);
status.textContent = '완료';
});
worker.js
self.onmessage = (e) => {
const { id, payload } = e.data;
const result = payload
.slice()
.sort((a, b) => a.value - b.value);
self.postMessage({ id, result });
};
Worker는 “작업을 빠르게” 하기보다 메인 스레드를 비워 INP를 안정화하는 효과가 큽니다.
6) 렌더링 비용 줄이기: DOM 수/리렌더 범위/가상화
Long Task가 JS가 아니라 Recalculate Style / Layout / Paint에서 터지는 경우가 많습니다.
- 리스트/테이블이 크면 가상 스크롤(virtualization)
- 한 번에 많은 노드 추가/삭제 대신 DocumentFragment로 묶기
- 애니메이션은
top/left대신transform사용
DocumentFragment로 대량 DOM 삽입 비용 줄이기
function renderList(container, items) {
const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item.title;
frag.appendChild(li);
}
container.replaceChildren(frag);
}
CSS containment로 레이아웃 영향 범위 제한
/* 컴포넌트 내부 변경이 페이지 전체 레이아웃에 파급되는 것을 줄임 */
.widget {
contain: layout paint style;
}
이건 특히 “어떤 버튼 누르면 주변 전체가 재배치되는 UI”에서 Long Task를 줄이는 데 도움이 됩니다.
7) 서드파티 스크립트가 원인일 때의 현실적인 대응
INP 튐의 범인이 광고/분석/위젯 같은 서드파티인 경우가 흔합니다.
- 로딩 지연:
defer,async, 동적 import - 사용 시점 로딩: 인터랙션 후 로드
- 격리: 가능한 것은 iframe sandbox
- 대체: 더 가벼운 SDK로 교체
동적 import로 초기 INP 방어
let analyticsLoaded = false;
async function loadAnalytics() {
if (analyticsLoaded) return;
analyticsLoaded = true;
await import('/analytics.js');
}
document.addEventListener('click', () => {
// 첫 클릭에서 바로 무거운 로딩을 하면 INP가 튈 수 있으니
// 다음 틱으로 넘기거나 idle에 태우는 전략을 사용
setTimeout(loadAnalytics, 0);
}, { once: true });
8) 측정 자동화: Long Task/INP를 코드로 수집하기
실서비스에서는 “내 컴퓨터에서만”이 아니라 사용자 환경에서 수집해야 합니다.
Long Task 수집 (PerformanceObserver)
if ('PerformanceObserver' in window) {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration이 50ms 이상이면 Long Task
console.log('LongTask', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name
});
}
});
try {
obs.observe({ entryTypes: ['longtask'] });
} catch {}
}
INP 수집은 web-vitals 라이브러리 활용
npm i web-vitals
import { onINP } from 'web-vitals';
onINP((metric) => {
// metric.value(ms), metric.attribution에 원인 이벤트/타겟 정보가 포함될 수 있음
console.log('INP', metric);
});
수집한 데이터를 기반으로 “특정 페이지/브라우저/디바이스에서만 200ms가 튄다” 같은 패턴을 찾으면, 최적화 우선순위가 명확해집니다.
9) 디버깅 관점 팁: 프론트만 보지 말고 병목을 분리하라
INP는 클라이언트 지표지만, 실제로는 다음이 얽혀 보이는 경우가 있습니다.
- API 응답을 기다리며 UI 스레드가 막히는 구조(동기 처리/과한 상태 업데이트)
- 서버가 느려서가 아니라 응답 후 파싱/렌더링이 무거운 구조
병목을 분리해 “네트워크 대기”와 “메인 스레드 점유”를 구분하는 습관이 중요합니다. 운영에서 지연 원인 진단을 체계화하는 관점은 백엔드 트러블슈팅에도 그대로 통합니다. 예를 들어 지연·교착을 원인별로 쪼개 진단하는 방법론은 Spring Boot 3 가상스레드 적용 후 지연·데드락 진단 같은 글에서 다루는 접근과 유사합니다.
또한 “리소스가 부족해 이벤트 루프가 흔들리는” 유형의 문제는 런타임이 다르더라도 본질이 같습니다. 이벤트 루프 관점의 문제 분해는 Python uvloop 도입 후 Event loop is closed 해결 가이드도 참고가 됩니다.
체크리스트: Long Task 200ms를 실제로 줄이는 순서
- Performance 트레이스로 200ms 블록을 클릭해 Call tree에서 범인 함수 확정
- 입력 핸들러에서 무거운 작업 제거(즉시 반응 UI → 무거운 작업 지연)
- 200ms 작업을 10ms 내외 chunk로 분할(Idle/Timeout/Yield)
- 계산은 Worker로 이동
- Layout/Paint가 원인이면 DOM/스타일 변경 범위 축소(contain, fragment, virtualization)
- 서드파티는 로딩 전략 변경(지연/분리/대체)
- web-vitals로 INP, PerformanceObserver로 Long Task를 실사용자 기준(RUM) 수집
마무리
Chrome에서 INP가 튀는 문제는 “최적화 잘하면 좋아진다” 수준이 아니라, 메인 스레드의 연속 점유 시간을 50ms 아래로 관리하는 엔지니어링 문제에 가깝습니다. 200ms Long Task를 발견했다면, 그건 개선 여지가 명확한 금광입니다.
가장 효과가 큰 조합은 보통 다음입니다.
- **작업 분할(Chunking)**로 연속 점유 시간 줄이기
- Worker로 계산 분리
- 렌더링 비용 절감(레이아웃 스래싱 제거, DOM 가상화, contain)
이 3가지만 제대로 적용해도 “가끔 튀는 INP”가 “안정적인 INP”로 바뀌는 경우가 많습니다.