- Published on
웹 성능 최적화 완전 가이드 2025: Core Web Vitals, 로딩 전략, 렌더링 패턴 총정리
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. 왜 성능이 중요한가
- 2. Core Web Vitals 심층 분석
- 3. 이미지 최적화
- 4. JavaScript 최적화
- 5. CSS 최적화
- 6. 폰트 최적화
- 7. 렌더링 패턴
- 8. 캐싱 전략
- 9. 네트워크 최적화
- 10. 성능 모니터링
- 11. Next.js 특화 최적화
- 12. 면접 질문 모음
- 13. 퀴즈
- 14. 참고 자료
들어가며
웹 성능은 사용자 경험과 비즈니스 성과에 직접적인 영향을 미칩니다. Google의 연구에 따르면, 페이지 로딩 시간이 1초에서 3초로 늘어나면 이탈률이 32% 증가하고, 5초가 되면 90% 증가합니다. Amazon은 페이지 로드 시간이 100ms 증가할 때마다 매출이 1% 감소한다고 보고했습니다.
2025년, Google은 Core Web Vitals를 검색 순위 요소로 강화했으며, INP(Interaction to Next Paint)가 FID를 대체하면서 상호작용 성능의 중요성이 더욱 커졌습니다. 이 글에서는 Core Web Vitals 최적화부터 이미지, JavaScript, CSS, 폰트, 렌더링 패턴, 캐싱, 네트워크, 모니터링, 그리고 Next.js 특화 최적화까지 웹 성능의 모든 것을 다룹니다.
1. 왜 성능이 중요한가
1.1 비즈니스 임팩트
성능과 비즈니스 지표:
├── 로딩 3초 초과 → 53% 이탈 (Google)
├── 100ms 지연 → 매출 1% 감소 (Amazon)
├── 500ms 지연 → 트래픽 20% 감소 (Google)
├── 1초 개선 → 전환율 7% 향상 (Walmart)
└── 2초 개선 → 바운스율 50% 감소 (COOK)
1.2 SEO와 Core Web Vitals
2025년 Google은 Core Web Vitals를 검색 순위의 핵심 요소로 확정했습니다.
| 지표 | 좋음 (Good) | 개선 필요 (Needs Improvement) | 나쁨 (Poor) |
|---|---|---|---|
| LCP | 2.5초 이내 | 2.5~4.0초 | 4.0초 초과 |
| INP | 200ms 이내 | 200~500ms | 500ms 초과 |
| CLS | 0.1 이하 | 0.1~0.25 | 0.25 초과 |
1.3 성능 예산 (Performance Budget)
성능 예산 예시:
├── 초기 로드 JS: 200KB 이하 (gzip)
├── 초기 로드 CSS: 50KB 이하 (gzip)
├── 전체 페이지 크기: 1.5MB 이하
├── LCP: 2.5초 이내
├── INP: 200ms 이내
├── CLS: 0.1 이하
├── Time to First Byte: 600ms 이내
└── 요청 수: 50개 이하
2. Core Web Vitals 심층 분석
2.1 LCP (Largest Contentful Paint)
LCP는 뷰포트 내에서 가장 큰 콘텐츠 요소가 렌더링되는 시간입니다.
LCP 대상 요소:
├── img 요소
├── video 요소 (포스터 이미지)
├── CSS background-image가 있는 요소
├── 텍스트 노드를 포함하는 블록 레벨 요소
└── svg 내의 image 요소
LCP 최적화 전략:
<!-- 1. 히어로 이미지 프리로드 -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<!-- 2. LCP 이미지에 fetchpriority 설정 -->
<img src="/hero.webp" alt="Hero" fetchpriority="high" loading="eager" />
<!-- 3. 서버 응답 시간 최적화 -->
<!-- TTFB 목표: 200ms 이내 -->
/* 4. 폰트 로딩 최적화 */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-display: swap;
}
2.2 INP (Interaction to Next Paint)
INP는 사용자 상호작용(클릭, 탭, 키보드)에서 다음 페인트까지의 지연 시간을 측정합니다.
INP 최적화 전략:
1. 긴 태스크 분할
- 50ms 이상의 태스크를 더 작은 단위로 분할
- requestIdleCallback, scheduler.yield() 활용
2. 메인 스레드 해방
- Web Worker로 무거운 계산 이동
- 불필요한 동기 JS 제거
3. 이벤트 핸들러 최적화
- debounce / throttle 적용
- passive 이벤트 리스너 사용
// 긴 태스크 분할 예시
async function processLargeList(items: Item[]) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);
// 브라우저에게 렌더링 기회 제공
await scheduler.yield();
}
}
// scheduler.yield 폴리필
if (!('scheduler' in globalThis)) {
(globalThis as any).scheduler = {
yield: () => new Promise(resolve => setTimeout(resolve, 0))
};
}
2.3 CLS (Cumulative Layout Shift)
CLS는 페이지 로드 중 예기치 않은 레이아웃 이동의 총합입니다.
<!-- CLS 방지: 이미지에 크기 명시 -->
<img src="/photo.webp" width="800" height="600" alt="Photo" />
<!-- CLS 방지: 동적 콘텐츠에 min-height 설정 -->
<div style="min-height: 250px;">
<!-- 광고 또는 동적 콘텐츠 -->
</div>
/* CLS 방지: 폰트 로딩 시 레이아웃 시프트 최소화 */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-display: optional; /* 또는 swap */
/* size-adjust로 대체 폰트와 크기 맞춤 */
size-adjust: 100.5%;
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
CLS 주요 원인과 해결:
| 원인 | 해결 방법 |
|---|---|
| 크기 미지정 이미지 | width/height 또는 aspect-ratio 속성 사용 |
| 동적 삽입 콘텐츠 | min-height로 공간 예약 |
| 웹 폰트 FOUT/FOIT | font-display: optional + preload |
| 동적 광고 | 고정 크기 컨테이너 사용 |
| 늦게 로드되는 CSS | Critical CSS 인라인 |
3. 이미지 최적화
3.1 차세대 포맷
이미지 포맷 비교 (같은 품질 기준):
├── JPEG: 100KB (기준)
├── WebP: 70KB (-30%)
├── AVIF: 50KB (-50%)
└── JXL (JPEG XL): 55KB (-45%)
브라우저 지원 (2025):
├── WebP: 97%+ (IE 미지원)
├── AVIF: 92%+ (Safari 16.4+)
└── JXL: Chrome에서 제거, Safari/Firefox 지원
<!-- picture 요소로 포맷 폴백 -->
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>
3.2 반응형 이미지
<!-- srcset과 sizes로 반응형 이미지 -->
<img
srcset="
/hero-400w.webp 400w,
/hero-800w.webp 800w,
/hero-1200w.webp 1200w,
/hero-1600w.webp 1600w
"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
src="/hero-800w.webp"
alt="Hero image"
width="1200"
height="600"
loading="lazy"
decoding="async"
/>
3.3 지연 로딩과 블러 플레이스홀더
<!-- 네이티브 lazy loading -->
<img src="/photo.webp" loading="lazy" decoding="async" alt="Photo" />
<!-- Intersection Observer로 커스텀 lazy loading -->
// 블러 플레이스홀더 구현
function BlurImage({ src, alt, width, height, blurDataURL }: ImageProps) {
return (
<div style={{ position: 'relative', width, height }}>
{/* 블러 플레이스홀더 */}
<img
src={blurDataURL}
alt=""
style={{
position: 'absolute',
inset: 0,
filter: 'blur(20px)',
transform: 'scale(1.1)',
}}
/>
{/* 실제 이미지 */}
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
onLoad={(e) => {
// 로드 완료 시 블러 제거
e.currentTarget.style.opacity = '1';
}}
style={{ position: 'relative', opacity: 0, transition: 'opacity 0.3s' }}
/>
</div>
);
}
4. JavaScript 최적화
4.1 Code Splitting
// React lazy + Suspense (Route-based splitting)
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// 조건부 동적 import
async function handleExport() {
// xlsx 라이브러리를 필요할 때만 로드
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();
// ...
}
4.2 Tree Shaking
// 나쁜 예: 전체 라이브러리 import
import _ from 'lodash';
_.debounce(fn, 300);
// 좋은 예: 필요한 함수만 import
import debounce from 'lodash/debounce';
debounce(fn, 300);
// 더 좋은 예: 네이티브 또는 경량 대안 사용
// lodash.debounce: 1.4KB vs lodash: 72KB
// webpack-bundle-analyzer로 번들 분석
// npm install --save-dev webpack-bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Next.js config
});
4.3 번들 최적화
// webpack splitChunks 설정
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
},
common: {
minChunks: 2,
priority: -10,
reuseExistingChunk: true,
},
},
},
},
};
4.4 스크립트 로딩 전략
<!-- 일반: 파싱 차단 -->
<script src="app.js"></script>
<!-- async: 비동기 다운로드, 즉시 실행 (파싱 차단 가능) -->
<script src="analytics.js" async></script>
<!-- defer: 비동기 다운로드, DOM 파싱 후 실행 (순서 보장) -->
<script src="app.js" defer></script>
<!-- module: defer와 동일한 동작 -->
<script type="module" src="app.js"></script>
스크립트 로딩 타임라인:
일반: [HTML 파싱...] [다운로드] [실행] [HTML 파싱...]
async: [HTML 파싱......다운로드......] [실행] [HTML 파싱...]
defer: [HTML 파싱......다운로드.............] [실행]
5. CSS 최적화
5.1 Critical CSS
<!-- Critical CSS 인라인 -->
<head>
<style>
/* 첫 화면(Above the fold)에 필요한 최소 CSS만 인라인 */
body { margin: 0; font-family: system-ui; }
.header { height: 60px; background: #fff; }
.hero { height: 400px; display: flex; align-items: center; }
</style>
<!-- 나머지 CSS는 비동기 로드 -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/styles.css" /></noscript>
</head>
5.2 CSS Containment
/* contain으로 렌더링 범위 제한 */
.card {
contain: layout style paint;
/* 또는 content-visibility로 오프스크린 요소 최적화 */
content-visibility: auto;
contain-intrinsic-size: 0 300px;
}
/* will-change로 GPU 가속 힌트 */
.animated-element {
will-change: transform;
/* 주의: 남용 시 메모리 낭비 */
}
5.3 불필요한 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: ['html', 'body', /^data-/]
})
]
};
6. 폰트 최적화
6.1 font-display 전략
/* swap: 대체 폰트 → 웹폰트 (FOUT 발생, 텍스트 즉시 표시) */
@font-face {
font-family: 'MyFont';
font-display: swap;
src: url('/fonts/myfont.woff2') format('woff2');
}
/* optional: 빠르면 웹폰트, 느리면 대체 폰트 유지 (CLS 최소) */
@font-face {
font-family: 'MyFont';
font-display: optional;
src: url('/fonts/myfont.woff2') format('woff2');
}
6.2 폰트 프리로드
<!-- 핵심 폰트 프리로드 -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
6.3 Variable Fonts
/* 기존: 각 굵기별 별도 파일 (400, 500, 600, 700 = 4파일) */
/* Variable Font: 1파일로 모든 굵기 */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
/* 사용 */
.light { font-weight: 300; }
.regular { font-weight: 400; }
.bold { font-weight: 700; }
6.4 폰트 서브셋
# pyftsubset으로 한글 폰트 서브셋 생성
# pip install fonttools brotli
pyftsubset NotoSansKR-Regular.otf \
--text-file=korean-chars.txt \
--output-file=NotoSansKR-subset.woff2 \
--flavor=woff2
# 결과: 4.5MB → 300KB (한글 2,350자 기준)
7. 렌더링 패턴
7.1 패턴 비교표
| 패턴 | TTFB | FCP | LCP | TTI | SEO | 사용 사례 |
|---|---|---|---|---|---|---|
| CSR | 빠름 | 느림 | 느림 | 느림 | 나쁨 | SPA, 대시보드 |
| SSR | 느림 | 빠름 | 빠름 | 느림 | 좋음 | 동적 콘텐츠, SEO 필요 |
| SSG | 매우 빠름 | 매우 빠름 | 매우 빠름 | 빠름 | 매우 좋음 | 블로그, 문서, 마케팅 |
| ISR | 매우 빠름 | 매우 빠름 | 매우 빠름 | 빠름 | 매우 좋음 | 이커머스, 뉴스 |
| Streaming SSR | 빠름 | 매우 빠름 | 빠름 | 빠름 | 좋음 | 복잡한 동적 페이지 |
7.2 CSR (Client-Side Rendering)
CSR 플로우:
Browser Server
│─ HTML 요청 ──→│
│←─ 빈 HTML ────│
│─ JS 요청 ────→│
│←─ JS 번들 ────│
│ [JS 파싱/실행] │
│─ API 요청 ───→│
│←─ 데이터 ─────│
│ [렌더링] │
▼ 화면 표시 ▼
7.3 SSR (Server-Side Rendering)
SSR 플로우:
Browser Server
│─ HTML 요청 ──→│
│ │ [데이터 패칭]
│ │ [HTML 렌더링]
│←─ 완성 HTML ──│
│ [화면 표시] │
│─ JS 요청 ────→│
│←─ JS 번들 ────│
│ [Hydration] │
▼ 인터랙티브 ▼
7.4 Streaming SSR
// Next.js App Router Streaming SSR
import { Suspense } from 'react';
async function SlowComponent() {
const data = await fetchSlowData(); // 3초 소요
return <div>{/* data 렌더링 */}</div>;
}
export default function Page() {
return (
<div>
{/* 즉시 렌더링 */}
<Header />
<Hero />
{/* 스트리밍: 준비되면 점진적 전송 */}
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
{/* 즉시 렌더링 */}
<Footer />
</div>
);
}
7.5 ISR (Incremental Static Regeneration)
// Next.js ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // 1시간마다 재생성
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
export default ProductPage;
// 정적 경로 생성
export async function generateStaticParams() {
const products = await getTopProducts(100);
return products.map(p => ({ id: p.id }));
}
8. 캐싱 전략
8.1 HTTP 캐시
HTTP 캐시 전략:
├── 정적 자산 (JS/CSS/이미지)
│ Cache-Control: public, max-age=31536000, immutable
│ (파일명에 해시 포함: app.abc123.js)
│
├── HTML
│ Cache-Control: public, max-age=0, must-revalidate
│ (항상 서버 확인)
│
├── API 응답
│ Cache-Control: private, max-age=60, stale-while-revalidate=300
│ (60초 캐시, 5분간 stale 허용)
│
└── 사용자별 데이터
Cache-Control: private, no-cache
(매번 서버 검증)
8.2 stale-while-revalidate
stale-while-revalidate 동작:
요청 1: [캐시 미스] → 서버 → 응답 + 캐시 저장
요청 2: [캐시 히트, fresh] → 즉시 응답
요청 3: [캐시 히트, stale] → 즉시 응답(stale) + 백그라운드 서버 갱신
요청 4: [캐시 히트, fresh] → 즉시 응답(갱신된 데이터)
// SWR 라이브러리 (React)
import useSWR from 'swr';
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher, {
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 30000, // 30초마다 갱신
});
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <UserCard user={data} />;
}
8.3 Service Worker 캐시
// Service Worker 캐싱 전략
// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/styles.css',
'/app.js',
'/offline.html'
];
// 설치: 정적 자산 사전 캐싱
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache =>
cache.addAll(STATIC_ASSETS)
)
);
});
// 요청: Cache First + Network Fallback
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
// 성공 응답을 캐시에 저장
const clone = response.clone();
caches.open(CACHE_NAME).then(cache =>
cache.put(event.request, clone)
);
return response;
}).catch(() => {
// 오프라인 폴백
return caches.match('/offline.html');
});
})
);
});
8.4 CDN 캐시
CDN 캐시 계층:
사용자 → [브라우저 캐시] → [CDN Edge] → [CDN Origin Shield] → [서버]
CDN 헤더 예시:
정적 자산:
Cache-Control: public, max-age=31536000, immutable
CDN-Cache-Control: public, max-age=31536000
동적 콘텐츠:
Cache-Control: public, max-age=0, must-revalidate
CDN-Cache-Control: public, max-age=60, stale-while-revalidate=300
Surrogate-Control: max-age=3600
9. 네트워크 최적화
9.1 리소스 힌트
<!-- DNS Prefetch: 외부 도메인 DNS 미리 해석 -->
<link rel="dns-prefetch" href="//api.example.com" />
<link rel="dns-prefetch" href="//cdn.example.com" />
<!-- Preconnect: DNS + TCP + TLS 미리 연결 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Prefetch: 다음 페이지 리소스 미리 로드 -->
<link rel="prefetch" href="/next-page.js" />
<!-- Preload: 현재 페이지 핵심 리소스 우선 로드 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" />
9.2 Priority Hints
<!-- fetchpriority로 리소스 우선순위 지정 -->
<!-- 히어로 이미지: 높은 우선순위 -->
<img src="/hero.webp" fetchpriority="high" />
<!-- 오프스크린 이미지: 낮은 우선순위 -->
<img src="/below-fold.webp" fetchpriority="low" loading="lazy" />
<!-- 핵심 스크립트: 높은 우선순위 -->
<script src="/critical.js" fetchpriority="high"></script>
9.3 HTTP/2와 HTTP/3
HTTP/1.1 vs HTTP/2 vs HTTP/3:
┌─────────────┬──────────────┬──────────────┬──────────────┐
│ 기능 │ HTTP/1.1 │ HTTP/2 │ HTTP/3 │
├─────────────┼──────────────┼──────────────┼──────────────┤
│ 멀티플렉싱 │ 불가 │ 지원 │ 지원 │
│ 헤더 압축 │ 없음 │ HPACK │ QPACK │
│ 서버 푸시 │ 없음 │ 지원 │ 제거됨 │
│ HOL 블로킹 │ TCP 레벨 │ TCP 레벨 │ 해결(QUIC) │
│ 전송 프로토콜│ TCP │ TCP │ QUIC(UDP) │
│ 연결 설정 │ TCP+TLS │ TCP+TLS │ 0-RTT 가능 │
└─────────────┴──────────────┴──────────────┴──────────────┘
10. 성능 모니터링
10.1 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 CI
uses: treosh/lighthouse-ci-action@v11
with:
configPath: './lighthouserc.json'
uploadArtifacts: true
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"interactive": ["error", { "maxNumericValue": 5000 }]
}
}
}
}
10.2 Real User Monitoring (RUM)
// Web Vitals 측정 및 보고
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric: any) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Beacon API로 비동기 전송
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
10.3 성능 대시보드
성능 모니터링 도구:
├── 합성 모니터링 (Lab Data)
│ ├── Lighthouse CI (자동화)
│ ├── WebPageTest (상세 분석)
│ └── PageSpeed Insights (Google)
│
├── 실사용자 모니터링 (Field Data)
│ ├── Chrome UX Report (CrUX)
│ ├── web-vitals 라이브러리
│ └── 상용: Datadog RUM, New Relic
│
└── 번들 분석
├── webpack-bundle-analyzer
├── source-map-explorer
└── bundlephobia.com
11. Next.js 특화 최적화
11.1 App Router와 Server Components
// Server Component (기본값 - JS 번들에 포함되지 않음)
// app/products/page.tsx
async function ProductsPage() {
// 서버에서 직접 데이터 패칭 (API 라우트 불필요)
const products = await db.product.findMany();
return (
<div>
<h1>Products</h1>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// Client Component (인터랙션이 필요한 부분만)
// components/AddToCart.tsx
'use client';
import { useState } from 'react';
export function AddToCart({ productId }: { productId: string }) {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Add to Cart ({count})
</button>
);
}
11.2 Next.js Image 최적화
import Image from 'next/image';
// 자동 WebP/AVIF 변환, 반응형, lazy loading
function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // LCP 이미지는 priority 설정
sizes="(max-width: 768px) 100vw, 1200px"
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
// 원격 이미지
function Avatar({ user }: { user: User }) {
return (
<Image
src={user.avatarUrl}
alt={user.name}
width={48}
height={48}
loading="lazy"
/>
);
}
11.3 Next.js Font 최적화
// next/font로 자동 최적화 (자체 호스팅, CLS 제거)
import { Inter, Noto_Sans_KR } from 'next/font/google';
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 }: { children: React.ReactNode }) {
return (
<html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
<body>{children}</body>
</html>
);
}
11.4 Route Segment Config
// app/blog/[slug]/page.tsx
// 정적 생성 + ISR (60초)
export const revalidate = 60;
// 또는 완전 정적
export const dynamic = 'force-static';
// 또는 항상 동적
export const dynamic = 'force-dynamic';
// 런타임 선택
export const runtime = 'edge'; // Edge Runtime (빠른 TTFB)
// export const runtime = 'nodejs'; // Node.js Runtime (기본)
12. 면접 질문 모음
기본 개념
Q1. Core Web Vitals 3가지 지표를 설명하세요.
-
LCP (Largest Contentful Paint): 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간. 목표: 2.5초 이내. 로딩 성능을 측정.
-
INP (Interaction to Next Paint): 사용자 상호작용(클릭, 키보드 등)에서 다음 페인트까지의 지연. 목표: 200ms 이내. 응답성을 측정.
-
CLS (Cumulative Layout Shift): 페이지 로드 중 예기치 않은 레이아웃 이동의 총합. 목표: 0.1 이하. 시각적 안정성을 측정.
Q2. CSR, SSR, SSG, ISR의 차이점을 설명하세요.
CSR (Client-Side Rendering): 브라우저에서 JS로 렌더링. 빈 HTML 전송 후 클라이언트에서 렌더링.
- 장점: 서버 부하 낮음, SPA 적합
- 단점: 느린 초기 로딩, SEO 불리
SSR (Server-Side Rendering): 서버에서 HTML을 매 요청마다 생성.
- 장점: 빠른 FCP, SEO 유리
- 단점: 서버 부하, 느린 TTFB
SSG (Static Site Generation): 빌드 시 HTML 미리 생성.
- 장점: 최고 성능, CDN 캐싱
- 단점: 빌드 시간, 동적 콘텐츠 제한
ISR (Incremental Static Regeneration): SSG + 백그라운드 재생성.
- 장점: SSG 성능 + 데이터 갱신
- 단점: 구현 복잡성
Q3. Code Splitting과 Tree Shaking의 차이를 설명하세요.
Code Splitting: JS 번들을 여러 청크로 분할하여 필요한 것만 로드하는 기법. 라우트 기반(dynamic import) 또는 컴포넌트 기반으로 분할. 초기 로딩 시간 단축.
Tree Shaking: 빌드 시 사용되지 않는(dead) 코드를 제거하는 최적화. ES Module의 정적 구조를 분석하여 import되지 않은 export를 제거. 최종 번들 크기 감소.
차이: Code Splitting은 "언제 로드할지" (When), Tree Shaking은 "무엇을 제거할지" (What).
Q4. LCP를 개선하는 방법을 설명하세요.
- 서버 응답 최적화: TTFB 200ms 이내, CDN 활용, 캐싱
- 리소스 프리로드: LCP 이미지에 preload + fetchpriority="high"
- 이미지 최적화: WebP/AVIF, 적절한 크기, 압축
- 렌더링 차단 제거: Critical CSS 인라인, JS defer
- 폰트 최적화: font-display: swap, preload
- SSR/SSG 활용: 서버에서 완성된 HTML 전송
- Third-party 최적화: 외부 스크립트 지연 로드
Q5. CLS의 주요 원인과 해결 방법을 설명하세요.
주요 원인:
- 크기 미지정 이미지/비디오
- 동적 삽입 콘텐츠(광고, 배너)
- 웹 폰트 로딩 시 레이아웃 시프트
- 늦게 로드되는 CSS
- DOM을 조작하는 JavaScript
해결 방법:
- img/video에 width, height 속성 또는 aspect-ratio CSS 명시
- 동적 콘텐츠 영역에 min-height로 공간 예약
- font-display: optional, 폰트 프리로드
- Critical CSS 인라인
- transform 애니메이션 사용(top/left 대신)
심화 질문
Q6. Service Worker의 캐싱 전략을 설명하세요.
- Cache First: 캐시 확인 후 없으면 네트워크. 정적 자산에 적합.
- Network First: 네트워크 시도 후 실패 시 캐시. API 응답에 적합.
- Stale While Revalidate: 캐시된 응답 즉시 반환 + 백그라운드 갱신. 빈번히 변경되는 자산에 적합.
- Cache Only: 캐시만 사용. 오프라인 자산에 적합.
- Network Only: 네트워크만 사용. 실시간 데이터에 적합.
선택 기준: 데이터의 신선도 요구사항과 오프라인 지원 필요성에 따라 결정.
Q7. HTTP 캐시 헤더를 설명하세요.
-
Cache-Control: 캐시 정책의 핵심 헤더
- max-age: 캐시 유효 시간(초)
- no-cache: 매번 서버 검증 필요
- no-store: 절대 캐시하지 않음
- public/private: CDN 캐시 가능 여부
- immutable: 변하지 않는 리소스
- stale-while-revalidate: stale 응답 허용 시간
-
ETag: 리소스 버전 식별자. 조건부 요청(If-None-Match)에 사용.
-
Last-Modified: 마지막 수정 시간. If-Modified-Since와 함께 사용.
추천 전략:
- 정적 자산(해시 포함): max-age=31536000, immutable
- HTML: no-cache 또는 max-age=0, must-revalidate
Q8. 이미지 최적화 전략을 종합적으로 설명하세요.
- 포맷: WebP/AVIF 사용, picture 요소로 폴백
- 크기: 반응형 이미지(srcset + sizes), 실제 표시 크기에 맞게
- 압축: 품질 75~85, 용도에 따라 조정
- 로딩: LCP 이미지는 eager + fetchpriority="high", 나머지는 lazy
- 프레임워크: Next.js Image 등 자동 최적화 도구 활용
- CDN: 이미지 CDN(Cloudinary, imgix) 활용
- 플레이스홀더: 블러 또는 LQIP로 사용자 경험 향상
Q9. INP(Interaction to Next Paint)를 최적화하는 방법을 설명하세요.
- 긴 태스크 분할: 50ms 이상의 작업을 scheduler.yield()나 requestIdleCallback으로 분할
- 메인 스레드 해방: Web Worker로 무거운 계산 이동
- 이벤트 핸들러 최적화: debounce/throttle, passive 이벤트 리스너
- JS 번들 최소화: 코드 스플리팅, 트리 셰이킹
- 가상화: 긴 리스트는 react-virtuoso 등으로 가상화
- startTransition: 긴급하지 않은 업데이트를 전환으로 표시
- React 최적화: useMemo, useCallback, React.memo 적절히 사용
Q10. CDN의 동작 원리와 성능 이점을 설명하세요.
동작 원리:
- 전 세계에 분산된 Edge 서버(PoP)에 콘텐츠 캐시
- 사용자의 DNS 요청을 가장 가까운 Edge로 라우팅
- Edge에 캐시가 있으면 즉시 응답, 없으면 Origin에서 가져와 캐시
성능 이점:
- 물리적 거리 단축: RTT(Round Trip Time) 감소
- Origin 서버 부하 분산
- DDoS 방어
- TLS 최적화 (Edge에서 TLS 종료)
- 자동 압축 (Brotli, gzip)
- HTTP/2, HTTP/3 지원
Q11. webpack의 코드 스플리팅 설정을 설명하세요.
webpack의 splitChunks 플러그인으로 코드 분할:
- Entry Points: 여러 진입점으로 수동 분할
- Dynamic Imports: import()로 동적 분할
- splitChunks: 자동 분할 규칙 설정
주요 설정:
- chunks: 'all'로 동기/비동기 모두 분할
- cacheGroups: vendor(node_modules)와 common(공유 모듈) 분리
- minSize: 최소 청크 크기 (기본 20KB)
- maxSize: 최대 청크 크기 (자동 분할)
효과: 초기 로딩 시간 단축, 캐시 효율 향상, 필요한 코드만 로드
Q12. Streaming SSR의 장점과 구현 방법을 설명하세요.
장점:
- TTFB 단축: HTML을 점진적으로 전송
- FCP 향상: 준비된 부분부터 표시
- 느린 데이터 소스 격리: Suspense로 감싸서 독립적 로딩
- 사용자 경험 향상: 스켈레톤/로딩 상태 즉시 표시
구현 (Next.js App Router):
- Server Component를 기본으로 사용
- 느린 컴포넌트를 Suspense로 감싸기
- fallback에 스켈레톤 UI 제공
- 데이터가 준비되면 자동으로 스트리밍
핵심: 전체 페이지가 준비될 때까지 기다리지 않고, 준비된 부분부터 점진적으로 전송.
Q13. Next.js Server Components의 성능 이점을 설명하세요.
- 제로 JS 번들: Server Component는 클라이언트 JS 번들에 포함되지 않음
- 직접 데이터 접근: 서버에서 직접 DB/API 접근 (API 라우트 불필요)
- 자동 코드 분할: Client Component만 번들에 포함
- 스트리밍: Suspense와 결합하여 점진적 렌더링
- 캐싱: 서버 측 캐싱으로 반복 요청 최적화
- 보안: 민감한 로직/키가 클라이언트에 노출되지 않음
사용 원칙: 인터랙션이 없는 컴포넌트는 Server, useState/useEffect가 필요한 부분만 Client로.
Q14. 웹 성능 예산(Performance Budget)의 설정과 적용 방법은?
설정:
- 경쟁사 분석: 주요 경쟁사의 성능 지표 측정
- 사용자 기기 분석: 대상 사용자의 평균 기기/네트워크 파악
- 비즈니스 목표 반영: 전환율, 이탈률 목표에 맞게 설정
- 구체적 수치: JS 200KB, CSS 50KB, LCP 2.5s, INP 200ms 등
적용:
- Lighthouse CI로 PR마다 자동 검사
- 예산 초과 시 빌드 실패 또는 경고
- bundlesize 또는 size-limit으로 번들 크기 제한
- 팀 대시보드에서 트렌드 모니터링
- 정기적으로 예산 검토 및 조정
Q15. 웹 폰트 최적화의 모범 사례를 설명하세요.
- Variable Font: 하나의 파일로 모든 굵기/스타일 (파일 수 감소)
- 서브셋: 필요한 글자만 포함 (한글: 4MB → 300KB)
- WOFF2 포맷: 최고 압축률의 웹 폰트 포맷
- font-display: swap(텍스트 즉시 표시) 또는 optional(CLS 최소)
- preload: 핵심 폰트를 link preload로 조기 다운로드
- 자체 호스팅: Google Fonts 대신 직접 호스팅 (DNS/연결 비용 절감)
- size-adjust: 대체 폰트와 웹 폰트의 크기를 맞추어 CLS 방지
13. 퀴즈
Q1. LCP의 "Good" 기준은?
정답: 2.5초 이내
LCP(Largest Contentful Paint)의 기준:
- Good: 2.5초 이내
- Needs Improvement: 2.5~4.0초
- Poor: 4.0초 초과
LCP는 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시간을 측정합니다.
Q2. FID를 대체한 Core Web Vitals 지표는?
정답: INP (Interaction to Next Paint)
2024년 3월부터 FID(First Input Delay)가 INP로 대체되었습니다. FID는 첫 번째 상호작용만 측정했지만, INP는 페이지 전체 수명 동안의 모든 상호작용을 측정하여 더 포괄적인 응답성 지표를 제공합니다.
Q3. HTTP 캐시에서 immutable의 의미는?
정답: 리소스가 절대 변경되지 않음을 나타내어 재검증 요청을 방지
Cache-Control: immutable은 리소스가 변경되지 않을 것임을 브라우저에게 알려줍니다. 이로 인해 브라우저가 max-age 내에서 재검증(304) 요청을 보내지 않습니다. 파일명에 해시가 포함된 정적 자산(app.abc123.js)에 적합합니다.
Q4. Tree Shaking이 동작하기 위한 전제 조건은?
정답: ES Module (import/export) 사용
Tree Shaking은 ES Module의 정적 구조를 분석하여 사용되지 않는 export를 제거합니다. CommonJS(require/module.exports)는 동적이므로 Tree Shaking이 불가능합니다. package.json의 "sideEffects": false 설정도 중요합니다.
Q5. Next.js Server Components가 클라이언트 번들에 포함되나요?
정답: 아니요, Server Components는 클라이언트 JS 번들에 포함되지 않습니다
Server Components는 서버에서만 실행되고 결과 HTML만 클라이언트에 전송됩니다. 따라서 JS 번들 크기를 크게 줄일 수 있습니다. 'use client' 디렉티브가 선언된 Client Components만 번들에 포함됩니다.
14. 참고 자료
공식 문서
측정 도구
이미지 최적화
성능 참고
- HTTP Archive Web Almanac
- Smashing Magazine Performance
- Addy Osmani - Image Optimization
- Philip Walton - web-vitals