본문으로 이동
·19분 읽기

2026년 Core Web Vitals: 실제로 성능을 바꾸는 방법

이론은 잊으세요. 실제 Next.js 프로덕션 사이트에서 LCP를 2.5초 이하로, CLS를 0으로, INP를 200ms 이하로 만들기 위해 제가 실제로 한 작업들입니다. 구체적인 기법들을 다룹니다.

공유:X / TwitterLinkedIn

이 사이트를 빠르게 만드는 데 거의 2주를 쏟았습니다. "M3 MacBook에서 Lighthouse 감사 돌렸을 때 빨라 보이는" 수준이 아닙니다. 진짜로 빠른 수준. 지하철 터널에서 흔들리는 4G 연결로 15만 원짜리 안드로이드 폰에서도 빠른 수준. 정말 중요한 곳에서 빠른 수준입니다.

결과: LCP 1.8초 미만, CLS 0.00, INP 120ms 미만. 세 지표 모두 CrUX 데이터에서 초록색이며, 랩 점수만이 아닙니다. 그리고 이 과정에서 한 가지 배웠습니다 — 인터넷에 있는 대부분의 성능 조언은 오래되었거나, 모호하거나, 둘 다입니다.

"이미지를 최적화하세요"는 조언이 아닙니다. 맥락 없는 "지연 로딩을 사용하세요"는 위험합니다. "JavaScript를 최소화하세요"는 당연하지만 무엇을 줄여야 하는지는 알려주지 않습니다.

제가 실제로 한 것을, 중요도 순서대로 정리했습니다.

2026년에도 Core Web Vitals가 중요한 이유#

솔직하게 말하겠습니다: Google은 Core Web Vitals를 랭킹 시그널로 사용합니다. 유일한 시그널도 아니고, 가장 중요한 시그널도 아닙니다. 콘텐츠 관련성, 백링크, 도메인 권위가 여전히 지배적입니다. 하지만 두 페이지의 콘텐츠와 권위가 비슷한 미세한 차이 — 에서 성능이 타이브레이커가 됩니다. 그리고 인터넷에서 수백만 개의 페이지가 그 미세한 차이에 있습니다.

하지만 잠시 SEO를 잊어보세요. 성능에 신경 써야 하는 진짜 이유는 사용자입니다. 데이터는 지난 5년간 크게 달라지지 않았습니다:

  • 모바일 방문의 53%가 이탈합니다 페이지 로드에 3초 이상 걸리면 (Google/SOASTA 리서치, 여전히 유효)
  • 지연 시간 100ms마다 전환율이 약 1% 하락합니다 (Amazon의 원래 연구 결과, 반복적으로 검증됨)
  • 레이아웃 시프트를 경험한 사용자는 구매를 완료하거나 폼을 작성할 가능성이 현저히 낮습니다

2026년 Core Web Vitals는 세 가지 메트릭으로 구성됩니다:

메트릭측정 대상양호개선 필요불량
LCP로딩 성능≤ 2.5s2.5s – 4.0s> 4.0s
CLS시각적 안정성≤ 0.10.1 – 0.25> 0.25
INP응답성≤ 200ms200ms – 500ms> 500ms

이 임계값은 INP가 2024년 3월에 FID를 대체한 이후 변경되지 않았습니다. 하지만 이를 달성하는 기법은 진화했으며, 특히 React/Next.js 생태계에서 그렇습니다.

LCP: 가장 중요한 메트릭#

Largest Contentful Paint는 뷰포트에서 가장 큰 보이는 요소가 렌더링을 완료하는 시점을 측정합니다. 대부분의 페이지에서 이것은 히어로 이미지, 제목, 또는 큰 텍스트 블록입니다.

1단계: 실제 LCP 요소 찾기#

무엇이든 최적화하기 전에, 여러분의 LCP 요소가 무엇인지 알아야 합니다. 사람들은 히어로 이미지라고 가정합니다. 때로는 <h1>을 렌더링하는 웹 폰트입니다. 때로는 CSS로 적용된 배경 이미지입니다. 때로는 <video> 포스터 프레임입니다.

Chrome DevTools를 열고, Performance 패널로 가서, 페이지 로드를 녹화하고, "LCP" 마커를 찾아보세요. 어떤 요소가 LCP를 트리거했는지 정확히 알려줍니다.

web-vitals 라이브러리를 사용해서 프로그래밍 방식으로 로깅할 수도 있습니다:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

이 사이트에서 LCP 요소는 홈페이지의 히어로 이미지와 블로그 포스트의 첫 번째 단락 텍스트였습니다. 두 가지 다른 요소, 두 가지 다른 최적화 전략입니다.

2단계: LCP 리소스 프리로드#

LCP 요소가 이미지라면, 할 수 있는 가장 효과적인 단일 조치는 프리로드하는 것입니다. 기본적으로 브라우저는 HTML을 파싱할 때 이미지를 발견하는데, 이것은 HTML이 다운로드되고 파싱되고 <img> 태그에 도달할 때까지 이미지 요청이 시작되지 않는다는 뜻입니다. 프리로드는 그 발견을 맨 처음으로 옮겨줍니다.

Next.js에서는 레이아웃이나 페이지에 프리로드 링크를 추가할 수 있습니다:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

fetchPriority="high"에 주목하세요. 이것은 최신 Fetch Priority API이며, 게임 체인저입니다. 이것 없이는 브라우저가 자체 휴리스틱으로 리소스 우선순위를 정하는데 — 그 휴리스틱이 종종 틀립니다, 특히 폴드 위에 여러 이미지가 있을 때.

이 사이트에서 LCP 이미지에 fetchPriority="high"를 추가하니 LCP가 ~400ms 줄었습니다. 한 줄 변경으로 얻은 가장 큰 성과입니다.

3단계: 렌더링 차단 리소스 제거#

CSS는 렌더링을 차단합니다. 전부요. <link rel="stylesheet">로 로드되는 200KB 스타일시트가 있다면, 브라우저는 완전히 다운로드되고 파싱될 때까지 아무것도 그리지 않습니다.

해결책은 세 가지입니다:

  1. 크리티컬 CSS 인라인 — 폴드 위 콘텐츠에 필요한 CSS를 추출하여 <head><style> 태그에 인라인합니다. Next.js는 CSS Modules나 적절한 퍼징이 적용된 Tailwind를 사용하면 이것을 자동으로 수행합니다.

  2. 비크리티컬 CSS 지연 — 폴드 아래 콘텐츠용 스타일시트(푸터 애니메이션 라이브러리, 차트 컴포넌트)가 있다면, 비동기로 로드합니다:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. 사용하지 않는 CSS 제거 — Tailwind CSS v4는 JIT 엔진으로 이것을 자동 처리합니다. 하지만 서드파티 CSS 라이브러리를 import하고 있다면, 감사하세요. 하나의 툴팁 컴포넌트를 위해 180KB의 CSS를 import하는 컴포넌트 라이브러리를 발견한 적 있습니다. 20줄짜리 커스텀 컴포넌트로 교체하고 170KB를 절약했습니다.

4단계: 서버 응답 시간 (TTFB)#

TTFB가 느리면 LCP도 빠를 수 없습니다. 서버가 응답하는 데 800ms 걸린다면, LCP는 최소 800ms + 나머지 전부입니다.

이 사이트(VPS 위의 Node.js + PM2 + Nginx)에서 콜드 히트 시 TTFB를 약 180ms로 측정했습니다. 이것을 유지하기 위해 한 것들입니다:

  • ISR (Incremental Static Regeneration) 블로그 포스트용 — 페이지가 빌드 타임에 사전 렌더링되고 주기적으로 재검증됩니다. 첫 방문은 Nginx의 리버스 프록시 캐시에서 직접 정적 파일을 제공합니다.
  • Edge 캐싱 헤더 — 정적 페이지에 Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400.
  • Nginx에서 Gzip/Brotli 압축 — 전송 크기를 60-80% 줄입니다.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

LCP 개선 전후:

  • 최적화 전: 3.8s (75번째 백분위수, CrUX)
  • 프리로드 + fetchPriority + 압축 후: 1.8s
  • 총 개선: 53% 감소

CLS: 수천 번의 작은 시프트로 인한 죽음#

Cumulative Layout Shift는 페이지 로드 중 보이는 콘텐츠가 얼마나 움직이는지를 측정합니다. CLS 0은 아무것도 움직이지 않았다는 뜻입니다. CLS 0.1 이상은 사용자에게 시각적으로 불편감을 주고 있다는 뜻입니다.

CLS는 대부분의 개발자가 과소평가하는 메트릭입니다. 모든 것이 캐시된 빠른 개발 머신에서는 알아차리지 못합니다. 사용자들은 느린 연결의 핸드폰에서, 폰트가 늦게 로드되고 이미지가 하나씩 튀어나오는 환경에서 알아차립니다.

흔한 범인들#

1. 명시적 치수가 없는 이미지

가장 흔한 CLS 원인입니다. 이미지가 로드되면 아래 콘텐츠를 밀어냅니다. 해결책은 부끄러울 정도로 간단합니다: <img> 태그에 항상 widthheight를 지정하세요.

tsx
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
 
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Next.js <Image>를 사용하고 있다면, 치수를 제공하거나 크기가 지정된 부모 컨테이너와 함께 fill을 사용하는 한 이것을 자동으로 처리합니다.

하지만 주의할 점이 있습니다: fill 모드를 사용하면, 부모 컨테이너에 명시적 치수가 반드시 있어야 합니다. 그렇지 않으면 이미지가 CLS를 유발합니다:

tsx
// BAD — parent has no dimensions
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. FOUT/FOIT를 유발하는 웹 폰트

커스텀 폰트가 로드되면, 폴백 폰트로 렌더링된 텍스트가 커스텀 폰트로 다시 렌더링됩니다. 두 폰트의 메트릭이 다르면 (거의 항상 다릅니다), 모든 것이 움직입니다.

현대적인 해결책은 font-display: swap크기 조정된 폴백 폰트의 조합입니다:

tsx
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font automatically generates size-adjusted fallback fonts
  // This eliminates CLS from font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font는 진정으로 Next.js에서 가장 좋은 기능 중 하나입니다. 빌드 타임에 폰트를 다운로드하고, 셀프 호스팅하며 (런타임에 Google Fonts 요청 없음), 폴백에서 커스텀 폰트로의 교체가 레이아웃 시프트를 전혀 유발하지 않도록 크기 조정된 폴백 폰트를 생성합니다. next/font로 전환한 후 폰트에서 발생하는 CLS를 0.00으로 측정했습니다. 이전에 표준 Google Fonts <link>를 사용할 때는 0.04-0.08이었습니다.

3. 동적 콘텐츠 주입

광고, 쿠키 배너, 알림 바 — 초기 렌더링 후 DOM에 주입되는 모든 것은 콘텐츠를 아래로 밀어내면 CLS를 유발합니다.

해결책: 동적 콘텐츠가 로드되기 전에 공간을 확보하세요.

tsx
// Cookie banner — reserve space at the bottom
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positioning doesn't cause CLS because it
    // doesn't affect document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

동적 요소에 position: fixedposition: absolute를 사용하는 것은 CLS 없는 접근법입니다. 이 요소들은 일반 문서 흐름에 영향을 주지 않기 때문입니다.

4. aspect-ratio CSS 트릭

종횡비는 알지만 정확한 치수는 모르는 반응형 컨테이너의 경우, CSS aspect-ratio 속성을 사용하세요:

tsx
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

aspect-video 유틸리티(aspect-ratio: 16/9)가 정확히 올바른 양의 공간을 확보합니다. iframe이 로드될 때 시프트가 없습니다.

5. 스켈레톤 스크린

비동기적으로 로드되는 콘텐츠(API 데이터, 동적 컴포넌트)에 대해, 예상 치수에 맞는 스켈레톤을 보여주세요:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

핵심은 PostCardSkeletonPostCard같은 치수를 가져야 한다는 것입니다. 스켈레톤이 200px이고 실제 카드가 280px이면, 여전히 시프트가 발생합니다.

CLS 결과:

  • 이전: 0.12 (폰트 교체만으로 0.06)
  • 이후: 0.00 — CrUX 데이터에서 수천 번의 페이지 로드에 걸쳐 문자 그대로 0

INP: 물어뜯는 신입#

Interaction to Next Paint는 2024년 3월에 First Input Delay를 대체했으며, 근본적으로 최적화하기 더 어려운 메트릭입니다. FID는 첫 인터랙션이 처리되기 전의 지연만 측정했습니다. INP는 페이지 수명 주기 전체에서 모든 인터랙션을 측정하고 최악의 것을 (75번째 백분위수에서) 보고합니다.

이것은 페이지가 좋은 FID를 가질 수 있지만, 예를 들어 로드 30초 후에 드롭다운 메뉴를 클릭하면 500ms 리플로우를 트리거하면 끔찍한 INP를 가질 수 있다는 뜻입니다.

높은 INP의 원인#

  1. 메인 스레드의 롱 태스크 — 50ms 이상 걸리는 JavaScript 실행은 메인 스레드를 차단합니다. 롱 태스크 중에 발생하는 사용자 인터랙션은 기다려야 합니다.
  2. React의 비싼 리렌더링 — 200개 컴포넌트를 리렌더링하는 상태 업데이트는 시간이 걸립니다. 사용자가 무언가를 클릭하고, React가 재조정하고, 페인트가 300ms 동안 발생하지 않습니다.
  3. 레이아웃 스래싱 — 레이아웃 속성(offsetHeight 등)을 읽고 나서 변경(style.height 등)하는 것을 루프에서 반복하면 브라우저가 동기적으로 레이아웃을 재계산합니다.
  4. 큰 DOM — DOM 노드가 많을수록 스타일 재계산과 레이아웃이 느려집니다. 5,000개 노드의 DOM은 500개 노드보다 눈에 띄게 느립니다.

scheduler.yield()로 롱 태스크 분할#

INP에 가장 효과적인 기법은 롱 태스크를 분할하여 브라우저가 청크 사이에서 사용자 인터랙션을 처리할 수 있게 하는 것입니다:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Every 10 items, yield to the browser
    // This lets pending user interactions get processed
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield()는 Chrome 129+ (2024년 9월)에서 사용 가능하며, 메인 스레드에 양보하는 권장 방법입니다. 지원하지 않는 브라우저에서는 setTimeout(0) 래퍼로 폴백할 수 있습니다:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

비긴급 업데이트를 위한 useTransition#

React 18+는 useTransition을 제공하는데, 이것은 특정 상태 업데이트가 긴급하지 않으며 더 중요한 작업(사용자 입력에 응답하는 것 등)에 의해 중단될 수 있다고 React에 알려줍니다:

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // This update is urgent — the input must reflect the keystroke immediately
    setQuery(value);
 
    // This update is NOT urgent — filtering 10,000 items can wait
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

startTransition 없이는, 검색 입력에 타이핑하면 React가 DOM을 업데이트하기 전에 10,000개 항목을 동기적으로 필터링하려 하기 때문에 느리게 느껴질 것입니다. startTransition을 사용하면, 입력이 즉시 업데이트되고 필터링은 백그라운드에서 발생합니다.

복잡한 입력 핸들러가 있는 도구 페이지에서 INP를 측정했습니다. useTransition 이전: 380ms INP. 이후: 90ms INP. React API 변경 하나로 76% 개선입니다.

입력 핸들러 디바운싱#

비싼 연산(API 호출, 무거운 계산)을 트리거하는 핸들러에는 디바운스를 적용하세요:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Usage
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300ms가 제가 사용하는 기본 디바운스 값입니다. 사용자가 지연을 알아차리지 못할 만큼 짧고, 매 키 입력마다 발동하는 것을 방지할 만큼 깁니다.

무거운 연산을 위한 Web Workers#

진정으로 무거운 연산(대용량 JSON 파싱, 이미지 조작, 복잡한 계산)이 있다면, 메인 스레드에서 완전히 분리하세요:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // This could take 500ms for large datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers는 별도 스레드에서 동작하므로, 2초짜리 연산도 INP에 전혀 영향을 주지 않습니다. 메인 스레드는 사용자 인터랙션을 처리할 수 있도록 자유롭게 유지됩니다.

INP 결과:

  • 이전: 340ms (최악의 인터랙션은 복잡한 입력 처리가 있는 정규식 테스터 도구)
  • useTransition + 디바운싱 후: 110ms
  • 개선: 68% 감소

Next.js 특화 최적화#

Next.js(13+ App Router)를 사용하고 있다면, 대부분의 개발자가 충분히 활용하지 않는 강력한 성능 프리미티브에 접근할 수 있습니다.

next/image — 제대로 설정해야#

next/image는 훌륭하지만, 기본 설정은 성능을 테이블 위에 남겨둡니다:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
  },
};
 
export default nextConfig;

핵심 설정:

  • formats: ["image/avif", "image/webp"] — AVIF는 WebP보다 20-50% 작습니다. 순서가 중요합니다: Next.js가 AVIF를 먼저 시도하고, WebP로 폴백하고, 원본 형식으로 폴백합니다.
  • minimumCacheTTL — 기본값은 60초입니다. 블로그의 경우 이미지는 변경되지 않습니다. 1년간 캐시하세요.
  • deviceSizesimageSizes — 기본값은 3840px을 포함합니다. 4K 이미지를 제공하지 않는 한, 이 목록을 줄이세요. 각 크기는 별도의 캐시된 이미지를 생성하며, 사용되지 않는 크기는 디스크 공간과 빌드 시간을 낭비합니다.

그리고 항상 sizes prop을 사용하여 이미지가 어떤 크기로 렌더링될지 브라우저에 알려주세요:

tsx
// Full-width hero image
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP image — don't lazy load it!
/>
 
// Card image in a responsive grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

sizes 없이는, 브라우저가 300px 슬롯에 1200px 이미지를 다운로드할 수 있습니다. 낭비되는 바이트이자 낭비되는 시간입니다.

LCP 이미지의 priority prop은 매우 중요합니다. 지연 로딩을 비활성화하고 fetchPriority="high"를 자동으로 추가합니다. LCP 요소가 next/image라면, priority만 추가하면 거의 다 된 것입니다.

next/font — 레이아웃 시프트 없는 폰트#

CLS 섹션에서 다뤘지만, 강조할 가치가 있습니다. next/font는 제가 본 폰트 로딩 솔루션 중 지속적으로 CLS 0을 달성하는 유일한 솔루션입니다:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

두 개의 폰트, CLS 0, 런타임에 외부 요청 0. 폰트는 빌드 타임에 다운로드되어 자체 도메인에서 제공됩니다.

Suspense를 활용한 스트리밍#

이것이 Next.js가 성능 면에서 정말 흥미로워지는 지점입니다. App Router를 사용하면, 페이지의 일부분을 준비되는 대로 브라우저에 스트리밍할 수 있습니다:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* This loads fast — stream it immediately */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* This requires a database query — stream it when ready */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar can load independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

브라우저가 셸(제목, 내비게이션, 레이아웃)을 즉시 받습니다. 포스트 목록과 사이드바는 데이터가 준비되는 대로 스트리밍됩니다. 사용자는 빠른 초기 로드를 보고, 콘텐츠가 점진적으로 채워집니다.

이것은 LCP에 특히 강력합니다. LCP 요소가 제목이라면 (포스트 목록이 아니라), 데이터베이스 쿼리가 얼마나 오래 걸리든 즉시 렌더링됩니다.

라우트 세그먼트 구성#

Next.js는 라우트 세그먼트 수준에서 캐싱과 재검증을 구성할 수 있게 해줍니다:

tsx
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";

이 사이트에서 블로그 포스트는 revalidate = 3600 (1시간)을 사용합니다. 도구 페이지는 배포 사이에 콘텐츠가 변경되지 않으므로 force-static을 사용합니다. 검색 API는 모든 요청이 고유하므로 force-dynamic을 사용합니다.

결과: 대부분의 페이지가 정적 캐시에서 제공되고, 캐시된 페이지의 TTFB는 50ms 미만이며, 서버는 거의 부담을 느끼지 않습니다.

측정 도구: 눈이 아니라 데이터를 믿으세요#

여러분의 성능에 대한 인식은 신뢰할 수 없습니다. 여러분의 개발 머신은 32GB RAM, NVMe SSD, 기가비트 연결을 가지고 있습니다. 여러분의 사용자들은 아닙니다.

제가 사용하는 측정 스택#

1. Chrome DevTools Performance 패널

사용 가능한 가장 상세한 도구입니다. 페이지 로드를 녹화하고, 플레임차트를 보고, 롱 태스크를 식별하고, 렌더링 차단 리소스를 찾으세요. 디버깅 시간의 대부분을 여기서 보냅니다.

찾아야 할 핵심 사항:

  • 태스크에 빨간 모서리 = 롱 태스크 (>50ms)
  • JavaScript가 트리거한 Layout/Paint 이벤트
  • 큰 "Evaluate Script" 블록 (너무 많은 JavaScript)
  • 늦게 발견된 리소스를 보여주는 네트워크 워터폴

2. Lighthouse

빠른 확인에 좋지만, Lighthouse 점수를 위해 최적화하지 마세요. Lighthouse는 실제 환경과 완벽하게 일치하지 않는 시뮬레이션된 쓰로틀링 환경에서 실행됩니다. Lighthouse에서 98점을 받고 실제 환경에서 4초 LCP를 가진 페이지를 본 적 있습니다.

Lighthouse를 방향성 가이드로 사용하세요, 스코어보드가 아닌.

3. PageSpeed Insights

프로덕션 사이트에 가장 중요한 도구입니다. 실제 CrUX 데이터 — 지난 28일 동안 실제 Chrome 사용자의 실측값을 보여주기 때문입니다. 랩 데이터는 일어날 수 있는 것을 알려줍니다. CrUX 데이터는 실제로 일어나는 것을 알려줍니다.

4. web-vitals 라이브러리

프로덕션 사이트에 추가하여 실제 사용자 메트릭을 수집하세요:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Use sendBeacon so it doesn't block page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

이것은 자체 CrUX 같은 데이터를 제공하지만, 더 상세합니다. 페이지, 기기 유형, 연결 속도, 지역별로 세분화할 수 있습니다 — 필요한 것은 무엇이든.

5. Chrome User Experience Report (CrUX)

CrUX BigQuery 데이터셋은 무료이며 수백만 오리진에 대한 28일 롤링 데이터를 포함합니다. 사이트에 충분한 트래픽이 있다면, 자체 데이터를 쿼리할 수 있습니다:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

워터폴 제거 리스트#

서드파티 스크립트는 대부분의 웹사이트에서 가장 큰 성능 저하 요인입니다. 제가 발견한 것과 그에 대해 한 것입니다.

Google Tag Manager (GTM)#

GTM 자체는 ~80KB입니다. 하지만 GTM은 다른 스크립트를 로드합니다 — 분석, 마케팅 픽셀, A/B 테스팅 도구. 총 2MB에 달하는 15개의 추가 스크립트를 로드하는 GTM 구성을 본 적 있습니다.

제 접근법: 프로덕션에서 GTM을 사용하지 마세요. 분석 스크립트를 직접 로드하고, 모든 것을 지연시키고, 기다릴 수 있는 스크립트에는 loading="lazy"를 사용하세요:

tsx
// Instead of GTM loading everything
// Load only what you need, when you need it
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

GTM을 반드시 사용해야 한다면, 페이지가 인터랙티브해진 후에 로드하세요:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wait until after page load to inject GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 second delay
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

네, 처음 3초 안에 이탈하는 사용자의 데이터를 잃게 됩니다. 제 경험상 그것은 충분히 가치 있는 트레이드오프입니다. 그 사용자들은 어차피 전환하지 않았을 겁니다.

채팅 위젯#

라이브 채팅 위젯(Intercom, Drift, Crisp)은 최악의 범인 중 하나입니다. Intercom만으로도 400KB+ JavaScript를 로드합니다. 사용자의 2%만 실제로 채팅 버튼을 클릭하는 페이지에서, 98%의 사용자를 위한 400KB JavaScript입니다.

제 해결책: 인터랙션 시 위젯 로드.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Load the chat widget script only when the user clicks
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialize the widget after script loads
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

사용하지 않는 JavaScript#

Chrome DevTools에서 Coverage를 실행하세요 (Ctrl+Shift+P > "Show Coverage"). 현재 페이지에서 각 스크립트가 실제로 얼마나 사용되는지 정확히 보여줍니다.

일반적인 Next.js 사이트에서 보통 발견하는 것:

  • 통째로 로드되는 컴포넌트 라이브러리 — UI 라이브러리에서 Button을 import하지만, 전체 라이브러리가 번들됩니다. 해결책: 트리 셰이킹이 가능한 라이브러리를 사용하거나 서브패스에서 import합니다 (import { Button } from "lib" 대신 import Button from "lib/Button").
  • 모던 브라우저를 위한 폴리필Promise, fetch, 또는 Array.prototype.includes에 대한 폴리필을 전달하고 있는지 확인하세요. 2026년에는 필요 없습니다.
  • 죽은 피처 플래그 — 6개월 동안 "on"이었던 피처 플래그 뒤의 코드 경로. 플래그와 죽은 분기를 제거하세요.

Next.js 번들 분석기를 사용하여 과도하게 큰 청크를 찾습니다:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

이것은 번들의 시각적 트리맵을 열어줍니다. 네이티브 Intl.DateTimeFormat으로 교체한 120KB 날짜 포맷팅 라이브러리를 발견했습니다. 마크다운을 사용하지 않는 페이지에서 import된 90KB 마크다운 파서를 발견했습니다. 합산되는 작은 승리들입니다.

렌더링 차단 CSS#

LCP 섹션에서 언급했지만, 너무 흔하기 때문에 반복할 가치가 있습니다. <head>의 모든 <link rel="stylesheet">는 렌더링을 차단합니다. 5개의 스타일시트가 있다면, 브라우저는 5개 모두를 기다린 후에야 아무것도 그립니다.

Tailwind를 사용한 Next.js는 이를 잘 처리합니다 — CSS가 인라인되고 최소화됩니다. 하지만 서드파티 CSS를 import하고 있다면, 감사하세요:

tsx
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
 
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS is imported inside the dynamic component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

실제 최적화 이야기#

이 사이트의 도구 페이지 실제 최적화 과정을 살펴보겠습니다. 15개 이상의 인터랙티브 도구가 있고, 각각 자체 컴포넌트가 있으며, 일부(정규식 테스터와 JSON 포맷터 등)는 JavaScript를 많이 사용하는 페이지입니다.

시작점#

초기 측정값 (CrUX 데이터, 모바일, 75번째 백분위수):

  • LCP: 3.8s — 불량
  • CLS: 0.12 — 개선 필요
  • INP: 340ms — 불량

Lighthouse 점수: 62.

조사#

LCP 분석: LCP 요소는 페이지 제목(<h1>)이었는데, 즉시 렌더링되어야 합니다. 하지만 다음에 의해 지연되었습니다:

  1. 컴포넌트 라이브러리의 200KB CSS 파일 (렌더링 차단)
  2. Google Fonts CDN을 통해 로드된 커스텀 폰트 (느린 연결에서 800ms FOIT)
  3. 캐싱 없이 매 요청마다 서버 렌더링되어 420ms의 TTFB

CLS 분석: 세 가지 원인:

  1. Google Fonts 폴백에서 커스텀 폰트로의 교체: 0.06
  2. 높이 확보 없이 로드되는 도구 카드: 0.04
  3. 페이지 상단에 주입되어 모든 것을 아래로 밀어내는 쿠키 배너: 0.02

INP 분석: 정규식 테스터 도구가 최악의 범인이었습니다. 정규식 입력의 모든 키 입력이 다음을 트리거했습니다:

  1. 전체 도구 컴포넌트의 완전한 리렌더링
  2. 테스트 문자열에 대한 정규식 평가
  3. 정규식 패턴의 구문 강조

키 입력당 총 시간: 280-400ms.

수정 사항#

1주차: LCP와 CLS

  1. Google Fonts CDN을 next/font로 교체. 폰트가 이제 셀프 호스팅되고, 빌드 타임에 로드되며, 크기 조정된 폴백이 적용됨. 폰트에서 발생하는 CLS: 0.06 → 0.00

  2. 컴포넌트 라이브러리 CSS 제거. 사용하던 3개 컴포넌트를 Tailwind로 재작성. 제거된 총 CSS: 180KB. 렌더링 차단 CSS: 제거됨

  3. 도구 페이지와 도구 상세 페이지에 revalidate = 3600 추가. 첫 히트는 서버 렌더링, 이후 히트는 캐시에서 제공. TTFB: 420ms → 45ms (캐시됨)

  4. 모든 도구 카드 컴포넌트에 명시적 치수를 추가하고 반응형 레이아웃에 aspect-ratio 사용. 카드에서 발생하는 CLS: 0.04 → 0.00

  5. 쿠키 배너를 화면 하단의 position: fixed로 이동. 배너에서 발생하는 CLS: 0.02 → 0.00

2주차: INP

  1. 정규식 테스터의 결과 계산을 startTransition으로 래핑:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update the input
 
    startTransition(() => {
      // Non-urgent: compute matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

정규식 테스터 INP: 380ms → 85ms

  1. JSON 포맷터의 입력 핸들러에 디바운싱 추가 (300ms 지연). JSON 포맷터 INP: 260ms → 60ms

  2. 해시 생성기의 연산을 Web Worker로 이동. 대용량 입력의 SHA-256 해싱이 이제 메인 스레드에서 완전히 분리됨. 해시 생성기 INP: 200ms → 40ms

결과#

2주간의 최적화 후 (CrUX 데이터, 모바일, 75번째 백분위수):

  • LCP: 3.8s → 1.8s (53% 개선)
  • CLS: 0.12 → 0.00 (100% 개선)
  • INP: 340ms → 110ms (68% 개선)

Lighthouse 점수: 62 → 97.

세 메트릭 모두 "양호" 범위에 확실히 들어갔습니다. 모바일에서 페이지가 즉각적으로 느껴집니다. 그리고 개선 후 한 달간 오가닉 검색 트래픽이 12% 증가했습니다 (인과관계를 증명할 수는 없지만 — 다른 요인도 있었습니다).

체크리스트#

이 글에서 아무것도 가져가지 않더라도, 모든 프로젝트에서 제가 확인하는 체크리스트는 이것입니다:

LCP#

  • DevTools로 LCP 요소 식별
  • LCP 이미지에 priority (또는 fetchPriority="high") 추가
  • <head>에서 LCP 리소스 프리로드
  • 렌더링 차단 CSS 제거
  • next/font로 폰트 셀프 호스팅
  • Brotli/Gzip 압축 활성화
  • 가능한 곳에서 정적 생성 또는 ISR 사용
  • 정적 자산에 공격적 캐시 헤더 설정

CLS#

  • 모든 이미지에 명시적 widthheight
  • 크기 조정된 폴백이 적용된 next/font 사용
  • 동적 콘텐츠는 position: fixed/absolute 또는 예약된 공간 사용
  • 스켈레톤 스크린이 실제 컴포넌트 치수와 일치
  • 로드 후 페이지 상단 콘텐츠 주입 없음

INP#

  • 인터랙션 핸들러 중 롱 태스크(>50ms) 없음
  • 비긴급 상태 업데이트를 startTransition으로 래핑
  • 입력 핸들러 디바운싱 (300ms)
  • 무거운 연산을 Web Workers로 분리
  • 가능하면 DOM 크기 1,500 노드 미만

일반#

  • 서드파티 스크립트는 페이지 인터랙티브 이후 로드
  • 번들 크기 분석 및 트리 셰이킹
  • 사용하지 않는 CSS 제거
  • 이미지를 AVIF/WebP 형식으로 제공
  • 프로덕션에서 실제 사용자 모니터링 (web-vitals 라이브러리)

마무리#

성능 최적화는 일회성 작업이 아닙니다. 습관입니다. 모든 새 기능, 모든 새 의존성, 모든 새 서드파티 스크립트는 잠재적인 회귀입니다. 빠른 상태를 유지하는 사이트는 누군가가 메트릭을 지속적으로 감시하는 사이트이지, 일회성 최적화 스프린트를 수행한 사이트가 아닙니다.

실제 사용자 모니터링을 설정하세요. 메트릭이 회귀할 때 알림을 설정하세요. 성능을 코드 리뷰 프로세스의 일부로 만드세요. 누군가 200KB 라이브러리를 추가하면, 5KB 대안이 있는지 물어보세요. 누군가 이벤트 핸들러에 동기적 연산을 추가하면, 지연하거나 Worker로 옮길 수 있는지 물어보세요.

이 글의 기법들은 이론이 아닙니다. 제가 이 사이트에서 실제로 한 것이며, 뒷받침하는 실제 숫자가 있습니다. 결과는 다를 수 있습니다 — 모든 사이트가 다르고, 모든 사용자가 다르고, 모든 인프라가 다릅니다. 하지만 원칙은 보편적입니다: 더 적게 로드하고, 더 똑똑하게 로드하고, 메인 스레드를 차단하지 마세요.

사용자들은 빠른 사이트에 감사 인사를 보내지 않을 겁니다. 하지만 머무를 겁니다. 다시 올 겁니다. 그리고 Google이 알아차릴 겁니다.

관련 게시물