성능 예산과 Lighthouse CI: 웹 성능 회귀를 자동으로 잡는 법

프론트엔드

성능Lighthouse CI성능 예산Core Web VitalsCI/CD

이 글은 누구를 위한 것인가

  • 배포할 때마다 성능이 조금씩 나빠지는 걸 막고 싶은 팀
  • Lighthouse CI로 PR 단계에서 성능 회귀를 잡고 싶은 개발자
  • 번들 크기와 Core Web Vitals를 자동으로 모니터링하려는 팀

들어가며

"성능은 기능이다." 배포 이후 Lighthouse 점수가 80에서 65로 떨어졌을 때 어떤 커밋이 원인인지 찾는 건 고통스럽다. Lighthouse CI는 모든 PR에서 자동으로 성능을 측정하고 기준 미달 시 머지를 막는다.

이 글은 bluefoxdev.kr의 웹 성능 자동화 가이드 를 참고하여 작성했습니다.


1. 성능 예산 정의

[Core Web Vitals 목표 (Good 등급)]
  LCP (Largest Contentful Paint): < 2.5초
  INP (Interaction to Next Paint): < 200ms
  CLS (Cumulative Layout Shift):  < 0.1

[성능 예산 기준]
Lighthouse 점수:
  Performance: ≥ 90
  Accessibility: ≥ 95
  Best Practices: ≥ 95
  SEO: ≥ 90

리소스 크기:
  총 JS 번들: < 300KB (gzip)
  총 CSS: < 50KB (gzip)
  최대 이미지: < 200KB
  총 페이지 크기: < 1MB

타이밍:
  Time to Interactive (TTI): < 3초
  Speed Index: < 3초
  First Contentful Paint: < 1.8초

[번들 크기 예산 전략]
  코드 스플리팅: 페이지별 분리
  Tree Shaking: 사용하지 않는 코드 제거
  Dynamic Import: 지연 로딩
  External CDN: 공통 라이브러리 CDN

2. Lighthouse CI 설정

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      // Next.js 정적 빌드 기준 측정
      staticDistDir: './.next',
      url: [
        'http://localhost:3000/',
        'http://localhost:3000/products',
        'http://localhost:3000/about',
      ],
      numberOfRuns: 3,  // 평균값으로 측정 (노이즈 제거)
      startServerCommand: 'npm start',
      startServerReadyPattern: 'ready on',
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        // Core Web Vitals
        'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['warn', { maxNumericValue: 300 }],
        
        // Lighthouse 점수
        'categories:performance': ['error', { minScore: 0.85 }],
        'categories:accessibility': ['error', { minScore: 0.9 }],
        'categories:seo': ['warn', { minScore: 0.9 }],
        
        // 번들 크기
        'resource-summary:script:size': ['error', { maxNumericValue: 350000 }],
        'resource-summary:document:size': ['warn', { maxNumericValue: 30000 }],
        
        // 이미지
        'uses-optimized-images': ['warn', {}],
        'uses-webp-images': ['warn', {}],
        'uses-responsive-images': ['error', {}],
        
        // 기타
        'no-unload-listeners': ['error', {}],
        'unused-javascript': ['warn', { maxLength: 0 }],
      },
    },
    upload: {
      target: 'lhci',
      serverBaseUrl: process.env.LHCI_SERVER_URL,
      token: process.env.LHCI_TOKEN,
    },
  },
};
# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Next.js 빌드
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ vars.PREVIEW_API_URL }}

      - name: Lighthouse CI 실행
        run: |
          npm install -g @lhci/cli@0.14.x
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
          LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
          LHCI_SERVER_URL: ${{ secrets.LHCI_SERVER_URL }}

      - name: 번들 크기 분석
        run: |
          npm run analyze
          # @next/bundle-analyzer 결과를 artifacts로 저장

      - name: 번들 크기 PR 댓글
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            
            // bundle-size.json은 커스텀 스크립트로 생성
            const sizes = JSON.parse(fs.readFileSync('bundle-size.json', 'utf-8'));
            
            const comment = `## 📦 번들 크기 분석
            
            | 파일 | 크기 | 변화 |
            |------|------|------|
            ${sizes.map(s => 
              `| ${s.name} | ${s.size}KB | ${s.diff > 0 ? `+${s.diff}KB ⚠️` : `${s.diff}KB ✅`} |`
            ).join('\n')}
            
            ${sizes.some(s => s.diff > 10) ? '⚠️ **10KB 이상 증가한 파일이 있습니다!**' : '✅ 번들 크기 정상'}
            `;
            
            // PR 댓글 업데이트
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            
            const existing = comments.data.find(c => c.body?.includes('번들 크기 분석'));
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body: comment,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: comment,
              });
            }
// next.config.js - 번들 분석
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

// "analyze": "ANALYZE=true next build"

// 번들 크기 추적 스크립트
// scripts/track-bundle-size.mjs
import { readFileSync, writeFileSync } from 'fs';
import { gzipSync } from 'zlib';
import { readdirSync, statSync } from 'fs';
import { join } from 'path';

function getGzipSize(filePath) {
  const content = readFileSync(filePath);
  return gzipSync(content).length;
}

function analyzeBundles() {
  const chunkDir = '.next/static/chunks';
  const files = readdirSync(chunkDir)
    .filter(f => f.endsWith('.js'))
    .map(f => ({
      name: f,
      size: Math.round(getGzipSize(join(chunkDir, f)) / 1024),
    }))
    .sort((a, b) => b.size - a.size)
    .slice(0, 10);  // 상위 10개
  
  writeFileSync('bundle-size.json', JSON.stringify(files.map(f => ({
    ...f,
    diff: 0,  // CI에서 이전 값과 비교
  }))));
  
  console.log('번들 크기 (gzip, KB):');
  files.forEach(f => console.log(`  ${f.name}: ${f.size}KB`));
}

analyzeBundles();
// Web Vitals 실측 데이터 수집
// app/layout.tsx
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    // 분석 서비스로 전송
    if (typeof window.gtag !== 'undefined') {
      window.gtag('event', metric.name, {
        value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
        event_label: metric.id,
        non_interaction: true,
      });
    }
    
    // 내부 모니터링으로 전송
    if (metric.name === 'LCP' && metric.value > 2500) {
      console.warn(`LCP 임계값 초과: ${metric.value}ms`);
      reportSlowPage(metric);
    }
  });
  
  return null;
}

function reportSlowPage(metric: { name: string; value: number }) {
  fetch('/api/performance', {
    method: 'POST',
    body: JSON.stringify({
      metric: metric.name,
      value: metric.value,
      url: window.location.href,
      userAgent: navigator.userAgent,
    }),
    headers: { 'Content-Type': 'application/json' },
  }).catch(() => {});  // 성능 리포팅이 실패해도 무시
}

마무리

성능 예산은 팀 합의로 설정한 "이보다 나빠지면 안 된다"는 기준선이다. Lighthouse CI는 PR마다 이 기준을 자동으로 검사해서 성능 회귀를 배포 전에 잡는다. 번들 크기 추적 댓글은 "이 PR에서 번들이 20KB 늘었다"를 가시화해 불필요한 의존성 추가를 억제하는 팀 문화를 만든다. 처음에는 warn으로 시작해 팀이 익숙해지면 error로 전환하는 점진적 도입을 권장한다.