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

구글(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:
useTransition및useDeferredValue를 사용하여 무거운 UI 업데이트의 우선순위를 낮추고, 사용자 입력을 최우선 처리합니다.- LCP(Largest Contentful Paint) 1초 단축: Hero 이미지의
fetchpriority="high", AVIF 포맷 최적화, Edge CDN(Cloudflare) HTML 스트리밍,next/fontzero-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. 실전 성능 디버깅 툴킷
- Chrome DevTools Performance Panel: Record 버튼을 누르고 사용자 동작을 수행한 뒤
Interactions트랙에서 빨간색 노치로 표시된 INP 지연 원인 분석 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)과 사용자 이탈률을 획기적으로 개선합니다.