- Published on
Chrome INP 폭증? Long Task를 50ms로 쪼개는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 멀쩡한데 사용자만 “클릭이 늦다”를 체감하는 순간이 있습니다. 크롬 성능 지표에서 INP가 폭증했다면, 대개 원인은 단순합니다. 메인 스레드가 길게 막혀 입력 이벤트를 제때 처리하지 못한 것입니다.
INP는 단순히 “느린 렌더”가 아니라 사용자 입력부터 다음 페인트까지의 지연을 봅니다. 즉, 클릭 직후에 해야 할 JavaScript, 스타일 계산, 레이아웃, 페인트 중 무엇이든 길게 이어지면 INP가 튑니다. 이 글은 그중에서도 가장 흔한 범인인 Long Task를 50ms 이하로 분해하는 방법을 코드 중심으로 다룹니다.
관련해서 원인 추적 흐름을 먼저 잡고 싶다면 아래 글도 함께 보시면 좋습니다.
INP 폭증의 전형적인 패턴
대부분 이런 형태로 터집니다.
- 클릭 핸들러에서 동기 루프, JSON 파싱, 정렬, 대량 DOM 작업을 한 번에 수행
- 상태 업데이트가 연쇄적으로 이어져 렌더가 길게 잠김
- 이벤트 직후에 레이아웃을 강제로 읽고 다시 쓰는 패턴이 반복되어 레이아웃 스래싱 발생
- 서드파티 스크립트가 메인 스레드를 점유
여기서 핵심은 **“사용자 입력 직후”**에 긴 작업이 붙는지입니다. 같은 200ms라도 입력 직후면 INP에 치명적이고, 백그라운드에서 한가하면 덜 치명적입니다.
50ms 기준이 실전에서 의미 있는 이유
브라우저 메인 스레드는 대략 16.6ms 단위로 프레임을 맞추려 합니다. 하지만 현실적으로 모든 작업을 16ms 안에 넣기 어렵습니다. 그래서 실무에서는 다음 같은 가이드를 씁니다.
- 16ms: 이상적(60fps)
- 50ms: 체감 지연이 커지기 시작하는 경계, Long Task로 분류되기 쉬움
- 100ms 이상: 입력 지연이 눈에 띄고 INP를 자주 폭발시킴
따라서 목표는 단순합니다. 입력 직후 실행되는 작업을 50ms 이하 조각으로 분해하고, 조각 사이에 브라우저에게 제어권을 반환하는 것입니다.
먼저 측정: “정말 Long Task가 원인인가”를 확인
Chrome DevTools Performance에서 확인하는 포인트
- Performance 기록 시작
- 문제 입력(클릭, 탭 등) 재현
- Main 트랙에서 길게 이어진 Task 확인
- 해당 Task가 어떤 함수에서 시작했는지 Call Tree로 추적
여기서 중요한 건 “긴 작업이 있다”가 아니라 그 긴 작업이 입력 이벤트 이후에 붙어 있느냐입니다.
Long Task를 코드로 관측하기
실서비스에서 재현이 어려우면 PerformanceObserver로 Long Task를 수집해 힌트를 얻을 수 있습니다.
// Long Task 관측 (브라우저 지원 범위 확인 필요)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: ms
// entry.attribution: 원인 스크립트 힌트(브라우저/정책에 따라 제한)
console.log('[LongTask]', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
attribution: entry.attribution,
});
}
});
observer.observe({ entryTypes: ['longtask'] });
이 로그를 “입력 직후” 타임라인과 함께 보면, 어떤 화면/행동에서 Long Task가 터지는지 좁히기 쉬워집니다.
Long Task를 50ms 이하로 쪼개는 6가지 패턴
1) 가장 단순한 분해: requestAnimationFrame로 프레임 양보
입력 핸들러에서 모든 일을 즉시 처리하지 말고, UI 반응을 먼저 보여준 뒤 무거운 작업을 다음 프레임으로 넘깁니다.
button.addEventListener('click', () => {
// 1) 즉시 반응(로딩 표시 등)
setLoading(true);
// 2) 다음 프레임으로 무거운 작업 이관
requestAnimationFrame(() => {
heavyWork();
setLoading(false);
});
});
이 방식은 “분해”라기보다 “우선순위 재배치”지만, INP 관점에서는 효과가 큽니다. 입력 후 첫 페인트를 빠르게 만들 수 있기 때문입니다.
2) 조각 처리: scheduler.postTask 또는 타임 슬라이싱
지원되는 환경이라면 scheduler.postTask로 우선순위를 낮춰 작업을 쪼갤 수 있습니다. 부등호 문자는 MDX 이슈가 있으니 코드로만 표기합니다.
async function runInChunks(items, chunkSize = 200) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// chunk 처리
processChunk(chunk);
// 브라우저에 제어권 반환
if (globalThis.scheduler?.postTask) {
await scheduler.postTask(() => {}, { priority: 'background' });
} else {
await new Promise((r) => setTimeout(r, 0));
}
}
}
포인트는 processChunk가 50ms를 넘지 않게 chunkSize를 조절하는 것입니다. 처음엔 작게 잡고, 측정하면서 키우는 편이 안전합니다.
3) requestIdleCallback로 “한가할 때만” 처리
입력 직후 해야 하는 일이 아니라면, 유휴 시간에만 돌리세요.
function runWhenIdle(task) {
if ('requestIdleCallback' in window) {
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 안에서만 조금씩 처리
while (deadline.timeRemaining() > 5) {
const done = task();
if (done) break;
}
// 아직 남았으면 다시 예약
if (!task.done) runWhenIdle(task);
});
} else {
setTimeout(() => task(), 0);
}
}
주의: requestIdleCallback은 “보장된 실행”이 아니라 “한가하면 실행”입니다. 그래서 사용자 입력 직후 반드시 끝나야 하는 로직에는 부적합합니다.
4) 계산을 메인 스레드 밖으로: Web Worker로 이동
정렬, 필터링, 대량 파싱 같은 CPU 작업은 Worker로 보내면 INP가 크게 안정화됩니다.
worker.js
self.onmessage = (e) => {
const { items } = e.data;
// 무거운 계산
const result = items
.map((x) => ({ ...x, score: calcScore(x) }))
.sort((a, b) => b.score - a.score);
self.postMessage({ result });
};
function calcScore(x) {
// 예시용 더미 연산
let s = 0;
for (let i = 0; i < 5000; i++) s += (x.value * i) % 97;
return s;
}
메인 코드
const worker = new Worker(new URL('./worker.js', import.meta.url), {
type: 'module',
});
function computeInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.result);
worker.postMessage({ items });
});
}
Worker로 옮기면 “분해” 자체가 필요 없을 때가 많습니다. 다만 메시지 직렬화 비용이 있으니, 큰 객체는 구조를 단순화하거나 Transferable을 고려하세요.
5) DOM 작업은 “모아서 한 번에”, 읽기/쓰기를 분리
Long Task의 상당수는 CPU 계산이 아니라 DOM 접근과 레이아웃 강제 계산에서 나옵니다. 특히 아래 패턴은 위험합니다.
getBoundingClientRect로 레이아웃 읽기- 곧바로
style변경(쓰기) - 다시 레이아웃 읽기
해결은 간단합니다.
- 읽기 단계에서 필요한 값을 전부 수집
- 쓰기 단계에서 DOM 업데이트를 한 번에 반영
function updateCards(cards) {
// 1) 읽기: 레이아웃/스타일 값 수집
const rects = cards.map((el) => el.getBoundingClientRect());
// 2) 쓰기: DOM 변경은 한 번에
requestAnimationFrame(() => {
cards.forEach((el, idx) => {
const r = rects[idx];
el.style.transform = `translateY(${Math.round(r.top)}px)`;
});
});
}
이 패턴만으로도 입력 직후 레이아웃 비용이 줄어 INP가 크게 내려가는 경우가 많습니다.
6) React 계열이라면: 입력 핸들러에서 동기 상태 폭발을 막기
React를 쓰는 경우, 클릭 한 번에 상태 업데이트가 연쇄적으로 일어나 렌더가 길어지는 일이 흔합니다.
- 불필요한 상위 컴포넌트 리렌더 제거
- 메모이제이션(
memo,useMemo,useCallback)은 “측정 후” 적용 - 긴 리스트는 가상화
- 입력 직후 꼭 필요하지 않은 업데이트는 우선순위를 낮추기
React 18의 startTransition은 “긴 업데이트를 덜 급한 업데이트로” 보내는 데 유용합니다.
import { startTransition, useState } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function onChange(e) {
const q = e.target.value;
setQuery(q); // 입력 반영은 즉시
startTransition(() => {
// 결과 갱신은 덜 급하게
setResults(expensiveSearch(q));
});
}
return (
// JSX는 코드 블록 내부라 부등호 문제 없음
<div>
<input value={query} onChange={onChange} />
<ResultList items={results} />
</div>
);
}
이건 Long Task를 “쪼개는” 것과는 결이 다르지만, 입력 처리 우선권을 확보한다는 점에서 INP 안정화에 매우 효과적입니다.
실전 체크리스트: “50ms 이하”를 달성하는 운영 방법
1) 입력 직후 실행되는 코드 경로를 분리
- 클릭 핸들러에 비즈니스 로직을 몰아넣지 말고
- UI 반응(로딩 표시, 버튼 비활성화)과 무거운 작업을 분리
2) 조각 크기는 측정으로 결정
chunkSize를 임의로 정하지 말고- DevTools Performance에서 조각당 실행 시간을 확인
- 30ms 전후를 목표로 시작해서 점진적으로 조정
3) 서드파티 스크립트 의심하기
- 광고/분석/위젯 스크립트가 입력 직후 실행되면 INP가 튑니다
- 가능하면 지연 로드, 상호작용 이후 로드, 또는 격리(Worker/iframe)를 검토
4) “한 번에 많이”보다 “자주 조금씩”
- 대량 렌더는 가상화
- 대량 데이터 처리도 조각 처리
- DOM 업데이트는 배치
예제: 300ms짜리 클릭 핸들러를 40ms 조각으로 분해
문제 코드(한 번에 처리)
button.addEventListener('click', () => {
setStatus('processing');
// 예: 큰 배열을 한 번에 정렬/가공
const items = getItems();
const processed = items
.map((x) => heavyTransform(x))
.sort((a, b) => b.score - a.score);
render(processed);
setStatus('done');
});
function heavyTransform(x) {
let acc = 0;
for (let i = 0; i < 20000; i++) acc += (x.value * i) % 101;
return { ...x, score: acc };
}
개선 코드(조각 처리 + 중간 양보)
button.addEventListener('click', async () => {
setStatus('processing');
// 입력 직후 페인트를 먼저 확보
await new Promise((r) => requestAnimationFrame(r));
const items = getItems();
const out = [];
const chunkSize = 50;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const x of chunk) out.push(heavyTransform(x));
// 50ms를 넘기지 않도록 브라우저에 제어권 반환
if (globalThis.scheduler?.postTask) {
await scheduler.postTask(() => {}, { priority: 'user-visible' });
} else {
await new Promise((r) => setTimeout(r, 0));
}
}
out.sort((a, b) => b.score - a.score);
render(out);
setStatus('done');
});
정렬(sort) 자체도 데이터가 크면 Long Task가 될 수 있습니다. 이 경우엔 Worker로 옮기거나, 정렬이 꼭 즉시 필요하지 않다면 “부분 렌더 후 나중에 정렬 완료본으로 교체” 같은 UX 전략을 씁니다.
마무리: INP를 안정화시키는 핵심은 “입력 직후의 메인 스레드 점유율”
INP 폭증을 잡는 가장 빠른 길은 화려한 최적화가 아니라, 입력 직후 실행되는 Long Task를 찾아 50ms 이하로 쪼개거나 메인 스레드 밖으로 보내는 것입니다.
정리하면 아래 순서로 접근하세요.
- DevTools Performance로 입력 직후 Long Task를 특정
- DOM 읽기/쓰기 분리, 렌더 폭발 차단, 조각 처리로 50ms 이하 분해
- CPU 작업은 Worker로 이동
- 실서비스에서는 Long Task 관측을 붙여 회귀를 빠르게 감지
위 과정을 한 번 체계화해두면, “갑자기 INP가 튄다”는 이슈가 와도 감으로 대응하지 않고 재현·측정·개선의 루프로 안정적으로 해결할 수 있습니다.