Next.js 이론 15강

성능 최적화와 테스트

Next.js 애플리케이션의 성능을 최적화하고 테스트하는 방법을 학습합니다.

1. Core Web Vitals

Core Web Vitals는 Google이 정의한 웹 성능 지표입니다. SEO 순위와 사용자 경험에 직접적인 영향을 미치며, 2021년부터 Google 검색 순위 요소로 반영되고 있습니다.

주요 지표

LCP (Largest Contentful Paint) - 로딩 성능

뷰포트 내 가장 큰 콘텐츠가 표시되는 시간

목표: 2.5초 이내 (Good), 4초 이상 (Poor)

측정 대상: 이미지, 비디오 포스터, 배경 이미지, 텍스트 블록

INP (Interaction to Next Paint) - 상호작용 응답성

사용자 상호작용부터 다음 화면 업데이트까지 시간

목표: 200ms 이내 (Good), 500ms 이상 (Poor)

측정 대상: 클릭, 탭, 키보드 입력에 대한 응답

CLS (Cumulative Layout Shift) - 시각적 안정성

페이지 로드 중 예상치 못한 레이아웃 이동 정도

목표: 0.1 이하 (Good), 0.25 이상 (Poor)

원인: 크기 미지정 이미지, 동적 콘텐츠, 웹폰트 로딩

측정 도구

도구유형특징
LighthouseLab 데이터Chrome DevTools 내장, CI/CD 통합 가능
PageSpeed InsightsLab + Field실제 사용자 데이터(CrUX) 포함
Chrome UX ReportField 데이터실제 Chrome 사용자 데이터
web-vitals 라이브러리실시간프로덕션 모니터링

Next.js에서 측정하기

// 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 개선 우선순위

  • LCP: 이미지 최적화, 서버 응답 시간 단축
  • INP: JavaScript 실행 최적화, 이벤트 핸들러 경량화
  • CLS: 이미지/광고 크기 지정, 폰트 로딩 최적화

2. 이미지 최적화

이미지는 웹 페이지에서 가장 큰 용량을 차지합니다. Next.js의 Image 컴포넌트를 사용하면 자동으로 최적화됩니다.

next/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 이미지에 사용
    />
  );
}

next/image 자동 최적화

포맷 변환

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>
  );
}

이미지 최적화 체크리스트

  • priority: LCP 이미지(첫 화면 메인 이미지)에 사용
  • sizes: 반응형 이미지에 적절한 sizes 지정
  • placeholder: blur로 로딩 UX 개선
  • fill: 부모 크기에 맞출 때 사용

3. 코드 스플리팅과 번들 최적화

코드 스플리팅은 JavaScript 번들을 작은 청크로 나누어 필요한 코드만 로드하는 기법입니다. Next.js는 자동으로 코드 스플리팅을 수행합니다.

Next.js 자동 코드 스플리팅

페이지별 스플리팅

각 페이지는 별도 청크로 분리, 해당 페이지 방문 시에만 로드

공통 모듈 추출

여러 페이지에서 사용하는 코드는 공통 청크로 분리

서드파티 라이브러리 분리

node_modules는 별도 청크로 분리되어 캐싱 효율 향상

Dynamic Import

// 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>
  );
}

React.lazy와 Suspense

'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 생성

Tree Shaking 최적화

// ❌ 전체 라이브러리 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>
  );
}

번들 최적화 체크리스트

  • dynamic import: 무거운 컴포넌트 지연 로딩
  • Tree Shaking: 필요한 것만 import
  • 번들 분석: 정기적으로 번들 크기 확인
  • 서드파티 최적화: 가벼운 대안 라이브러리 검토

4. 폰트 최적화

웹 폰트는 CLS(레이아웃 이동)의 주요 원인입니다. Next.js의 next/font를 사용하면 폰트 로딩을 최적화하고 CLS를 방지할 수 있습니다.

next/font 기본 사용

// 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'],
      },
    },
  },
};

next/font 장점

자동 셀프 호스팅

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폰트 로드까지 텍스트 숨김아이콘 폰트
fallback100ms 대기 후 시스템 폰트빠른 네트워크 환경
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/font 사용: 자동 최적화 및 CLS 방지
  • Variable Font: 여러 굵기를 하나의 파일로
  • display: swap: 텍스트 즉시 표시
  • 필요한 weight만: 사용하지 않는 굵기 제외

5. 스크립트 최적화

서드파티 스크립트(분석, 광고, 채팅 등)는 성능에 큰 영향을 미칩니다. Next.js의 Script 컴포넌트를 사용하면 로딩 전략을 세밀하게 제어할 수 있습니다.

next/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>
  );
}

로딩 전략 (strategy)

전략로딩 시점사용 예시
beforeInteractive페이지 하이드레이션 전봇 감지, 동의 관리
afterInteractive하이드레이션 직후 (기본값)분석, 태그 매니저
lazyOnload브라우저 idle 시채팅, 소셜 위젯
workerWeb 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 적극 활용

채팅, 소셜 위젯 등 즉시 필요하지 않은 스크립트

불필요한 스크립트 제거

사용하지 않는 분석 도구, 위젯 정리

스크립트 최적화 체크리스트

  • strategy 선택: 용도에 맞는 로딩 전략
  • 조건부 로드: 필요한 페이지에서만
  • 에러 처리: onError로 실패 대응
  • 정기 점검: 불필요한 스크립트 제거

6. 테스트 전략

Next.js 프로젝트에서는 단위 테스트, 통합 테스트, E2E 테스트를 조합하여 코드 품질을 보장합니다. 각 테스트 유형의 목적과 도구를 알아봅니다.

테스트 피라미드

E2E 테스트 (적게)

실제 브라우저에서 전체 플로우 테스트. Playwright, Cypress

통합 테스트 (중간)

컴포넌트 간 상호작용 테스트. React Testing Library

단위 테스트 (많이)

개별 함수, 컴포넌트 테스트. Jest, Vitest

Jest + React Testing Library 설정

// 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);
  });
});

E2E 테스트 (Playwright)

// 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 통합: PR마다 자동 테스트 실행

7. 성능 모니터링과 CI/CD

성능 최적화는 일회성이 아닌 지속적인 과정입니다. 모니터링과 CI/CD를 통해 성능 저하를 조기에 발견하고 대응합니다.

Vercel Analytics

// 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;
}

CI/CD 성능 테스트

# .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로 실제 성능 파악

성능 최적화 요약

  • 측정: Core Web Vitals, Lighthouse로 현재 상태 파악
  • 최적화: 이미지, 폰트, 코드 스플리팅, 스크립트
  • 모니터링: 실사용자 데이터로 지속적 추적
  • 자동화: CI/CD로 성능 저하 조기 발견