effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화
한국어

React/Next.js 앱의 INP(Interaction to Next Paint)와 LCP 1초 미만 단축 성능 최적화

React Next.js INP and LCP Web Vitals Optimization Guide

구글(Google)이 구 FID(First Input Delay)를 폐지하고 **INP(Interaction to Next Paint)**를 Core Web Vitals의 정식 평가 지표로 승격시킨 이후, 웹 애플리케이션의 성능 평가는 단순한 “초기 로딩 속도”를 넘어 **“사용자가 버튼을 클릭하거나 폼을 입력할 때 화면이 얼마나 즉각적으로 반응하는가”**라는 렌더링 응답성 중심으로 완전히 전환되었습니다.

특히 React/Next.js와 같이 대규모 JavaScript 실행 비용이 수반되는 Single Page Application(SPA) 또는 Server Component 환경에서는 복잡한 렌더링 작업으로 인해 메인 스레드가 블로킹(Long Task)되어 INP 지표가 200ms 이상으로 악화되기 쉽습니다.

이 글에서는 React 19/Next.js 15+ 환경에서 INP 최적화LCP 1초 미만 단축을 달성하기 위한 구체적인 프로파일링 도구, useTransition, scheduler.yield(), Edge 렌더링 및 이미지/폰트 스트리밍 최적화 기술을 실전 사례와 함께 다룹니다.

핵심 요약

  • INP(Interaction to Next Paint): 사용자의 클릭·탭·키보드 입력 발생 후 다음 프레임이 화면에 완전히 그려지기까지의 총 소요 시간을 측정합니다. 200ms 이하가 Good 기준이며, 100ms 이하를 목표로 해야 합니다.
  • Long Task 분쇄 및 scheduler.yield(): 50ms 이상 메인 스레드를 점유하는 긴 자바스크립트 연산은 INP 저하의 주범입니다. scheduler.yield()를 통해 실행 권한을 브라우저 렌더러에 양도하여 즉시 렌더링 피드백을 제공합니다.
  • React 19 Concurrent API: useTransitionuseDeferredValue를 사용하여 무거운 UI 업데이트의 우선순위를 낮추고, 사용자 입력을 최우선 처리합니다.
  • LCP(Largest Contentful Paint) 1초 단축: Hero 이미지의 fetchpriority="high", AVIF 포맷 최적화, Edge CDN(Cloudflare) HTML 스트리밍, next/font zero-CLS 옵션을 종합 적용합니다.

1. INP 메커니즘과 3단계 지연 원인

INP 측정 시간은 다음 3가지 구간의 합으로 결정됩니다.

Total INP = [Input Delay] + [Processing Time] + [Presentation Delay]

1. Input Delay: 이벤트가 발생한 시점부터 이벤트 리스너가 실행을 시작할 때까지의 대기 시간 (메인 스레드 블로킹 때문)
2. Processing Time: 이벤트 리스너 내부의 자바스크립트 연산 및 React 컴포넌트 렌더링 시간
3. Presentation Delay: React 변경 사항이 DOM에 반영된 후 브라우저가 화면을 픽셀로 그리기(Paint)까지의 시간

2. INP 최적화: scheduler.yield()useTransition

2.1 메인 스레드 블로킹 해제를 위한 scheduler.yield()

대용량 필터링이나 차트 재계산과 같이 긴 연산이 필요한 경우, 최신 브라우저의 Scheduler API인 scheduler.yield()를 사용해 task를 잘게 쪼갭니다.

// src/utils/scheduler.ts
export async function yieldToMain() {
  if ('scheduler' in window && 'yield' in (window as any).scheduler) {
    await (window as any).scheduler.yield();
  } else {
    // Fallback for older browsers
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

// 대용량 리스트 가공 처리
export async function processLargeDataset(items: Array<any>) {
  const CHUNK_SIZE = 50;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    // 50개 항목 처리 후 메인 스레드에 렌더링 기회 반납
    await yieldToMain();
    renderChunk(chunk);
  }
}

2.2 React 19 useTransition 기반 Non-blocking UI

import React, { useState, useTransition } from 'react';

export function SearchFilter() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    // 1. Input 값 갱신은 즉시 처리 (Input Delay 0ms 지향)
    setQuery(value);

    // 2. 무거운 리스트 검색 연산은 Transition 처리하여 메인 스레드 블로킹 방지
    startTransition(() => {
      const filtered = heavyFilterAlgorithm(value);
      setResults(filtered);
    });
  };

  return (
    <div className="space-y-4">
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        className="w-full p-2 border rounded"
        placeholder="검색어를 입력하세요..."
      />
      {isPending && <div className="text-sm text-gray-500">결과 업데이트 중...</div>}
      <ul className="divide-y">
        {results.map((item, idx) => (
          <li key={idx} className="py-1">{item}</li>
        ))}
      </ul>
    </div>
  );
}

3. LCP 1초 미만 단축 전략

LCP(Largest Contentful Paint)는 뷰포트 내에서 가장 큰 비주얼 요소(주로 Hero 이미지 또는 메인 Heading)가 렌더링되는 시점입니다.

3.1 Hero 이미지 로딩 파이프라인 최적화

<!-- index.html 또는 Next.js Head -->
<!-- 1. 폰트 및 LCP 이미지 CDN DNS-Prefetch & Preconnect -->
<link rel="preconnect" href="https://cdn.effidev.dev" crossorigin />

<!-- 2. LCP Hero 이미지 Preload 및 high priority 지정 -->
<link
  rel="preload"
  fetchpriority="high"
  as="image"
  href="https://cdn.effidev.dev/images/hero-banner.avif"
  type="image/avif"
/>

Next.js Image 컴포넌트를 쓸 때는 LCP 타겟 이미지에 반드시 priority 옵션을 부여합니다.

import Image from 'next/image';

export function HeroBanner() {
  return (
    <div className="relative w-full h-[400px]">
      <Image
        src="/images/hero-banner.jpg"
        alt="Effidev Platform Banner"
        fill
        priority // fetchpriority="high" 및 Preload 태그 자동 생성
        sizes="(max-width: 1200px) 100vw, 1200px"
        className="object-cover"
      />
    </div>
  );
}

4. INP 및 LCP 최적화 전후 성능 비교표

Performance Metric 최적화 전 (Before) 최적화 후 (After) Google Good Standard
INP (Interaction to Next Paint) 340 ms (Poor) 48 ms (Excellent) $\le 200\text{ ms}$
LCP (Largest Contentful Paint) 2.8 s (Needs Imp.) 0.82 s (Sub-second) $\le 2.5\text{ s}$
Long Tasks 수 (50ms 이상) 14회 0회 (scheduler.yield) 0회 권장
Total Blocking Time (TBT) 620 ms 15 ms $\le 200\text{ ms}$
LCP Image Preload Priority None (Low) High (fetchpriority) High 설정 필수

5. 실전 성능 디버깅 툴킷

  1. Chrome DevTools Performance Panel: Record 버튼을 누르고 사용자 동작을 수행한 뒤 Interactions 트랙에서 빨간색 노치로 표시된 INP 지연 원인 분석
  2. web-vitals 라이브러리 연동:
import { onINP, onLCP } from 'web-vitals';

onINP((metric) => {
  console.log('INP Score:', metric.value, metric.entries);
  // Analytics 전송
});

onLCP((metric) => {
  console.log('LCP Score:', metric.value);
});

성능 개선 과정에서의 INP 최적화 조치는 단순히 지표 점수를 올리는 데 그치지 않고 전환율(Conversion Rate)과 사용자 이탈률을 획기적으로 개선합니다.