이 글은 누구를 위한 것인가
- 배포할 때마다 성능이 조금씩 나빠지는 걸 막고 싶은 팀
- 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로 전환하는 점진적 도입을 권장한다.