- Published on
Chrome INP 느림 - Long Task 분해와 scheduler 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
INP(Interaction to Next Paint)는 사용자의 입력(클릭/탭/키 입력 등)이 발생한 순간부터, 그 입력에 대한 화면의 “다음 페인트”가 일어날 때까지 걸린 시간을 측정합니다. 즉 “이벤트 핸들러가 빨리 끝났는가?”가 아니라, 입력 → JS 실행 → 스타일/레이아웃 → 페인트까지의 전체 경로에서 병목이 있으면 INP가 튑니다.
현장에서 INP가 느린 케이스를 보면 대다수가 메인 스레드에서 **Long Task(보통 50ms 이상)**가 연속으로 발생해 입력 처리가 큐에 쌓이고, 그 사이 렌더링이 밀리는 형태입니다. 이 글은 Chrome DevTools로 Long Task를 찾아내고, 작업을 잘게 쪼개고(splitting), scheduler로 양보(yield)하며, 필요하면 **오프메인스레드(Worker)**로 옮겨 INP를 낮추는 실전 접근을 다룹니다.
참고로 성능 지표 중 CLS가 함께 흔들리는 경우도 많습니다. 입력 이후 레이아웃이 요동치면 다음 페인트가 늦어지기 때문입니다. 레이아웃 시프트가 동반된다면 Chrome CLS 급증 - 폰트·이미지·광고 시프트 해결도 같이 점검하세요.
INP가 느려지는 전형적인 메커니즘
INP는 크게 세 구간의 합으로 이해하면 디버깅이 쉬워집니다.
- Input delay: 사용자의 입력 이벤트가 발생했지만, 메인 스레드가 바빠서 이벤트를 처리하지 못하고 대기하는 시간
- Processing time: 이벤트 핸들러 및 관련 JS 실행 시간(동기 작업이 길면 여기서 폭발)
- Presentation delay: JS가 끝난 뒤 스타일 계산/레이아웃/페인트가 실제로 화면에 반영되기까지의 시간
Long Task는 보통 1)과 3)을 동시에 악화시킵니다. 메인 스레드가 점유되면 입력 이벤트가 큐에 쌓이고, 렌더링 파이프라인도 돌지 못합니다.
DevTools로 “진짜” Long Task 찾기
1) Performance 패널: INP 후보 입력을 찍고 Long Task 확인
- DevTools → Performance
- “Web Vitals” 오버레이가 보이면 켜고(버전에 따라 다름), 혹은 Performance Insights를 사용
- 느리다고 느낀 동작(클릭/탭) 재현 후 Record 종료
- Main 트랙에서 빨간 삼각형/긴 블록(긴 JS 실행)을 찾고, 해당 구간에 입력 이벤트가 끼어 있는지 확인
여기서 중요한 건 “내가 클릭한 이벤트 핸들러”만 보는 게 아니라, 클릭 직전/직후에 메인을 오래 점유한 작업을 찾는 겁니다. 예를 들어 클릭과 무관한 리스트 정렬/필터링/대량 DOM 생성이 클릭 직전에 실행되면 Input delay가 튀면서 INP가 나빠집니다.
2) Long Task의 정체: 흔한 패턴
- 대량 DOM 생성/수정(append를 수천 번)
- 큰 JSON 파싱 + 가공
- 복잡한 정렬/필터링/그룹핑
- 동기식 마크다운/하이라이트 변환
- 캔버스/이미지 처리
- 스타일 강제 동기 계산(레이아웃 스래싱)
3) Performance API로 현장 계측(선택)
실서비스에서 “어느 페이지/어떤 상호작용이” 문제인지 잡으려면 RUM이 필요합니다. 최소한 Long Task를 수집하면 원인 추적이 쉬워집니다.
// Long Task 수집(브라우저 지원 범위 확인)
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: long task duration
// entry.attribution: 원인 스크립트(Chrome 등 일부)
console.log('[LongTask]', entry.duration, entry);
}
});
try {
po.observe({ type: 'longtask', buffered: true });
} catch (e) {
// 지원하지 않는 브라우저
}
핵심 전략 1: Long Task를 “쪼개서” 메인 스레드에 양보하기
Long Task를 줄이는 가장 보편적인 방법은 큰 일을 작은 청크로 나누고, 중간중간 브라우저에 **양보(yield)**하는 것입니다. 양보가 들어가면 메인 스레드는 입력 처리/렌더링을 먼저 수행할 수 있어 INP가 안정됩니다.
1) 나쁜 예: 1,000개 아이템을 한 번에 처리
function heavyWork(items) {
// 1) 동기 계산
const processed = items.map(expensiveTransform);
// 2) 동기 DOM 업데이트
const root = document.querySelector('#root');
root.innerHTML = '';
for (const item of processed) {
const el = document.createElement('div');
el.textContent = item.title;
root.appendChild(el);
}
}
이 코드는 클릭 핸들러에서 호출되면 곧장 Long Task가 됩니다.
2) 개선 예: 청크 처리 + requestAnimationFrame로 렌더링 기회 제공
function chunk(array, size) {
const out = [];
for (let i = 0; i < array.length; i += size) out.push(array.slice(i, i + size));
return out;
}
function raf() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function heavyWorkChunked(items, chunkSize = 50) {
const root = document.querySelector('#root');
root.innerHTML = '';
for (const part of chunk(items, chunkSize)) {
// 계산
const processed = part.map(expensiveTransform);
// DOM 반영
const frag = document.createDocumentFragment();
for (const item of processed) {
const el = document.createElement('div');
el.textContent = item.title;
frag.appendChild(el);
}
root.appendChild(frag);
// 다음 프레임에 양보(입력 처리/렌더링 기회)
await raf();
}
}
포인트는 “청크 사이에 await”를 넣어 메인 스레드 독점을 끊는 것입니다.
3) setTimeout(0) vs rAF vs microtask
Promise.then/queueMicrotask는 microtask라서 같은 프레임에서 계속 이어질 수 있어 “양보” 효과가 약합니다.setTimeout(0)는 macrotask로 양보는 되지만 타이밍이 거칠고 지연이 들쭉날쭉할 수 있습니다.requestAnimationFrame은 다음 페인트 타이밍에 맞춰 양보할 수 있어 UI 업데이트와 궁합이 좋습니다.
다만 “렌더링과 무관한 순수 계산”은 rAF가 과할 수 있으니, 아래의 scheduler를 쓰면 더 정교해집니다.
핵심 전략 2: scheduler로 “의도적으로” 우선순위와 양보를 설계
최근 Chrome/Chromium 계열에서는 scheduler.postTask(Task Scheduler API)를 통해 작업 우선순위를 지정할 수 있습니다. React의 Scheduler와도 결이 비슷하지만, 브라우저 레벨에서 우선순위 기반 태스크 큐잉이 가능해집니다.
1) 기본 사용: background/low priority로 밀어내기
async function runNonUrgentWork() {
// 사용자 입력 직후 꼭 필요하지 않은 작업은 background로
await scheduler.postTask(() => {
expensiveAnalytics();
}, { priority: 'background' });
}
클릭 직후 “즉시 화면에 반영되어야 하는 것”과 “나중에 해도 되는 것(로그/프리페치/정리 작업)”을 분리하면 INP가 눈에 띄게 좋아집니다.
2) 긴 루프에서 yield: scheduler.yield() 패턴
브라우저/버전에 따라 scheduler.yield() 지원이 다를 수 있어, 안전하게는 postTask를 이용한 양보를 구현합니다.
function yieldToBrowser(priority = 'user-visible') {
if (globalThis.scheduler?.postTask) {
return scheduler.postTask(() => {}, { priority });
}
// 폴백: rAF
return new Promise((resolve) => requestAnimationFrame(resolve));
}
async function processBigList(items) {
const out = [];
const start = performance.now();
for (let i = 0; i < items.length; i++) {
out.push(expensiveTransform(items[i]));
// 대략 8~12ms마다 양보: 60fps 기준 프레임 예산 고려
if (i % 50 === 0 && performance.now() - start > 10) {
await yieldToBrowser('user-visible');
}
}
return out;
}
여기서 핵심은 “몇 개마다”가 아니라 시간 기반으로 끊는 것입니다. 기기 성능이 다르기 때문에, 처리량 기준은 특정 디바이스에서만 맞고 다른 곳에서 깨집니다.
3) 클릭 핸들러 설계: urgent vs non-urgent 분리
button.addEventListener('click', async () => {
// 1) 즉시 반응(urgent): 버튼 로딩/비활성화 등
button.disabled = true;
button.textContent = 'Loading...';
// 2) 다음 페인트를 먼저 확보
await new Promise((r) => requestAnimationFrame(r));
// 3) 무거운 작업은 우선순위 낮춰 실행
const data = await scheduler.postTask(() => computeSomethingHeavy(), {
priority: 'user-visible'
});
render(data);
button.disabled = false;
button.textContent = 'Done';
});
사용자가 “눌렀는데 아무 반응이 없다”고 느끼는 구간을 먼저 제거하면, INP뿐 아니라 체감 성능도 좋아집니다.
핵심 전략 3: 오프메인스레드로 옮기기(Worker)
작업을 쪼개도 총량이 너무 크면 메인을 계속 괴롭힙니다. 이때는 계산을 Worker로 보내고, 메인은 입력/렌더링에 집중하게 하는 게 정석입니다.
1) Worker로 무거운 계산 이동
worker.js
self.onmessage = (e) => {
const items = e.data;
const result = items.map(expensiveTransform);
self.postMessage(result);
};
main.js
const worker = new Worker('/worker.js');
function computeInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(items);
});
}
async function onClick() {
// 즉시 UI 반응
showLoading();
await new Promise((r) => requestAnimationFrame(r));
const processed = await computeInWorker(bigItems);
render(processed);
hideLoading();
}
DOM 조작은 Worker에서 할 수 없지만, “비싼 계산”을 떼어내는 것만으로도 INP가 크게 안정됩니다.
핵심 전략 4: 렌더링 비용 줄이기(레이아웃 스래싱 방지)
Long Task는 JS만의 문제가 아닙니다. 이벤트 핸들러에서 DOM을 읽고 쓰는 순서가 꼬이면 스타일/레이아웃이 반복 강제 실행되어 Presentation delay가 커집니다.
1) 나쁜 예: 읽기-쓰기-읽기 반복
function bad() {
const el = document.querySelector('.card');
el.style.width = (el.offsetWidth + 10) + 'px'; // offsetWidth 읽기가 레이아웃 강제
const h = el.offsetHeight; // 다시 레이아웃
console.log(h);
}
2) 개선: 읽기(측정)와 쓰기(변경) 분리
function good() {
const el = document.querySelector('.card');
const w = el.offsetWidth;
const h = el.offsetHeight;
// 쓰기는 한 번에
el.style.width = (w + 10) + 'px';
// 필요하면 다음 프레임에
requestAnimationFrame(() => {
console.log(h);
});
}
레이아웃/페인트 비용이 커서 INP가 튄다면, DevTools의 Rendering(Paint flashing, Layout Shift Regions 등)과 Performance의 Bottom-Up에서 “Recalculate Style / Layout” 비중을 확인하세요.
실전 체크리스트: INP 개선을 빠르게 만드는 순서
- 문제 상호작용 1개를 고정: “이 클릭에서 INP가 600ms 튄다”처럼 목표를 좁힘
- Performance 트레이스에서 입력 직전/직후 Long Task를 특정
- Long Task가 JS면:
- (a) urgent/non-urgent 분리
- (b) 청크 처리 + 시간 기반 yield
- (c) scheduler 우선순위 적용
- (d) 그래도 크면 Worker로 이동
- Long Task가 렌더링이면:
- (a) DOM 업데이트 배치(Fragment)
- (b) 레이아웃 스래싱 제거(읽기/쓰기 분리)
- (c) 큰 컴포넌트는 가상화(virtualization) 고려
- 개선 후 모바일/저사양 CPU에서 다시 측정(데스크탑만으로는 착시가 큼)
자주 묻는 함정
microtask로 쪼개면 왜 INP가 안 좋아지나?
await Promise.resolve() 같은 방식은 실제로는 같은 이벤트 루프 턴 안에서 계속 실행될 수 있어, 브라우저가 렌더링/입력 처리로 제어권을 가져가지 못합니다. “양보”를 원하면 macrotask(rAF, setTimeout, scheduler.postTask 등)가 필요합니다.
청크를 너무 잘게 쪼개면 오히려 느려지나?
가능합니다. 청크가 너무 작으면 오버헤드(함수 호출/await/스케줄링)가 커지고 총 처리 시간이 늘 수 있습니다. 그래서 시간 예산(예: 8~12ms) 기반으로 끊는 방식이 실전에서 안전합니다.
INP만 낮추면 끝인가?
INP는 “상호작용 응답성”의 대표 지표지만, 실제 UX는 FCP/LCP/CLS와 함께 움직입니다. 특히 입력 후 레이아웃이 크게 바뀌면 INP도 악화될 수 있으니, 레이아웃 안정성은 Chrome CLS 급증 - 폰트·이미지·광고 시프트 해결처럼 별도로 관리하는 게 좋습니다.
결론
Chrome에서 INP가 느릴 때 가장 먼저 의심해야 할 것은 메인 스레드 Long Task입니다. 해결의 핵심은 “최적화”보다도 구조적 분해입니다.
- 큰 일을 청크로 쪼개고
- 프레임/태스크 사이에 **의도적으로 양보(yield)**하고
scheduler.postTask로 우선순위를 설계하며- 필요하면 Worker로 계산을 이관
이 4가지만 체계적으로 적용해도, 특정 페이지에서만 튀던 INP를 “항상 괜찮은 수준”으로 안정화할 수 있습니다.