INP(Interaction to Next Paint): 완벽 최적화 가이드 (2025)
2024년 3월, 구글은 FID(First Input Delay)를 **INP(Interaction to Next Paint)**로 공식 대체했습니다. 이는 단순한 지표 변경이 아닙니다. 웹 반응성을 측정하는 방식의 근본적인 변화입니다.
그동안 우리는 '첫 클릭' 반응 속도에만 집중해왔습니다. 하지만 사용자는 한 번만 클릭하지 않습니다. 타이핑하고, 토글하고, 드래그하며, 인터페이스가 매 순간 즉각적으로 반응하기를 기대합니다. FID가 첫인상만 봤다면, INP는 사용자와의 전체적인 관계를 측정합니다.
만약 "Total Blocking Time"(TBT)이 높거나, Lighthouse 점수는 좋은데 실제 사용자들로부터 "앱이 버벅거린다"는 피드백을 받고 있다면, 이 글이 도움이 될 것입니다. 브라우저 메인 스레드의 동작 원리를 파헤쳐보고, 왜 인터랙션 지연이 발생하는지, 그리고 어떻게 해결해야 하는지 깊이 있게 알아봅시다.
인터랙션의 해부학 (The Anatomy of an Interaction)
INP를 최적화하려면 버튼을 클릭했을 때 무슨 일이 일어나는지 정확히 이해해야 합니다. 클릭은 즉각적이지 않죠. 브라우저는 세 가지 단계를 거칩니다.
- 입력 지연 (Input Delay): 사용자가 탭한 순간부터 브라우저가 이벤트 핸들러 실행을 시작하기까지의 시간입니다. 주로 메인 스레드가 다른 작업(예: 하이드레이션)으로 바쁠 때 발생합니다.
- 처리 시간 (Processing Time): 이벤트 콜백(React 상태 업데이트, DOM 조작, 복잡한 로직 등)을 실행하는 데 걸리는 시간입니다.
- 표시 지연 (Presentation Delay): 코드가 실행을 마친 후 브라우저가 실제로 다음 프레임을 화면에 그리기까지의 시간입니다.
INP = 입력 지연 + 처리 시간 + 표시 지연
대부분의 개발자는 2번(코드 최적화)에만 집중합니다. 하지만 나쁜 INP 점수의 상당 부분은 1번과 3번에서 발생합니다.
왜 console.time()은 거짓말을 할까?
클릭 핸들러를 console.time()으로 감싸서 측정해보면 20ms가 나온다고 칩시다. "좋아, 내 코드는 빠르군!" 하지만 INP 점수는 400ms가 나옵니다. 도대체 왜 그럴까요?
console.time()은 자바스크립트 실행 시간만 측정하기 때문입니다. 렌더링 비용은 포함하지 않습니다.
20ms짜리 React 상태 업데이트가 전체 컴포넌트 트리의 리렌더링을 유발하고, 5,000개의 DOM 노드에 대한 스타일과 레이아웃을 다시 계산하게 만든다면? 이 '레이아웃 스래싱(Layout Thrashing)'은 자바스크립트가 끝난 후, 다음 페인트 전에 발생합니다. 이것이 바로 **표시 지연(Presentation Delay)**이며, INP 점수를 깎아먹는 주범입니다.
전략 1: 메인 스레드 양보하기 (Yielding to the Main Thread)
반응성의 황금률은 **"메인 스레드를 독점하지 말라"**입니다.
긴 작업(예: 대용량 데이터 처리)을 수행하면 브라우저는 "얼음" 상태가 됩니다. 새로운 입력을 처리하거나 화면을 그릴 수 없죠. 해결책은 긴 작업을 작은 덩어리로 쪼개고, 중간중간 브라우저에게 제어권을 "양보(yield)"하는 것입니다.
예전 방식: setTimeout
function processData(items) { if (items.length === 0) return; // 아이템 하나 처리 doHeavyWork(items[0]); // 나머지는 나중으로 미룸 setTimeout(() => { processData(items.slice(1)); }, 0); }
이 방식도 동작은 하지만, setTimeout은 최소 지연 시간(보통 4ms 이상)이 있고 작업 우선순위를 지능적으로 관리하지 못합니다.
모던한 방식: scheduler.yield()
새로운 scheduler.yield() API(현재 오리진 트라이얼 중이거나 폴리필 사용 가능)를 사용하면 setTimeout의 페널티 없이 메인 스레드에 양보하고 즉시 실행을 재개할 수 있습니다.
async function processData(items) { for (const item of items) { doHeavyWork(item); // 메인 스레드에 양보하여 브라우저가 입력/페인팅을 처리하게 함 // 대기 중인 사용자 입력이 있는지 체크합니다! await scheduler.yield(); } }
이건 정말 게임 체인저입니다. 브라우저에게 이렇게 말하는 것과 같죠. "잠깐 멈출게. 클릭 들어온 거 있어? 있으면 먼저 처리해. 없으면 내가 계속 할게."
전략 2: 표시 지연(Presentation Delay) 최적화
자바스크립트는 빠른데 페인트가 느리다면, 렌더링 문제입니다.
- DOM 크기 줄이기: DOM 트리가 거대하면 스타일 재계산(Style Recalculation)과 레이아웃(Layout) 비용이 증가합니다. 긴 리스트는 가상화(Virtualization)를 사용하세요.
- CSS Containment:
content-visibility: auto속성을 사용하여 화면 밖의 콘텐츠 렌더링을 건너뛰세요. - 레이아웃 스래싱 피하기: 레이아웃 속성(예:
offsetHeight)을 쓴 직후에 읽지 마세요. 이는 브라우저가 동기적으로 레이아웃을 계산하도록 강제합니다.
전략 3: 즉각적인 피드백 (낙관적 UI)
심리적으로 사용자는 반응이 없으면 앱이 "느리다"고 느낍니다. 기술적으로 INP는 다음 페인트까지의 시간을 측정합니다.
무거운 작업(예: API 호출)이 있다면, 즉시 무언가를 그리세요.
나쁜 패턴:
button.addEventListener('click', async () => { const data = await fetchData(); // 네트워크 대기... renderData(data); // ...그제서야 페인트 });
INP에 네트워크 대기 시간이 포함되어 버립니다!
좋은 패턴:
button.addEventListener('click', () => { showSpinner(); // 즉시 페인트! 여기서 INP 측정 종료. fetchData().then(data => { renderData(data); hideSpinner(); }); });
스피너나 활성 상태(Active State)를 즉시 보여줌으로써, 브라우저 관점에서의 인터랙션을 "완료"시키는 것입니다. 이후의 네트워크 요청과 최종 렌더링은 별도의 작업이므로 INP 점수에 영향을 주지 않습니다.
결론
INP는 엄격한 지표지만, 우리를 더 나은 소프트웨어 엔지니어로 만듭니다. "자바스크립트 떡칠, 메인 스레드 차단" 구조에서 벗어나 "비동기적이고, 양보하며, 반응하는" 설계로 나아가게 합니다.
가장 느린 인터랙션을 프로파일링하는 것부터 시작해보세요. 로직 문제인가요? 렌더링 문제인가요? 아니면 그냥 메인 스레드가 바쁜 건가요? 병목 구간을 찾았다면, scheduler.yield(), DOM 가상화, 낙관적 UI 업데이트 같은 도구들이 여러분을 기다리고 있습니다.
여러분의 사이트를 '즉각적'으로 만드세요. 사용자(그리고 여러분의 SEO 순위)가 고마워할 것입니다.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요