Skip to content
Published on

웹 성능 최적화 완전 가이드 2025: Core Web Vitals, 로딩 전략, 렌더링 패턴 총정리

Authors

들어가며

웹 성능은 사용자 경험과 비즈니스 성과에 직접적인 영향을 미칩니다. Google의 연구에 따르면, 페이지 로딩 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고, 5초가 되면 90% 증가합니다. Amazon은 페이지 로드 시간이 100ms 증가할 때마다 매출이 1% 감소한다고 보고했습니다.

2025년, Google은 Core Web Vitals를 검색 순위 요소로 강화했으며, INP(Interaction to Next Paint)가 FID를 대체하면서 상호작용 성능의 중요성이 더욱 커졌습니다. 이 글에서는 Core Web Vitals 최적화부터 이미지, JavaScript, CSS, 폰트, 렌더링 패턴, 캐싱, 네트워크, 모니터링, 그리고 Next.js 특화 최적화까지 웹 성능의 모든 것을 다룹니다.


1. 왜 성능이 중요한가

1.1 비즈니스 임팩트

성능과 비즈니스 지표:
├── 로딩 3초 초과 → 53% 이탈 (Google)
├── 100ms 지연 → 매출 1% 감소 (Amazon)
├── 500ms 지연 → 트래픽 20% 감소 (Google)
├── 1초 개선 → 전환율 7% 향상 (Walmart)
└── 2초 개선 → 바운스율 50% 감소 (COOK)

1.2 SEO와 Core Web Vitals

2025년 Google은 Core Web Vitals를 검색 순위의 핵심 요소로 확정했습니다.

지표좋음 (Good)개선 필요 (Needs Improvement)나쁨 (Poor)
LCP2.5초 이내2.5~4.0초4.0초 초과
INP200ms 이내200~500ms500ms 초과
CLS0.1 이하0.1~0.250.25 초과

1.3 성능 예산 (Performance Budget)

성능 예산 예시:
├── 초기 로드 JS: 200KB 이하 (gzip)
├── 초기 로드 CSS: 50KB 이하 (gzip)
├── 전체 페이지 크기: 1.5MB 이하
├── LCP: 2.5초 이내
├── INP: 200ms 이내
├── CLS: 0.1 이하
├── Time to First Byte: 600ms 이내
└── 요청 수: 50개 이하

2. Core Web Vitals 심층 분석

2.1 LCP (Largest Contentful Paint)

LCP는 뷰포트 내에서 가장 큰 콘텐츠 요소가 렌더링되는 시간입니다.

LCP 대상 요소:
├── img 요소
├── video 요소 (포스터 이미지)
├── CSS background-image가 있는 요소
├── 텍스트 노드를 포함하는 블록 레벨 요소
└── svg 내의 image 요소

LCP 최적화 전략:

<!-- 1. 히어로 이미지 프리로드 -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />

<!-- 2. LCP 이미지에 fetchpriority 설정 -->
<img src="/hero.webp" alt="Hero" fetchpriority="high" loading="eager" />

<!-- 3. 서버 응답 시간 최적화 -->
<!-- TTFB 목표: 200ms 이내 -->
/* 4. 폰트 로딩 최적화 */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

2.2 INP (Interaction to Next Paint)

INP는 사용자 상호작용(클릭, 탭, 키보드)에서 다음 페인트까지의 지연 시간을 측정합니다.

INP 최적화 전략:
1. 긴 태스크 분할
   - 50ms 이상의 태스크를 더 작은 단위로 분할
   - requestIdleCallback, scheduler.yield() 활용

2. 메인 스레드 해방
   - Web Worker로 무거운 계산 이동
   - 불필요한 동기 JS 제거

3. 이벤트 핸들러 최적화
   - debounce / throttle 적용
   - passive 이벤트 리스너 사용
// 긴 태스크 분할 예시
async function processLargeList(items: Item[]) {
  const CHUNK_SIZE = 50;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    processChunk(chunk);

    // 브라우저에게 렌더링 기회 제공
    await scheduler.yield();
  }
}

// scheduler.yield 폴리필
if (!('scheduler' in globalThis)) {
  (globalThis as any).scheduler = {
    yield: () => new Promise(resolve => setTimeout(resolve, 0))
  };
}

2.3 CLS (Cumulative Layout Shift)

CLS는 페이지 로드 중 예기치 않은 레이아웃 이동의 총합입니다.

<!-- CLS 방지: 이미지에 크기 명시 -->
<img src="/photo.webp" width="800" height="600" alt="Photo" />

<!-- CLS 방지: 동적 콘텐츠에 min-height 설정 -->
<div style="min-height: 250px;">
  <!-- 광고 또는 동적 콘텐츠 -->
</div>
/* CLS 방지: 폰트 로딩 시 레이아웃 시프트 최소화 */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: optional; /* 또는 swap */
  /* size-adjust로 대체 폰트와 크기 맞춤 */
  size-adjust: 100.5%;
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

CLS 주요 원인과 해결:

원인해결 방법
크기 미지정 이미지width/height 또는 aspect-ratio 속성 사용
동적 삽입 콘텐츠min-height로 공간 예약
웹 폰트 FOUT/FOITfont-display: optional + preload
동적 광고고정 크기 컨테이너 사용
늦게 로드되는 CSSCritical CSS 인라인

3. 이미지 최적화

3.1 차세대 포맷

이미지 포맷 비교 (같은 품질 기준):
├── JPEG: 100KB (기준)
├── WebP: 70KB (-30%)
├── AVIF: 50KB (-50%)
└── JXL (JPEG XL): 55KB (-45%)

브라우저 지원 (2025):
├── WebP: 97%+ (IE 미지원)
├── AVIF: 92%+ (Safari 16.4+)
└── JXL: Chrome에서 제거, Safari/Firefox 지원
<!-- picture 요소로 포맷 폴백 -->
<picture>
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>

3.2 반응형 이미지

<!-- srcset과 sizes로 반응형 이미지 -->
<img
  srcset="
    /hero-400w.webp 400w,
    /hero-800w.webp 800w,
    /hero-1200w.webp 1200w,
    /hero-1600w.webp 1600w
  "
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
  src="/hero-800w.webp"
  alt="Hero image"
  width="1200"
  height="600"
  loading="lazy"
  decoding="async"
/>

3.3 지연 로딩과 블러 플레이스홀더

<!-- 네이티브 lazy loading -->
<img src="/photo.webp" loading="lazy" decoding="async" alt="Photo" />

<!-- Intersection Observer로 커스텀 lazy loading -->
// 블러 플레이스홀더 구현
function BlurImage({ src, alt, width, height, blurDataURL }: ImageProps) {
  return (
    <div style={{ position: 'relative', width, height }}>
      {/* 블러 플레이스홀더 */}
      <img
        src={blurDataURL}
        alt=""
        style={{
          position: 'absolute',
          inset: 0,
          filter: 'blur(20px)',
          transform: 'scale(1.1)',
        }}
      />
      {/* 실제 이미지 */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
        onLoad={(e) => {
          // 로드 완료 시 블러 제거
          e.currentTarget.style.opacity = '1';
        }}
        style={{ position: 'relative', opacity: 0, transition: 'opacity 0.3s' }}
      />
    </div>
  );
}

4. JavaScript 최적화

4.1 Code Splitting

// React lazy + Suspense (Route-based splitting)
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}
// 조건부 동적 import
async function handleExport() {
  // xlsx 라이브러리를 필요할 때만 로드
  const XLSX = await import('xlsx');
  const workbook = XLSX.utils.book_new();
  // ...
}

4.2 Tree Shaking

// 나쁜 예: 전체 라이브러리 import
import _ from 'lodash';
_.debounce(fn, 300);

// 좋은 예: 필요한 함수만 import
import debounce from 'lodash/debounce';
debounce(fn, 300);

// 더 좋은 예: 네이티브 또는 경량 대안 사용
// lodash.debounce: 1.4KB vs lodash: 72KB
// webpack-bundle-analyzer로 번들 분석
// npm install --save-dev webpack-bundle-analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // Next.js config
});

4.3 번들 최적화

// webpack splitChunks 설정
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
        },
        common: {
          minChunks: 2,
          priority: -10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

4.4 스크립트 로딩 전략

<!-- 일반: 파싱 차단 -->
<script src="app.js"></script>

<!-- async: 비동기 다운로드, 즉시 실행 (파싱 차단 가능) -->
<script src="analytics.js" async></script>

<!-- defer: 비동기 다운로드, DOM 파싱 후 실행 (순서 보장) -->
<script src="app.js" defer></script>

<!-- module: defer와 동일한 동작 -->
<script type="module" src="app.js"></script>
스크립트 로딩 타임라인:
일반:    [HTML 파싱...] [다운로드] [실행] [HTML 파싱...]
async:   [HTML 파싱......다운로드......] [실행] [HTML 파싱...]
defer:   [HTML 파싱......다운로드.............] [실행]

5. CSS 최적화

5.1 Critical CSS

<!-- Critical CSS 인라인 -->
<head>
  <style>
    /* 첫 화면(Above the fold)에 필요한 최소 CSS만 인라인 */
    body { margin: 0; font-family: system-ui; }
    .header { height: 60px; background: #fff; }
    .hero { height: 400px; display: flex; align-items: center; }
  </style>

  <!-- 나머지 CSS는 비동기 로드 -->
  <link rel="preload" href="/styles.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'" />
  <noscript><link rel="stylesheet" href="/styles.css" /></noscript>
</head>

5.2 CSS Containment

/* contain으로 렌더링 범위 제한 */
.card {
  contain: layout style paint;
  /* 또는 content-visibility로 오프스크린 요소 최적화 */
  content-visibility: auto;
  contain-intrinsic-size: 0 300px;
}

/* will-change로 GPU 가속 힌트 */
.animated-element {
  will-change: transform;
  /* 주의: 남용 시 메모리 낭비 */
}

5.3 불필요한 CSS 제거

// PurgeCSS 설정 (postcss.config.js)
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: [
        './src/**/*.{js,jsx,ts,tsx}',
        './public/index.html'
      ],
      defaultExtractor: content =>
        content.match(/[\w-/:]+(?<!:)/g) || [],
      safelist: ['html', 'body', /^data-/]
    })
  ]
};

6. 폰트 최적화

6.1 font-display 전략

/* swap: 대체 폰트 → 웹폰트 (FOUT 발생, 텍스트 즉시 표시) */
@font-face {
  font-family: 'MyFont';
  font-display: swap;
  src: url('/fonts/myfont.woff2') format('woff2');
}

/* optional: 빠르면 웹폰트, 느리면 대체 폰트 유지 (CLS 최소) */
@font-face {
  font-family: 'MyFont';
  font-display: optional;
  src: url('/fonts/myfont.woff2') format('woff2');
}

6.2 폰트 프리로드

<!-- 핵심 폰트 프리로드 -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

6.3 Variable Fonts

/* 기존: 각 굵기별 별도 파일 (400, 500, 600, 700 = 4파일) */
/* Variable Font: 1파일로 모든 굵기 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

/* 사용 */
.light { font-weight: 300; }
.regular { font-weight: 400; }
.bold { font-weight: 700; }

6.4 폰트 서브셋

# pyftsubset으로 한글 폰트 서브셋 생성
# pip install fonttools brotli
pyftsubset NotoSansKR-Regular.otf \
  --text-file=korean-chars.txt \
  --output-file=NotoSansKR-subset.woff2 \
  --flavor=woff2

# 결과: 4.5MB → 300KB (한글 2,350자 기준)

7. 렌더링 패턴

7.1 패턴 비교표

패턴TTFBFCPLCPTTISEO사용 사례
CSR빠름느림느림느림나쁨SPA, 대시보드
SSR느림빠름빠름느림좋음동적 콘텐츠, SEO 필요
SSG매우 빠름매우 빠름매우 빠름빠름매우 좋음블로그, 문서, 마케팅
ISR매우 빠름매우 빠름매우 빠름빠름매우 좋음이커머스, 뉴스
Streaming SSR빠름매우 빠름빠름빠름좋음복잡한 동적 페이지

7.2 CSR (Client-Side Rendering)

CSR 플로우:
Browser        Server
  │─ HTML 요청 ──→│
  │←─ 빈 HTML ────│
  │─ JS 요청 ────→│
  │←─ JS 번들 ────│
[JS 파싱/실행]  │─ API 요청 ───→│
  │←─ 데이터 ─────│
[렌더링]  ▼ 화면 표시      ▼

7.3 SSR (Server-Side Rendering)

SSR 플로우:
Browser        Server
  │─ HTML 요청 ──→│
  │               │ [데이터 패칭]
  │               │ [HTML 렌더링]
  │←─ 완성 HTML ──│
[화면 표시]  │─ JS 요청 ────→│
  │←─ JS 번들 ────│
[Hydration]  ▼ 인터랙티브     ▼

7.4 Streaming SSR

// Next.js App Router Streaming SSR
import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetchSlowData(); // 3초 소요
  return <div>{/* data 렌더링 */}</div>;
}

export default function Page() {
  return (
    <div>
      {/* 즉시 렌더링 */}
      <Header />
      <Hero />

      {/* 스트리밍: 준비되면 점진적 전송 */}
      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>

      {/* 즉시 렌더링 */}
      <Footer />
    </div>
  );
}

7.5 ISR (Incremental Static Regeneration)

// Next.js ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // 1시간마다 재생성

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  );
}

export default ProductPage;

// 정적 경로 생성
export async function generateStaticParams() {
  const products = await getTopProducts(100);
  return products.map(p => ({ id: p.id }));
}

8. 캐싱 전략

8.1 HTTP 캐시

HTTP 캐시 전략:
├── 정적 자산 (JS/CSS/이미지)
Cache-Control: public, max-age=31536000, immutable
   (파일명에 해시 포함: app.abc123.js)
├── HTML
Cache-Control: public, max-age=0, must-revalidate
   (항상 서버 확인)
├── API 응답
Cache-Control: private, max-age=60, stale-while-revalidate=300
   (60초 캐시, 5분간 stale 허용)
└── 사용자별 데이터
    Cache-Control: private, no-cache
    (매번 서버 검증)

8.2 stale-while-revalidate

stale-while-revalidate 동작:
요청 1: [캐시 미스] → 서버 → 응답 + 캐시 저장
요청 2: [캐시 히트, fresh] → 즉시 응답
요청 3: [캐시 히트, stale] → 즉시 응답(stale) + 백그라운드 서버 갱신
요청 4: [캐시 히트, fresh] → 즉시 응답(갱신된 데이터)
// SWR 라이브러리 (React)
import useSWR from 'swr';

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher, {
    revalidateOnFocus: true,
    revalidateOnReconnect: true,
    refreshInterval: 30000, // 30초마다 갱신
  });

  if (isLoading) return <Skeleton />;
  if (error) return <Error />;
  return <UserCard user={data} />;
}

8.3 Service Worker 캐시

// Service Worker 캐싱 전략
// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/styles.css',
  '/app.js',
  '/offline.html'
];

// 설치: 정적 자산 사전 캐싱
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache =>
      cache.addAll(STATIC_ASSETS)
    )
  );
});

// 요청: Cache First + Network Fallback
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      if (cached) return cached;

      return fetch(event.request).then(response => {
        // 성공 응답을 캐시에 저장
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache =>
          cache.put(event.request, clone)
        );
        return response;
      }).catch(() => {
        // 오프라인 폴백
        return caches.match('/offline.html');
      });
    })
  );
});

8.4 CDN 캐시

CDN 캐시 계층:
사용자 → [브라우저 캐시][CDN Edge][CDN Origin Shield][서버]

CDN 헤더 예시:
정적 자산:
  Cache-Control: public, max-age=31536000, immutable
  CDN-Cache-Control: public, max-age=31536000

동적 콘텐츠:
  Cache-Control: public, max-age=0, must-revalidate
  CDN-Cache-Control: public, max-age=60, stale-while-revalidate=300
  Surrogate-Control: max-age=3600

9. 네트워크 최적화

9.1 리소스 힌트

<!-- DNS Prefetch: 외부 도메인 DNS 미리 해석 -->
<link rel="dns-prefetch" href="//api.example.com" />
<link rel="dns-prefetch" href="//cdn.example.com" />

<!-- Preconnect: DNS + TCP + TLS 미리 연결 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!-- Prefetch: 다음 페이지 리소스 미리 로드 -->
<link rel="prefetch" href="/next-page.js" />

<!-- Preload: 현재 페이지 핵심 리소스 우선 로드 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" />

9.2 Priority Hints

<!-- fetchpriority로 리소스 우선순위 지정 -->
<!-- 히어로 이미지: 높은 우선순위 -->
<img src="/hero.webp" fetchpriority="high" />

<!-- 오프스크린 이미지: 낮은 우선순위 -->
<img src="/below-fold.webp" fetchpriority="low" loading="lazy" />

<!-- 핵심 스크립트: 높은 우선순위 -->
<script src="/critical.js" fetchpriority="high"></script>

9.3 HTTP/2와 HTTP/3

HTTP/1.1 vs HTTP/2 vs HTTP/3:
┌─────────────┬──────────────┬──────────────┬──────────────┐
│  기능        │  HTTP/1.1HTTP/2HTTP/3├─────────────┼──────────────┼──────────────┼──────────────┤
│ 멀티플렉싱   │ 불가         │ 지원          │ 지원          │
│ 헤더 압축    │ 없음         │ HPACKQPACK│ 서버 푸시    │ 없음         │ 지원          │ 제거됨        │
HOL 블로킹   │ TCP 레벨     │ TCP 레벨      │ 해결(QUIC)│ 전송 프로토콜│ TCPTCPQUIC(UDP)│ 연결 설정    │ TCP+TLSTCP+TLS0-RTT 가능   │
└─────────────┴──────────────┴──────────────┴──────────────┘

10. 성능 모니터링

10.1 Lighthouse CI

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: './lighthouserc.json'
          uploadArtifacts: true
{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.9 }],
        "categories:best-practices": ["warn", { "minScore": 0.9 }],
        "categories:seo": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "interactive": ["error", { "maxNumericValue": 5000 }]
      }
    }
  }
}

10.2 Real User Monitoring (RUM)

// Web Vitals 측정 및 보고
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric: any) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });

  // Beacon API로 비동기 전송
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

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

10.3 성능 대시보드

성능 모니터링 도구:
├── 합성 모니터링 (Lab Data)
│   ├── Lighthouse CI (자동화)
│   ├── WebPageTest (상세 분석)
│   └── PageSpeed Insights (Google)
├── 실사용자 모니터링 (Field Data)
│   ├── Chrome UX Report (CrUX)
│   ├── web-vitals 라이브러리
│   └── 상용: Datadog RUM, New Relic
└── 번들 분석
    ├── webpack-bundle-analyzer
    ├── source-map-explorer
    └── bundlephobia.com

11. Next.js 특화 최적화

11.1 App Router와 Server Components

// Server Component (기본값 - JS 번들에 포함되지 않음)
// app/products/page.tsx
async function ProductsPage() {
  // 서버에서 직접 데이터 패칭 (API 라우트 불필요)
  const products = await db.product.findMany();

  return (
    <div>
      <h1>Products</h1>
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

// Client Component (인터랙션이 필요한 부분만)
// components/AddToCart.tsx
'use client';

import { useState } from 'react';

export function AddToCart({ productId }: { productId: string }) {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Add to Cart ({count})
    </button>
  );
}

11.2 Next.js Image 최적화

import Image from 'next/image';

// 자동 WebP/AVIF 변환, 반응형, lazy loading
function HeroSection() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      priority  // LCP 이미지는 priority 설정
      sizes="(max-width: 768px) 100vw, 1200px"
      quality={85}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

// 원격 이미지
function Avatar({ user }: { user: User }) {
  return (
    <Image
      src={user.avatarUrl}
      alt={user.name}
      width={48}
      height={48}
      loading="lazy"
    />
  );
}

11.3 Next.js Font 최적화

// next/font로 자동 최적화 (자체 호스팅, CLS 제거)
import { Inter, Noto_Sans_KR } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const notoSansKR = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['400', '500', '700'],
  display: 'swap',
  variable: '--font-noto-sans-kr',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
      <body>{children}</body>
    </html>
  );
}

11.4 Route Segment Config

// app/blog/[slug]/page.tsx

// 정적 생성 + ISR (60초)
export const revalidate = 60;

// 또는 완전 정적
export const dynamic = 'force-static';

// 또는 항상 동적
export const dynamic = 'force-dynamic';

// 런타임 선택
export const runtime = 'edge'; // Edge Runtime (빠른 TTFB)
// export const runtime = 'nodejs'; // Node.js Runtime (기본)

12. 면접 질문 모음

기본 개념

Q1. Core Web Vitals 3가지 지표를 설명하세요.
  1. LCP (Largest Contentful Paint): 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간. 목표: 2.5초 이내. 로딩 성능을 측정.

  2. INP (Interaction to Next Paint): 사용자 상호작용(클릭, 키보드 등)에서 다음 페인트까지의 지연. 목표: 200ms 이내. 응답성을 측정.

  3. CLS (Cumulative Layout Shift): 페이지 로드 중 예기치 않은 레이아웃 이동의 총합. 목표: 0.1 이하. 시각적 안정성을 측정.

Q2. CSR, SSR, SSG, ISR의 차이점을 설명하세요.

CSR (Client-Side Rendering): 브라우저에서 JS로 렌더링. 빈 HTML 전송 후 클라이언트에서 렌더링.

  • 장점: 서버 부하 낮음, SPA 적합
  • 단점: 느린 초기 로딩, SEO 불리

SSR (Server-Side Rendering): 서버에서 HTML을 매 요청마다 생성.

  • 장점: 빠른 FCP, SEO 유리
  • 단점: 서버 부하, 느린 TTFB

SSG (Static Site Generation): 빌드 시 HTML 미리 생성.

  • 장점: 최고 성능, CDN 캐싱
  • 단점: 빌드 시간, 동적 콘텐츠 제한

ISR (Incremental Static Regeneration): SSG + 백그라운드 재생성.

  • 장점: SSG 성능 + 데이터 갱신
  • 단점: 구현 복잡성
Q3. Code Splitting과 Tree Shaking의 차이를 설명하세요.

Code Splitting: JS 번들을 여러 청크로 분할하여 필요한 것만 로드하는 기법. 라우트 기반(dynamic import) 또는 컴포넌트 기반으로 분할. 초기 로딩 시간 단축.

Tree Shaking: 빌드 시 사용되지 않는(dead) 코드를 제거하는 최적화. ES Module의 정적 구조를 분석하여 import되지 않은 export를 제거. 최종 번들 크기 감소.

차이: Code Splitting은 "언제 로드할지" (When), Tree Shaking은 "무엇을 제거할지" (What).

Q4. LCP를 개선하는 방법을 설명하세요.
  1. 서버 응답 최적화: TTFB 200ms 이내, CDN 활용, 캐싱
  2. 리소스 프리로드: LCP 이미지에 preload + fetchpriority="high"
  3. 이미지 최적화: WebP/AVIF, 적절한 크기, 압축
  4. 렌더링 차단 제거: Critical CSS 인라인, JS defer
  5. 폰트 최적화: font-display: swap, preload
  6. SSR/SSG 활용: 서버에서 완성된 HTML 전송
  7. Third-party 최적화: 외부 스크립트 지연 로드
Q5. CLS의 주요 원인과 해결 방법을 설명하세요.

주요 원인:

  1. 크기 미지정 이미지/비디오
  2. 동적 삽입 콘텐츠(광고, 배너)
  3. 웹 폰트 로딩 시 레이아웃 시프트
  4. 늦게 로드되는 CSS
  5. DOM을 조작하는 JavaScript

해결 방법:

  1. img/video에 width, height 속성 또는 aspect-ratio CSS 명시
  2. 동적 콘텐츠 영역에 min-height로 공간 예약
  3. font-display: optional, 폰트 프리로드
  4. Critical CSS 인라인
  5. transform 애니메이션 사용(top/left 대신)

심화 질문

Q6. Service Worker의 캐싱 전략을 설명하세요.
  1. Cache First: 캐시 확인 후 없으면 네트워크. 정적 자산에 적합.
  2. Network First: 네트워크 시도 후 실패 시 캐시. API 응답에 적합.
  3. Stale While Revalidate: 캐시된 응답 즉시 반환 + 백그라운드 갱신. 빈번히 변경되는 자산에 적합.
  4. Cache Only: 캐시만 사용. 오프라인 자산에 적합.
  5. Network Only: 네트워크만 사용. 실시간 데이터에 적합.

선택 기준: 데이터의 신선도 요구사항과 오프라인 지원 필요성에 따라 결정.

Q7. HTTP 캐시 헤더를 설명하세요.
  • Cache-Control: 캐시 정책의 핵심 헤더

    • max-age: 캐시 유효 시간(초)
    • no-cache: 매번 서버 검증 필요
    • no-store: 절대 캐시하지 않음
    • public/private: CDN 캐시 가능 여부
    • immutable: 변하지 않는 리소스
    • stale-while-revalidate: stale 응답 허용 시간
  • ETag: 리소스 버전 식별자. 조건부 요청(If-None-Match)에 사용.

  • Last-Modified: 마지막 수정 시간. If-Modified-Since와 함께 사용.

추천 전략:

  • 정적 자산(해시 포함): max-age=31536000, immutable
  • HTML: no-cache 또는 max-age=0, must-revalidate
Q8. 이미지 최적화 전략을 종합적으로 설명하세요.
  1. 포맷: WebP/AVIF 사용, picture 요소로 폴백
  2. 크기: 반응형 이미지(srcset + sizes), 실제 표시 크기에 맞게
  3. 압축: 품질 75~85, 용도에 따라 조정
  4. 로딩: LCP 이미지는 eager + fetchpriority="high", 나머지는 lazy
  5. 프레임워크: Next.js Image 등 자동 최적화 도구 활용
  6. CDN: 이미지 CDN(Cloudinary, imgix) 활용
  7. 플레이스홀더: 블러 또는 LQIP로 사용자 경험 향상
Q9. INP(Interaction to Next Paint)를 최적화하는 방법을 설명하세요.
  1. 긴 태스크 분할: 50ms 이상의 작업을 scheduler.yield()나 requestIdleCallback으로 분할
  2. 메인 스레드 해방: Web Worker로 무거운 계산 이동
  3. 이벤트 핸들러 최적화: debounce/throttle, passive 이벤트 리스너
  4. JS 번들 최소화: 코드 스플리팅, 트리 셰이킹
  5. 가상화: 긴 리스트는 react-virtuoso 등으로 가상화
  6. startTransition: 긴급하지 않은 업데이트를 전환으로 표시
  7. React 최적화: useMemo, useCallback, React.memo 적절히 사용
Q10. CDN의 동작 원리와 성능 이점을 설명하세요.

동작 원리:

  1. 전 세계에 분산된 Edge 서버(PoP)에 콘텐츠 캐시
  2. 사용자의 DNS 요청을 가장 가까운 Edge로 라우팅
  3. Edge에 캐시가 있으면 즉시 응답, 없으면 Origin에서 가져와 캐시

성능 이점:

  • 물리적 거리 단축: RTT(Round Trip Time) 감소
  • Origin 서버 부하 분산
  • DDoS 방어
  • TLS 최적화 (Edge에서 TLS 종료)
  • 자동 압축 (Brotli, gzip)
  • HTTP/2, HTTP/3 지원
Q11. webpack의 코드 스플리팅 설정을 설명하세요.

webpack의 splitChunks 플러그인으로 코드 분할:

  1. Entry Points: 여러 진입점으로 수동 분할
  2. Dynamic Imports: import()로 동적 분할
  3. splitChunks: 자동 분할 규칙 설정

주요 설정:

  • chunks: 'all'로 동기/비동기 모두 분할
  • cacheGroups: vendor(node_modules)와 common(공유 모듈) 분리
  • minSize: 최소 청크 크기 (기본 20KB)
  • maxSize: 최대 청크 크기 (자동 분할)

효과: 초기 로딩 시간 단축, 캐시 효율 향상, 필요한 코드만 로드

Q12. Streaming SSR의 장점과 구현 방법을 설명하세요.

장점:

  • TTFB 단축: HTML을 점진적으로 전송
  • FCP 향상: 준비된 부분부터 표시
  • 느린 데이터 소스 격리: Suspense로 감싸서 독립적 로딩
  • 사용자 경험 향상: 스켈레톤/로딩 상태 즉시 표시

구현 (Next.js App Router):

  • Server Component를 기본으로 사용
  • 느린 컴포넌트를 Suspense로 감싸기
  • fallback에 스켈레톤 UI 제공
  • 데이터가 준비되면 자동으로 스트리밍

핵심: 전체 페이지가 준비될 때까지 기다리지 않고, 준비된 부분부터 점진적으로 전송.

Q13. Next.js Server Components의 성능 이점을 설명하세요.
  1. 제로 JS 번들: Server Component는 클라이언트 JS 번들에 포함되지 않음
  2. 직접 데이터 접근: 서버에서 직접 DB/API 접근 (API 라우트 불필요)
  3. 자동 코드 분할: Client Component만 번들에 포함
  4. 스트리밍: Suspense와 결합하여 점진적 렌더링
  5. 캐싱: 서버 측 캐싱으로 반복 요청 최적화
  6. 보안: 민감한 로직/키가 클라이언트에 노출되지 않음

사용 원칙: 인터랙션이 없는 컴포넌트는 Server, useState/useEffect가 필요한 부분만 Client로.

Q14. 웹 성능 예산(Performance Budget)의 설정과 적용 방법은?

설정:

  1. 경쟁사 분석: 주요 경쟁사의 성능 지표 측정
  2. 사용자 기기 분석: 대상 사용자의 평균 기기/네트워크 파악
  3. 비즈니스 목표 반영: 전환율, 이탈률 목표에 맞게 설정
  4. 구체적 수치: JS 200KB, CSS 50KB, LCP 2.5s, INP 200ms 등

적용:

  1. Lighthouse CI로 PR마다 자동 검사
  2. 예산 초과 시 빌드 실패 또는 경고
  3. bundlesize 또는 size-limit으로 번들 크기 제한
  4. 팀 대시보드에서 트렌드 모니터링
  5. 정기적으로 예산 검토 및 조정
Q15. 웹 폰트 최적화의 모범 사례를 설명하세요.
  1. Variable Font: 하나의 파일로 모든 굵기/스타일 (파일 수 감소)
  2. 서브셋: 필요한 글자만 포함 (한글: 4MB → 300KB)
  3. WOFF2 포맷: 최고 압축률의 웹 폰트 포맷
  4. font-display: swap(텍스트 즉시 표시) 또는 optional(CLS 최소)
  5. preload: 핵심 폰트를 link preload로 조기 다운로드
  6. 자체 호스팅: Google Fonts 대신 직접 호스팅 (DNS/연결 비용 절감)
  7. size-adjust: 대체 폰트와 웹 폰트의 크기를 맞추어 CLS 방지

13. 퀴즈

Q1. LCP의 "Good" 기준은?

정답: 2.5초 이내

LCP(Largest Contentful Paint)의 기준:

  • Good: 2.5초 이내
  • Needs Improvement: 2.5~4.0초
  • Poor: 4.0초 초과

LCP는 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간을 측정합니다.

Q2. FID를 대체한 Core Web Vitals 지표는?

정답: INP (Interaction to Next Paint)

2024년 3월부터 FID(First Input Delay)가 INP로 대체되었습니다. FID는 첫 번째 상호작용만 측정했지만, INP는 페이지 전체 수명 동안의 모든 상호작용을 측정하여 더 포괄적인 응답성 지표를 제공합니다.

Q3. HTTP 캐시에서 immutable의 의미는?

정답: 리소스가 절대 변경되지 않음을 나타내어 재검증 요청을 방지

Cache-Control: immutable은 리소스가 변경되지 않을 것임을 브라우저에게 알려줍니다. 이로 인해 브라우저가 max-age 내에서 재검증(304) 요청을 보내지 않습니다. 파일명에 해시가 포함된 정적 자산(app.abc123.js)에 적합합니다.

Q4. Tree Shaking이 동작하기 위한 전제 조건은?

정답: ES Module (import/export) 사용

Tree Shaking은 ES Module의 정적 구조를 분석하여 사용되지 않는 export를 제거합니다. CommonJS(require/module.exports)는 동적이므로 Tree Shaking이 불가능합니다. package.json의 "sideEffects": false 설정도 중요합니다.

Q5. Next.js Server Components가 클라이언트 번들에 포함되나요?

정답: 아니요, Server Components는 클라이언트 JS 번들에 포함되지 않습니다

Server Components는 서버에서만 실행되고 결과 HTML만 클라이언트에 전송됩니다. 따라서 JS 번들 크기를 크게 줄일 수 있습니다. 'use client' 디렉티브가 선언된 Client Components만 번들에 포함됩니다.


14. 참고 자료

공식 문서

측정 도구

이미지 최적화

성능 참고

프레임워크