INP를 잡는 핵심은 "이벤트 핸들러 안에서 벌어지는 긴 자바스크립트 작업을 잘게 쪼개, 브라우저가 화면을 다시 그릴 틈을 주는 것"이다. scheduler.yield()나 await를 이용한 양보(yield)만 제대로 넣어도 200ms 벽은 대부분 넘긴다.
2024년 3월 FID가 코어 웹 바이탈에서 빠지고 INP(Interaction to Next Paint)가 정식 지표가 됐다. FID는 "첫 입력의 지연"만 봤지만 INP는 페이지 수명 동안 발생한 모든 상호작용의 응답성을 본다. 훨씬 엄격하다. 그래서 예전에 FID 초록불이던 사이트가 INP에서 빨간불로 떨어지는 일이 흔하다. 이 글은 그걸 실제로 어떻게 되돌리는지에 대한 기록이다.
솔직히 말하면, 나는 FID 시절에 코어 웹 바이탈을 좀 얕봤다. 우리 서비스 FID가 늘 20ms 언저리라 "응답성? 우린 문제없지"라고 생각했다. 그러다 2024년 초 서치 콘솔에 INP 경고가 뜨기 시작했다. 필드 데이터 기준 p75가 340ms. 빨간불. 같은 코드, 같은 서버인데 지표 하나 바뀌었다고 갑자기 낙제생이 된 거다. 그때부터 두 달을 INP에 매달렸고, 이 글은 그 과정에서 정리한 실전 노트다.
| 지표 | Good (p75) | Needs work | Poor | 무엇을 재나 |
|---|---|---|---|---|
| INP | ≤ 200ms | 200~500ms | > 500ms | 상호작용→다음 페인트 |
| LCP | ≤ 2.5s | 2.5~4.0s | > 4.0s | 최대 콘텐츠 렌더 |
| CLS | ≤ 0.1 | 0.1~0.25 | > 0.25 | 누적 레이아웃 이동 |
| FID (폐기) | ≤ 100ms | 100~300ms | > 300ms | 첫 입력의 지연만 |
임계값은 web.dev 공식 기준(2024~2026 동일). p75는 실제 사용자의 상위 25% 지점 값이라, 소수 저사양 기기에서 튀는 값이 지표를 끌어내린다는 점이 핵심이다.
INP가 정확히 뭐고, FID랑 뭐가 다른가?
INP는 페이지가 살아있는 동안 사용자가 한 모든 클릭·탭·키 입력 각각에 대해 "입력이 들어온 순간부터 그 결과로 화면이 다시 그려지기까지" 걸린 시간을 재고, 그중 가장 느린 축(대략 최악값, 상호작용이 많으면 상위 몇 개를 버린 값)을 대표값으로 삼는 지표다. 반면 FID는 오직 첫 입력의 "지연", 그것도 이벤트 핸들러가 실행되기까지 기다린 시간만 쟀다. 핸들러가 얼마나 오래 돌든, 그 뒤 렌더링이 얼마나 밀리든 FID는 신경 쓰지 않았다.
차이가 결정적이다. FID는 "문 앞에서 얼마나 기다렸나"만 보고, INP는 "문 열고 들어가서 원하는 걸 손에 쥐기까지"를 본다. 그래서 무거운 SPA, 상태 업데이트가 큰 React 앱, 리스트 필터링 같은 건 FID는 멀쩡한데 INP에서 처참하게 무너진다. 나도 그 케이스였다.
FID에서 INP로, 실무에서 뭐가 바뀌었나
가장 크게 바뀐 건 "이제 이벤트 핸들러의 실행 시간과 렌더링 비용이 그대로 점수에 반영된다"는 점이다. FID 시절엔 핸들러 안에서 300ms짜리 동기 계산을 해도 첫 입력만 아니면 티가 안 났다. INP는 그 300ms를 정면으로 물고 늘어진다. 아래가 두 지표를 나란히 놓은 비교다.
| 항목 | FID (구) | INP (현) |
|---|---|---|
| 측정 대상 | 첫 상호작용 1회 | 전체 세션의 모든 상호작용 |
| 측정 구간 | 입력 지연만 | 입력 지연 + 처리 + 렌더 |
| 핸들러 실행 비용 | 반영 안 됨 | 그대로 반영 |
| SPA 상태 업데이트 | 거의 무해 | 가장 큰 위험 요소 |
| 체감과의 상관 | 낮음 | 높음(실제 버벅임 반영) |
개인적으로는 이 변화가 반갑다. FID는 "숫자를 위한 숫자" 같았는데, INP는 사용자가 진짜 느끼는 버벅임과 붙어 있다. 지표를 개선하면 체감도 같이 좋아진다. 다만 그만큼 대충 넘어갈 구석이 없어졌다는 뜻이기도 하다.
진짜 범인은 롱태스크(Long Task)다
INP가 나쁜 사이트의 90%는 원인이 하나로 수렴한다 — 메인 스레드를 50ms 이상 독점하는 롱태스크. 브라우저의 메인 스레드는 한 번에 하나만 한다. 자바스크립트가 200ms 동안 돌고 있으면 그 200ms 내내 클릭도, 렌더링도, 아무것도 못 한다. 사용자가 그 사이 버튼을 누르면? 그 200ms가 통째로 입력 지연으로 잡힌다.
W3C가 정의한 롱태스크는 "50ms를 넘는 태스크"다. 왜 하필 50ms인가 하면, 그 이상 메인 스레드를 막으면 다음 입력에 대한 응답이 사람 눈에 지연으로 보이기 시작하는 경계이기 때문이다. 그래서 최적화의 목표는 단순하다 — 긴 작업을 50ms 미만 조각으로 쪼개고, 조각 사이에 브라우저에게 제어권을 돌려준다. 이걸 "양보(yield)"라고 부른다.
// 롱태스크를 실시간으로 잡아내는 옵저버
// 콘솔에 50ms 넘는 작업의 길이와 원인을 찍어준다
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`롱태스크 ${Math.round(entry.duration)}ms`, entry);
// entry.attribution 으로 어느 스크립트/프레임인지 힌트
}
});
observer.observe({ type: 'longtask', buffered: true });
롱태스크 쪼개기 — yield와 scheduler.yield()
메인 스레드를 양보하는 가장 확실한 현대적 방법은 scheduler.yield()다. Chrome 129(2024년 하반기)부터 정식 지원되며, await scheduler.yield()를 만나면 브라우저는 지금까지 밀린 렌더링·입력을 먼저 처리하고, 그 다음 남은 작업을 우선순위를 유지한 채 이어서 실행한다. 예전에 쓰던 setTimeout(fn, 0) 트릭은 양보는 되지만 남은 작업이 큐 맨 뒤로 밀려 오히려 느려질 수 있는데, scheduler.yield()는 그 문제를 해결한다.
// 큰 배열을 처리하면서 50ms마다 메인 스레드를 양보
async function processInChunks(items, handle) {
let lastYield = performance.now();
for (const item of items) {
handle(item);
// 50ms 넘게 붙잡고 있었으면 브라우저에 제어권을 넘긴다
if (performance.now() - lastYield > 50) {
await yieldToMain();
lastYield = performance.now();
}
}
}
// scheduler.yield()가 있으면 그걸, 없으면 폴백
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield(); // 우선순위 유지 양보
}
return new Promise((r) => setTimeout(r, 0)); // 구형 폴백
}
실전에서 하나 데인 적이 있는데, await를 이벤트 핸들러의 동기 로직 앞에 두면 안 된다. 사용자 입력에 대한 즉각적인 시각 피드백(버튼 눌린 상태, 스피너 표시)은 양보 전에 동기로 먼저 그리고, 무거운 계산만 양보 뒤로 미뤄야 한다. 순서를 반대로 하면 피드백까지 늦어져서 INP가 더 나빠진다. 이건 문서에 잘 안 나온다.
button.addEventListener('click', async () => {
// 1) 먼저 즉각 피드백을 동기로 — 다음 페인트에 바로 반영
button.setAttribute('aria-busy', 'true');
showSpinner();
// 2) 브라우저가 그 프레임을 그리도록 양보
await yieldToMain();
// 3) 이제 무거운 작업 (여기서부터는 INP 창 밖)
const result = await computeExpensiveThing();
render(result);
});
isInputPending()로 "지금 대기 중인 입력이 있을 때만 양보"하는 최적화도 있지만, 크롬 팀도 요즘은 대기 여부와 무관하게 주기적으로 양보하는 쪽(위 코드처럼)을 권한다. isInputPending()은 입력만 보고 렌더링 기회는 놓칠 수 있기 때문이다. 정말 순수 CPU 계산이 무겁다면 그건 양보가 아니라 Web Worker로 통째로 빼는 게 정답이다.React·프레임워크에서의 INP
React 앱에서 INP를 망치는 전형은 "입력 하나에 거대한 리렌더가 딸려오는 것"이다. 검색창에 글자를 칠 때마다 수천 개 리스트를 필터링하고 통째로 리렌더하면, 그 리렌더 비용이 고스란히 처리+표현 지연으로 잡힌다. React 18+의 동시성 기능이 여기서 무기가 된다.
import { useState, useDeferredValue, useMemo } from 'react';
function SearchList({ items }) {
const [query, setQuery] = useState('');
// 입력값(긴급)과 필터링 결과(덜 긴급)를 분리
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter((i) => i.name.includes(deferredQuery)),
[items, deferredQuery]
);
return (
<>
{/* 입력창은 즉시 반응 — 타이핑이 안 밀린다 */}
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List rows={filtered} />
</>
);
}
useDeferredValue나 startTransition은 무거운 리렌더를 "중단 가능한(interruptible)" 저우선 작업으로 강등시킨다. 사용자가 계속 타이핑하면 React가 진행 중이던 필터 렌더를 버리고 입력부터 처리한다. 여기에 리스트가 길면 @tanstack/react-virtual 같은 가상화를 얹어 실제 DOM 노드 수 자체를 줄이는 게 표현 지연에 직방이다. 개인적으로는 INP 문제의 절반은 "화면 밖 DOM을 왜 다 그리고 있나"에서 나온다고 본다.
LCP와 CLS도 같이 봐야 하는 이유
INP만 파고들면 안 된다. 세 지표는 서로 얽혀 있고, 구글은 셋 다 Good이어야 "코어 웹 바이탈 통과"로 친다. LCP(로딩)와 CLS(시각 안정성)를 방치하면 INP를 아무리 잘 잡아도 검색 콘솔은 여전히 빨간불이다.
- LCP: 가장 큰 요소(대개 히어로 이미지·제목)를 빨리 그리는 것.
fetchpriority="high", 이미지 사전 로드, 서버 응답(TTFB) 단축, 렌더 블로킹 CSS/JS 제거가 핵심. 초기 자바스크립트 번들을 줄이면 LCP와 INP가 동시에 좋아진다 — 파싱·실행이 곧 롱태스크라서. - CLS: 이미지·광고·폰트가 뒤늦게 들어오며 레이아웃을 밀어내는 것.
<img>에 width/height 지정, 폰트는font-display:optional이나 사이즈 조정 디스크립터, 동적 삽입 영역엔 공간 미리 확보(min-height). - 연결 고리: 무거운 서드파티 스크립트(태그 매니저, 채팅 위젯, A/B 툴)는 LCP를 늦추고 롱태스크로 INP를 망치고 늦은 삽입으로 CLS까지 튀게 한다. 세 지표의 공통 범인인 경우가 정말 많다.
측정 — Lighthouse는 참고용, RUM이 진짜다
여기서 초심자가 가장 많이 헤맨다. Lighthouse(랩 데이터)로는 INP를 제대로 못 잰다. INP는 실제 사용자의 상호작용이 있어야 나오는 값인데, Lighthouse는 자동화된 로드 시점만 보고 상호작용을 흉내 내지 않기 때문이다. 그래서 Lighthouse 리포트엔 INP가 아예 안 뜨거나 "실험실에서 측정 불가"로 나온다. INP는 반드시 필드 데이터(RUM)로 봐야 한다.
| 도구 | 종류 | INP 측정 | 쓰임새 |
|---|---|---|---|
| CrUX / 서치 콘솔 | 필드(RUM) | O (p75) | 순위에 쓰이는 실제 점수 |
| web-vitals.js | 필드(자체 RUM) | O (원인 귀속까지) | 어느 요소가 느린지 추적 |
| Lighthouse / PSI 랩 | 랩 | X (대략치·불가) | TBT로 간접 추정 |
| DevTools Performance | 로컬 랩 | O (수동 상호작용) | 구간 분해·디버깅 |
랩에서 대신 볼 지표는 TBT(Total Blocking Time)다. TBT와 INP는 완벽히 일치하진 않지만 상관이 높아서, "실험실에서 TBT를 줄이면 필드 INP도 대체로 따라 내려간다"는 게 크롬 팀 가이드다. 아래는 실제 사용자 INP를 자체 수집하는 최소 코드다.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const attr = metric.attribution;
// 어떤 요소·어느 구간이 느렸는지까지 함께 전송
navigator.sendBeacon('/rum', JSON.stringify({
value: metric.value, // INP 값(ms)
rating: metric.rating, // good/needs-improvement/poor
target: attr.interactionTarget, // 문제의 DOM 셀렉터
inputDelay: attr.inputDelay, // 입력 지연 구간
processing: attr.processingDuration, // 핸들러 처리 구간
presentation: attr.presentationDelay, // 렌더 구간
}));
}, { reportAllChanges: false });
이 attribution 빌드가 물건이다. INP 값만 알면 "느리다"까지만 알지만, interactionTarget과 세 구간 분해까지 있으면 "결제 버튼의 처리 구간이 280ms"처럼 범인을 콕 집을 수 있다. 나는 이걸 붙이고 나서야 최적화 방향이 잡혔다.
실전 개선 사례 — 340ms에서 170ms로
앞서 말한 우리 서비스 얘기로 마무리한다. p75 INP 340ms짜리 상품 필터 페이지였다. RUM attribution을 붙여 보니 범인이 명확했다 — 필터 체크박스 클릭 하나가 (1) 전역 상태 업데이트, (2) 2,300개 상품 배열 재필터·재정렬, (3) 전체 그리드 리렌더를 동기로 몰아서 처리 구간만 210ms를 먹고 있었다.
손을 댄 순서와 효과는 이랬다.
- 즉각 피드백 분리 — 체크박스의 시각 상태만 동기로 먼저 반영하고 필터 계산은
startTransition으로 강등. 체감 반응이 즉시 붙었다. (약 340→270ms) - 가상화 도입 — 화면 밖 2,000여 개 카드를 안 그리도록
react-virtual적용. 표현 지연이 뚝 떨어졌다. (270→200ms) - 정렬 로직 양보 — 그래도 남던 대량 정렬을
scheduler.yield()로 50ms 단위 청크 처리. (200→170ms) - 서드파티 정리 — 필터와 무관한 채팅 위젯 스크립트를 지연 로드로 돌려 클릭 시점의 메인 스레드 경합을 줄였다. LCP도 덤으로 0.4s 개선.
4주 뒤 서치 콘솔 필드 데이터가 초록불로 넘어갔다. 교훈이라면 — 측정으로 범인부터 특정하고, 큰 덩어리(가상화·transition)를 먼저 치고, yield는 마무리로. 순서를 반대로 해서 yield부터 뿌리면 근본 원인은 그대로라 효과가 미미하다. 이건 내가 실제로 순서를 잘못 잡아 일주일 날려먹고 얻은 교훈이다.
자주 묻는 질문
INP가 200ms를 넘으면 무조건 검색 순위가 떨어지나요?
아니다. 코어 웹 바이탈은 순위 신호 중 하나일 뿐이고 콘텐츠 적합성이 훨씬 크다. 다만 경쟁 페이지들과 콘텐츠가 비슷하면 타이브레이커로 작동하고, 무엇보다 응답성이 나쁘면 사용자가 이탈해 간접적으로 손해다. 순위보다 사용자 경험 때문에 잡는다고 생각하는 게 건강하다.
Lighthouse 점수는 100인데 왜 INP는 빨간불인가요?
Lighthouse는 랩 데이터라 실제 상호작용을 재현하지 않아 INP를 제대로 못 잰다. INP는 실사용자 데이터(CrUX·서치 콘솔·자체 RUM)에서만 진짜 값이 나온다. 랩에서는 TBT를 대리 지표로 보라. Lighthouse 만점과 INP 통과는 별개다.
scheduler.yield()를 아직 못 쓰는 브라우저는 어떻게 하나요?
기능 감지 후 폴백하면 된다. 'scheduler' in window && 'yield' in scheduler로 확인하고, 없으면 await new Promise(r => setTimeout(r, 0))로 양보한다. 양보 효과 자체는 폴백으로도 얻는다. 다만 폴백은 우선순위 유지가 안 돼 남은 작업이 뒤로 밀릴 수 있다는 차이만 감안하면 된다.
디바운스(debounce)만 걸면 INP가 해결되나요?
부분적으로만. 디바운스는 무거운 작업의 호출 빈도를 줄여줄 뿐, 실제로 실행될 때의 롱태스크 자체는 그대로다. 그 한 번의 실행이 300ms면 그 순간의 INP는 여전히 나쁘다. 빈도 줄이기(디바운스)와 작업 쪼개기(yield)·강등(transition)은 함께 써야 한다.
Web Worker로 옮기면 INP가 좋아지나요?
순수 계산(파싱, 정렬, 이미지 처리, 대량 데이터 변환)이라면 매우 효과적이다. 메인 스레드에서 통째로 빠지니 롱태스크가 사라진다. 단, DOM 조작은 워커에서 못 하므로 "계산은 워커, 렌더는 메인"으로 나눠야 하고, 데이터 직렬화 비용이 오히려 클 만큼 작은 작업엔 오버킬이다.
SPA에서 페이지 전환도 INP에 잡히나요?
잡힌다. 링크 클릭으로 라우트가 바뀌며 큰 리렌더가 일어나면 그 상호작용의 INP가 나빠진다. 라우트 전환 시 코드 스플리팅(지연 로드)으로 실행할 JS를 줄이고, 전환 렌더를 startTransition으로 감싸며, 낙관적 UI로 즉각 피드백을 주는 조합이 효과적이다. 최신 브라우저라면 View Transitions API도 체감 개선에 도움이 된다.

댓글 0