- Published on
Chrome INP 개선 - Long Task 50ms 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 빠른데도 UX가 답답한 경우가 있습니다. 클릭은 했는데 화면이 늦게 바뀌고, 스크롤이 끊기고, 입력창 타이핑이 밀리는 느낌. 이런 “반응성” 문제를 정량화한 지표가 INP(Interaction to Next Paint) 입니다.
INP가 나쁜 사이트를 Chrome DevTools로 보면 공통점이 하나 있습니다. 메인 스레드(Main Thread)가 50ms 이상 붙잡혀 있는 Long Task가 반복됩니다. 브라우저는 메인 스레드에서 JS 실행, 스타일 계산, 레이아웃, 페인트, 이벤트 처리를 수행하므로, 한 번 길게 점유되면 입력 이벤트가 대기열에 쌓이고 “다음 페인트”가 늦어져 INP가 악화됩니다.
이 글은 “Long Task 50ms를 어떻게 찾아서, 어떻게 쪼개고, 어떻게 재발을 막는지”를 Chrome 중심으로 실전적으로 다룹니다.
INP와 Long Task의 관계를 한 문장으로
INP는 사용자 입력(클릭/탭/키 입력 등) 이후, UI가 다음으로 그려질 때까지 걸린 시간이며, 그 지연의 가장 흔한 원인은 입력 직후(또는 직전) 메인 스레드를 점유한 Long Task(>50ms) 입니다.
- 좋음: INP ≤ 200ms
- 개선 필요: 200ms ~ 500ms
- 나쁨: ≥ 500ms
Long Task는 “50ms 이상 메인 스레드를 연속으로 점유한 작업”으로 정의됩니다. 50ms는 대략 60Hz 환경에서 3프레임(16.7ms × 3) 정도를 통째로 날려버리는 수준이라 체감이 큽니다.
Chrome에서 Long Task를 정확히 찾는 3단계
1) Performance 패널로 ‘입력 → 다음 페인트’ 경로를 잡기
- DevTools → Performance
- Record(녹화) 시작
- 문제가 되는 상호작용(클릭/입력/스크롤)을 2~3회 반복
- Stop 후 타임라인에서 Interactions/Timings(환경에 따라 표시 다름) 또는 이벤트 트랙을 확인
여기서 핵심은 “입력 이벤트가 발생한 시점”과 “화면이 바뀐 페인트” 사이에 무엇이 끼어있는지 보는 것입니다.
- Main 트랙에서 노란색(스크립트) 블록이 길게 이어지면 JS Long Task
- 보라색(렌더링), 초록색(페인트) 구간이 길면 레이아웃/스타일/페인트 병목
2) Long Task를 클릭해서 ‘원인 함수’까지 내려가기
Performance에서 Long Task를 클릭하면 하단에 Summary/Bottom-Up/Call Tree가 나옵니다.
- Bottom-Up: 가장 비싼 함수부터 정렬되어 원인 파악에 유리
- Call Tree: 호출 관계 추적에 유리
여기서 “내 코드인지 / 라이브러리인지 / 프레임워크 런타임인지”를 먼저 구분하세요. 해결 전략이 달라집니다.
3) Long Task가 ‘입력 직후’에 있는지 확인
INP는 입력과 연관된 지연이므로, Long Task가 있어도 입력과 상관없는 구간이면 INP에 직접 영향이 덜할 수 있습니다.
- 입력 직전: 이벤트 처리 대기열이 밀림
- 입력 직후: 이벤트 핸들러/상태 업데이트/렌더가 길어짐
즉, “가장 느린 Long Task”가 아니라 **“입력과 엮인 Long Task”**를 우선순위로 두는 게 실전에서 빠릅니다.
Long Task 50ms를 줄이는 대표 패턴 6가지
1) 이벤트 핸들러에서 무거운 일을 하지 말고 ‘스케줄링’하라
클릭 핸들러에서 즉시 데이터 가공/정렬/필터/DOM 대량 변경을 하면 입력 직후 Long Task가 됩니다.
전략: 사용자에게 즉시 피드백(버튼 눌림, 로딩 스피너 등)을 먼저 페인트하고, 무거운 작업은 다음 틱으로 미룹니다.
button.addEventListener('click', () => {
// 1) 즉시 UI 피드백
button.disabled = true;
button.textContent = 'Loading...';
// 2) 다음 프레임/다음 틱으로 무거운 작업을 분리
requestAnimationFrame(() => {
// 레이아웃/스타일 변경이 반영된 뒤 실행되기 쉬움
doHeavyWork();
button.disabled = false;
button.textContent = 'Done';
});
});
requestAnimationFrame은 “렌더링 타이밍”과 맞물려 동작하므로, UI 반영을 먼저 하고 싶을 때 유용합니다. 단, doHeavyWork()가 너무 무거우면 결국 프레임 드랍이 발생하므로 쪼개기가 필요합니다(아래 2번).
2) 50ms를 ‘쪼개서’ 양보(yield)하기: cooperative scheduling
Long Task는 “연속 점유”가 문제입니다. 200ms 일을 하더라도 10~20ms 단위로 쪼개서 중간중간 브라우저에 양보하면 입력 처리와 페인트가 끼어들 수 있습니다.
가장 쉬운 방법은 setTimeout(0) 또는 requestAnimationFrame을 이용한 청크 처리입니다.
function chunkedProcess(items, chunkSize = 200) {
let index = 0;
function runChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
// CPU-heavy work
compute(items[index]);
}
if (index < items.length) {
// 메인 스레드에 양보
setTimeout(runChunk, 0);
}
}
runChunk();
}
더 모던하게는 scheduler.yield()(지원 범위 주의)나 isInputPending()을 활용해 “입력이 들어오면 즉시 양보” 전략을 취할 수 있습니다.
async function processWithYield(items) {
for (let i = 0; i < items.length; i++) {
compute(items[i]);
// 입력이 밀려 있으면 양보
if (navigator.scheduling?.isInputPending?.()) {
await new Promise(requestAnimationFrame);
}
}
}
이 패턴은 INP에 특히 직결됩니다. 입력이 들어오는데도 루프가 계속 돌면, 이벤트가 처리되지 못해 INP가 튀기 때문입니다.
3) DOM 대량 변경은 한 번에, 레이아웃 스래싱을 피하라
DOM을 읽고(레이아웃 유발) 바로 쓰고(레이아웃 무효화) 다시 읽는 패턴이 반복되면 렌더링 비용이 폭증합니다.
나쁜 예(스래싱):
for (const el of list) {
const h = el.getBoundingClientRect().height; // read (layout)
el.style.height = (h + 10) + 'px'; // write
}
개선: 읽기와 쓰기를 분리(배치)합니다.
const heights = list.map(el => el.getBoundingClientRect().height);
list.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});
또는 DocumentFragment로 DOM 삽입을 묶습니다.
const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item.name;
frag.appendChild(li);
}
ul.replaceChildren(frag);
4) 렌더링 비용이 큰 컴포넌트는 ‘가상화’하고, 불필요한 리렌더를 줄여라
React/Vue/Svelte 등 프레임워크 사용 시, INP를 망치는 흔한 원인은 “입력 한 번에 너무 많은 컴포넌트가 리렌더”되는 것입니다.
- 긴 리스트는 virtualization(예: react-window)
- 상태 변경 범위를 최소화(상위 컴포넌트의 광범위 리렌더 방지)
- expensive 계산은 memoization
프레임워크별 최적화는 방법이 다르지만, Performance 패널에서 Long Task의 콜스택이 렌더링으로 채워져 있다면 “컴포넌트 트리 업데이트량”이 의심 포인트입니다.
5) 무거운 계산은 Web Worker로 오프로드하라
메인 스레드에서 CPU를 태우는 계산(정렬, 압축, 파싱, 암호화, 이미지 처리)은 Worker로 보내면 Long Task 자체를 제거할 수 있습니다.
메인:
const worker = new Worker('/worker.js');
worker.onmessage = (e) => {
const result = e.data;
render(result);
};
button.addEventListener('click', () => {
worker.postMessage({ type: 'PROCESS', payload: bigData });
});
워커(/worker.js):
self.onmessage = (e) => {
const { type, payload } = e.data;
if (type === 'PROCESS') {
const result = heavyCompute(payload);
self.postMessage(result);
}
};
INP 관점에서 Worker는 특히 강력합니다. 입력 이벤트 처리를 막는 “연속 점유”가 사라지기 때문입니다.
6) 서드파티 스크립트는 ‘늦게/적게/격리’가 기본
광고/태그/분석 스크립트가 메인 스레드를 점유하면, 내 코드가 아무리 최적화돼도 INP가 흔들립니다.
- 가능한
defer/async적용 - 초기 상호작용 이전에는 로드하지 않기(사용자 동의/첫 입력 이후 로드)
- Tag Manager에서 불필요 태그 제거
- 필요 시
iframe격리(완전 해결은 아니지만 영향 분리)
“Long Task 50ms”를 재발 방지하는 측정/가드 레이어
Long Task API로 현장에서(Real User) 감시하기
필드에서 Long Task를 수집하면 “특정 기기/특정 페이지/특정 상호작용”에서만 터지는 문제를 잡기 쉬워집니다.
if ('PerformanceObserver' in window) {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration >= 50ms 인 태스크가 여기에 잡힘
console.log('LongTask', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
});
}
});
try {
obs.observe({ type: 'longtask', buffered: true });
} catch {
// 일부 브라우저/환경에서 미지원
}
}
실무에서는 console 대신 로그 수집 파이프라인(예: 자체 endpoint, RUM 도구)에 붙여 “50ms 이상 태스크가 특정 릴리즈 이후 증가했는지”를 추적합니다.
CI에서 성능 회귀를 막는 방법(간단 버전)
- Lighthouse CI로 INP 자체를 직접 게이트로 쓰기는 아직 제약이 있을 수 있습니다(측정 조건 민감).
- 대신 Total Blocking Time(TBT), main-thread work 증가, 번들 사이즈 증가를 회귀 지표로 삼아 Long Task 증가를 간접 방지할 수 있습니다.
배포 자동화/권한 이슈로 CI가 불안정하면 성능 게이트도 무력화되기 쉬운데, 이런 류의 파이프라인 안정화는 별도로 정리해둔 글(GitHub Actions OIDC로 AWS 배포 권한 오류 해결)도 참고할 만합니다.
디버깅 체크리스트: “50ms를 누가 먹었나”
아래 순서대로 보면 원인 분류가 빨라집니다.
- 입력 이벤트 핸들러가 무겁지 않은가?
- 클릭/키 입력 콜스택에 데이터 가공/DOM 대량 조작이 붙어 있으면 1차 후보
- 렌더링(스타일/레이아웃/페인트)이 폭증했나?
- DOM 읽기/쓰기 교차, 큰 그림자/블러, 큰 영역 repaint 의심
- 프레임워크 리렌더 범위가 과도한가?
- 상태 한 번 바뀌는데 대규모 컴포넌트 업데이트
- 서드파티 스크립트가 메인 스레드를 잡나?
- Performance 콜스택에서 vendor 파일 비중 확인
- 저사양/모바일에서만 재현되나?
- CPU throttling(4x/6x)로 재현성을 올리고, 청크/Worker를 우선 적용
실전 예시: “검색 입력창 타이핑이 밀리는” INP 개선 흐름
가장 흔한 케이스를 하나만 예로 들면, 입력할 때마다 필터링/정렬/DOM 업데이트를 동기 처리하는 경우입니다.
- 문제:
input이벤트마다 10,000개 목록 필터링 + DOM 갱신 → Long Task 80~200ms - 개선:
- 입력 이벤트는 debounce
- 필터링은 Worker
- DOM 갱신은 가상화/Fragment
간단히 debounce만 적용해도 INP가 크게 좋아지는 경우가 많습니다.
function debounce(fn, delay = 150) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
const onSearch = debounce((q) => {
// 무거운 작업은 여기서 실행(또는 Worker로)
updateList(q);
}, 150);
input.addEventListener('input', (e) => onSearch(e.target.value));
마무리: INP는 “빠른 서버”가 아니라 “비는 메인 스레드”가 만든다
INP를 올리는 가장 확실한 길은 입력 직후 메인 스레드를 비워두는 것입니다. Long Task 50ms는 그 목표를 방해하는 대표적인 적이고, Chrome Performance 패널로 “입력과 엮인 Long Task”를 먼저 잡아내면 개선 속도가 빨라집니다.
정리하면 우선순위는 다음과 같습니다.
- 입력 핸들러에서 무거운 일을 즉시 하지 않기(피드백 먼저)
- 50ms 이상 작업은 청크로 쪼개서 양보하기
- 계산은 Worker로 보내 메인 스레드를 비우기
- DOM/렌더링 비용을 배치/가상화로 제어하기
- 서드파티 스크립트의 메인 스레드 점유를 줄이기
성능 문제는 네트워크/인프라 이슈와 겹쳐 보일 때도 있습니다. 예를 들어 특정 환경에서만 느리다면 네트워크 레이어 진단도 병행해야 하는데, 인프라 쪽 빠른 진단 루틴이 필요하면 Azure VM SSH 타임아웃 10분 진단 - NSG·UDR·DNS 같은 “10분 진단” 스타일의 접근이 문제 분리(클라이언트 vs 네트워크)에 도움이 됩니다.
이제 Performance 녹화를 켜고, “입력 직후 50ms+” 블록을 하나만 골라 쪼개보세요. INP는 대부분 그 한 덩어리에서 시작해 눈에 띄게 좋아집니다.