- Published on
Chrome INP 폭증? Long Task 추적·분해 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답도 빠르고 LCP도 괜찮은데, 어느 날부터 Chrome에서 INP가 폭증하는 경우가 있습니다. 사용자 입장에서는 클릭이 먹통처럼 느껴지고, 개발자 입장에서는 “분명 이벤트 핸들러는 짧은데?”라는 착시가 생깁니다. 이때 가장 흔한 원인은 메인 스레드가 Long Task로 막혀 입력 이벤트가 제때 처리되지 못하는 상황입니다.
이 글에서는 Chrome DevTools만으로 INP 악화 구간을 재현하고, Long Task를 정확히 “어떤 코드가” “왜” 오래 잡고 있는지 추적한 뒤, 실제로 분해하는(쪼개는) 패턴까지 한 번에 정리합니다.
INP와 Long Task의 관계를 한 문장으로 정리
INP는 사용자의 입력(클릭, 탭, 키 입력 등)부터 다음 페인트까지 걸린 지연을 측정합니다. 이 지연이 커지는 대표적인 이유는 다음 두 가지입니다.
- 입력 이벤트가 큐에 쌓였는데 메인 스레드가 바빠서 핸들러가 늦게 실행됨(입력 지연)
- 핸들러는 빨리 돌았지만 그 뒤 렌더링/레이아웃/페인트가 무거워서 화면 반영이 늦음(렌더링 지연)
Long Task(일반적으로 50ms 초과의 메인 스레드 작업)는 위 두 문제를 동시에 만들 수 있습니다. 즉 “클릭은 들어왔는데 화면이 안 바뀐다”는 체감으로 이어집니다.
1) 먼저: INP가 진짜 ‘현장’에서 터지는지 확인
실서비스에서 INP가 나쁜지, 특정 브라우저/디바이스에서만 나쁜지부터 확인해야 합니다.
- RUM(Web Vitals)로 INP 샘플 수집
- Chrome UX Report(CrUX)로 필드 데이터 확인
- 재현이 어려우면 실사용자 입력 시퀀스를 녹화(예: 특정 페이지 진입 후 검색 입력, 필터 토글 등)
필드에서만 터지는 INP는 보통 아래 요인이 섞입니다.
- 저사양 CPU에서만 Long Task가 50ms를 훌쩍 넘김
- 백그라운드 탭/스레들링(throttling)
- 광고/서드파티 스크립트, A/B 실험 스크립트
- Hydration, 초기 렌더 직후 대량 연산
2) Chrome DevTools로 Long Task를 “잡히게” 만들기
Performance 패널 기본 세팅
- DevTools
Performance열기 - 우측 상단 설정에서 CPU throttling을
4x또는6x로 설정(현장 체감 재현에 유리) Screenshots옵션 켜기(입력 이후 화면이 언제 바뀌는지 확인)- 녹화 시작 후 문제 입력(클릭/키 입력)을 실제처럼 수행
녹화가 끝나면 타임라인에서 다음을 집중적으로 봅니다.
Main트랙의 긴 막대(노란색 스크립팅이 길게 이어짐)Interaction또는Event관련 마커(입력 이벤트)- Long Task 경고(빨간 삼각형/긴 Task)
Long Task를 코드로 역추적하는 요령
Long Task를 클릭하면 하단에 Summary와 Call Tree, Bottom-Up이 나옵니다.
Bottom-Up: “시간을 가장 많이 쓴 함수”부터 보여줘서 원인 파악이 빠름Call Tree: 호출 경로를 따라가며 어디서 시작됐는지 확인
여기서 중요한 포인트는 “내 코드가 짧아 보이는데도” Long Task가 길 수 있다는 점입니다. 예를 들어 이벤트 핸들러 안에서 상태 업데이트를 한 번 했을 뿐인데, 그 뒤에 연쇄적으로 발생한 렌더/레이아웃/스타일 계산이 Long Task로 합쳐져 보일 수 있습니다.
3) 입력 지연 vs 렌더링 지연: INP를 두 토막으로 쪼개서 본다
INP 악화는 크게 두 구간으로 나눠서 접근하면 디버깅이 빨라집니다.
- 입력 지연: 이벤트가 발생했는데 핸들러가 늦게 실행됨
- 렌더링 지연: 핸들러 이후 다음 페인트까지가 늦음
Performance 타임라인에서 클릭 직후에 스크립팅이 길게 이어지고, 그 뒤에 Recalculate Style, Layout, Paint가 연속으로 붙어 있으면 렌더링 비용이 크다는 뜻입니다.
반대로 클릭 직후 아무 반응이 없다가 한참 뒤에 이벤트 핸들러가 실행되면, 이미 이전 작업(다른 Long Task)이 메인 스레드를 점유하고 있어 입력이 밀린 것입니다.
4) 흔한 Long Task 패턴 6가지와 “증거”
패턴 A: 대량 배열/문자열 처리(필터링, 정렬, 파싱)
증거: Bottom-Up에 Array.prototype.sort, JSON.parse, 커스텀 normalize 같은 함수가 상위에 등장
패턴 B: 무거운 렌더(특히 리스트)
증거: 스크립팅은 짧은데 Layout/Recalculate Style이 길고, DOM 노드 수가 급증
패턴 C: 동기식 스토리지/동기 I/O에 준하는 작업
증거: 특정 라이브러리 내부에서 반복적으로 로컬 스토리지 접근, 큰 base64 처리 등
패턴 D: 서드파티 스크립트
증거: 콜스택에 서드파티 도메인/번들 chunk가 상위에 등장
패턴 E: 과도한 이벤트 핸들러(스크롤/포인터 이동)
증거: 입력과 무관한 이벤트가 연속 호출되며 메인 스레드가 계속 바쁨
패턴 F: Hydration/초기 부트스트랩
증거: 페이지 로드 직후 Main이 길게 막히고, 첫 상호작용이 그 여파를 받음
5) Long Task를 “분해”하는 실전 패턴
Long Task를 줄이는 핵심은 “작업량 자체를 줄이거나” “메인 스레드 점유를 쪼개서 양보”하는 것입니다.
5-1) 큰 루프 쪼개기: requestIdleCallback 또는 타임 슬라이싱
아래는 큰 배열 처리(예: 검색 인덱싱, 대량 정규화)를 청크로 나눠 메인 스레드 점유를 줄이는 예시입니다.
function processInChunks(items, chunkSize, handler, onDone) {
let i = 0;
function run(deadline) {
// deadline이 없으면(일부 환경) 시간 기반으로 쪼갬
const start = performance.now();
while (i < items.length) {
handler(items[i]);
i += 1;
const timeExceeded = performance.now() - start > 8;
const shouldYield = deadline ? deadline.timeRemaining() < 3 : timeExceeded;
if (shouldYield) break;
}
if (i < items.length) {
requestIdleCallback(run);
} else {
onDone?.();
}
}
requestIdleCallback(run);
}
// 사용 예
processInChunks(bigList, 1, (item) => {
// 무거운 정규화/계산
normalize(item);
}, () => {
console.log("done");
});
주의할 점은 requestIdleCallback이 “언제든 지연될 수 있다”는 것입니다. 사용자 입력 직후 반드시 완료돼야 하는 작업이라면 idle에 맡기면 UX가 흔들릴 수 있습니다. 그럴 땐 다음 패턴이 더 안전합니다.
5-2) 입력 직후는 가볍게, 무거운 건 다음 프레임으로: requestAnimationFrame
입력에 대한 즉각적인 시각 피드백(버튼 눌림, 로딩 스피너)은 먼저 주고, 무거운 연산은 다음 프레임으로 미루는 방식입니다.
button.addEventListener("click", () => {
button.disabled = true;
button.textContent = "Loading...";
requestAnimationFrame(() => {
// 다음 페인트 이후 무거운 작업
expensiveWork();
button.textContent = "Done";
});
});
이 패턴은 INP에 특히 유효합니다. “다음 페인트”를 빨리 만들어 입력-반응 체감을 개선하기 때문입니다.
5-3) 계산을 메인 스레드 밖으로: Web Worker
정렬/검색/파싱/압축처럼 순수 계산 성격이 강하면 Worker로 보내는 게 가장 확실합니다.
worker.js
self.onmessage = (e) => {
const { items } = e.data;
// 예: 무거운 정렬
const sorted = items.slice().sort((a, b) => a.score - b.score);
self.postMessage({ sorted });
};
main.js
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.onmessage = (e) => {
renderList(e.data.sorted);
};
function onFilter(items) {
worker.postMessage({ items });
}
Worker로 옮기면 Long Task가 “사라지는” 게 아니라 “메인 스레드에서 빠져나간다”는 점이 중요합니다. 렌더링 비용이 큰 경우에는 Worker만으로 INP가 해결되지 않으니, DOM/레이아웃 최적화와 함께 봐야 합니다.
5-4) DOM/레이아웃 비용 줄이기: 리스트 가상화, 배치 업데이트
긴 리스트 렌더가 원인이면 가상화가 정답인 경우가 많습니다.
- 화면에 보이는 범위만 렌더
- 스크롤 위치에 따라 항목을 교체
- DOM 노드 수를 제한
또한 스타일/레이아웃을 강제로 여러 번 트리거하는 코드(예: 읽기-쓰기-읽기 혼합)는 배치가 필요합니다.
// 나쁜 예: 읽기/쓰기 섞어서 레이아웃 스래싱 유발
for (const el of cards) {
const h = el.offsetHeight; // layout read
el.style.height = (h + 10) + "px"; // layout write
}
// 개선: 읽기 먼저 모으고, 쓰기를 한 번에
const heights = cards.map((el) => el.offsetHeight);
heights.forEach((h, idx) => {
cards[idx].style.height = (h + 10) + "px";
});
6) “내 이벤트 핸들러는 짧은데 INP가 나쁨”을 깨는 체크리스트
- 클릭 핸들러 자체가 아니라, 그 뒤의 렌더/레이아웃이 Long Task인지 확인
- 상태 업데이트가 연쇄 렌더를 유발하는지 확인(불필요한 상위 컴포넌트 리렌더)
- 입력 직후 동기적으로 실행되는 분석/로깅/추적 코드가 있는지 확인
- 서드파티 스크립트가 메인 스레드를 막는지 확인
- 같은 프레임에서 DOM 측정과 DOM 변경을 섞었는지 확인
프론트 성능 문제도 결국 “원인 추적”이 전부입니다. 서버 장애에서 OOM/크래시 원인을 좁혀가듯이, 브라우저에서도 타임라인과 콜스택으로 범인을 특정해야 합니다. 이 관점은 예를 들어 Linux OOM Killer 원인추적 - dmesg·cgroup·로그처럼 시스템 레벨에서 근거를 쌓아 원인을 확정하는 접근과도 닮아 있습니다.
7) 실전 디버깅 루틴: 15분 안에 결론 내기
1단계: 재현성 확보
- CPU throttling
4x - 같은 입력 시나리오 3번 녹화
- 공통으로 나타나는 Long Task를 찾기
2단계: 가장 긴 Task 1개를 “대표 원인”으로 지정
Bottom-Up에서 상위 함수/파일 확인- 소스맵이 없으면 번들 chunk 이름이라도 기록
3단계: 분해 전략 선택
- 순수 계산이면 Worker 또는 청크 처리
- 렌더링이면 가상화/리렌더 범위 축소
- 레이아웃이면 측정/변경 분리, CSS 단순화
4단계: 전후 비교
- 같은 시나리오로 Performance 재측정
- Long Task 길이와 입력 이후 첫 페인트까지 시간 비교
8) 측정 자동화: Long Task를 코드에서 감지해 로그 남기기
현장에서만 터지는 INP는 “사용자 세션에서 Long Task가 언제 발생했는지”를 남겨야 잡히는 경우가 많습니다. PerformanceObserver로 Long Task를 관측할 수 있습니다.
export function observeLongTasks({ minDuration = 50 } = {}) {
if (!("PerformanceObserver" in window)) return;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration >= minDuration) {
// 여기서 Sentry, Datadog RUM 등으로 전송
console.log("Long Task", {
name: entry.name,
startTime: entry.startTime,
duration: entry.duration
});
}
}
});
observer.observe({ entryTypes: ["longtask"] });
return observer;
}
이 로그에 “현재 라우트”, “최근 사용자 입력 종류”, “번들 버전”, “실험 플래그” 등을 붙이면, 특정 배포 이후 INP가 폭증하는 회귀(regression)를 훨씬 빨리 찾습니다. 배포/운영 환경에서 원인 좁히는 습관은 JWT kid 불일치로 401? JWKS 캐시 버그 추적 같은 글에서 다룬 방식처럼, 증거를 구조화하는 게 핵심입니다.
9) 마지막으로: INP 개선의 우선순위
INP가 폭증하면 이것저것 만지기 쉬운데, 효과가 큰 순서가 있습니다.
- 메인 스레드 Long Task 제거 또는 분해(가장 큰 레버리지)
- 입력 직후 “다음 페인트”를 빠르게 만들기(
requestAnimationFrame활용) - 렌더 비용 큰 UI는 가상화/리렌더 최소화
- 서드파티 스크립트 격리/지연 로드
- 필요하면 Worker로 계산 오프로딩
INP는 “사용자가 느끼는 반응성” 그 자체라서, 1~2개의 Long Task만 잡아도 체감이 크게 바뀝니다. DevTools에서 가장 긴 Task 하나를 골라 콜스택으로 범인을 특정하고, 분해 패턴(청크 처리, 다음 프레임으로 미루기, Worker)을 적용한 뒤, 동일 시나리오로 전후 비교까지 하는 루틴을 팀의 기본 절차로 만들어두면 회귀도 빠르게 막을 수 있습니다.
다음 단계로는, INP가 나쁜 페이지에서 “어떤 상호작용이” 최악값을 만드는지(예: 검색 입력, 필터 토글, 모달 오픈) 상호작용 단위로 분리해 대시보드화하는 것을 추천합니다. 그러면 성능 이슈가 생겼을 때도 기능 디버깅처럼 재현 경로가 명확해져 대응 속도가 올라갑니다.