- Published on
Chrome INP 느림? Long Task·TBT 7분 개선
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 빠른데도 Chrome에서 클릭이나 입력 반응이 굼뜨다면, 대개 원인은 메인 스레드가 바쁜 상태입니다. 특히 INP는 사용자의 실제 상호작용(클릭, 탭, 키 입력)에 대한 반응성을 측정하기 때문에, 네트워크보다 Long Task와 TBT가 더 직접적인 적입니다.
이 글은 “원인 파악부터 즉시 적용 가능한 개선”까지를 7분 안에 끝내는 흐름으로 구성했습니다. 성능을 미세 최적화하기 전에, 먼저 어디서 메인 스레드가 막히는지를 빠르게 찾아내는 게 핵심입니다.
INP, Long Task, TBT를 한 번에 정리
INP란
INP(Interaction to Next Paint)는 사용자의 상호작용 이후 화면이 실제로 다음 페인트로 반응하기까지 걸린 시간을 봅니다. 단순히 이벤트 핸들러가 끝나는 시간이 아니라, 다음 화면 업데이트가 가능한 상태가 되었는지까지 포함합니다.
- 예: 버튼 클릭
onClick이 20ms에 끝나도, 그 직후 렌더링/레이아웃/스타일 계산이 밀리면 INP는 커질 수 있습니다.
Long Task란
메인 스레드에서 50ms를 초과해 실행되는 작업입니다. Long Task가 있으면 사용자 입력이 큐에 쌓이고, 브라우저는 페인트를 제때 못 합니다.
TBT란
TBT(Total Blocking Time)는 페이지 로드 구간에서 Long Task들이 “사용자 입력을 막은 시간”의 누적치입니다. INP는 로드 이후 상호작용도 반영하지만, 현실에서는 로딩 중 스크립트가 과도하면 INP도 같이 나빠지는 경우가 많습니다.
핵심 관계는 이렇습니다.
Long Task가 많다 → 입력 처리 지연 증가- 입력 처리 지연 증가 →
INP악화 - 로딩 중 Long Task 누적 →
TBT증가 → 초기 상호작용 INP 악화
7분 개선 플로우: “측정 2분 + 처방 5분”
아래 순서대로 하면, 대부분의 “Chrome에서 INP가 느린데 이유를 모르겠다” 케이스는 방향이 잡힙니다.
- DevTools로 Long Task 위치 찾기
- 해당 작업이 무엇인지 분류(렌더링, JS, 서드파티)
- 큰 것부터 쪼개기(청킹), 미루기(지연 로딩), 옮기기(워커)
1) DevTools Performance로 Long Task 1개만 잡아도 반은 끝
재현 준비
- Chrome DevTools 열기
Performance탭Web Vitals확장 또는Performance내 Insights가 있으면 함께 활용- CPU 스로틀
4x정도로 걸면 병목이 더 잘 보입니다
기록 방법
Record시작- 문제 상호작용 수행(클릭, 입력, 스크롤)
Stop
무엇을 봐야 하나
- Main 트랙에서 긴 막대가
Long Task - 막대 클릭 후 하단
Bottom-Up또는Call Tree로 “어떤 함수가 시간을 먹는지” 확인 - Scripting, Rendering, Painting 중 어디가 두꺼운지 먼저 구분
빠른 판단 기준
Scripting이 두껍다: JS 실행/파싱/컴파일/GC 의심Rendering이 두껍다: 레이아웃 스래싱, 큰 DOM, 스타일 계산 의심Painting이 두껍다: 큰 영역 리페인트, 이미지/캔버스 의심
2) INP를 코드에서 바로 계측해 “범인 상호작용”을 특정
실서비스에서 재현이 어렵다면, INP를 직접 수집해 어떤 상호작용이 문제인지부터 좁히는 게 빠릅니다.
아래는 web-vitals로 INP를 수집하는 최소 예시입니다.
// analytics/webVitals.ts
import { onINP } from 'web-vitals';
export function initWebVitals() {
onINP((metric) => {
// metric.value: INP(ms)
// metric.attribution: 어떤 상호작용/타겟/이벤트인지 단서
console.log('INP', metric.value, metric);
// 예: 사내 수집 API로 전송
navigator.sendBeacon(
'/vitals',
JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
rating: metric.rating,
attribution: metric.attribution,
})
);
});
}
Next.js라면 클라이언트 엔트리에서 한 번만 실행되게 붙이면 됩니다.
// app/layout.tsx 또는 pages/_app.tsx에서 클라이언트에서만 실행
'use client';
import { useEffect } from 'react';
import { initWebVitals } from './analytics/webVitals';
export default function WebVitalsInit() {
useEffect(() => {
initWebVitals();
}, []);
return null;
}
이렇게 수집하면 “특정 버튼 클릭에서만 INP가 튄다”, “검색 입력에서만 느리다”처럼 타겟팅이 됩니다.
3) Long Task를 5분 안에 줄이는 실전 처방 6가지
여기부터가 진짜 “7분 개선” 파트입니다. 가장 흔하고 효과 큰 순서대로 정리합니다.
3-1. 큰 루프/파싱/정렬을 청킹해서 메인 스레드 숨통 트기
한 번에 200ms를 써버리는 작업은 20ms씩 10번으로 나누는 것만으로도 INP가 크게 좋아집니다.
function yieldToMain() {
return new Promise<void>((resolve) => {
// requestIdleCallback이 없으면 setTimeout으로 폴백
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(() => resolve());
} else {
setTimeout(() => resolve(), 0);
}
});
}
export async function chunkedMap<T, R>(items: T[], fn: (item: T) => R) {
const out: R[] = [];
for (let i = 0; i < items.length; i++) {
out.push(fn(items[i]));
// 50개마다 메인 스레드에 양보
if (i % 50 === 0) {
await yieldToMain();
}
}
return out;
}
포인트는 “완료 시간이 약간 늘어도, 사용자 입력을 막지 않게” 만드는 겁니다.
3-2. 입력 이벤트에서 무거운 작업을 즉시 실행하지 말고 디바운스
검색 자동완성, 필터링, 실시간 검증에서 흔합니다.
function debounce<F extends (...args: any[]) => void>(fn: F, delayMs: number) {
let t: number | undefined;
return (...args: Parameters<F>) => {
if (t) window.clearTimeout(t);
t = window.setTimeout(() => fn(...args), delayMs);
};
}
const onChange = debounce((value: string) => {
// 여기서 큰 필터링/검색 호출
expensiveFilter(value);
}, 150);
입력 중 매 키스트로크마다 30ms씩만 써도, 사용자는 “키가 씹힌다”고 느낍니다.
3-3. React를 쓰면 startTransition으로 렌더 우선순위 낮추기
상호작용 직후 UI 업데이트가 무거운 렌더를 동반하면, INP가 튀는 패턴이 나옵니다.
import { startTransition, useState } from 'react';
export function SearchBox() {
const [q, setQ] = useState('');
const [results, setResults] = useState<string[]>([]);
function handleChange(v: string) {
setQ(v); // 입력값은 즉시 반영
startTransition(() => {
// 무거운 결과 렌더는 낮은 우선순위로
setResults(expensiveSearch(v));
});
}
return (
<div>
<input value={q} onChange={(e) => handleChange(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
);
}
startTransition은 Long Task 자체를 없애진 않지만, “입력 반응”을 먼저 살려 체감 INP를 개선하는 데 효과가 큽니다.
3-4. 레이아웃 스래싱 제거: 읽기와 쓰기를 분리
getBoundingClientRect() 같은 레이아웃 읽기와 style 변경 같은 쓰기가 섞이면 강제 리플로우가 반복됩니다.
나쁜 예(읽기-쓰기-읽기-쓰기 반복):
for (const el of elements) {
const rect = el.getBoundingClientRect();
el.style.transform = `translateY(${rect.top}px)`;
}
개선(읽기 먼저, 쓰기 나중에):
const rects = elements.map((el) => el.getBoundingClientRect());
rects.forEach((rect, i) => {
elements[i].style.transform = `translateY(${rect.top}px)`;
});
이것만으로 Rendering 시간이 눈에 띄게 줄어드는 경우가 많습니다.
3-5. 서드파티 스크립트 지연 로딩: 초기 상호작용 보호
광고/태그매니저/AB테스트/채팅 위젯은 Long Task의 단골입니다. “당장 필요하지 않다면” 상호작용 이후로 미루세요.
<script>
function loadThirdParty() {
const s = document.createElement('script');
s.src = 'https://example.com/third-party.js';
s.async = true;
document.head.appendChild(s);
}
// 첫 사용자 입력 이후 로드
window.addEventListener('pointerdown', loadThirdParty, { once: true });
</script>
핵심은 “초기 상호작용 구간의 메인 스레드 독점”을 피하는 것입니다.
3-6. 진짜 무거운 계산은 Web Worker로 옮기기
암호화, 대용량 JSON 변환, 복잡한 정렬/스코어링은 워커가 정답인 경우가 많습니다.
// worker.ts
self.onmessage = (e) => {
const data = e.data as number[];
const sorted = data.slice().sort((a, b) => a - b);
(self as any).postMessage(sorted);
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
export function sortInWorker(data: number[]) {
return new Promise<number[]>((resolve) => {
worker.onmessage = (e) => resolve(e.data as number[]);
worker.postMessage(data);
});
}
메인 스레드 Long Task를 구조적으로 제거할 수 있어, INP 개선 폭이 큽니다.
4) “어디서부터 손대야 할지” 우선순위 체크리스트
아래 순서대로 보면 시행착오가 줄어듭니다.
- Performance에서 가장 긴
Long Task하나를 고른다 - 그 작업이
Scripting이면- 대용량 루프 청킹
- 디바운스/스로틀
- 서드파티 지연 로딩
- 워커 이전
- 그 작업이
Rendering이면- 레이아웃 스래싱 제거
- DOM 크기 줄이기(가상 스크롤)
- 불필요한 리렌더 줄이기
- 개선 후 다시 측정해서 “Longest 1개”가 바뀌는지 확인한다
성능은 대부분 “상위 1개 또는 2개 Long Task”가 지배합니다. 잔챙이를 10개 없애는 것보다, 1개를 반으로 줄이는 게 더 큽니다.
5) 자주 나오는 함정: INP는 이벤트 핸들러만의 문제가 아니다
INP가 느릴 때 흔히 onClick 함수만 들여다보고 끝내는데, 실제로는 다음이 더 흔합니다.
- 클릭 이후 상태 업데이트로 인해 렌더 트리가 커지고 커밋이 길어짐
- 모달 오픈 시 폰트 로딩, 이미지 디코딩, 레이아웃 변화가 겹침
- 입력창 포커스 시 스크롤/레이아웃 보정 로직이 동작
그래서 Performance에서 “상호작용 타임라인”을 보고, 이벤트 이후에 이어지는 Rendering 비용까지 함께 봐야 합니다.
6) 운영에서 성능 회귀를 막는 방법
한 번 개선하고 끝내면 다시 느려집니다. 배포 파이프라인이나 PR 단계에서 성능 회귀를 감지하는 장치를 두는 편이 장기적으로 이득입니다.
- Lighthouse CI로 TBT, INP 관련 지표를 수치로 관리
- 특정 페이지 번들 크기 상한 설정
- 서드파티 스크립트 추가 시 성능 영향 검토
CI/CD가 복잡한 모노레포라면 워크플로우가 과도하게 돌면서 팀의 개선 속도를 떨어뜨릴 수 있습니다. 이런 경우에는 빌드/테스트 비용을 줄여 성능 개선 작업 자체를 빠르게 반복할 수 있게 만드는 것도 중요합니다. 관련해서는 GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기도 함께 참고할 만합니다.
또한 타입 안정성이 떨어져 런타임 가드나 방어 코드가 과도해지면, 핫패스에서 불필요한 분기와 변환이 늘어날 수 있습니다. 객체 검증을 타입 시스템으로 더 밀어넣는 접근은 유지보수와 성능 모두에 도움이 됩니다. 예시는 TS 5.x satisfies로 타입 안전 유지하며 객체 검증에서 아이디어를 얻을 수 있습니다.
마무리: “INP는 메인 스레드의 여유”를 사는 게임
정리하면, Chrome에서 INP가 느릴 때 가장 빠른 해결 루트는 다음입니다.
- Performance로
Long Task하나를 정확히 잡는다 - 그 작업을 청킹하거나, 지연시키거나, 워커로 옮긴다
- 렌더링 병목이면 레이아웃 스래싱과 DOM/리렌더를 줄인다
이 과정을 한 번만 제대로 해두면, “왜 느린지 모르는 상태”에서 벗어나 매번 같은 방식으로 재현하고 고칠 수 있습니다. INP는 감이 아니라, 타임라인에서 보이는 작업을 줄이는 것으로 확실하게 개선됩니다.