- Published on
Chrome INP 악화? Long Task 원인추적·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답은 빠른데도 사용자 체감이 “버벅임”으로 바뀌는 순간이 있습니다. 특히 Chrome에서 INP가 악화되면, 대개 메인 스레드가 한 번에 너무 오래 점유되는 Long Task가 숨어 있습니다. 문제는 “느리다”가 아니라 “어떤 입력(클릭/탭/키보드)에 대해 어떤 작업이 언제 길어졌는지”를 찾아야 한다는 점입니다.
이 글은 Chrome DevTools 기준으로 Long Task를 원인별로 분해해 추적하고, INP를 실제로 개선하는 패턴(코드 포함)을 정리합니다. LCP/CLS처럼 한 번 측정하고 끝나는 지표가 아니라, 사용자 입력의 연속에서 발생하는 병목을 다루므로 재현·측정·수정의 루프가 중요합니다.
INP와 Long Task의 관계를 빠르게 이해하기
INP(Interaction to Next Paint)는 사용자의 입력이 발생한 시점부터, 그 입력에 대한 UI 업데이트가 화면에 “그려질 때”까지의 지연을 측정합니다. 즉 아래 구간이 길어지면 INP가 나빠집니다.
- 입력 이벤트가 큐에 쌓여 대기하는 시간(메인 스레드가 바쁨)
- 이벤트 핸들러 실행 시간(자바스크립트가 오래 돎)
- 스타일 계산/레이아웃/페인트 등 렌더링 파이프라인 비용
Long Task는 보통 “메인 스레드에서 50ms 이상 연속 실행되는 작업”을 의미합니다. Long Task가 있으면 입력 이벤트가 제때 처리되지 못하고 대기열에서 밀리며, 그 결과 INP가 급격히 악화됩니다.
핵심은 “Long Task를 없애는 것”이 아니라, 사용자 입력 직후의 경로에서 Long Task를 제거하거나 쪼개는 것입니다.
1단계: DevTools에서 Long Task를 재현하고 범인 좁히기
Performance 패널로 입력 한 번을 잡아내기
- Chrome DevTools
Performance탭 Screenshots켜기(시각적으로 언제 멈추는지 확인)- 페이지에서 문제 입력(클릭/스크롤/타이핑)을 수행
- 녹화 중지 후, 메인 스레드(Main) 타임라인에서 긴 노란색(스크립트) 구간 확인
여기서 중요한 포인트:
- 문제 입력 직후에 긴 작업이 있는지 확인
Summary에서 Scripting/Rendering 중 무엇이 큰지 확인- 콜스택(Call Tree)에서 “내 코드”인지 “서드파티”인지 먼저 분리
Long Task가 내 코드인지 서드파티인지
- 번들 내부 함수로 이어지면 내 코드일 확률이 큼
gtag,fbq,clarity, 광고 SDK 등으로 이어지면 서드파티 가능성이 큼- 소스맵이 있다면 함수/파일 단위로 바로 식별 가능
서드파티가 원인이라면 “제거”가 최선이지만, 현실적으로는 지연 로딩/격리/우선순위 조정으로 피해를 줄이는 전략이 필요합니다.
2단계: 원인 유형별로 Long Task를 부수는 방법
Long Task는 대개 아래 네 가지로 수렴합니다.
- 입력 이벤트 핸들러가 무거움
- 렌더링(레이아웃/스타일/페인트)이 비쌈
- 대량 연산(JSON 파싱, 정렬, 마크다운 렌더 등)을 메인 스레드에서 수행
- 서드파티 스크립트가 메인 스레드를 점유
각 유형별로 “바로 적용 가능한” 처방을 보겠습니다.
2-1) 입력 이벤트 핸들러가 무거운 경우
증상
- 클릭 한 번에 API 호출 + 상태 업데이트 + 대량 DOM 변경이 한 이벤트 루프에 몰림
onClick내부에서 동기 작업이 길게 이어짐
해결: 작업을 쪼개고, 입력 직후에는 최소 작업만
아래는 “입력 직후 UI 피드백을 먼저 주고”, 무거운 작업은 다음 틱으로 미루는 패턴입니다.
button.addEventListener('click', async () => {
// 1) 즉시 피드백(최소한의 DOM 변경)
button.disabled = true;
button.textContent = '처리 중...';
// 2) 다음 프레임으로 넘겨서 페인트 기회를 줌
await new Promise(requestAnimationFrame);
// 3) 무거운 작업은 이후에 수행
const data = await fetch('/api/heavy').then(r => r.json());
renderResult(data);
button.disabled = false;
button.textContent = '완료';
});
포인트는 requestAnimationFrame을 이용해 브라우저가 한 번 그릴 시간을 확보하는 것입니다. 이것만으로도 “클릭했는데 멈춘 느낌”이 크게 줄어 INP가 개선되는 경우가 많습니다.
해결: 디바운스/스로틀은 “입력 종류”에 맞게
스크롤/리사이즈에 무거운 로직을 붙이면 INP뿐 아니라 전체 인터랙션이 나빠집니다.
function throttle(fn, wait) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn(...args);
}
};
}
window.addEventListener('scroll', throttle(() => {
// 스크롤마다 무거운 계산 금지: 필요한 최소만
updateStickyShadow();
}, 100));
2-2) 렌더링이 비싼 경우(레이아웃 스래싱, 대량 DOM)
증상
- Performance에서 Scripting보다 Rendering 비중이 큼
Recalculate Style,Layout이 입력 직후 길게 나타남
원인 1: 레이아웃 스래싱(읽기/쓰기 교차)
아래처럼 DOM 측정과 DOM 변경이 반복되면 레이아웃이 강제 동기 실행됩니다.
// 나쁜 예: 읽기-쓰기-읽기-쓰기 반복
items.forEach(el => {
const h = el.offsetHeight; // 읽기
el.style.height = (h + 10) + 'px'; // 쓰기
});
개선: 읽기 단계와 쓰기 단계를 분리합니다.
// 좋은 예: 읽기 먼저, 쓰기 나중
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});
원인 2: 한 번에 너무 많은 DOM 업데이트
리스트 2천 개를 한 번에 렌더링하면 입력 이벤트 직후 렌더링이 터집니다.
해결 옵션
- 가상 스크롤(virtualization)
- 점진 렌더링(청크로 나눠 추가)
점진 렌더링 예시:
function appendInChunks(container, nodes, chunkSize = 50) {
let i = 0;
function work() {
const frag = document.createDocumentFragment();
for (let c = 0; c < chunkSize && i < nodes.length; c++, i++) {
frag.appendChild(nodes[i]);
}
container.appendChild(frag);
if (i < nodes.length) {
// 브라우저가 입력/페인트 처리할 틈을 주기 위해 다음 프레임으로
requestAnimationFrame(work);
}
}
requestAnimationFrame(work);
}
이 방식은 총 렌더링 시간은 비슷할 수 있어도, “입력에 대한 응답”이 끊기지 않게 만들어 INP에 특히 유리합니다.
2-3) 대량 연산을 메인 스레드에서 수행하는 경우
증상
- 긴 노란색 스크립트 구간이 입력 직후 나타남
- JSON 파싱, 정렬, 텍스트 변환, 이미지 처리 등이 콜스택 상단에 있음
해결: Web Worker로 메인 스레드에서 분리
정렬/가공 같은 순수 계산은 Worker로 빼면 체감이 확 달라집니다.
worker.js
self.onmessage = (e) => {
const { items } = e.data;
items.sort((a, b) => b.score - a.score);
self.postMessage({ items });
};
메인 코드:
const worker = new Worker('/worker.js');
function sortInWorker(items) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.items);
worker.postMessage({ items });
});
}
button.addEventListener('click', async () => {
button.disabled = true;
await new Promise(requestAnimationFrame);
const items = await fetch('/api/items').then(r => r.json());
const sorted = await sortInWorker(items);
render(sorted);
button.disabled = false;
});
주의할 점은 Worker로 넘기는 데이터가 너무 크면 복사 비용이 생긴다는 것입니다. 가능하면 구조를 단순화하거나, 필요 시 Transferable을 고려하세요.
2-4) 서드파티 스크립트가 원인인 경우
증상
- 콜스택에 분석/광고/태그 매니저 관련 함수가 보임
- 입력과 무관한 시점에 긴 작업이 발생해 입력을 막음
해결 1: 로딩 전략 변경(지연, 조건부, 상호작용 이후)
Next.js라면 next/script의 strategy를 조정합니다.
import Script from 'next/script';
export default function Page() {
return (
<>
<Script
src="https://example.com/third-party.js"
strategy="afterInteractive"
/>
</>
);
}
가능하면 lazyOnload로 더 늦추거나, 특정 페이지에서만 로드하도록 조건을 거세요.
해결 2: 태그를 “기능 단위로” 줄이기
서드파티는 하나만 느린 게 아니라, 여러 개가 합쳐져 Long Task를 만들기도 합니다. 실제로는 “측정 도구가 성능을 망치는” 아이러니가 자주 발생합니다.
- 꼭 필요한 태그만 유지
- 동일 목적의 태그 중복 제거
- A/B 테스트 도구는 대상 페이지를 제한
3단계: Long Task를 코드로 감지하고 회귀를 막기
성능은 한 번 고치고 끝나지 않습니다. 배포 후 다시 악화되는 경우가 대부분이라, 회귀 방지 장치가 필요합니다.
Long Task 관측: PerformanceObserver
브라우저에서 Long Task 엔트리를 수집할 수 있습니다.
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: Long Task 지속 시간(ms)
// entry.startTime: 시작 시점
console.log('Long Task', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
});
}
});
try {
observer.observe({ entryTypes: ['longtask'] });
} catch {
// 일부 환경에서는 longtask 미지원
}
}
이 로그를 Sentry 같은 곳으로 보내면 “어느 사용자 환경에서 Long Task가 폭발했는지”를 추적할 수 있습니다.
INP 수집: web-vitals
현업에서는 실사용자(RUM) 기반으로 INP를 보고, 특정 릴리즈에서 악화되었는지 확인해야 합니다.
import { onINP } from 'web-vitals';
onINP((metric) => {
// metric.value, metric.id, metric.navigationType
console.log('INP', metric);
});
수집한 INP와 Long Task 로그를 같은 세션 키로 묶으면, “INP가 튄 순간의 Long Task”를 역추적하기 쉬워집니다.
4단계: 디버깅 체크리스트(현업용)
아래 순서로 보면 시간을 아낄 수 있습니다.
- 재현 입력 1개를 고정(예: 검색 버튼 클릭)
- Performance에서 입력 직후 Main 스레드가 막히는지 확인
- Scripting vs Rendering 중 큰 쪽을 먼저 처리
- 콜스택으로 내 코드/서드파티 분리
- 내 코드면
- 이벤트 핸들러에서 동기 연산 제거
- 렌더링 청크 분리, 가상화
- Worker로 계산 분리
- 서드파티면
- 로딩 전략 변경
- 필요 태그만 남기기
- 수정 후
- 동일 입력으로 재측정
- 회귀 방지(RUM + Long Task 관측) 추가
이 접근은 “원인 추적”이 핵심이라는 점에서, 장애 원인 추적 글들과 결이 같습니다. 예를 들어 리눅스에서 프로세스가 죽는 근본 원인을 파고드는 방식처럼, 프론트에서도 Long Task의 근본 원인을 끝까지 좁히는 게 가장 빠른 해결책입니다. 참고로 원인 추적 사고방식은 리눅스 OOM Killer로 프로세스 죽을 때 원인 추적 같은 글과도 유사한 구조로 적용할 수 있습니다.
실전 예시: “클릭하면 모달이 늦게 뜨는” INP 개선
상황:
- 클릭 시 모달을 열면서 동시에 큰 데이터를 정렬해 목록을 채움
- 모달이 뜨기 전 화면이 멈춘 느낌
개선 목표:
- 모달은 즉시 열기
- 데이터 정렬/렌더는 모달 오픈 후 분리
openButton.addEventListener('click', async () => {
// 1) 모달 먼저 열기
modal.classList.add('open');
modal.innerHTML = '<p>불러오는 중...</p>';
// 2) 페인트 기회 제공
await new Promise(requestAnimationFrame);
// 3) 데이터 로드
const items = await fetch('/api/items').then(r => r.json());
// 4) 정렬은 Worker로
const sorted = await sortInWorker(items);
// 5) 렌더는 청크로(대량일 때)
const nodes = sorted.map(item => {
const li = document.createElement('li');
li.textContent = item.title;
return li;
});
const ul = document.createElement('ul');
modal.innerHTML = '';
modal.appendChild(ul);
appendInChunks(ul, nodes, 80);
});
이 조합은 INP를 악화시키는 대표적인 “한 방에 다 하기”를 피합니다.
자주 하는 실수
setTimeout(fn, 0)만으로 충분하다고 믿기
setTimeout은 분할에 도움이 되지만, 사용자 입력 직후 페인트를 보장하지는 않습니다. UI를 먼저 보여줘야 한다면 requestAnimationFrame을 함께 고려하세요.
성능 측정이 개발자 PC에서만 끝나는 경우
INP는 저사양 모바일에서 더 크게 악화됩니다. 최소한 CPU 스로틀링을 걸고 확인하세요.
“최적화”가 아니라 “우선순위 재배치”가 필요한데 계속 미시 최적화만 하는 경우
INP는 특히 “입력 직후”가 중요합니다. 전체 처리 시간을 10퍼센트 줄이는 것보다, 입력 직후 200ms를 비워주는 게 효과가 더 큰 경우가 많습니다.
마무리
Chrome에서 INP가 악화되면, 대부분은 Long Task가 메인 스레드를 점유해 입력 이벤트가 밀리는 문제로 귀결됩니다. 해결의 핵심은
- Performance로 입력 직후의 Long Task를 잡고
- Scripting/Rendering/서드파티 중 범주를 나눈 뒤
- 작업을 쪼개고(프레임 양보), 계산을 분리하고(Worker), 렌더를 줄이는(가상화/청크) 방식으로
“사용자 입력에 대한 다음 페인트”를 빠르게 만드는 것입니다.
원인 추적을 체계화해두면, 다음 릴리즈에서 INP가 다시 나빠져도 빠르게 되돌릴 수 있습니다. 이런 진단 루틴은 인프라 문제를 짧은 시간에 좁혀가는 방식과도 닮아 있으니, 트러블슈팅 관점이 필요하다면 systemd 서비스 재시작 루프 10분 진단 가이드 같은 글의 접근법도 함께 참고하면 도움이 됩니다.