Next.js 애플리케이션의 성능을 최적화하고 테스트하는 방법을 학습합니다.
Core Web Vitals는 Google이 정의한 웹 성능 지표입니다. SEO 순위와 사용자 경험에 직접적인 영향을 미치며, 2021년부터 Google 검색 순위 요소로 반영되고 있습니다.
뷰포트 내 가장 큰 콘텐츠가 표시되는 시간
목표: 2.5초 이내 (Good), 4초 이상 (Poor)
측정 대상: 이미지, 비디오 포스터, 배경 이미지, 텍스트 블록
사용자 상호작용부터 다음 화면 업데이트까지 시간
목표: 200ms 이내 (Good), 500ms 이상 (Poor)
측정 대상: 클릭, 탭, 키보드 입력에 대한 응답
페이지 로드 중 예상치 못한 레이아웃 이동 정도
목표: 0.1 이하 (Good), 0.25 이상 (Poor)
원인: 크기 미지정 이미지, 동적 콘텐츠, 웹폰트 로딩
| 도구 | 유형 | 특징 |
|---|---|---|
| Lighthouse | Lab 데이터 | Chrome DevTools 내장, CI/CD 통합 가능 |
| PageSpeed Insights | Lab + Field | 실제 사용자 데이터(CrUX) 포함 |
| Chrome UX Report | Field 데이터 | 실제 Chrome 사용자 데이터 |
| web-vitals 라이브러리 | 실시간 | 프로덕션 모니터링 |
// app/layout.tsx - web-vitals 설정
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
// 또는 커스텀 측정
// lib/web-vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
export function reportWebVitals() {
onCLS((metric) => {
console.log('CLS:', metric.value);
// 분석 서비스로 전송
sendToAnalytics({ name: 'CLS', value: metric.value });
});
onINP((metric) => {
console.log('INP:', metric.value);
sendToAnalytics({ name: 'INP', value: metric.value });
});
onLCP((metric) => {
console.log('LCP:', metric.value);
sendToAnalytics({ name: 'LCP', value: metric.value });
});
}
function sendToAnalytics(metric: { name: string; value: number }) {
// Google Analytics, DataDog 등으로 전송
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}Core Web Vitals 개선 우선순위
이미지는 웹 페이지에서 가장 큰 용량을 차지합니다. Next.js의 Image 컴포넌트를 사용하면 자동으로 최적화됩니다.
// ❌ 일반 img 태그 - 최적화 없음
<img src="/product.jpg" alt="상품" />
// ✅ next/image - 자동 최적화
import Image from 'next/image';
// 로컬 이미지
import productImage from '@/public/product.jpg';
export function ProductCard() {
return (
<Image
src={productImage}
alt="상품 이미지"
width={400}
height={300}
placeholder="blur" // 블러 플레이스홀더
/>
);
}
// 외부 이미지
export function ExternalImage() {
return (
<Image
src="https://cdn.myshop.com/products/123.jpg"
alt="상품"
width={400}
height={300}
priority // LCP 이미지에 사용
/>
);
}포맷 변환
WebP, AVIF 등 최신 포맷으로 자동 변환 (브라우저 지원 시)
리사이징
디바이스 크기에 맞게 자동 리사이징
Lazy Loading
뷰포트에 들어올 때 로딩 (기본값)
CLS 방지
width/height로 레이아웃 공간 확보
// fill 속성으로 부모 크기에 맞춤
export function ResponsiveImage() {
return (
<div className="relative w-full h-64">
<Image
src="/hero.jpg"
alt="히어로 이미지"
fill
style={{ objectFit: 'cover' }}
sizes="100vw"
/>
</div>
);
}
// sizes 속성으로 반응형 최적화
export function ProductImage() {
return (
<Image
src="/product.jpg"
alt="상품"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
// 모바일: 100vw, 태블릿: 50vw, 데스크톱: 33vw
/>
);
}
// srcSet 자동 생성
// Next.js가 다양한 크기의 이미지를 자동 생성
// 640w, 750w, 828w, 1080w, 1200w, 1920w, 2048w, 3840w// next.config.js
module.exports = {
images: {
// 외부 이미지 도메인 허용
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.myshop.com',
pathname: '/products/**',
},
{
protocol: 'https',
hostname: '*.amazonaws.com',
},
],
// 이미지 포맷 설정
formats: ['image/avif', 'image/webp'],
// 디바이스 크기
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// 이미지 크기
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// 캐시 TTL (초)
minimumCacheTTL: 60 * 60 * 24 * 30, // 30일
},
};// components/ProductCard.tsx
import Image from 'next/image';
interface Product {
id: string;
name: string;
image: string;
price: number;
}
export function ProductCard({ product }: { product: Product }) {
return (
<div className="group relative">
{/* 상품 이미지 */}
<div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
{/* 상품 정보 */}
<div className="mt-4">
<h3 className="text-sm font-medium">{product.name}</h3>
<p className="text-lg font-bold">{product.price.toLocaleString()}원</p>
</div>
</div>
);
}
// 상품 상세 페이지 - LCP 이미지
export function ProductDetail({ product }: { product: Product }) {
return (
<div className="relative aspect-square">
<Image
src={product.image}
alt={product.name}
fill
priority // LCP 이미지는 priority 사용
sizes="(max-width: 768px) 100vw, 50vw"
className="object-contain"
/>
</div>
);
}이미지 최적화 체크리스트
코드 스플리팅은 JavaScript 번들을 작은 청크로 나누어 필요한 코드만 로드하는 기법입니다. Next.js는 자동으로 코드 스플리팅을 수행합니다.
페이지별 스플리팅
각 페이지는 별도 청크로 분리, 해당 페이지 방문 시에만 로드
공통 모듈 추출
여러 페이지에서 사용하는 코드는 공통 청크로 분리
서드파티 라이브러리 분리
node_modules는 별도 청크로 분리되어 캐싱 효율 향상
// next/dynamic으로 컴포넌트 지연 로딩
import dynamic from 'next/dynamic';
// 기본 사용
const HeavyChart = dynamic(() => import('@/components/HeavyChart'));
// 로딩 상태 표시
const ProductReviews = dynamic(
() => import('@/components/ProductReviews'),
{
loading: () => <div className="animate-pulse h-40 bg-gray-200 rounded" />,
}
);
// SSR 비활성화 (클라이언트 전용 컴포넌트)
const MapComponent = dynamic(
() => import('@/components/Map'),
{ ssr: false }
);
// 사용 예시
export function ProductPage() {
return (
<div>
<ProductInfo />
{/* 스크롤 시 로드 */}
<ProductReviews />
{/* 클라이언트에서만 렌더링 */}
<MapComponent />
</div>
);
}'use client';
import { lazy, Suspense } from 'react';
// React.lazy로 지연 로딩
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const Chart = lazy(() => import('./Chart'));
export function Dashboard() {
return (
<div>
<h1>대시보드</h1>
{/* Suspense로 로딩 상태 처리 */}
<Suspense fallback={<div>차트 로딩 중...</div>}>
<Chart />
</Suspense>
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
</div>
);
}
// 여러 컴포넌트를 하나의 Suspense로 감싸기
export function ProductPage() {
return (
<Suspense fallback={<PageSkeleton />}>
<ProductInfo />
<ProductReviews />
<RelatedProducts />
</Suspense>
);
}// @next/bundle-analyzer 설치
// npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// 기존 설정
});
// 분석 실행
// ANALYZE=true npm run build
// 결과: .next/analyze/client.html, server.html 생성// ❌ 전체 라이브러리 import (Tree Shaking 불가)
import _ from 'lodash';
const result = _.debounce(fn, 300);
// ✅ 필요한 함수만 import
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// ❌ 전체 아이콘 import
import * as Icons from 'lucide-react';
// ✅ 필요한 아이콘만 import
import { ShoppingCart, Heart, Search } from 'lucide-react';
// barrel export 주의
// ❌ index.ts에서 모든 것을 re-export하면 Tree Shaking 어려움
// shared/ui/index.ts
export * from './Button';
export * from './Input';
export * from './Modal';
// ... 100개 컴포넌트
// ✅ 직접 import 권장
import { Button } from '@/shared/ui/button';// 상품 상세 페이지 최적화
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// 무거운 컴포넌트 지연 로딩
const ProductReviews = dynamic(() => import('./ProductReviews'));
const RelatedProducts = dynamic(() => import('./RelatedProducts'));
const SizeChart = dynamic(() => import('./SizeChart'), { ssr: false });
export default function ProductPage({ product }) {
return (
<div>
{/* 즉시 로드: 핵심 정보 */}
<ProductInfo product={product} />
<AddToCartButton product={product} />
{/* 지연 로드: 스크롤 시 필요 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={product.id} />
</Suspense>
<Suspense fallback={<ProductGridSkeleton />}>
<RelatedProducts categoryId={product.categoryId} />
</Suspense>
</div>
);
}번들 최적화 체크리스트
웹 폰트는 CLS(레이아웃 이동)의 주요 원인입니다. Next.js의 next/font를 사용하면 폰트 로딩을 최적화하고 CLS를 방지할 수 있습니다.
// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google';
// Google Fonts 사용
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 }) {
return (
<html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
<body className="font-sans">
{children}
</body>
</html>
);
}
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-noto-sans-kr)', 'var(--font-inter)', 'sans-serif'],
},
},
},
};자동 셀프 호스팅
Google Fonts를 빌드 시 다운로드하여 자체 서버에서 제공
CLS 제로
CSS size-adjust로 폰트 로딩 중 레이아웃 이동 방지
프라이버시 보호
Google 서버로 요청하지 않아 사용자 추적 방지
자동 서브셋
필요한 문자만 포함하여 파일 크기 최소화
// 로컬 폰트 파일 사용
import localFont from 'next/font/local';
const pretendard = localFont({
src: [
{
path: '../public/fonts/Pretendard-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/Pretendard-Medium.woff2',
weight: '500',
style: 'normal',
},
{
path: '../public/fonts/Pretendard-Bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-pretendard',
});
// Variable Font 사용 (권장)
const pretendardVariable = localFont({
src: '../public/fonts/PretendardVariable.woff2',
display: 'swap',
variable: '--font-pretendard',
weight: '100 900', // Variable font 범위
});
export default function RootLayout({ children }) {
return (
<html className={pretendard.variable}>
<body>{children}</body>
</html>
);
}| display 값 | 동작 | 사용 시점 |
|---|---|---|
| swap | 시스템 폰트 → 웹 폰트 | 대부분의 경우 (권장) |
| block | 폰트 로드까지 텍스트 숨김 | 아이콘 폰트 |
| fallback | 100ms 대기 후 시스템 폰트 | 빠른 네트워크 환경 |
| optional | 캐시된 경우만 웹 폰트 | 성능 최우선 |
// app/layout.tsx - 이커머스 최적화 폰트 설정
import { Noto_Sans_KR } from 'next/font/google';
import localFont from 'next/font/local';
// 본문용 폰트
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700'],
display: 'swap',
variable: '--font-body',
preload: true,
});
// 가격/숫자용 폰트 (선택)
const roboto = localFont({
src: '../public/fonts/Roboto-Bold.woff2',
display: 'swap',
variable: '--font-price',
weight: '700',
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={`${notoSansKR.variable} ${roboto.variable}`}>
<body className="font-body">
{children}
</body>
</html>
);
}
// 가격 표시 컴포넌트
export function Price({ value }: { value: number }) {
return (
<span className="font-price text-2xl">
{value.toLocaleString()}원
</span>
);
}폰트 최적화 체크리스트
서드파티 스크립트(분석, 광고, 채팅 등)는 성능에 큰 영향을 미칩니다. Next.js의 Script 컴포넌트를 사용하면 로딩 전략을 세밀하게 제어할 수 있습니다.
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Google Analytics */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_ID');
`}
</Script>
</body>
</html>
);
}| 전략 | 로딩 시점 | 사용 예시 |
|---|---|---|
| beforeInteractive | 페이지 하이드레이션 전 | 봇 감지, 동의 관리 |
| afterInteractive | 하이드레이션 직후 (기본값) | 분석, 태그 매니저 |
| lazyOnload | 브라우저 idle 시 | 채팅, 소셜 위젯 |
| worker | Web Worker에서 실행 | 무거운 분석 (실험적) |
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Google Tag Manager - 분석 필수 */}
<Script
id="gtm"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXX');
`,
}}
/>
{/* 채팅 위젯 - 나중에 로드 */}
<Script
src="https://chat-widget.example.com/widget.js"
strategy="lazyOnload"
onLoad={() => {
console.log('Chat widget loaded');
}}
/>
{/* 결제 SDK - 결제 페이지에서만 */}
{/* 페이지별로 조건부 로드 */}
</body>
</html>
);
}
// app/checkout/page.tsx - 결제 페이지 전용 스크립트
import Script from 'next/script';
export default function CheckoutPage() {
return (
<div>
<CheckoutForm />
{/* 결제 SDK - 이 페이지에서만 로드 */}
<Script
src="https://pay.example.com/sdk.js"
strategy="afterInteractive"
onReady={() => {
// SDK 초기화
window.PaySDK.init({ merchantId: 'xxx' });
}}
/>
</div>
);
}import Script from 'next/script';
export function PaymentScript() {
return (
<Script
src="https://pay.example.com/sdk.js"
strategy="afterInteractive"
onLoad={() => {
// 스크립트 로드 완료
console.log('Payment SDK loaded');
}}
onReady={() => {
// 스크립트 실행 준비 완료
window.PaySDK.init({ key: 'xxx' });
}}
onError={(e) => {
// 로드 실패
console.error('Payment SDK failed to load', e);
}}
/>
);
}
// 조건부 스크립트 로드
export function ConditionalScript({ shouldLoad }: { shouldLoad: boolean }) {
if (!shouldLoad) return null;
return (
<Script
src="https://example.com/script.js"
strategy="lazyOnload"
/>
);
}필요한 페이지에서만 로드
결제 SDK는 결제 페이지에서만, 지도 SDK는 지도 페이지에서만
lazyOnload 적극 활용
채팅, 소셜 위젯 등 즉시 필요하지 않은 스크립트
불필요한 스크립트 제거
사용하지 않는 분석 도구, 위젯 정리
스크립트 최적화 체크리스트
Next.js 프로젝트에서는 단위 테스트, 통합 테스트, E2E 테스트를 조합하여 코드 품질을 보장합니다. 각 테스트 유형의 목적과 도구를 알아봅니다.
E2E 테스트 (적게)
실제 브라우저에서 전체 플로우 테스트. Playwright, Cypress
통합 테스트 (중간)
컴포넌트 간 상호작용 테스트. React Testing Library
단위 테스트 (많이)
개별 함수, 컴포넌트 테스트. Jest, Vitest
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
module.exports = createJestConfig(customJestConfig);
// jest.setup.js
import '@testing-library/jest-dom';
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}// components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './ProductCard';
const mockProduct = {
id: '1',
name: '테스트 상품',
price: 10000,
image: '/test.jpg',
};
describe('ProductCard', () => {
it('상품 정보를 표시한다', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('테스트 상품')).toBeInTheDocument();
expect(screen.getByText('10,000원')).toBeInTheDocument();
});
it('클릭 시 상세 페이지로 이동한다', async () => {
const user = userEvent.setup();
render(<ProductCard product={mockProduct} />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/products/1');
});
});
// hooks/useCart.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCart } from './useCart';
describe('useCart', () => {
it('상품을 장바구니에 추가한다', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: '1', name: '상품', price: 1000 });
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].name).toBe('상품');
});
it('총 금액을 계산한다', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: '1', name: '상품1', price: 1000 });
result.current.addItem({ id: '2', name: '상품2', price: 2000 });
});
expect(result.current.totalPrice).toBe(3000);
});
});// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('결제 플로우', () => {
test('상품을 장바구니에 담고 결제한다', async ({ page }) => {
// 상품 페이지 방문
await page.goto('/products/1');
// 장바구니 담기
await page.click('button:has-text("장바구니 담기")');
// 장바구니 확인
await page.goto('/cart');
await expect(page.locator('.cart-item')).toHaveCount(1);
// 결제 진행
await page.click('button:has-text("결제하기")');
await expect(page).toHaveURL('/checkout');
// 배송 정보 입력
await page.fill('input[name="name"]', '홍길동');
await page.fill('input[name="address"]', '서울시 강남구');
// 결제 완료
await page.click('button:has-text("결제 완료")');
await expect(page).toHaveURL(/\/orders\/\d+/);
});
});| 테스트 대상 | 테스트 유형 | 도구 |
|---|---|---|
| 유틸리티 함수 | 단위 테스트 | Jest/Vitest |
| 커스텀 훅 | 단위 테스트 | renderHook |
| UI 컴포넌트 | 통합 테스트 | Testing Library |
| 폼 제출 | 통합 테스트 | Testing Library |
| 결제 플로우 | E2E 테스트 | Playwright |
테스트 작성 원칙
성능 최적화는 일회성이 아닌 지속적인 과정입니다. 모니터링과 CI/CD를 통해 성능 저하를 조기에 발견하고 대응합니다.
// Vercel 배포 시 자동 활성화
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
// 제공되는 지표:
// - Core Web Vitals (LCP, INP, CLS)
// - 페이지별 성능
// - 디바이스/브라우저별 분석
// - 실시간 사용자 데이터// lib/performance.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB, Metric } from 'web-vitals';
type AnalyticsPayload = {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
page: string;
};
function sendToAnalytics(metric: Metric) {
const payload: AnalyticsPayload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
page: window.location.pathname,
};
// 분석 서비스로 전송
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true,
});
}
export function initPerformanceMonitoring() {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}
// app/layout.tsx
'use client';
import { useEffect } from 'react';
import { initPerformanceMonitoring } from '@/lib/performance';
export function PerformanceMonitor() {
useEffect(() => {
initPerformanceMonitoring();
}, []);
return null;
}# .github/workflows/performance.yml
name: Performance Check
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v10
with:
configPath: './lighthouserc.json'
uploadArtifacts: true
# lighthouserc.json
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"url": [
"http://localhost:3000/",
"http://localhost:3000/products",
"http://localhost:3000/products/1"
],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.9 }],
"first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}# .github/workflows/bundle-size.yml
name: Bundle Size Check
on:
pull_request:
branches: [main]
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and analyze
run: ANALYZE=true npm run build
- name: Check bundle size
uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
pattern: ".next/static/**/*.js"
# next.config.js에 번들 분석 추가
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// 기존 설정
});Core Web Vitals 모니터링
LCP < 2.5s, INP < 200ms, CLS < 0.1
Lighthouse CI
PR마다 성능 점수 확인, 기준 미달 시 머지 차단
번들 크기 추적
PR별 번들 크기 변화 확인, 급격한 증가 방지
실사용자 데이터 분석
Vercel Analytics, Google Analytics로 실제 성능 파악
성능 최적화 요약