INP(Interaction to Next Paint) 완벽 가이드: 2026년 웹 응답성 최적화 전략

INP의 동작 원리부터 scheduler.yield(), LoAF API, Web Worker, 코드 스플리팅까지 — 2026년 웹 응답성 최적화를 위한 실전 전략과 코드 예제를 총정리합니다.

웹 성능 최적화의 흐름이 바뀌고 있습니다. 2024년 3월, Google이 Core Web Vitals에서 FID(First Input Delay)를 빼고 INP(Interaction to Next Paint)를 공식 지표로 채택했죠. 이건 단순히 이름만 바뀐 게 아닙니다. 웹사이트의 응답성을 평가하는 방식 자체가 근본적으로 달라졌다는 뜻이에요.

INP는 사용자가 페이지와 상호작용하는 전체 경험을 측정합니다. 클릭, 탭, 키보드 입력 등 모든 상호작용에 대해 브라우저가 시각적 피드백을 주기까지 걸리는 시간을 추적하고, 페이지 방문 중 발생한 모든 상호작용의 75번째 백분위수 값을 최종 점수로 사용하죠. 이전의 FID가 페이지 로드 후 첫 번째 상호작용의 입력 지연만 봤던 것과 비교하면 훨씬 포괄적인 접근입니다.

2026년 현재, INP는 Google 검색 순위에 직접 영향을 주는 핵심 지표입니다. 그런데 솔직히, INP 최적화의 진짜 가치는 SEO를 넘어섭니다. 인도의 대형 여행 예약 플랫폼 redBus는 INP를 개선한 뒤 판매가 7%나 증가했습니다. 사용자는 느린 웹사이트를 참지 않습니다. 빠른 응답성은 곧 전환율이에요.

이 가이드에서는 INP의 동작 원리부터 실전 최적화 전략까지, 2026년 웹 응답성 최적화에 필요한 거의 모든 것을 다룹니다. scheduler.yield() 같은 최신 브라우저 API부터 Long Animation Frames API를 활용한 디버깅까지, 실무에 바로 쓸 수 있는 구체적인 방법들을 정리했습니다.

INP의 동작 원리

INP를 제대로 최적화하려면, 먼저 브라우저가 상호작용을 어떻게 처리하고 측정하는지 이해해야 합니다. INP는 어떤 한 시점의 스냅샷이 아니라 사용자의 전체 페이지 경험을 반영하는 지표거든요.

상호작용의 세 가지 단계

사용자가 버튼을 클릭하면, 브라우저는 세 가지 단계를 거쳐 응답합니다.

입력 지연(Input Delay)은 사용자가 상호작용을 시작한 순간부터 이벤트 핸들러가 실행되기 시작할 때까지의 시간입니다. 메인 스레드가 다른 작업(JavaScript 실행, 렌더링 등)으로 바쁘면 입력 지연이 생깁니다. 사용자가 클릭했는데 메인 스레드가 긴 태스크를 처리하고 있다면? 그 태스크가 끝날 때까지 이벤트 핸들러는 실행되지 못합니다.

처리 시간(Processing Time)은 이벤트 핸들러가 실행을 시작한 후 완료될 때까지의 시간입니다. 클릭 핸들러, 상태 업데이트, 데이터 처리 등 JavaScript 코드 실행 시간이 여기에 포함되죠. 복잡한 계산이나 대량의 데이터를 처리할수록 이 시간이 길어집니다.

프레젠테이션 지연(Presentation Delay)은 이벤트 핸들러 실행이 끝난 뒤, 브라우저가 다음 프레임을 화면에 그릴 때까지의 시간입니다. DOM 변경 사항 계산, 레이아웃 재계산, 페인트 작업이 이 단계에서 이루어지며, DOM 크기가 크거나 복잡한 CSS가 적용되어 있으면 이 지연이 커집니다.

INP는 이 세 단계의 합계로 계산됩니다. 각 단계는 최적화의 서로 다른 영역을 대표하기 때문에, 어디서 병목이 생기는지 파악하는 게 중요해요.

브라우저의 INP 측정 방식

브라우저는 페이지 방문 동안 발생하는 모든 상호작용을 추적합니다. 클릭, 탭, 키보드 입력(keydown, keyup, keypress)이 측정 대상이고, 마우스 이동이나 스크롤은 포함되지 않습니다.

페이지를 떠날 때 브라우저는 모든 상호작용 지연 시간 중 75번째 백분위수 값을 INP로 보고합니다. 왜 최댓값이 아니라 75번째 백분위수일까요? 이상치(outlier) 때문입니다. 네트워크 지연이나 일시적인 시스템 부하로 생긴 극단적인 값이 전체 점수를 왜곡하지 않으면서도, 대부분의 사용자가 실제로 경험하는 응답성을 반영하기 위함이죠.

INP 임계값

Google이 정의한 INP 임계값은 다음과 같습니다.

  • 좋음(Good): 200ms 이하 — 사용자가 즉각적인 응답을 느낍니다.
  • 개선 필요(Needs Improvement): 200ms~500ms — 약간의 지연이 느껴질 수 있습니다.
  • 나쁨(Poor): 500ms 초과 — 명확한 지연이 느껴지고 사용자 경험에 부정적입니다.

Core Web Vitals를 통과하려면 페이지 방문의 최소 75%가 '좋음' 기준을 충족해야 합니다.

FID와의 차이점

FID는 페이지 로드 후 첫 번째 상호작용의 입력 지연만 측정했습니다. 세 단계 중 입력 지연만 보고, 첫 상호작용만 추적한 거죠. 초기 로드 성능을 파악하는 데는 괜찮았지만, 전체 사용자 경험을 대표하기엔 부족했습니다.

INP는 모든 상호작용을 측정하고, 처리 시간과 프레젠테이션 지연까지 포함합니다. 그래서 SPA에서 페이지 전환 시 발생하는 지연이나, 복잡한 폼 입력 중의 응답성 문제도 잡아낼 수 있어요. 실제 사용자 경험을 훨씬 정확하게 반영하는 지표라고 할 수 있습니다.

INP 측정 방법

최적화를 하려면 먼저 정확하게 측정해야겠죠. 측정 방법은 크게 필드 데이터(Field Data)와 랩 데이터(Lab Data)로 나뉩니다.

필드 데이터 vs 랩 데이터

필드 데이터는 실제 사용자가 실제 환경에서 페이지를 방문할 때 수집됩니다. 다양한 기기, 네트워크 조건, 사용 패턴이 반영되므로 실제 사용자 경험을 가장 정확하게 나타내죠. Google 검색 순위에 영향을 미치는 건 오직 필드 데이터입니다. Chrome UX Report(CrUX)가 대표적인 소스고요.

랩 데이터는 개발자가 통제된 환경에서 테스트할 때 수집됩니다. 재현 가능하고 디버깅은 쉽지만, 실제 사용자 환경의 다양성은 반영 못합니다. Lighthouse와 Chrome DevTools가 여기에 해당합니다.

보통 필드 데이터로 문제를 찾고, 랩 데이터로 원인을 분석하고 해결책을 테스트하는 식으로 진행합니다.

측정 도구

Chrome DevTools Performance 패널은 가장 강력한 랩 측정 도구입니다. 녹화 기능으로 상호작용을 캡처하고, 타임라인에서 긴 태스크나 이벤트 핸들러 실행 시간, 렌더링 작업을 시각적으로 분석할 수 있습니다. Long Animation Frames(LoAF) 정보와 함께 사용하면 병목 지점을 정확히 짚어낼 수 있어요.

Lighthouse는 자동화된 성능 감사 도구로 INP 관련 권장사항을 줍니다. 다만 Lighthouse는 페이지 로드만 시뮬레이션하기 때문에 실제 사용자 상호작용을 완전히 재현하진 못합니다. Lighthouse 점수가 좋아도 실제 INP는 나쁠 수 있다는 거죠. (이건 꽤 흔한 함정입니다.)

PageSpeed Insights는 CrUX 필드 데이터와 Lighthouse 랩 데이터를 함께 보여줍니다. URL을 입력하면 지난 28일간의 실제 사용자 INP를 확인할 수 있습니다.

web-vitals 라이브러리 사용

프로덕션에서 실시간으로 INP를 측정하려면 Google의 web-vitals 라이브러리가 가장 편합니다.

// npm install web-vitals

import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value);
  console.log('Rating:', metric.rating); // 'good', 'needs-improvement', 'poor'

  // 분석 서버로 전송
  if (metric.rating === 'poor') {
    fetch('/api/analytics', {
      method: 'POST',
      body: JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        id: metric.id,
        navigationType: metric.navigationType,
        attribution: metric.attribution
      }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
}, {
  reportAllChanges: true,
  durationThreshold: 40
});

Attribution Build를 사용하면 INP를 발생시킨 구체적인 요소, 이벤트 타입, 로딩 상태까지 상세한 디버깅 정보를 얻을 수 있습니다. 제 경험상, 이 정보 없이는 프로덕션 INP 문제를 해결하기가 정말 어렵습니다.

Chrome UX Report (CrUX) 데이터

CrUX는 실제 Chrome 사용자로부터 수집된 공개 데이터셋입니다. BigQuery, PageSpeed Insights API, CrUX Dashboard를 통해 접근할 수 있고요. 자기 사이트뿐 아니라 경쟁사의 INP 데이터도 볼 수 있어서 벤치마킹에 꽤 유용합니다.

Real User Monitoring (RUM) 설정

장기적인 성능 모니터링을 위해서는 RUM 솔루션이 필수입니다. 간단한 구현 예시를 보겠습니다.

// performance-monitor.js
import { onINP, onCLS, onFCP, onLCP, onTTFB } from 'web-vitals/attribution';

function sendToAnalytics(metric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: Date.now()
  };

  if (metric.name === 'INP' && metric.attribution) {
    body.debug = {
      element: metric.attribution.interactionTarget,
      eventType: metric.attribution.interactionType,
      loadState: metric.attribution.loadState,
      interactionTime: metric.attribution.interactionTime
    };
  }

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/web-vitals', JSON.stringify(body));
  } else {
    fetch('/api/web-vitals', {
      method: 'POST',
      body: JSON.stringify(body),
      headers: { 'Content-Type': 'application/json' },
      keepalive: true
    });
  }
}

onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);

이 데이터를 시계열 데이터베이스에 저장하고 대시보드로 시각화하면, 배포마다 성능 변화를 추적하고 특정 페이지나 사용자 세그먼트의 문제를 빠르게 잡아낼 수 있습니다.

Long Task 분석과 LoAF API

INP 문제의 뿌리는 거의 항상 긴 태스크(Long Task)에 있습니다. 자, 긴 태스크가 뭔지, 어떻게 찾아서 해결하는지 알아봅시다.

긴 태스크란?

긴 태스크는 메인 스레드를 50ms 이상 차단하는 작업을 말합니다. 브라우저는 초당 60프레임(프레임당 약 16.67ms)을 목표로 하니까, 50ms 이상 차단되면 최소 3프레임이 날아가는 셈이에요. 사용자는 분명히 지연을 느낍니다.

긴 태스크가 실행 중일 때 사용자가 클릭하면 입력 지연이 발생합니다. 태스크가 끝나야 이벤트 핸들러가 돌아가니까요. 이벤트 핸들러 자체가 긴 태스크를 만들면 처리 시간이 늘고, 렌더링이 무거우면 프레젠테이션 지연이 커집니다.

Long Animation Frames API (LoAF)

Chrome 123부터 쓸 수 있는 LoAF API는 긴 태스크 분석의 게임 체인저입니다. 기존 Long Tasks API는 "어떤 태스크가 오래 걸렸다" 정도만 알려줬는데, LoAF는 프레임을 길게 만든 구체적인 스크립트, 함수 이름, 소스 URL까지 알려줍니다.

LoAF가 Long Tasks API보다 나은 점을 정리하면:

  • 정확한 귀속(Attribution): 어떤 스크립트의 어떤 함수가 범인인지 콕 집어줍니다.
  • 프레임 중심 측정: 렌더링과 JavaScript 실행을 함께 고려해서 실제 사용자가 느끼는 지연을 반영합니다.
  • 세부 타이밍 정보: 렌더링 시작 시간, 스타일·레이아웃 계산 시간 같은 디테일을 제공합니다.

LoAF를 활용한 INP 디버깅

LoAF로 INP 문제를 찾아내는 코드를 살펴봅시다.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 150) {
      console.log('Long Animation Frame:', {
        duration: entry.duration,
        renderStart: entry.renderStart,
        styleAndLayoutStart: entry.styleAndLayoutStart,
        scripts: entry.scripts.map(s => ({
          sourceURL: s.sourceURL,
          sourceFunctionName: s.sourceFunctionName,
          duration: s.duration,
          executionStart: s.executionStart,
          invoker: s.invoker,
          invokerType: s.invokerType
        }))
      });

      const longestScript = entry.scripts.reduce((prev, current) =>
        (prev.duration > current.duration) ? prev : current
      );

      console.warn('Longest script in frame:', {
        url: longestScript.sourceURL,
        function: longestScript.sourceFunctionName,
        duration: longestScript.duration
      });
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

이 코드는 150ms 이상 걸린 애니메이션 프레임을 잡아내고, 해당 프레임에서 실행된 스크립트 정보를 출력합니다. buffered: true 옵션을 넣으면 관찰자 등록 전에 발생한 프레임도 캡처할 수 있어요.

프로덕션에서는 이걸 분석 서버로 보내서 실사용자들의 성능 문제를 추적하면 됩니다.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      fetch('/api/loaf-report', {
        method: 'POST',
        body: JSON.stringify({
          duration: entry.duration,
          url: window.location.href,
          scripts: entry.scripts.map(s => ({
            url: s.sourceURL,
            function: s.sourceFunctionName,
            duration: s.duration
          })),
          timestamp: Date.now()
        }),
        headers: { 'Content-Type': 'application/json' },
        keepalive: true
      }).catch(() => {});
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

메인 스레드 최적화 전략

INP를 개선하는 가장 확실한 방법은 메인 스레드를 차단하는 긴 태스크를 없애거나 쪼개는 것입니다. 2026년 현재 가장 효과적인 전략들을 하나씩 살펴보겠습니다.

scheduler.yield()를 활용한 태스크 분할

긴 태스크를 여러 작은 조각으로 나누면 메인 스레드가 중간중간 다른 일(사용자 입력 처리, 렌더링)을 할 수 있습니다. 예전에는 setTimeout()을 썼지만, Chrome에서 나온 scheduler.yield()가 훨씬 낫습니다.

왜 scheduler.yield()가 더 나을까요? setTimeout(fn, 0)은 태스크 큐 맨 뒤에 콜백을 넣습니다. 큐에 다른 태스크가 쌓여있으면 한참 기다려야 할 수도 있죠. 반면 scheduler.yield()는 continuation을 큐 앞쪽에 배치해서 불필요한 대기를 줄이면서도, 브라우저가 사용자 입력이나 렌더링 같은 급한 일은 먼저 처리할 수 있게 해줍니다.

// Before: 긴 블로킹 태스크
function processLargeDataset(items) {
  for (const item of items) {
    heavyComputation(item);
  }
}

// After: scheduler.yield()를 사용한 분할
async function processLargeDataset(items) {
  for (const item of items) {
    heavyComputation(item);
    await scheduler.yield();
  }
}

// 더 효율적인 방식: 배치 처리와 결합
async function processLargeDataset(items, batchSize = 10) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    for (const item of batch) {
      heavyComputation(item);
    }
    await scheduler.yield();
  }
}

아직 모든 브라우저가 scheduler.yield()를 지원하진 않으니 폴백이 필요합니다.

// 크로스 브라우저 호환 yield 함수
async function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

// 사용 예시
async function processData(items) {
  for (const item of items) {
    processItem(item);
    await yieldToMain();
  }
}

scheduler.yield()와 setTimeout()의 차이는 경쟁이 심한 환경에서 확 드러납니다. 서드파티 스크립트가 잔뜩 돌아가는 페이지나, 복잡한 SPA에서 여러 컴포넌트가 동시에 업데이트되는 상황에서 scheduler.yield()가 훨씬 예측 가능한 응답성을 보여줍니다.

Web Worker를 활용한 오프로딩

CPU를 많이 잡아먹는 작업은 아예 메인 스레드에서 빼버리는 게 최선입니다. Web Worker를 쓰면 별도 스레드에서 JavaScript를 실행하니까 메인 스레드를 전혀 건드리지 않습니다.

// worker.js - 백그라운드 스레드에서 실행
self.addEventListener('message', (e) => {
  const { data } = e;
  const result = performHeavyCalculation(data);
  self.postMessage(result);
});

function performHeavyCalculation(data) {
  let result = 0;
  for (let i = 0; i < data.length; i++) {
    result += Math.sqrt(data[i]) * Math.random();
  }
  return result;
}
// main.js - 메인 스레드에서 실행
const worker = new Worker('/worker.js');

worker.addEventListener('message', (e) => {
  const result = e.data;
  console.log('계산 완료:', result);
  updateUI(result);
});

worker.addEventListener('error', (e) => {
  console.error('Worker 오류:', e.message);
});

function startCalculation(data) {
  worker.postMessage(data);
}

Web Worker는 DOM에 접근할 수 없다는 제약이 있지만, 순수 계산 작업이라면 이만한 게 없습니다. 이미지 처리, JSON 파싱, 데이터 변환, 암호화 같은 작업에 딱이에요.

requestIdleCallback 활용

requestIdleCallback()은 브라우저가 한가할 때만 콜백을 실행합니다. 급하지 않은 백그라운드 작업에 적합하죠.

function performNonUrgentWork() {
  sendAnalyticsData();
  cleanupOldCache();
}

if ('requestIdleCallback' in window) {
  requestIdleCallback(performNonUrgentWork, { timeout: 2000 });
} else {
  setTimeout(performNonUrgentWork, 1000);
}

한 가지 주의할 점 — requestIdleCallback()은 낮은 우선순위 작업에만 쓰세요. 사용자 상호작용 응답으로는 절대 사용하면 안 됩니다.

비교: setTimeout vs requestIdleCallback vs scheduler.yield()

  • setTimeout(fn, 0): 태스크 큐 맨 뒤에 추가. 간단한 태스크 분할에 적합하고 모든 브라우저에서 동작합니다.
  • scheduler.yield(): 큐 앞쪽에 배치되어 우선순위가 높음. 효율적인 분할에 적합하나 Chromium 기반 브라우저에서만 지원됩니다.
  • requestIdleCallback(): 브라우저 유휴 시에만 실행. 낮은 우선순위 작업 전용이고 Safari 제외 대부분 브라우저에서 지원됩니다.

JavaScript 번들 최적화

번들이 크면 파싱, 컴파일, 실행 시간이 다 늘어나면서 메인 스레드를 잡아먹습니다. 번들 최적화는 INP 개선의 기본 중 기본입니다.

코드 스플리팅과 동적 임포트

JavaScript 전부를 초기 번들에 담지 말고, 필요한 시점에 로드하도록 분할하세요.

// Before: 모든 컴포넌트를 초기에 로드
import HeavyChart from './components/HeavyChart';
import DataTable from './components/DataTable';
import Editor from './components/Editor';

// After: 동적 임포트로 필요할 때만 로드
import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./components/HeavyChart'));
const DataTable = lazy(() => import('./components/DataTable'));
const Editor = lazy(() => import('./components/Editor'));

function App() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <HeavyChart />
      <DataTable />
      <Editor />
    </Suspense>
  );
}

라우트 기반 코드 스플리팅은 더 효과적입니다. 사용자가 방문한 페이지의 코드만 로드되니까 초기 번들 크기가 확 줄어들죠.

import { lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';

const HomePage = lazy(() => import('./pages/Home'));
const DashboardPage = lazy(() => import('./pages/Dashboard'));
const ProfilePage = lazy(() => import('./pages/Profile'));

const router = createBrowserRouter([
  { path: '/', element: <HomePage /> },
  { path: '/dashboard', element: <DashboardPage /> },
  { path: '/profile', element: <ProfilePage /> }
]);

트리 쉐이킹

트리 쉐이킹은 사용하지 않는 코드를 번들에서 걸러내는 과정입니다. 최신 번들러(Webpack, Rollup, Vite)가 자동으로 해주지만, 효과를 최대로 끌어내려면 몇 가지 원칙을 지켜야 합니다.

  • ES6 모듈(import/export) 사용
  • 사이드 이펙트 없는 순수 함수 작성
  • 라이브러리에서 필요한 부분만 임포트
// Bad: 전체 라이브러리 임포트
import _ from 'lodash';
const result = _.debounce(fn, 300);

// Good: 필요한 함수만 임포트
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

DOM 최적화

DOM 크기와 복잡도는 렌더링 성능에 직접적인 영향을 미칩니다. 이게 바로 INP의 프레젠테이션 지연으로 나타나죠.

DOM 크기 최소화

DOM 노드가 많을수록 스타일 계산, 레이아웃, 페인트에 시간이 더 걸립니다. Google은 1,500개 이하의 DOM 노드, 깊이 32 이하, 자식 노드 60개 이하를 권장합니다. 대량의 항목을 표시해야 한다면 가상 스크롤링으로 뷰포트에 보이는 것만 렌더링하세요.

가상 스크롤링

// react-window를 사용한 가상 스크롤링
import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={35}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

효율적인 이벤트 위임

수백 개 항목에 이벤트 리스너를 하나하나 달지 마세요. 상위 요소 하나에만 리스너를 달면 메모리도 아끼고 성능도 좋아집니다.

// 이벤트 위임 사용
function TodoList({ items }) {
  const handleClick = (e) => {
    if (e.target.matches('button[data-action="delete"]')) {
      const id = e.target.dataset.id;
      deleteItem(id);
    }
  };

  return (
    <ul onClick={handleClick}>
      {items.map(item => (
        <li key={item.id}>
          {item.text}
          <button data-action="delete" data-id={item.id}>삭제</button>
        </li>
      ))}
    </ul>
  );
}

디바운싱과 스로틀링

검색 입력이나 스크롤처럼 이벤트가 폭풍처럼 쏟아지는 상황에서는 디바운싱이나 스로틀링이 필수입니다.

// 디바운스: 마지막 호출 후 일정 시간 대기
function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

// 스로틀: 일정 시간마다 최대 한 번만 실행
function throttle(func, delay) {
  let lastCall = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      func.apply(this, args);
    }
  };
}

// 사용 예시: 검색 입력
const handleSearch = debounce((value) => {
  performSearch(value);
}, 300);

이벤트 핸들러 최적화

이벤트 핸들러는 INP의 처리 시간과 직결됩니다. 여기를 잘 다듬으면 응답성이 눈에 띄게 좋아져요.

시각적 피드백과 데이터 처리 분리

사용자가 버튼을 클릭하면 즉각적인 반응을 기대합니다. 무거운 처리는 뒤로 미루고, UI 업데이트를 먼저 보여주는 게 핵심이에요.

// Bad: 모든 처리가 끝날 때까지 UI 업데이트 안 됨
async function handleSubmit(e) {
  e.preventDefault();
  const data = await processFormData();
  await validateData(data);
  await sendToServer(data);
  showSuccessMessage();
}

// Good: 즉시 피드백, 비동기로 처리
async function handleSubmit(e) {
  e.preventDefault();
  showLoadingState();
  await yieldToMain();

  try {
    const data = await processFormData();
    await yieldToMain();
    await validateData(data);
    await yieldToMain();
    await sendToServer(data);
    showSuccessMessage();
  } catch (error) {
    showErrorMessage(error);
  } finally {
    hideLoadingState();
  }
}

CSS로 애니메이션 처리

JavaScript 애니메이션보다 CSS 애니메이션이 훨씬 효율적입니다. CSS 애니메이션은 컴포지터 스레드에서 돌아가니까 메인 스레드를 건드리지 않거든요.

// Bad: JavaScript로 애니메이션
button.addEventListener('click', () => {
  let opacity = 1;
  const interval = setInterval(() => {
    opacity -= 0.05;
    element.style.opacity = opacity;
    if (opacity <= 0) clearInterval(interval);
  }, 16);
});

// Good: CSS 트랜지션 사용
button.addEventListener('click', () => {
  element.classList.add('fade-out');
});

/* CSS - transform과 opacity만 애니메이션 (GPU 가속) */
.fade-out {
  opacity: 0;
  transition: opacity 300ms ease-out;
}

최적화된 클릭 핸들러 패턴

INP를 고려한 종합적인 클릭 핸들러 패턴입니다. 실무에서 바로 참고하시면 좋습니다.

async function optimizedClickHandler(event) {
  event.currentTarget.classList.add('active');
  await yieldToMain();

  try {
    const validationResult = quickValidation();
    if (!validationResult.valid) {
      showError(validationResult.message);
      return;
    }

    const result = await processInChunks(event.data);
    await yieldToMain();
    updateUI(result);
  } catch (error) {
    handleError(error);
  } finally {
    event.currentTarget.classList.remove('active');
  }
}

async function processInChunks(data) {
  const chunks = splitIntoChunks(data, 100);
  const results = [];
  for (const chunk of chunks) {
    results.push(processChunk(chunk));
    await yieldToMain();
  }
  return results;
}

서드파티 스크립트 관리

서드파티 스크립트(광고, 분석, 채팅 위젯 등)는 INP에 생각보다 큰 영향을 줍니다. 내가 작성하지 않은 코드를 통제하기란 쉽지 않지만, 로딩 전략은 최적화할 수 있습니다.

서드파티 스크립트의 INP 영향

서드파티 스크립트가 INP를 악화시키는 방식은 다양합니다.

  • 메인 스레드를 차단하는 긴 JavaScript 실행
  • 대량의 DOM 노드 추가로 인한 렌더링 부담
  • 이벤트 리스너 과다 등록
  • 폴링이나 타이머를 통한 지속적인 백그라운드 작업

로딩 전략: async와 defer

<!-- Bad: 동기 로딩 (HTML 파싱 차단) -->
<script src="https://third-party.com/script.js"></script>

<!-- Good: async (다운로드 후 즉시 실행) -->
<script async src="https://third-party.com/script.js"></script>

<!-- Good: defer (HTML 파싱 후 실행, 순서 보장) -->
<script defer src="https://third-party.com/script.js"></script>

분석 스크립트처럼 당장 실행 안 해도 되는 건 async, 실행 순서가 중요하면 defer를 쓰세요.

Partytown으로 서드파티 격리

Partytown은 서드파티 스크립트를 Web Worker에서 돌려서 메인 스레드를 완전히 해방시켜주는 라이브러리입니다. 솔직히 처음 알았을 때 꽤 감동받았어요.

<script>
  partytown = {
    forward: ['dataLayer.push', 'gtag']
  };
</script>
<script src="/~partytown/partytown.js"></script>

<!-- Google Analytics를 Worker에서 실행 -->
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'GA_ID');
</script>

Facade 패턴으로 무거운 임베드 지연

YouTube 임베드나 채팅 위젯처럼 무거운 서드파티는 사용자가 실제로 상호작용할 때까지 로드를 미루는 게 좋습니다. 처음에는 가벼운 섬네일만 보여주고, 클릭하면 그때 실제 iframe을 로드하는 방식이죠. 초기 JavaScript 실행량과 네트워크 요청을 크게 줄일 수 있습니다.

프레임워크별 INP 최적화

요즘 프론트엔드 프레임워크들은 각자 나름의 INP 최적화 방법을 갖고 있습니다.

React 최적화

useDeferredValue는 긴급하지 않은 상태 업데이트를 뒤로 미룹니다.

import { useDeferredValue, useState } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const results = searchData(deferredQuery);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ResultsList results={results} />
    </div>
  );
}

useTransition은 상태 업데이트를 낮은 우선순위로 표시합니다. 탭 전환 같은 무거운 UI 변경에 아주 유용해요.

import { useTransition, useState } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('overview');

  const selectTab = (nextTab) => {
    startTransition(() => {
      setTab(nextTab);
    });
  };

  return (
    <div>
      <button onClick={() => selectTab('overview')}>개요</button>
      <button onClick={() => selectTab('details')}>상세</button>
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {tab === 'overview' ? <Overview /> : <Details />}
      </div>
    </div>
  );
}

React.memo로 불필요한 리렌더링을 방지하면 INP가 개선됩니다.

import { memo } from 'react';

const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
  return <div>{processData(data)}</div>;
}, (prevProps, nextProps) => {
  return prevProps.data.id === nextProps.data.id;
});

Next.js 최적화

Server Components는 서버에서 렌더링하고 클라이언트 JavaScript를 최소화합니다. 상호작용이 필요한 부분만 Client Component로 분리해서 번들 크기를 줄이는 거죠.

// app/page.js (Server Component - 기본값)
async function ProductList() {
  const products = await fetchProducts();
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// Client Component - 상호작용이 필요한 부분만
'use client';
function ProductCard({ product }) {
  const [liked, setLiked] = useState(false);
  return (
    <div>
      {product.name}
      <button onClick={() => setLiked(!liked)}>
        {liked ? '♥' : '♡'}
      </button>
    </div>
  );
}

Vue와 Nuxt

Vue는 defineAsyncComponent로 코드 스플리팅을 지원하고, Nuxt는 자동 코드 스플리팅과 Server Components를 제공합니다.

import { defineAsyncComponent } from 'vue';

const HeavyComponent = defineAsyncComponent(() =>
  import('./components/HeavyComponent.vue')
);

실전 INP 디버깅 워크플로우

이론은 충분합니다. 이제 실전에서 쓸 수 있는 체계적인 디버깅 프로세스를 정리해봅시다.

1단계: 문제 식별

PageSpeed Insights나 CrUX로 실사용자의 INP 데이터를 확인합니다. 어떤 페이지에서 INP가 안 좋은지, 어떤 상호작용이 문제인지부터 파악하세요.

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  if (metric.rating === 'poor') {
    console.group('Poor INP detected');
    console.log('Value:', metric.value);
    console.log('Element:', metric.attribution.interactionTarget);
    console.log('Type:', metric.attribution.interactionType);
    console.log('Input Delay:', metric.attribution.inputDelay);
    console.log('Processing Time:', metric.attribution.processingDuration);
    console.log('Presentation Delay:', metric.attribution.presentationDelay);
    console.groupEnd();
  }
});

2단계: Chrome DevTools Performance 패널 사용

  1. DevTools 열기 (F12)
  2. Performance 탭 선택
  3. CPU 스로틀링 활성화 (4x slowdown)
  4. 녹화 시작
  5. 문제가 되는 상호작용 수행
  6. 녹화 중지

타임라인에서 긴 태스크(빨간색 삼각형 표시)를 찾고, 클릭해서 콜 스택을 분석합니다. Bottom-Up 탭에서 Self Time이 긴 함수를 찾으면 그게 최적화 대상이에요.

3단계: LoAF로 구체적인 원인 파악

const loafData = [];
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      loafData.push({
        duration: entry.duration,
        renderStart: entry.renderStart,
        scripts: entry.scripts.map(s => ({
          url: s.sourceURL,
          function: s.sourceFunctionName,
          duration: s.duration,
          type: s.invokerType
        }))
      });
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });

// 콘솔에서 분석
setTimeout(() => {
  const allScripts = loafData.flatMap(laf => laf.scripts);
  const longestScripts = allScripts
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 10);
  console.log('Top 10 longest scripts:', longestScripts);
}, 10000);

4단계: 최적화 적용

발견한 문제에 맞는 최적화를 적용합니다.

  • 긴 JavaScript 실행 → scheduler.yield()로 분할
  • 무거운 계산 → Web Worker로 오프로딩
  • 큰 번들 → 코드 스플리팅
  • 서드파티 스크립트 → async/defer 또는 Partytown
  • 많은 DOM 노드 → 가상 스크롤링
  • 복잡한 렌더링 → React.memo, useMemo

5단계: 검증 및 모니터링

배포 후에는 반드시 필드 데이터로 개선 효과를 확인하세요. RUM 대시보드를 만들어서 지속적으로 모니터링하고, INP가 다시 나빠지지 않는지 지켜봐야 합니다.

// lighthouse-ci 설정으로 CI/CD에 성능 테스트 통합
{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", {"minScore": 0.9}],
        "interactive": ["error", {"maxNumericValue": 3500}]
      }
    }
  }
}

결론

INP는 2026년 웹 성능의 핵심 지표로 완전히 자리잡았습니다. Google 검색 순위뿐 아니라, 실제 사용자 경험과 비즈니스 성과에 직접 영향을 줍니다. redBus의 7% 판매 증가 사례가 보여주듯, INP 최적화는 충분히 투자할 가치가 있어요.

이 가이드에서 다룬 전략을 정리하면:

  • 측정: web-vitals 라이브러리와 LoAF API로 문제를 정확히 식별
  • 메인 스레드 해방: scheduler.yield()로 태스크 분할, Web Worker로 무거운 작업 오프로딩
  • 번들 최적화: 코드 스플리팅과 트리 쉐이킹으로 JavaScript 크기 축소
  • DOM 최적화: 가상 스크롤링, 이벤트 위임, 디바운싱으로 렌더링 부담 감소
  • 이벤트 핸들러 개선: 즉각적 피드백 제공, CSS 애니메이션 활용
  • 서드파티 관리: async/defer, Partytown, Facade 패턴으로 영향 최소화
  • 프레임워크 활용: React의 useTransition, Next.js의 Server Components 등

어디서부터 시작할까?

모든 걸 한꺼번에 할 수는 없습니다. 다음 순서로 접근하는 걸 추천합니다.

  1. 측정 시스템부터 구축: RUM과 LoAF 모니터링을 먼저 세팅해서 데이터 기반으로 판단할 수 있게 하세요.
  2. 쉽고 효과 큰 것부터: 서드파티 스크립트 async/defer 적용, 간단한 코드 스플리팅 등 금방 적용할 수 있는 것들부터 시작합니다.
  3. 주요 병목 해결: LoAF 데이터에서 가장 오래 걸리는 태스크를 찾아서 집중 최적화하세요.
  4. 구조적 개선: 아키텍처 변경이나 프레임워크 업그레이드 같은 큰 작업은 마지막에 합니다.

앞으로의 방향

웹 성능 최적화는 멈추지 않고 진화하고 있습니다.

성능 예산(Performance Budget)을 CI/CD에 녹여서 성능 저하를 배포 전에 잡으세요. Lighthouse CI나 WebPageTest API를 활용하면 됩니다.

자동화된 모니터링도 중요합니다. INP가 기준을 넘으면 알림을 받고, 문제 배포를 빠르게 롤백하거나 핫픽스를 적용하는 시스템을 갖추세요.

새로운 웹 플랫폼 기능도 계속 나옵니다. View Transitions API, Speculation Rules API 같은 것들을 활용하면 사용자 경험을 한 단계 더 끌어올릴 수 있어요.

결국 INP 최적화는 일회성이 아니라 끊임없는 사이클입니다. 측정하고, 분석하고, 개선하고, 다시 측정하기. 이걸 반복하면서 사용자에게 빠르고 쾌적한 웹 경험을 제공하세요. 웹 성능은 사용자 만족도, SEO, 비즈니스 성과 모두에 직결되는 진짜 경쟁력입니다.

저자 소개 Editorial Team

Our team of expert writers and editors.