- Published on
Chrome INP 개선 - 롱태스크 분해와 isInputPending
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 아무리 빠르고 TTFB가 좋아도, 사용자가 클릭했는데 화면이 멈춘 듯 반응이 늦으면 체감 성능은 최악입니다. INP는 바로 그 지점을 수치화합니다. 특히 SPA에서 라우팅, 렌더링, 상태 업데이트, 서드파티 스크립트가 한 번에 몰리면 메인 스레드가 길게 막히고, 그 순간 발생한 입력 이벤트의 처리가 뒤로 밀리면서 INP가 급격히 나빠집니다.
이 글은 Chrome에서 INP를 개선할 때 가장 효과가 큰 두 가지 축을 다룹니다.
- 롱태스크를 기능적으로 안전하게 분해하는 방법
isInputPending을 이용해 입력을 우선 처리하는 패턴
관련해서 롱태스크 분해 자체의 추적과 진단 흐름은 아래 글도 함께 참고하면 연결이 잘 됩니다.
INP를 망치는 전형적인 구조
INP는 사용자의 입력이 발생한 시점부터, 그 입력에 대한 다음 페인트가 완료될 때까지의 지연을 봅니다. 즉 아래가 길어지면 INP가 나빠집니다.
- 입력 이벤트 핸들러가 늦게 실행됨
- 핸들러는 실행됐지만, 이후 렌더링과 페인트가 늦음
롱태스크는 보통 다음 형태로 나타납니다.
- 대량 배열 처리, JSON 파싱, 정렬, 필터링
- 큰 DOM 트리 변경, 스타일 계산 유발
- 초기 로딩 시 많은 동기 스크립트 실행
- 프레임워크 상태 업데이트가 한 틱에 과도하게 몰림
핵심은 “사용자 입력이 들어올 수 있는 시간대에 메인 스레드를 오래 점유하지 말자”입니다.
1) 롱태스크 분해: 단순 setTimeout이 답이 아닌 이유
가장 흔한 접근은 “긴 루프를 setTimeout으로 나눠서 돌리자”입니다. 그런데 이 방식은 다음 문제가 있습니다.
- 청크 사이에도 입력이 있을지 없을지 모름
- 청크 크기를 잘못 잡으면 여전히 롱태스크가 남음
- 입력이 밀려있는데도 다음 청크를 계속 실행할 수 있음
즉 “양보”만 하고 “우선순위”를 고려하지 않으면 INP 개선 폭이 제한됩니다.
목표: 프레임 예산 기준으로 쪼개기
일반적으로 60fps 기준 한 프레임 예산은 약 16ms입니다. 물론 모든 프레임을 16ms로 맞출 필요는 없지만, 입력 직후에는 특히 메인 스레드를 길게 잡아먹지 않는 게 중요합니다.
아래는 작업을 청크로 나누되, 프레임 예산을 넘기면 양보하는 기본 패턴입니다.
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processInChunks(items, chunkMs = 8) {
let i = 0;
while (i < items.length) {
const start = performance.now();
while (i < items.length && (performance.now() - start) < chunkMs) {
// CPU 작업 예시
heavyWork(items[i]);
i += 1;
}
// 메인 스레드에 양보
await yieldToMain();
}
}
function heavyWork(x) {
// 예시용 더미 연산
let v = 0;
for (let k = 0; k < 2000; k += 1) v += (x * k) % 7;
return v;
}
이 패턴만으로도 “한 방에 200ms” 같은 롱태스크는 크게 줄어듭니다. 하지만 입력 우선 처리가 필요하면 여기서 한 단계 더 가야 합니다.
2) isInputPending으로 입력 우선 처리하기
Chrome은 navigator.scheduling.isInputPending() API를 제공합니다. 현재 처리 대기 중인 사용자 입력이 있는지 확인해, CPU 작업을 잠시 멈추고 입력 처리와 렌더링 기회를 먼저 줄 수 있습니다.
중요 포인트는 아래입니다.
- 입력이 대기 중이면 즉시 양보해 이벤트 처리와 페인트가 앞서가게 함
- 입력이 없으면 청크를 더 진행해 처리량을 확보함
실전 패턴: 청크 루프에 입력 체크를 섞기
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function isInputPendingSafe() {
const sch = navigator.scheduling;
if (!sch || typeof sch.isInputPending !== "function") return false;
try {
return sch.isInputPending();
} catch {
return false;
}
}
async function processWithInputPriority(items, chunkMs = 8) {
let i = 0;
while (i < items.length) {
const start = performance.now();
while (i < items.length) {
// 입력이 밀려있다면 즉시 양보
if (isInputPendingSafe()) break;
heavyWork(items[i]);
i += 1;
// 시간 예산도 초과하면 양보
if ((performance.now() - start) >= chunkMs) break;
}
await yieldToMain();
}
}
function heavyWork(x) {
let v = 0;
for (let k = 0; k < 2000; k += 1) v += (x * k) % 7;
return v;
}
이 방식은 “사용자 입력이 들어오는 순간”을 더 민감하게 감지해 양보하므로, INP 개선에 직접적으로 기여합니다.
isInputPending을 언제 쓰면 특히 효과적인가
- 입력 직후 바로 실행되는 무거운 작업이 있는 경우
- 예: 클릭 후 리스트 3만 건 필터링, 정렬, 그룹핑
- 스크롤 중에 무거운 JS가 돌아가는 경우
- 예: 무한 스크롤에서 대량 DOM 추가 전처리
- 입력 이벤트 핸들러에서 동기적으로 많은 일을 하는 경우
- 예: 입력값 변경 시 즉시 복잡한 검증과 파생 상태 계산
3) 입력 핸들러 자체를 가볍게 만들기
롱태스크 분해는 “긴 작업을 나누는 것”이고, 더 근본적인 개선은 “입력 핸들러가 시작하는 일을 줄이는 것”입니다.
패턴 A: 입력 핸들러는 상태만 기록하고, 무거운 일은 비동기로
let latestQuery = "";
let scheduled = false;
function onInput(e) {
latestQuery = e.target.value;
if (scheduled) return;
scheduled = true;
setTimeout(async () => {
scheduled = false;
const data = getBigData();
const result = await filterDataIncrementally(data, latestQuery);
render(result);
}, 0);
}
async function filterDataIncrementally(data, q) {
const out = [];
for (let i = 0; i < data.length; i += 1) {
if (data[i].includes(q)) out.push(data[i]);
// 간단한 양보
if (i % 500 === 0) {
await new Promise((r) => setTimeout(r, 0));
}
}
return out;
}
function getBigData() {
return Array.from({ length: 20000 }, (_, i) => `item-${i}`);
}
function render(list) {
// DOM 업데이트는 배치로
const root = document.getElementById("root");
root.textContent = list.slice(0, 50).join(", ");
}
핵심은 “입력 이벤트 핸들러는 가능한 한 빨리 반환”하도록 설계하는 것입니다.
패턴 B: DOM 업데이트를 쪼개고, 한 번에 너무 많이 그리지 않기
INP는 다음 페인트까지를 보기 때문에, JS 작업을 나눴더라도 DOM 업데이트가 한 번에 너무 크면 페인트가 늦어질 수 있습니다.
- 리스트는 가상화
- 한 번에 추가하는 노드 수 제한
- 레이아웃 스래싱 방지: 읽기와 쓰기 분리
간단한 예시로, DOM 추가를 청크로 나눌 수 있습니다.
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function appendItemsChunked(container, items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const frag = document.createDocumentFragment();
const slice = items.slice(i, i + chunkSize);
for (const text of slice) {
const li = document.createElement("li");
li.textContent = text;
frag.appendChild(li);
}
container.appendChild(frag);
await yieldToMain();
}
}
4) isInputPending 적용 시 주의점
1) 지원 범위와 폴백
navigator.scheduling.isInputPending은 브라우저 지원이 제한될 수 있습니다. 따라서 위 예시처럼 안전 체크와 폴백이 필요합니다.
- 미지원이면 기존 시간 예산 기반 청킹만으로도 충분히 의미가 있음
- 지원 브라우저에서는 입력 우선 효과가 더해짐
2) 너무 자주 확인하면 오버헤드가 생길 수 있음
루프 매 반복마다 체크하는 대신, 아래처럼 “N번에 한 번” 또는 “시간 예산이 임계치에 가까워질 때”만 확인하는 최적화도 가능합니다.
function isInputPendingSafe() {
const sch = navigator.scheduling;
if (!sch || typeof sch.isInputPending !== "function") return false;
try {
return sch.isInputPending();
} catch {
return false;
}
}
async function processOptimized(items) {
let i = 0;
while (i < items.length) {
const start = performance.now();
let steps = 0;
while (i < items.length) {
heavyWork(items[i]);
i += 1;
steps += 1;
// 32회마다 한 번만 입력 체크
if (steps % 32 === 0 && isInputPendingSafe()) break;
if ((performance.now() - start) > 8) break;
}
await new Promise((r) => setTimeout(r, 0));
}
}
function heavyWork(x) {
let v = 0;
for (let k = 0; k < 2000; k += 1) v += (x * k) % 7;
return v;
}
3) “양보”만으로 해결되지 않는 경우도 있음
- 작업 자체가 너무 큼: 알고리즘 개선, 데이터 구조 변경 필요
- JS가 아니라 렌더링이 병목: 스타일 계산, 레이아웃, 페인트 비용 확인 필요
- 서드파티 스크립트가 메인을 점유: 로딩 전략 조정 필요
즉 롱태스크 분해는 치료제라기보다 “증상 완화와 체감 개선에 매우 강한 수단”이고, 근본 원인은 프로파일링으로 함께 제거해야 합니다.
5) 체크리스트: INP 개선을 위한 적용 순서
- 문제 구간의 롱태스크를 찾는다
- 입력 직후 실행되는 동기 작업을 최소화한다
- 남는 CPU 작업은 청크로 분해한다
- 가능하면
isInputPending으로 입력 우선 양보를 넣는다 - DOM 업데이트는 배치하고, 큰 업데이트는 쪼갠다
- 다시 측정해 “입력 지연”과 “다음 페인트 지연” 중 어디가 남는지 확인한다
마무리
INP는 결국 “사용자 입력에 얼마나 빨리 반응해 다음 화면을 그려주느냐”의 문제입니다. 롱태스크 분해는 메인 스레드 점유 시간을 줄여 입력 처리 기회를 늘리고, isInputPending은 그중에서도 입력이 실제로 대기 중인 순간을 포착해 우선순위를 보장합니다.
두 기법을 함께 적용하면, 평균 성능보다 “최악의 순간”이 크게 개선되는 경우가 많습니다. INP가 간헐적으로 튀는 서비스라면 특히 체감 차이가 큽니다.