- Published on
Chrome INP 나쁨 - Long Task 쪼개기 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능이 충분한데도 Chrome에서 INP(Interaction to Next Paint)가 ‘나쁨’으로 찍히는 경우가 있습니다. 대개 원인은 간단합니다. 메인 스레드가 너무 오래 붙잡혀(= Long Task) 사용자 입력(클릭/키 입력/탭)이 들어와도 다음 페인트까지 진행하지 못하는 상태가 반복되는 것입니다.
INP는 “입력 → 이벤트 처리 → 다음 페인트”까지의 지연을 측정합니다. 즉, 단순히 API 응답이 빠르다고 해결되지 않습니다. 브라우저가 UI를 그릴 틈을 얻어야 합니다. 이 글은 “Long Task를 어떻게 실전에서 쪼개고(분해)”, “우선순위를 어떻게 조정하며”, “어디서부터 손대야 하는지”를 코드 중심으로 정리합니다.
관련해서 더 깊게 scheduler 관점까지 보고 싶다면: Chrome INP 느림 - Long Task 분해와 scheduler 실전
INP가 나빠지는 전형적인 패턴
INP가 나빠지는 코드에는 공통점이 있습니다.
- 클릭/입력 이벤트 핸들러에서 대량 연산(필터링/정렬/파싱/암호화/압축/대량 DOM 조작)을 한 번에 수행
- React/Vue 등에서 상태 업데이트 직후 무거운 동기 렌더가 연달아 발생
for루프 안에서 DOM 측정/수정이 교차하며 레이아웃 스래싱(layout thrashing) 유발- 이미지 디코딩/캔버스 처리/JSON 대량 파싱을 메인 스레드에서 처리
Long Task는 보통 50ms를 초과하는 메인 스레드 작업을 의미합니다. 하지만 INP 관점에서는 “50ms가 아니라도” 입력 직후에 운 나쁘게 겹치면 체감이 크게 나빠집니다. 따라서 목표는 단순히 Long Task 제거가 아니라, 입력 직후의 작업을 잘게 쪼개고 양보(yield)하여 다음 페인트를 빠르게 만드는 것입니다.
0단계: 어디가 Long Task인지 먼저 찾기
DevTools로 Long Task 확인
- Chrome DevTools → Performance
- “Web Vitals” 또는 “Experience” 관련 오버레이를 켜고(버전에 따라 다름)
- 문제 재현(클릭/입력)
- Main 스레드 타임라인에서 빨간 삼각형/긴 Task를 찾고, Call Tree로 원인을 추적
추가로 Console에서 performance.mark/measure를 박아두면 “내 코드”의 비용을 빠르게 식별할 수 있습니다.
// 입력 핸들러 시작/끝에 측정 포인트
function onSearchInput(e) {
performance.mark('search:start');
// ... 무거운 처리 ...
performance.mark('search:end');
performance.measure('search', 'search:start', 'search:end');
console.log(performance.getEntriesByName('search').at(-1));
}
핵심은 “어떤 입력이 INP를 깨는지”와 “그 입력 직후 메인 스레드에서 무엇이 오래 도는지”를 5분 안에 특정하는 것입니다.
1단계: 이벤트 핸들러에서 ‘즉시 필요한 것’만 하고 나머지는 미루기
INP는 입력 직후가 중요합니다. 따라서 입력 핸들러에서 동기적으로 다 처리하려는 습관을 먼저 버립니다.
나쁜 예: 클릭에서 모든 연산 + DOM 반영까지 한 번에
button.addEventListener('click', () => {
// 1) 대량 데이터 가공
const result = heavyTransform(bigList);
// 2) DOM 대량 업데이트
renderList(result);
// 3) 추가 계산
updateStats(result);
});
위 코드는 클릭 직후 메인 스레드를 오래 점유하여 다음 페인트를 지연시킵니다.
개선 예: 입력 직후에는 “피드백”만, 나머지는 분리
button.addEventListener('click', async () => {
// 1) 즉각적인 UI 피드백(스피너/버튼 비활성화)
setLoading(true);
// 2) 다음 페인트를 먼저 허용
await nextPaint();
// 3) 무거운 작업은 쪼개서 수행
const result = await chunkedTransform(bigList, { chunkSize: 500 });
// 4) 렌더도 필요하면 분할/배치
renderList(result);
updateStats(result);
setLoading(false);
});
function nextPaint() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
requestAnimationFrame을 한 번 양보하면 브라우저가 “지금까지의 UI 변경(loading 표시 등)”을 화면에 그릴 기회를 얻습니다. INP는 이런 작은 양보에서 크게 개선되는 경우가 많습니다.
2단계: Long Task를 진짜로 ‘쪼개는’ 패턴 4가지
패턴 A) 루프를 청크로 쪼개고 틈틈이 yield
가장 흔한 대량 연산은 배열 루프입니다. 이를 일정량씩 처리하고 이벤트 루프에 제어권을 돌려줍니다.
async function chunkedTransform(items, { chunkSize = 300 } = {}) {
const out = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// chunk 처리(동기)
for (const item of chunk) {
out.push(transformOne(item));
}
// 다음 페인트/입력 처리를 위해 양보
await yieldToBrowser();
}
return out;
}
function yieldToBrowser() {
// rAF는 페인트 타이밍에 맞춰 양보
return new Promise((resolve) => requestAnimationFrame(resolve));
}
- 장점: 구현이 쉽고 프레임 드랍/INP 악화가 크게 줄어듦
- 단점: 총 처리 시간은 약간 늘 수 있음(대신 UX는 좋아짐)
패턴 B) requestIdleCallback로 “급하지 않은 일”을 idle로 보내기
검색 인덱스 구축, 로그 전송, 프리컴퓨팅 같은 작업은 입력 직후에 할 필요가 없습니다.
function scheduleNonCriticalWork(fn) {
if ('requestIdleCallback' in window) {
requestIdleCallback((deadline) => {
// 남은 idle 시간이 있을 때만 실행
if (deadline.timeRemaining() > 10) fn();
else scheduleNonCriticalWork(fn);
});
} else {
// 폴백: 다음 틱으로 미루기
setTimeout(fn, 0);
}
}
button.addEventListener('click', () => {
setLoading(true);
// 즉시 필요한 렌더만
openModal();
// 나중에 해도 되는 작업은 idle로
scheduleNonCriticalWork(() => warmUpCache());
});
주의: requestIdleCallback은 모바일/저사양에서 idle이 거의 오지 않을 수 있어 “반드시 실행되어야 하는 작업”에는 부적합합니다.
패턴 C) DOM 업데이트는 “한 번에”, 측정/수정은 분리
레이아웃 스래싱은 생각보다 흔합니다.
// 나쁜 예: 측정(getBoundingClientRect)과 수정(style 변경)이 루프에서 섞임
for (const el of elements) {
const h = el.getBoundingClientRect().height;
el.style.height = (h + 10) + 'px';
}
개선: 먼저 모두 측정 → 그 다음 모두 수정(배치)
const heights = elements.map(el => el.getBoundingClientRect().height);
// 수정은 한 번에 몰아서(가능하면 class 토글)
for (let i = 0; i < elements.length; i++) {
elements[i].style.height = (heights[i] + 10) + 'px';
}
가능하면 inline style보다 class 추가/제거로 바꾸고, 애니메이션은 transform/opacity 중심으로 설계하면 메인 스레드 부담이 줄어듭니다.
패턴 D) 메인 스레드에서 할 이유가 없는 연산은 Worker로 보내기
JSON 대량 파싱, 텍스트 처리, 압축/암호화, 이미지 처리 등은 Web Worker로 옮기면 INP가 구조적으로 좋아집니다.
// main.js
const worker = new Worker('/worker.js');
function runHeavyInWorker(payload) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data);
worker.onerror = reject;
worker.postMessage(payload);
});
}
button.addEventListener('click', async () => {
setLoading(true);
await nextPaint();
const result = await runHeavyInWorker({ items: bigList });
renderList(result);
setLoading(false);
});
// worker.js
self.onmessage = (e) => {
const { items } = e.data;
const out = items.map(transformOne);
self.postMessage(out);
};
function transformOne(item) {
// CPU-heavy 작업 가정
return { ...item, score: (item.a * 17) % 97 };
}
Worker는 “메인 스레드 점유”를 제거하는 가장 확실한 방법입니다. 단, 메시지 복사 비용이 있으니 큰 데이터를 자주 주고받는다면 Transferable(ArrayBuffer 등)도 고려하세요.
3단계: 프레임 예산 관점으로 쪼개기(16ms/8ms 룰)
60fps 기준 한 프레임은 약 16.6ms입니다. 입력 직후에는 이벤트 처리 + 스타일 계산 + 레이아웃 + 페인트까지 들어가므로, JS는 보수적으로 8ms 이하를 목표로 쪼개는 편이 안전합니다.
실전 팁:
- “청크 크기”는 고정값이 아니라 시간 기반으로 조절하는 것이 더 안정적입니다.
- 처리하다가 일정 시간(예: 6~8ms)이 지나면 yield 합니다.
async function timeSliced(items, fn, { budgetMs = 8 } = {}) {
const out = [];
let start = performance.now();
for (let i = 0; i < items.length; i++) {
out.push(fn(items[i]));
if (performance.now() - start > budgetMs) {
await yieldToBrowser();
start = performance.now();
}
}
return out;
}
이 방식은 기기 성능이 달라도 “프레임을 망가뜨리지 않는” 방향으로 동작합니다.
4단계: 프레임워크(React 등)에서의 실전 포인트
프레임워크를 쓰면 Long Task의 상당 부분이 “렌더링/리컨실리에이션”으로 나타납니다. 이때는 연산을 쪼개는 것뿐 아니라 렌더 범위를 줄이는 전략이 중요합니다.
1) 상태 업데이트 묶기/불필요한 리렌더 제거
- 파생 상태(derived state)를 state로 저장하지 말고 memoization 사용
- 리스트는 virtualization(예: react-window) 고려
- 큰 객체를 매번 새로 만들지 말고 구조 공유/정규화
2) 입력과 무거운 렌더를 분리
- 입력 필드 타이핑 즉시 전체 리스트 필터링/정렬을 하지 말고 debounce + yield
- “즉시 반영”이 꼭 필요하다면 Worker + 점진적 결과 반영
간단한 debounce 예시:
function debounce(fn, delay = 150) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
const onInput = debounce(async (value) => {
await nextPaint();
const result = await chunkedTransform(bigList, { chunkSize: 300 });
renderList(result);
}, 150);
debounce는 “입력 횟수” 자체를 줄여 INP를 안정화시키는 데 효과적입니다.
5단계: 개선 여부를 재현 가능하게 검증하기
INP는 실사용 환경에서 분포로 관찰하는 게 가장 정확하지만, 개발 단계에서도 재현 가능한 검증 루틴을 만들 수 있습니다.
- Performance 패널에서 동일 시나리오(클릭 → 모달 → 리스트 렌더)를 3~5번 반복 측정
- Long Task(>50ms) 개수, 입력 직후 메인 스레드 블로킹 구간 길이 비교
performance.measure로 “내 코드 구간”의 평균/최댓값을 비교
추가로 현업에서는 “한 번 고치고 끝”이 아니라, 릴리즈마다 회귀(regression)를 막는 게 중요합니다. 타입 레벨에서 안정성을 확보하듯 성능도 지속적으로 관리해야 합니다(타입 추론 이슈를 다룬 글이지만 ‘회귀 방지’ 관점은 비슷합니다): TS 5.x satisfies로 타입 추론 깨짐 해결하기
체크리스트: INP ‘나쁨’에서 빠져나오는 우선순위
- 입력 핸들러에서 즉시 필요한 UI만 처리하고 나머지는 뒤로 미루기
- 대량 루프/연산은 청크 + yield(rAF) 또는 시간 예산 기반 슬라이싱 적용
- DOM 작업은 측정/수정 분리, 가능하면 class 토글 중심으로
- CPU-heavy는 Worker로 오프로딩
- 리스트/렌더가 크면 렌더 범위 축소(virtualization), 불필요 리렌더 제거
마무리
INP가 ‘나쁨’으로 나오는 순간은 대부분 “특정 입력 직후 메인 스레드가 길게 막히는” 순간입니다. 해결의 핵심은 거창한 최적화가 아니라, 작업을 쪼개고 양보하는 구조로 바꾸는 것입니다.
- 빠른 피드백(loading/pressed 상태)을 먼저 보여주고
- 다음 페인트를 확보한 뒤
- 무거운 일을 분할하거나 Worker로 옮기면
대부분의 INP 문제는 실전에서 체감될 정도로 개선됩니다.