INP 200ms 초과 원인 진단: Input Delay·Processing Duration·Presentation Delay 단계별 프로파일링
버튼을 눌렀는데 화면이 늦게 반응하는 건 분명히 느껴지는데, DevTools 타임라인을 열어봐도 "어디서 터지는 건지" 바로 안 보이는 상황 — 저도 꽤 오랫동안 그 상태였습니다. 이벤트 핸들러를 파야 하는지, 렌더링을 봐야 하는지, 아니면 그 이전 어딘가에서 메인 스레드가 막혀 있는 건지 감이 안 잡혔거든요. 원인을 모르면 엉뚱한 곳만 파게 됩니다.
2024년 3월 Google이 FID를 대체해 INP를 Core Web Vitals 공식 지표로 올린 이후, 이 수치를 제대로 잡아야 하는 이유가 하나 더 생겼습니다. HTTP Archive 2025 Web Almanac 기준으로 전체 모바일 페이지의 77%가 Good(≤200ms)을 달성했지만, 상위 1,000개 인기 사이트 중 53%만 Good을 달성한 상황입니다. 트래픽이 많고 기능이 복잡한 서비스일수록 오히려 더 어렵고, SEO에도 직결됩니다.
이 글을 읽고 나면, Attribution 데이터 한 줄만 봐도 다음 최적화 방향이 보이게 됩니다. Chrome DevTools, web-vitals v4 Attribution 빌드, Long Animation Frames API(LoAF)를 조합해서 세 하위 단계(Input Delay·Processing Duration·Presentation Delay) 중 어디가 느린지 특정하고, 각각에 맞는 해결 패턴을 적용하는 흐름을 다룹니다. React/Next.js 코드도 포함돼 있어서 프레임워크 환경에서 고생 중인 분들께도 바로 쓸 수 있는 내용이 될 겁니다.
핵심 개념
INP는 세 단계의 합산이다
"버튼 누르고 화면 바뀌기까지의 시간"이라고만 이해하면 최적화할 수 있는 범위가 좁아집니다. INP는 실제로 아래 세 구간의 합산입니다.
INP = Input Delay + Processing Duration + Presentation Delay| 단계 | 정의 | 주요 원인 |
|---|---|---|
| Input Delay | 사용자 입력 발생 → 이벤트 핸들러 시작까지 대기 | 메인 스레드를 점유 중인 다른 Long Task |
| Processing Duration | 이벤트 핸들러(click, keyup 등) 전체 실행 시간 | 핸들러 내부의 무거운 동기 연산 |
| Presentation Delay | 핸들러 완료 → 실제 화면 업데이트(페인트)까지 | 스타일 재계산, 레이아웃, 과도한 DOM 변경 |
왜 세 단계를 구분하는가? 각 단계의 원인이 완전히 다르기 때문입니다. Input Delay는 핸들러 밖에서 해결해야 하고, Processing Duration은 핸들러 내부를 손봐야 하며, Presentation Delay는 렌더링 파이프라인을 건드려야 합니다. 원인을 특정하지 않으면 엉뚱한 곳을 최적화하게 됩니다.
Good / Needs Improvement / Poor 경계
INP 등급 기준은 아래와 같습니다. 75번째 백분위수(p75) 기준으로 판단합니다.
| 범위 | 등급 |
|---|---|
| ≤ 200ms | ✅ Good |
| 200ms ~ 500ms | ⚠️ Needs Improvement |
| > 500ms | ❌ Poor |
p75란? 100명의 사용자 데이터를 모았을 때 느린 쪽에서 25번째에 해당하는 값입니다. "대부분은 괜찮은데 일부 사용자만 느리다"는 건 INP 기준에서 통하지 않습니다. 세션 내 모든 상호작용 중 가장 느린 것의 p75가 기준이 됩니다.
실전 적용
디버깅 워크플로는 Field 데이터로 먼저 "어디서 터지는지" 범위를 좁히고, 그 다음에 DevTools로 재현해서 원인을 특정하는 순서가 가장 효율적입니다.
예시 1: web-vitals v4 Attribution으로 현장(Field) 데이터 수집
Lab 데이터만으로는 충분하지 않습니다. 실제 사용자 환경에서 어떤 요소를 클릭할 때 INP가 터지는지 알아야 진짜 원인에 접근할 수 있습니다. web-vitals v4의 Attribution 빌드를 쓰면 LoAF 데이터까지 연동해서 원인 스크립트 URL을 직접 찍어줍니다.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const { value, attribution } = metric;
const {
interactionTarget, // 상호작용 발생 요소 (CSS 셀렉터)
interactionType, // "pointer" | "keyboard"
inputDelay, // Input Delay (ms)
processingDuration, // Processing Duration (ms)
presentationDelay, // Presentation Delay (ms)
longAnimationFrameEntries, // 연관된 LoAF 항목들
} = attribution;
console.log(`INP: ${value}ms | Target: ${interactionTarget}`);
console.log(` InputDelay: ${inputDelay}ms`);
console.log(` Processing: ${processingDuration}ms`);
console.log(` Presentation: ${presentationDelay}ms`);
// LoAF로 원인 스크립트 URL 특정 (3rd-party 포함)
// sourceURL은 익명 함수나 인라인 스크립트에서 빈 값이 올 수 있음
longAnimationFrameEntries.forEach((entry) => {
entry.scripts.forEach((script) => {
console.log(` Script: ${script.sourceURL || '(anonymous)'} (${script.duration}ms)`);
});
});
// 실제로는 콘솔이 아닌 분석 서비스로 전송해야 합니다
sendToAnalytics(metric);
}, { reportAllChanges: true }); // 최악값만 아닌 모든 상호작용 보고| 속성 | 의미 |
|---|---|
interactionTarget |
어떤 요소인지 — 이걸 보면 재현 경로를 빠르게 찾을 수 있습니다 |
inputDelay |
이 값이 크면 다른 Long Task가 메인 스레드를 막고 있다는 신호 |
processingDuration |
핸들러 자체가 무겁다는 신호 |
presentationDelay |
렌더링 비용이 크다는 신호 |
longAnimationFrameEntries |
어떤 스크립트가 프레임을 막았는지 URL까지 |
LoAF(Long Animation Frames API) Chrome 123에서 정식 출시된 API로, 50ms 이상 걸린 프레임에 대해 스크립트 실행 시간과 귀인(어떤 파일에서 왔는지)을 제공합니다. Long Tasks API가 태스크 단위 실행 시간만 측정한 것과 달리, LoAF는 렌더링 시간까지 포함하기 때문에 Presentation Delay 진단에도 활용할 수 있습니다.
예시 2: Chrome DevTools로 병목 단계 재현하기
Field 데이터에서 "어떤 요소에서 어떤 단계가 길다"는 신호를 잡았다면, DevTools로 해당 인터랙션을 재현해서 원인 함수까지 특정해볼 수 있습니다.
1. DevTools(F12) → Performance 탭
2. 좌측 상단 "Record" 클릭
3. 느리다고 느껴지는 상호작용 수행 (버튼 클릭, 텍스트 입력 등)
4. "Record" 재클릭으로 종료
5. 타임라인 상단 Interactions 트랙에서 빗금 표시(200ms 초과) 인터랙션 블록 클릭
6. Performance 패널 하단 Summary에서 Input Delay / Processing / Presentation 세분화 확인
7. Flame Chart에서 가장 긴 Task 클릭 → 원인 함수 특정개발 머신의 DevTools 결과와 실제 사용자 기기(특히 보급형 모바일)에서의 결과가 꽤 다를 수 있습니다. Performance 탭 상단의 CPU 스로틀링을 4x slowdown으로 설정해두면 실사용 환경에 좀 더 가까운 측정이 가능합니다.
예시 3: Processing Duration이 길 때 — scheduler.yield()로 태스크 분할
핸들러가 범인이라면 가장 먼저 시도해볼 게 이겁니다. scheduler.yield()를 사용하면 브라우저에게 중간에 메인 스레드를 돌려줄 수 있어서, INP 프레임 커밋을 먼저 처리할 수 있게 됩니다.
// CPU-bound한 동기 연산을 async 래퍼로 감싼 형태
async function handleClick(_event: MouseEvent) {
// 1. 즉각적인 UI 피드백 먼저
updateButtonState('loading');
// 2. 메인 스레드를 브라우저에 양도 → 이 시점에 INP 프레임 커밋
// 'scheduler' in window 체크: scheduler 자체가 없는 환경에서 직접 접근하면 ReferenceError 발생
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
// 3. 이후 무거운 작업 수행
await processHeavyData();
updateButtonState('done');
}scheduler.yield()는 Chrome 129(2024년 8월 안정 채널)에서 정식 출시됐습니다. 적용 사례에서 p75 INP를 60~65% 낮춘 결과가 보고됐고, 폴백(setTimeout(0))과 함께 쓰면 브라우저 지원 여부와 관계없이 효과를 볼 수 있어서 지금 당장 도입해볼 만한 패턴입니다.
예시 4: Input Delay가 길 때 — Long Task 청킹
Input Delay가 길다는 건 이미 다른 작업이 메인 스레드를 잡고 있다는 뜻입니다. 대량의 데이터를 처리하는 루프가 있다면 5ms 단위마다 양도하는 청킹 패턴이 효과적입니다.
// Bad: 수백 개 항목을 한 번에 처리하면 50ms 초과 단일 태스크 발생
function processItems(items: Item[]) {
items.forEach(item => expensiveOperation(item));
}
// Good: 5ms 단위 청킹으로 메인 스레드 주기적 양도
async function processItemsYielding(items: Item[]) {
const DEADLINE_MS = 5;
let deadline = performance.now() + DEADLINE_MS;
for (const item of items) {
expensiveOperation(item);
if (performance.now() >= deadline) {
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else {
await new Promise<void>((r) => setTimeout(r, 0));
}
deadline = performance.now() + DEADLINE_MS;
}
}
}예시 5: React/Next.js에서 번들 분리와 하이드레이션 Input Delay 개선
React 앱에서 페이지 로드 직후 상호작용이 느린 경우, 하이드레이션이 메인 스레드를 통째로 잡아먹고 있을 가능성이 높습니다. 여기서 한 가지 구분이 중요합니다.
React.lazy() + Suspense는 코드 스플리팅입니다. JS 번들을 지연 로드해서 초기 파싱 부담을 줄이는 기법이고, CSR 환경에서도 동작합니다. React 18의 선택적 하이드레이션(Selective Hydration) 은 이와 달리 ReactDOM.hydrateRoot + 스트리밍 SSR 환경에서 Suspense 경계 단위로 하이드레이션을 독립적으로 처리하는 기능입니다. 두 기법은 효과도 적용 조건도 다릅니다. CSR 환경에서 lazy()를 붙이면 번들 크기로 인한 Input Delay는 줄어들 수 있지만, 하이드레이션 자체를 비블로킹 처리한다는 의미는 아닙니다.
// CSR + 코드 스플리팅: 무거운 컴포넌트의 번들을 지연 로드해서 초기 파싱 부담 감소
import { Suspense, lazy } from 'react';
const HeavyWidget = lazy(() => import('./HeavyWidget'));
export default function Page() {
return (
<main>
<CriticalContent /> {/* 즉시 렌더링 */}
<Suspense fallback={<Skeleton />}>
<HeavyWidget /> {/* 번들 지연 로드 */}
</Suspense>
</main>
);
}SSR/Next.js 환경에서 선택적 하이드레이션까지 활용하려면 ReactDOM.hydrateRoot와 스트리밍 렌더링이 조합돼야 합니다. Next.js App Router는 이 구조를 기본으로 지원합니다.
실제 사례
Wix는 선택적 하이드레이션과 Suspense를 조합해서 상호작용 속도 40% 향상을 달성했습니다. 하이드레이션이 Suspense 경계 단위로 독립적으로 처리되면서 전체 페이지가 블로킹되는 구간이 사라진 덕분입니다.
Preply는 App Router 없이도 이벤트 핸들러 분리와 코드 스플리팅만으로 INP를 Good 범위로 진입시켰습니다. 무거운 핸들러를 lazy()로 분리하고 처리 순서를 조정해서 Processing Duration을 줄인 케이스입니다. 프레임워크 마이그레이션 없이도 접근할 수 있다는 점에서 참고할 만합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 전체 세션 반영 | FID는 최초 입력 하나만 측정했지만, INP는 세션 내 모든 상호작용의 p75를 봐서 실사용자 경험에 훨씬 가깝습니다 |
| 세분화된 진단 | 세 하위 지표로 병목 위치를 명확히 구분할 수 있어 "어디를 고칠지"가 분명해집니다 |
| 3rd-party 귀인 | LoAF + web-vitals Attribution으로 외부 스크립트가 원인이어도 소스 URL까지 특정 가능합니다 |
| SEO 직결 | Core Web Vitals 구성 지표라 개선이 검색 랭킹과 직결됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Lab vs Field 괴리 | DevTools에서 재현 안 되는 케이스(느린 기기, 배터리 절약 모드)가 실제 p75를 끌어올립니다 | RUM 데이터(web-vitals, DebugBear 등)를 반드시 병행합니다 |
| 단일 이상치 영향 | 한 번의 극단적으로 느린 상호작용이 75th 퍼센타일에 영향을 줄 수 있습니다 | p95, p99도 함께 모니터링해서 이상치를 별도 관리합니다 |
| 프레임워크별 차이 | React 합성 이벤트, Angular Zone.js, Vue 반응형 시스템이 각각 Processing Duration에 다른 영향을 미칩니다 | 프레임워크별 Profiler와 DevTools를 조합해서 접근합니다 |
RUM(Real User Monitoring) 실제 사용자의 브라우저에서 수집한 성능 데이터입니다. DevTools에서 직접 측정하는 Lab 데이터와 달리, 다양한 기기·네트워크 환경을 반영합니다. DebugBear, SpeedCurve, RUMvision 같은 도구가 대표적입니다.
실무에서 가장 흔한 실수
- Lab 데이터만 보고 "해결됐다"고 판단하는 것 — DevTools에서 재현이 안 된다고 해서 Field에서 문제가 없는 건 아닙니다. RUM 데이터로 검증하는 과정이 필요합니다. 솔직히 이 함정에 한 번쯤은 빠지게 됩니다.
isInputPending()을 여전히 쓰는 것 — 과거에 권장됐던 패턴이지만 현재는 비권장 방향입니다. 실제 입력이 있어도false를 반환하는 버그가 있어서,scheduler.yield()또는setTimeout패턴으로 교체하는 것을 권장합니다.- 세 단계를 구분하지 않고 이벤트 핸들러만 최적화하는 것 — Input Delay가 원인인데 핸들러를 아무리 가볍게 만들어도 수치가 개선되지 않습니다. Attribution 데이터에서 어느 단계가 긴지 먼저 확인해보시면 방향이 잡힙니다.
마치며
INP 최적화의 핵심은 200ms라는 숫자보다, 세 하위 단계 중 어디서 시간이 새는지를 먼저 파악하는 것입니다. Attribution 데이터 없이 짐작으로 최적화를 시도하면 엉뚱한 곳에 시간을 쏟게 됩니다.
지금 바로 시작할 수 있는 3단계:
- 수집 설정:
pnpm add web-vitals후 Attribution 코드를 프로덕션에 추가해보시면 좋습니다.console.log가 아닌 분석 서비스로 데이터를 전송하도록 구성해두시면, 실제 사용자 환경에서 어떤 요소의 어느 단계가 긴지 바로 파악할 수 있습니다. - 진단 판독:
inputDelay/processingDuration/presentationDelay중 가장 큰 값이 어느 단계인지 확인해보시면 됩니다. 이것만 알아도 다음 액션이 "Long Task 청킹"인지, "핸들러 분리"인지, "렌더링 최적화"인지 방향이 갈립니다. - 1순위 해결책 적용: Processing Duration이 크다면
scheduler.yield()폴백 패턴을 핸들러에 추가해보시면 좋습니다. 코드 변경량 대비 INP 개선 효과가 가장 빠르게 나타나는 방법 중 하나입니다.
참고 자료
- Interaction to Next Paint (INP) | web.dev
- Manually diagnose slow interactions in the lab | web.dev
- Find slow interactions in the field | web.dev
- Optimize long tasks | web.dev
- Long Animation Frames API | Chrome for Developers
- The Long Animation Frame API has now shipped | Chrome Blog
- Performance panel overview | Chrome DevTools
- How To Improve INP With Chrome DevTools | DebugBear
- How To Fix Missing INP Attribution Data With The LoAF API | DebugBear
- The Definitive Guide to Long Animation Frames (LoAF) | SpeedCurve
- How To Improve INP: Yield Patterns | Jacob 'Kurt' Groß
- React 19.2 Further Advances INP Optimization | Web Performance Calendar
- Improving INP with React 18 and Suspense | Vercel
- 40% Faster Interaction: How Wix Solved React's Hydration Problem | Wix Engineering
- How Preply improved INP on a Next.js application | Medium
- web-vitals v4 to support LoAF + INP breakdown | RUMvision
- GoogleChrome/web-vitals GitHub
- INP in Frameworks | Chrome Aurora
- Understanding INP | Google Codelabs