Skip to content

✍️ 필사 모드: 웹 성능 최적화 완전 가이드 2025: Core Web Vitals, LCP/INP/CLS, 로딩 전략

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

목차

1. 웹 성능이 중요한 이유

웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고, 5초로 늘어나면 90% 증가합니다.

1.1 성능이 비즈니스에 미치는 영향

지표개선 효과
로드 시간 0.1초 단축전환율 8% 증가 (Walmart)
로드 시간 50% 단축매출 12% 증가 (AutoAnything)
로드 시간 2.2초 단축다운로드 15.4% 증가 (Mozilla)
성능 점수 10점 향상이탈률 5-10% 감소

1.2 Google의 페이지 경험 시그널

2021년부터 Google은 Core Web Vitals를 검색 순위 요소에 포함했습니다. 이는 모바일과 데스크톱 검색 모두에 적용됩니다.

페이지 경험 시그널 구성:
├── Core Web Vitals (LCP, INP, CLS)
├── HTTPS 보안
├── 모바일 친화성
├── 침입적 광고 없음
└── Safe Browsing

2. Core Web Vitals 완전 정복

Core Web Vitals는 Google이 정의한 세 가지 핵심 사용자 경험 지표입니다. 2024년 3월부터 INP(Interaction to Next Paint)가 FID를 대체했습니다.

2.1 LCP (Largest Contentful Paint)

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

임계값:

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

LCP 대상 요소:

  • img 요소
  • svg 내부의 image 요소
  • video 요소의 poster 이미지
  • CSS background-image가 있는 요소
  • 텍스트 노드를 포함하는 블록 레벨 요소
// LCP 측정 코드
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP:', lastEntry.startTime);
  console.log('LCP Element:', lastEntry.element);
  console.log('LCP URL:', lastEntry.url);
  console.log('LCP Size:', lastEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });

LCP 최적화 전략:

// 1. 히어로 이미지에 fetchpriority="high" 추가
<img
  src="/hero-image.webp"
  alt="Hero"
  fetchpriority="high"
  width={1200}
  height={600}
/>

// 2. Preload로 LCP 리소스 먼저 로드
<link
  rel="preload"
  as="image"
  href="/hero-image.webp"
  fetchpriority="high"
/>

// 3. Next.js Image 컴포넌트 활용
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.webp"
      alt="Hero"
      width={1200}
      height={600}
      priority // LCP 이미지에 priority 추가
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

2.2 INP (Interaction to Next Paint)

INP는 사용자 상호작용(클릭, 탭, 키 입력)부터 다음 프레임이 렌더링될 때까지의 시간을 측정합니다. FID와 달리 전체 세션의 모든 상호작용을 고려합니다.

임계값:

  • Good: 200ms 이하
  • Needs Improvement: 200ms ~ 500ms
  • Poor: 500ms 초과
// INP 측정
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (entry.interactionId) {
      const duration = entry.duration;
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;

      console.log('INP Breakdown:', {
        duration,
        inputDelay,      // 입력 지연
        processingTime,  // 처리 시간
        presentationDelay // 렌더링 지연
      });
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

INP 최적화 전략:

// 1. 긴 작업을 yield로 분리
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // 매 100개마다 메인 스레드에 양보
    if (i % 100 === 0) {
      await scheduler.yield(); // Scheduler API
      // 또는 fallback:
      // await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// 2. React에서 useTransition으로 우선순위 분리
function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // 입력은 즉시 반영 (긴급 업데이트)
    setQuery(e.target.value);

    // 검색 결과는 낮은 우선순위 (전환 업데이트)
    startTransition(() => {
      setSearchResults(filterResults(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList />}
    </div>
  );
}

// 3. requestIdleCallback으로 비필수 작업 지연
function deferAnalytics(data) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      sendAnalytics(data);
    }, { timeout: 2000 });
  } else {
    setTimeout(() => sendAnalytics(data), 100);
  }
}

2.3 CLS (Cumulative Layout Shift)

CLS는 페이지 수명 동안 발생하는 예상치 못한 레이아웃 이동의 누적 점수를 측정합니다.

임계값:

  • Good: 0.1 이하
  • Needs Improvement: 0.1 ~ 0.25
  • Poor: 0.25 초과
// CLS 측정
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // 사용자 입력 후 500ms 이내의 이동은 제외
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      // 세션 윈도우: 1초 이내, 5초 한도
      if (
        sessionValue &&
        entry.startTime - lastSessionEntry.startTime < 1000 &&
        entry.startTime - firstSessionEntry.startTime < 5000
      ) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      if (sessionValue > clsValue) {
        clsValue = sessionValue;
        clsEntries = [...sessionEntries];
      }
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

CLS 최적화 전략:

/* 1. 이미지/비디오에 항상 크기 지정 */
img, video {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* CSS aspect-ratio 활용 */
}

/* 2. 광고/임베드 영역 사전 확보 */
.ad-slot {
  min-height: 250px;
  contain: layout; /* CSS Containment */
}

/* 3. 폰트 로드 시 레이아웃 이동 방지 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: optional; /* CLS를 위해 optional 사용 */
  size-adjust: 100.5%; /* 폴백 폰트와 크기 맞춤 */
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* 4. 동적 콘텐츠에 contain 속성 */
.dynamic-content {
  contain: layout style;
  content-visibility: auto;
  contain-intrinsic-size: 0 500px;
}

3. 이미지 최적화

이미지는 평균적으로 웹 페이지 바이트의 50% 이상을 차지합니다. 이미지 최적화는 성능 향상의 가장 효과적인 방법입니다.

3.1 차세대 이미지 포맷

포맷별 비교 (동일 품질 기준):
┌─────────┬────────┬─────────┬──────────┬────────────┐
│ 포맷    │ 압축률 │ 투명도  │ 애니메이션│ 브라우저   │
├─────────┼────────┼─────────┼──────────┼────────────┤
JPEG    │ 기준   │ XX100%PNG     │ 낮음   │ OX100%WebP25-34%OO97%+AVIF50%+OO92%+JPEG XL35-60%OO        │ 제한적     │
└─────────┴────────┴─────────┴──────────┴────────────┘
<!-- picture 요소로 포맷 폴백 -->
<picture>
  <source srcset="/image.avif" type="image/avif" />
  <source srcset="/image.webp" type="image/webp" />
  <img src="/image.jpg" alt="Description" width="800" height="600" />
</picture>

3.2 반응형 이미지

<!-- srcset과 sizes로 적절한 크기 제공 -->
<img
  srcset="
    /image-400w.webp  400w,
    /image-800w.webp  800w,
    /image-1200w.webp 1200w,
    /image-1600w.webp 1600w
  "
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 50vw,
    33vw
  "
  src="/image-800w.webp"
  alt="Responsive image"
  loading="lazy"
  decoding="async"
  width="800"
  height="600"
/>

3.3 Lazy Loading과 Priority Hints

// Native lazy loading
<img src="/below-fold.webp" loading="lazy" decoding="async" />

// Intersection Observer를 이용한 커스텀 lazy loading
const imageObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.srcset = img.dataset.srcset || '';
        img.classList.add('loaded');
        imageObserver.unobserve(img);
      }
    });
  },
  {
    rootMargin: '200px 0px', // 200px 전에 로드 시작
    threshold: 0.01,
  }
);

document.querySelectorAll('img[data-src]').forEach((img) => {
  imageObserver.observe(img);
});

3.4 Blur Placeholder 구현

// Next.js에서 blur placeholder
import Image from 'next/image';
import { getPlaiceholder } from 'plaiceholder';

export async function getStaticProps() {
  const { base64 } = await getPlaiceholder('/public/hero.jpg');

  return {
    props: { blurDataURL: base64 },
  };
}

export default function Page({ blurDataURL }) {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      placeholder="blur"
      blurDataURL={blurDataURL}
    />
  );
}

// CSS로 blur 효과 직접 구현
const BlurImage = ({ src, alt }) => {
  const [loaded, setLoaded] = useState(false);

  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {!loaded && (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundImage: `url(${src}?w=20&q=10)`,
            backgroundSize: 'cover',
            filter: 'blur(20px)',
            transform: 'scale(1.1)',
          }}
        />
      )}
      <img
        src={src}
        alt={alt}
        onLoad={() => setLoaded(true)}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
      />
    </div>
  );
};

4. JavaScript 최적화

4.1 Tree Shaking

Tree Shaking은 사용하지 않는 코드를 번들에서 제거하는 최적화 기법입니다.

// package.json - sideEffects 설정
{
  "name": "my-library",
  "sideEffects": false,
  // 또는 사이드 이펙트가 있는 파일만 지정
  "sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]
}
// Bad: 전체 라이브러리 임포트 (tree shaking 불가)
import _ from 'lodash';
const result = _.map(data, fn);

// Good: 개별 함수만 임포트
import map from 'lodash/map';
const result = map(data, fn);

// Best: lodash-es 사용 (ES Module)
import { map } from 'lodash-es';
const result = map(data, fn);
// webpack.config.js - Tree Shaking 최적화
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,     // 사용된 export만 마킹
    minimize: true,         // 미사용 코드 제거
    sideEffects: true,      // sideEffects 플래그 활용
    concatenateModules: true, // 모듈 연결 (Scope Hoisting)
  },
};

4.2 Code Splitting

// 1. 동적 import로 코드 분할
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyComponent />
    </Suspense>
  );
}

// 2. 라우트 기반 코드 분할 (Next.js)
import dynamic from 'next/dynamic';

const DashboardChart = dynamic(
  () => import('@/components/DashboardChart'),
  {
    loading: () => <ChartSkeleton />,
    ssr: false, // 클라이언트에서만 로드
  }
);

// 3. webpack magic comments로 청크 제어
const AdminPanel = React.lazy(
  () => import(
    /* webpackChunkName: "admin" */
    /* webpackPrefetch: true */
    './AdminPanel'
  )
);

// 4. Named Export 코드 분할
const MyComponent = React.lazy(() =>
  import('./MyModule').then((module) => ({
    default: module.MyComponent,
  }))
);

4.3 번들 분석

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

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

// 실행: ANALYZE=true next build
# 번들 크기 모니터링 도구들
# 1. bundlephobia - 패키지 크기 확인
npx bundlephobia lodash

# 2. source-map-explorer
npx source-map-explorer dist/main.*.js

# 3. webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/stats.json

# 4. size-limit - CI에서 크기 제한
npx size-limit
// .size-limit.json - 번들 크기 제한 설정
[
  {
    "path": "dist/index.js",
    "limit": "50 KB",
    "import": "{ Button }",
    "ignore": ["react", "react-dom"]
  },
  {
    "path": "dist/index.js",
    "limit": "100 KB"
  }
]

5. CSS 최적화

5.1 Critical CSS 추출

// critters 플러그인으로 Critical CSS 인라인
// next.config.js
module.exports = {
  experimental: {
    optimizeCss: true, // Next.js 내장 CSS 최적화
  },
};

// 수동 Critical CSS 추출
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  target: 'index-critical.html',
  width: 1300,
  height: 900,
  penthouse: {
    blockJSRequests: false,
  },
});

5.2 사용하지 않는 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: {
        standard: [/^modal-/, /^tooltip-/],
        deep: [/^data-theme/],
        greedy: [/animate/],
      },
    }),
  ],
};

5.3 CSS Containment

/* contain 속성으로 렌더링 범위 제한 */
.card {
  contain: layout style paint;
  /* layout: 레이아웃 격리 */
  /* style: 카운터/quotes 격리 */
  /* paint: 페인트 격리 (overflow: hidden 효과) */
}

/* content-visibility로 화면 밖 렌더링 건너뛰기 */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* 예상 크기 힌트 */
}

/* 긴 목록의 각 아이템에 적용 */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px;
}

6. 폰트 최적화

6.1 font-display 전략

/* font-display 옵션 비교 */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');

  /* swap: FOUT 허용, CLS 발생 가능 */
  font-display: swap;

  /* optional: FOIT 3초 후 시스템 폰트 유지 (CLS 없음) */
  font-display: optional;

  /* fallback: 100ms FOIT 후 swap, 3초 후 유지 */
  font-display: fallback;
}

6.2 폰트 프리로드와 Variable Fonts

<!-- 폰트 프리로드 -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin="anonymous"
/>
/* Variable Font으로 파일 수 줄이기 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;    /* 가변 weight */
  font-style: normal;
  font-display: optional;
}

/* subset으로 필요한 문자만 포함 */
@font-face {
  font-family: 'NotoSansKR';
  src: url('/fonts/noto-sans-kr-subset.woff2') format('woff2');
  unicode-range: U+AC00-D7A3; /* 한글 완성형만 */
  font-display: swap;
}
// Next.js - next/font 최적화
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'optional',
  preload: true,
  variable: '--font-inter',
  adjustFontFallback: true, // CLS 방지를 위한 폴백 조정
});

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

7. 캐싱 전략

7.1 HTTP Cache Headers

캐시 전략 흐름도:
┌─────────────────────────────────────────┐
│ 리소스가 재사용 가능한가?├── NoCache-Control: no-store         │
├── Yes → 매번 서버 확인이 필요한가?│   ├── YesCache-Control: no-cache     │
│   └── No  → 중간 캐시 허용?│       ├── YesCache-Control: public│       └── NoCache-Control: private│            └── max-age 설정             │
│               ├── 해시된 파일 → 31536000│               └── HTML0 + ETag└─────────────────────────────────────────┘
# Nginx 캐시 설정 예시
server {
    # HTML - 항상 서버 확인
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
        add_header ETag $upstream_http_etag;
    }

    # 해시된 정적 에셋 - 1년 캐시
    location ~* \.(js|css|webp|avif|woff2)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # API 응답 - stale-while-revalidate
    location /api/ {
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
    }
}

7.2 Service Worker 캐싱

// service-worker.js - Workbox 기반 캐싱 전략
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 빌드 시 생성된 에셋 프리캐시
precacheAndRoute(self.__WB_MANIFEST);

// 이미지: Cache First
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
      }),
    ],
  })
);

// API: Stale While Revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5분
      }),
    ],
  })
);

// HTML 페이지: Network First
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
    networkTimeoutSeconds: 3,
  })
);

7.3 CDN Edge Caching

// Vercel Edge Config 예시
// next.config.js
module.exports = {
  headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=60, stale-while-revalidate=3600',
          },
          {
            key: 'Vercel-CDN-Cache-Control',
            value: 'public, max-age=3600, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },
};

// CloudFront Cache Policy (AWS CDK)
const cachePolicy = new cloudfront.CachePolicy(this, 'CachePolicy', {
  defaultTtl: Duration.hours(1),
  maxTtl: Duration.days(365),
  minTtl: Duration.seconds(0),
  enableAcceptEncodingGzip: true,
  enableAcceptEncodingBrotli: true,
  headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
    'Accept',
    'Accept-Encoding'
  ),
  queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
});

8. 렌더링 패턴 비교

8.1 CSR vs SSR vs SSG vs ISR vs Streaming

렌더링 패턴 비교:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│          │ TTFBFCPTTISEO├──────────┼──────────┼──────────┼──────────┼──────────┤
CSR      │ 빠름    │ 느림    │ 느림    │ 나쁨    │
SSR      │ 느림    │ 빠름    │ 보통    │ 좋음    │
SSG      │ 매우빠름│ 매우빠름│ 빠름    │ 좋음    │
ISR      │ 매우빠름│ 매우빠름│ 빠름    │ 좋음    │
Streaming│ 빠름    │ 매우빠름│ 빠름    │ 좋음    │
└──────────┴──────────┴──────────┴──────────┴──────────┘

8.2 Streaming SSR (React 18 + Next.js App Router)

// app/dashboard/page.tsx - Streaming SSR
import { Suspense } from 'react';

async function SlowDataComponent() {
  const data = await fetchSlowData(); // 3초 소요
  return <div>{/* data rendering */}</div>;
}

async function FastDataComponent() {
  const data = await fetchFastData(); // 100ms 소요
  return <div>{/* data rendering */}</div>;
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* 빠른 컴포넌트는 즉시 렌더링 */}
      <FastDataComponent />

      {/* 느린 컴포넌트는 스트리밍 */}
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowDataComponent />
      </Suspense>
    </div>
  );
}
// app/layout.tsx - loading.tsx 활용
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
      <div className="h-4 bg-gray-200 rounded w-1/2" />
    </div>
  );
}

8.3 ISR (Incremental Static Regeneration)

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

export async function generateStaticParams() {
  const products = await getTopProducts();
  return products.map((product) => ({
    id: product.id.toString(),
  }));
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <ProductDetail product={product} />;
}

// On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request) {
  const { path, tag, secret } = await request.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 });
  }

  if (tag) {
    revalidateTag(tag);
  } else if (path) {
    revalidatePath(path);
  }

  return Response.json({ revalidated: true, now: Date.now() });
}

9. Prefetching 전략

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

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

<!-- Prefetch: 다음 네비게이션에 필요한 리소스 미리 가져오기 -->
<link rel="prefetch" href="/next-page.html" />
<link rel="prefetch" href="/api/data.json" as="fetch" />

<!-- Prerender: 전체 페이지 미리 렌더링 -->
<link rel="prerender" href="/likely-next-page" />

9.2 Speculation Rules API

<!-- Speculation Rules API (Chrome 121+) -->
<script type="speculationrules">
{
  "prerender": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout" } },
          { "not": { "href_matches": "/api/*" } }
        ]
      },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "urls": ["/products", "/about"],
      "eagerness": "eager"
    }
  ]
}
</script>
// Next.js에서의 Prefetch 전략
import Link from 'next/link';

// Link 컴포넌트는 자동으로 viewport에 들어오면 prefetch
<Link href="/dashboard" prefetch={true}>
  Dashboard
</Link>

// router.prefetch로 프로그래매틱 prefetch
import { useRouter } from 'next/navigation';

function Navigation() {
  const router = useRouter();

  const handleMouseEnter = () => {
    router.prefetch('/settings');
  };

  return (
    <button onMouseEnter={handleMouseEnter} onClick={() => router.push('/settings')}>
      Settings
    </button>
  );
}

10. 서드파티 스크립트 최적화

10.1 defer/async와 로딩 전략

<!-- 스크립트 로딩 패턴 비교 -->
<!-- 1. 기본: HTML 파싱 차단 -->
<script src="script.js"></script>

<!-- 2. async: 다운로드 병렬, 실행 시 차단 (순서 보장 X) -->
<script async src="analytics.js"></script>

<!-- 3. defer: 다운로드 병렬, DOMContentLoaded 전 순서대로 실행 -->
<script defer src="app.js"></script>

<!-- 4. type=module: defer처럼 동작 + ES Module -->
<script type="module" src="app.mjs"></script>

10.2 Partytown으로 서드파티 격리

// Partytown: 서드파티 스크립트를 Web Worker로 이동
// next.config.js
const { withPartytown } = require('@builder.io/partytown/next');

module.exports = withPartytown({
  partytown: {
    forward: ['dataLayer.push', 'gtag'],
  },
});
<!-- Partytown 적용 -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
// Next.js Script 컴포넌트 활용
import Script from 'next/script';

export default function MyApp({ Component, pageProps }) {
  return (
    <>
      {/* beforeInteractive: _document에서 로드 */}
      <Script
        src="https://polyfill.io/v3/polyfill.min.js"
        strategy="beforeInteractive"
      />

      {/* afterInteractive: 페이지 하이드레이션 후 (기본값) */}
      <Script
        src="https://www.googletagmanager.com/gtag/js"
        strategy="afterInteractive"
      />

      {/* lazyOnload: 브라우저 idle 시 로드 */}
      <Script
        src="https://connect.facebook.net/en_US/fbevents.js"
        strategy="lazyOnload"
      />

      {/* worker: Partytown으로 Web Worker 실행 */}
      <Script
        src="https://example.com/tracking.js"
        strategy="worker"
      />

      <Component {...pageProps} />
    </>
  );
}

11. Lighthouse 심층 분석

11.1 Lighthouse 점수 체계

Lighthouse Performance 점수 가중치 (v12):
┌──────────────────────┬────────┐
│ 지표                 │ 가중치 │
├──────────────────────┼────────┤
FCP (First Content.)10%SI (Speed Index)10%LCP (Largest Cont.)25%TBT (Total Blocking)30%CLS (Cumulative L.S.)25%└──────────────────────┴────────┘
INP는 필드 데이터(CrUX)에서만 측정됨

11.2 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
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: ./lighthouserc.json
          uploadArtifacts: true
          temporaryPublicStorage: true
// lighthouserc.json
{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/blog",
        "http://localhost:3000/products"
      ],
      "startServerCommand": "npm start"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["warn", { "minScore": 0.95 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

12. 성능 모니터링

12.1 Web Vitals 라이브러리

// web-vitals 라이브러리로 실제 사용자 지표 수집
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  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,
    entries: metric.entries,
  });

  // Beacon API로 페이지 이탈 시에도 안정적 전송
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

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

12.2 RUM (Real User Monitoring) 대시보드

// 커스텀 RUM 수집기
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // Navigation Timing
    window.addEventListener('load', () => {
      const nav = performance.getEntriesByType('navigation')[0];
      this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;
      this.metrics.tcp = nav.connectEnd - nav.connectStart;
      this.metrics.ttfb = nav.responseStart - nav.requestStart;
      this.metrics.domLoad = nav.domContentLoadedEventEnd - nav.fetchStart;
      this.metrics.fullLoad = nav.loadEventEnd - nav.fetchStart;

      this.report();
    });

    // Resource Timing
    const resourceObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.transferSize === 0) continue; // 캐시에서 로드된 리소스 제외

        this.trackResource({
          name: entry.name,
          type: entry.initiatorType,
          duration: entry.duration,
          size: entry.transferSize,
        });
      }
    });
    resourceObserver.observe({ type: 'resource', buffered: true });

    // Long Tasks
    const longTaskObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.trackLongTask({
          duration: entry.duration,
          startTime: entry.startTime,
          attribution: entry.attribution,
        });
      }
    });

    try {
      longTaskObserver.observe({ type: 'longtask', buffered: true });
    } catch (e) {
      // longtask observer not supported
    }
  }

  trackResource(data) {
    // 느린 리소스 경고
    if (data.duration > 1000) {
      console.warn('Slow resource:', data.name, data.duration + 'ms');
    }
  }

  trackLongTask(data) {
    // 50ms 이상의 Long Task 기록
    console.warn('Long Task:', data.duration + 'ms');
  }

  report() {
    console.table(this.metrics);
  }
}

new PerformanceMonitor();

12.3 CrUX (Chrome User Experience Report)

// CrUX API로 필드 데이터 조회
async function getCruxData(url) {
  const apiKey = process.env.CRUX_API_KEY;
  const response = await fetch(
    'https://chromeuxreport.googleapis.com/v1/records:queryRecord' +
    '?key=' + apiKey,
    {
      method: 'POST',
      body: JSON.stringify({
        url: url,
        formFactor: 'PHONE',
        metrics: [
          'largest_contentful_paint',
          'interaction_to_next_paint',
          'cumulative_layout_shift',
          'experimental_time_to_first_byte',
        ],
      }),
    }
  );

  const data = await response.json();

  // p75 값 추출
  const lcp = data.record.metrics.largest_contentful_paint;
  console.log('LCP p75:', lcp.percentiles.p75 + 'ms');
  console.log('LCP distribution:', lcp.histogram);

  return data;
}

13. 실전 최적화 체크리스트

성능 최적화 체크리스트:
[ ] 이미지
    [ ] WebP/AVIF 포맷 사용
    [ ] 적절한 크기의 반응형 이미지 제공
    [ ] LCP 이미지에 priority/fetchpriority 설정
    [ ] 스크롤 아래 이미지에 loading="lazy"
    [ ] aspect-ratio 또는 width/height 명시

[ ] JavaScript
    [ ] 코드 분할 (route-based + component-based)
    [ ] Tree Shaking 확인 (sideEffects: false)
    [ ] 번들 크기 모니터링
    [ ] 불필요한 polyfill 제거
    [ ] 서드파티 스크립트 defer/async/worker

[ ] CSS
    [ ] Critical CSS 인라인
    [ ] 미사용 CSS 제거
    [ ] content-visibility 활용
    [ ] CSS Containment 적용

[ ] 폰트
    [ ] WOFF2 포맷 사용
    [ ] font-display: optional 또는 swap
    [ ] 서브셋 적용 (한글/라틴 분리)
    [ ] Variable Font 활용
    [ ] preload 설정

[ ] 캐싱
    [ ] 정적 에셋: immutable + 1    [ ] HTML: no-cache + ETag
    [ ] API: stale-while-revalidate
    [ ] Service Worker 캐싱 전략
    [ ] CDN Edge 캐싱 설정

[ ] 렌더링
    [ ] 적절한 렌더링 패턴 선택
    [ ] Suspense로 스트리밍
    [ ] ISR revalidate 설정
    [ ] loading.tsx 스켈레톤

[ ] 모니터링
    [ ] Lighthouse CI 자동화
    [ ] Web Vitals RUM 수집
    [ ] CrUX 데이터 모니터링
    [ ] 성능 회귀 알림 설정

14. 퀴즈

아래 퀴즈로 학습 내용을 점검해 보세요.

Q1. LCP(Largest Contentful Paint)의 Good 기준값과 이를 개선하기 위한 가장 효과적인 방법 두 가지는?

A1. LCP의 Good 기준값은 2.5초 이하입니다.

가장 효과적인 개선 방법:

  1. LCP 이미지에 fetchpriority="high" 설정 - 브라우저가 LCP 리소스를 최우선으로 다운로드하게 합니다.
  2. preload 링크 태그 사용 - 파서가 발견하기 전에 LCP 리소스 다운로드를 시작합니다.

추가로 서버 응답 시간 단축(CDN, 캐싱), 렌더링 차단 리소스 제거, 이미지 최적화(WebP/AVIF) 등이 효과적입니다.

Q2. INP와 이전 지표인 FID의 차이점은 무엇이며, INP를 개선하기 위한 React 패턴은?

A2. FID는 첫 번째 상호작용의 입력 지연만 측정했지만, INP는 전체 세션의 모든 상호작용을 고려하여 가장 느린 상호작용(또는 p98)을 보고합니다. 또한 INP는 입력 지연뿐 아니라 처리 시간과 렌더링 지연까지 포함합니다.

React에서의 개선 패턴:

  • useTransition: 긴급하지 않은 상태 업데이트를 낮은 우선순위로 처리
  • useDeferredValue: 값의 업데이트를 지연시켜 UI 응답성 유지
  • scheduler.yield(): 긴 작업을 분할하여 메인 스레드에 양보
Q3. Service Worker에서 CacheFirst와 StaleWhileRevalidate 전략의 차이점과 적합한 사용 사례는?

A3.

CacheFirst: 캐시에 있으면 캐시에서 즉시 반환, 없으면 네트워크 요청. 캐시 히트 시 네트워크 요청을 하지 않습니다.

  • 적합한 사례: 이미지, 폰트, 정적 에셋 등 자주 변경되지 않는 리소스

StaleWhileRevalidate: 캐시에서 즉시 반환하면서 동시에 백그라운드에서 네트워크 요청으로 캐시를 갱신합니다.

  • 적합한 사례: API 응답, 뉴스 피드 등 최신성은 필요하지만 즉각적인 응답도 중요한 리소스

NetworkFirst는 HTML 페이지처럼 항상 최신 콘텐츠가 필요한 경우에 적합합니다.

Q4. Streaming SSR이 기존 SSR보다 나은 점과 Next.js App Router에서의 구현 방법은?

A4. 기존 SSR은 모든 데이터 fetching이 완료될 때까지 HTML 전송을 시작하지 못했습니다. Streaming SSR은 준비된 부분부터 HTML 청크를 즉시 전송하여:

  • TTFB를 단축합니다 (느린 데이터에 의존하지 않음)
  • FCP를 개선합니다 (빠른 컴포넌트 먼저 표시)
  • 사용자 체감 성능이 향상됩니다

Next.js App Router에서의 구현:

  1. Suspense 컴포넌트로 느린 데이터 컴포넌트를 감쌈
  2. fallback prop에 스켈레톤/로딩 UI 제공
  3. loading.tsx 파일로 라우트 레벨 로딩 상태 정의
  4. 서버 컴포넌트에서 async/await으로 데이터 페칭
Q5. Speculation Rules API와 기존 link rel="prefetch"의 차이점은 무엇이며, 어떤 장점이 있나?

A5. 기존 link rel="prefetch"는 리소스를 미리 다운로드만 하지만, Speculation Rules API는 전체 페이지를 미리 렌더링(prerender)할 수 있습니다.

주요 차이점:

  • Prefetch: 리소스 다운로드만 (HTML, JS 등)
  • Prerender (Speculation Rules): 전체 페이지를 숨겨진 탭에서 렌더링까지 완료

장점:

  1. 즉각적인 페이지 전환 (이미 렌더링 완료)
  2. 조건부 규칙: URL 패턴, eagerness 레벨 설정 가능
  3. 브라우저 최적화: 메모리/네트워크 상황에 따라 자동 조절
  4. JSON 기반 선언적 문법으로 유지보수 용이

단, Chrome 121 이상에서만 지원되며 로그아웃이나 API 엔드포인트는 제외해야 합니다.

15. 참고 자료

  1. web.dev - Core Web Vitals - Google의 Core Web Vitals 공식 가이드
  2. web.dev - Optimize LCP - LCP 최적화 가이드
  3. web.dev - Optimize INP - INP 최적화 가이드
  4. web.dev - Optimize CLS - CLS 최적화 가이드
  5. Chrome Developers - Speculation Rules API - Speculation Rules 가이드
  6. Next.js Documentation - Optimizing - Next.js 최적화 문서
  7. Workbox - Service Worker Libraries - Google Workbox 공식 문서
  8. web.dev - Optimize Images - 이미지 최적화 가이드
  9. Partytown - Web Worker for Third-party Scripts - Partytown 공식 사이트
  10. web-vitals - JavaScript Library - web-vitals 라이브러리
  11. CrUX Dashboard - Chrome UX Report 문서
  12. Lighthouse CI - Lighthouse CI 자동화
  13. HTTP Caching - MDN - HTTP 캐싱 완전 가이드

현재 단락 (1/1029)

웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고...

작성 글자: 0원문 글자: 25,147작성 단락: 0/1029