목차
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" 추가
src="/hero-image.webp"
alt="Hero"
fetchpriority="high"
width={1200}
height={600}
/>
// 2. Preload로 LCP 리소스 먼저 로드
rel="preload"
as="image"
href="/hero-image.webp"
fetchpriority="high"
/>
// 3. Next.js Image 컴포넌트 활용
export default function Hero() {
return (
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 (
{isPending ? <Spinner /> : <ResultsList />}
);
}
// 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 │ 기준 │ X │ X │ 100% │
│ PNG │ 낮음 │ O │ X │ 100% │
│ WebP │ 25-34% │ O │ O │ 97%+ │
│ AVIF │ 50%+ │ O │ O │ 92%+ │
│ JPEG XL │ 35-60% │ O │ O │ 제한적 │
└─────────┴────────┴─────────┴──────────┴────────────┘
<!-- picture 요소로 포맷 폴백 -->
3.2 반응형 이미지
<!-- srcset과 sizes로 적절한 크기 제공 -->
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
// 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
export async function getStaticProps() {
const { base64 } = await getPlaiceholder('/public/hero.jpg');
return {
props: { blurDataURL: base64 },
};
}
export default function Page({ blurDataURL }) {
return (
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 (
{!loaded && (
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${src}?w=20&q=10)`,
backgroundSize: 'cover',
filter: 'blur(20px)',
transform: 'scale(1.1)',
}}
/>
)}
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
);
};
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 불가)
const result = _.map(data, fn);
// Good: 개별 함수만 임포트
const result = map(data, fn);
// Best: lodash-es 사용 (ES Module)
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 (
);
}
// 2. 라우트 기반 코드 분할 (Next.js)
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
<!-- 폰트 프리로드 -->
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 최적화
const inter = Inter({
subsets: ['latin'],
display: 'optional',
preload: true,
variable: '--font-inter',
adjustFontFallback: true, // CLS 방지를 위한 폴백 조정
});
export default function RootLayout({ children }) {
return (
);
}
7. 캐싱 전략
7.1 HTTP Cache Headers
캐시 전략 흐름도:
┌─────────────────────────────────────────┐
│ 리소스가 재사용 가능한가? │
├── No → Cache-Control: no-store │
├── Yes → 매번 서버 확인이 필요한가? │
│ ├── Yes → Cache-Control: no-cache │
│ └── No → 중간 캐시 허용? │
│ ├── Yes → Cache-Control: public │
│ └── No → Cache-Control: private │
│ └── max-age 설정 │
│ ├── 해시된 파일 → 31536000│
│ └── HTML → 0 + 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 기반 캐싱 전략
CacheFirst,
StaleWhileRevalidate,
NetworkFirst,
} from 'workbox-strategies';
// 빌드 시 생성된 에셋 프리캐시
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
렌더링 패턴 비교:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ │ TTFB │ FCP │ TTI │ SEO │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│ CSR │ 빠름 │ 느림 │ 느림 │ 나쁨 │
│ SSR │ 느림 │ 빠름 │ 보통 │ 좋음 │
│ SSG │ 매우빠름│ 매우빠름│ 빠름 │ 좋음 │
│ ISR │ 매우빠름│ 매우빠름│ 빠름 │ 좋음 │
│ Streaming│ 빠름 │ 매우빠름│ 빠름 │ 좋음 │
└──────────┴──────────┴──────────┴──────────┴──────────┘
8.2 Streaming SSR (React 18 + Next.js App Router)
// app/dashboard/page.tsx - Streaming SSR
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 (
{/* 빠른 컴포넌트는 즉시 렌더링 */}
{/* 느린 컴포넌트는 스트리밍 */}
);
}
// app/layout.tsx - loading.tsx 활용
// app/dashboard/loading.tsx
export default function Loading() {
return (
);
}
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
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 전략
9.1 Link Prefetch와 Route Prefetch
<!-- DNS Prefetch: 외부 도메인 DNS 미리 해석 -->
<!-- Preconnect: DNS + TCP + TLS 미리 연결 -->
<!-- Prefetch: 다음 네비게이션에 필요한 리소스 미리 가져오기 -->
<!-- Prerender: 전체 페이지 미리 렌더링 -->
9.2 Speculation Rules API
<!-- Speculation Rules API (Chrome 121+) -->
{
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } },
{ "not": { "href_matches": "/api/*" } }
]
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"urls": ["/products", "/about"],
"eagerness": "eager"
}
]
}
// Next.js에서의 Prefetch 전략
// Link 컴포넌트는 자동으로 viewport에 들어오면 prefetch
Dashboard
// router.prefetch로 프로그래매틱 prefetch
function Navigation() {
const router = useRouter();
const handleMouseEnter = () => {
router.prefetch('/settings');
};
return (
Settings
);
}
10. 서드파티 스크립트 최적화
10.1 defer/async와 로딩 전략
<!-- 스크립트 로딩 패턴 비교 -->
<!-- 1. 기본: HTML 파싱 차단 -->
<!-- 2. async: 다운로드 병렬, 실행 시 차단 (순서 보장 X) -->
<!-- 3. defer: 다운로드 병렬, DOMContentLoaded 전 순서대로 실행 -->
<!-- 4. type=module: defer처럼 동작 + ES Module -->
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 적용 -->
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
// Next.js Script 컴포넌트 활용
export default function MyApp({ Component, pageProps }) {
return (
<>
{/* beforeInteractive: _document에서 로드 */}
src="https://polyfill.io/v3/polyfill.min.js"
strategy="beforeInteractive"
/>
{/* afterInteractive: 페이지 하이드레이션 후 (기본값) */}
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
/>
{/* lazyOnload: 브라우저 idle 시 로드 */}
src="https://connect.facebook.net/en_US/fbevents.js"
strategy="lazyOnload"
/>
{/* worker: Partytown으로 Web Worker 실행 */}
src="https://example.com/tracking.js"
strategy="worker"
/>
</>
);
}
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 라이브러리로 실제 사용자 지표 수집
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. 퀴즈
아래 퀴즈로 학습 내용을 점검해 보세요.
**A1.** LCP의 Good 기준값은 **2.5초 이하**입니다.
가장 효과적인 개선 방법:
1. **LCP 이미지에 `fetchpriority="high"` 설정** - 브라우저가 LCP 리소스를 최우선으로 다운로드하게 합니다.
2. **`preload` 링크 태그 사용** - 파서가 발견하기 전에 LCP 리소스 다운로드를 시작합니다.
추가로 서버 응답 시간 단축(CDN, 캐싱), 렌더링 차단 리소스 제거, 이미지 최적화(WebP/AVIF) 등이 효과적입니다.
**A2.** FID는 **첫 번째 상호작용의 입력 지연만** 측정했지만, INP는 **전체 세션의 모든 상호작용**을 고려하여 가장 느린 상호작용(또는 p98)을 보고합니다. 또한 INP는 입력 지연뿐 아니라 처리 시간과 렌더링 지연까지 포함합니다.
React에서의 개선 패턴:
- **`useTransition`**: 긴급하지 않은 상태 업데이트를 낮은 우선순위로 처리
- **`useDeferredValue`**: 값의 업데이트를 지연시켜 UI 응답성 유지
- **`scheduler.yield()`**: 긴 작업을 분할하여 메인 스레드에 양보
**A3.**
**CacheFirst**: 캐시에 있으면 캐시에서 즉시 반환, 없으면 네트워크 요청. 캐시 히트 시 네트워크 요청을 하지 않습니다.
- 적합한 사례: 이미지, 폰트, 정적 에셋 등 자주 변경되지 않는 리소스
**StaleWhileRevalidate**: 캐시에서 즉시 반환하면서 동시에 백그라운드에서 네트워크 요청으로 캐시를 갱신합니다.
- 적합한 사례: API 응답, 뉴스 피드 등 최신성은 필요하지만 즉각적인 응답도 중요한 리소스
NetworkFirst는 HTML 페이지처럼 항상 최신 콘텐츠가 필요한 경우에 적합합니다.
**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`으로 데이터 페칭
**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](https://web.dev/vitals/) - Google의 Core Web Vitals 공식 가이드
2. [web.dev - Optimize LCP](https://web.dev/optimize-lcp/) - LCP 최적화 가이드
3. [web.dev - Optimize INP](https://web.dev/optimize-inp/) - INP 최적화 가이드
4. [web.dev - Optimize CLS](https://web.dev/optimize-cls/) - CLS 최적화 가이드
5. [Chrome Developers - Speculation Rules API](https://developer.chrome.com/docs/web-platform/prerender-pages) - Speculation Rules 가이드
6. [Next.js Documentation - Optimizing](https://nextjs.org/docs/app/building-your-application/optimizing) - Next.js 최적화 문서
7. [Workbox - Service Worker Libraries](https://developer.chrome.com/docs/workbox/) - Google Workbox 공식 문서
8. [web.dev - Optimize Images](https://web.dev/fast/#optimize-your-images) - 이미지 최적화 가이드
9. [Partytown - Web Worker for Third-party Scripts](https://partytown.builder.io/) - Partytown 공식 사이트
10. [web-vitals - JavaScript Library](https://github.com/GoogleChrome/web-vitals) - web-vitals 라이브러리
11. [CrUX Dashboard](https://developer.chrome.com/docs/crux/) - Chrome UX Report 문서
12. [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci) - Lighthouse CI 자동화
13. [HTTP Caching - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) - HTTP 캐싱 완전 가이드
현재 단락 (1/954)
웹 성능은 사용자 경험, 비즈니스 성과, SEO 순위에 직접적으로 영향을 미칩니다. Google 연구에 따르면 페이지 로드 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고...