JUNSEOK
05 · 심화 FE·22분·5개 레슨

웹 성능 최적화

Core Web Vitals(LCP, CLS, INP), 번들·이미지.

학습 목표

  • Core Web Vitals (LCP / INP / CLS)의 정의와 측정 방법을 이해한다
  • Critical Rendering Path와 리소스 힌트(preload/preconnect/prefetch)를 익힌다
  • 이미지·폰트·JS 최적화 기법을 실무 레벨로 적용할 수 있어야 한다
  • 런타임 성능 이슈(리플로우, 메인 스레드 블로킹)를 진단·해결할 수 있어야 한다

0. "성능 최적화"가 뭔지 먼저 — 초심자용

0-1. 성능 = "느낌의 문제"가 아니다

"우리 사이트 느린 것 같은데" 는 이다. 성능 최적화는 감을 숫자로 바꾸는 일부터 시작한다. 숫자 없이는 개선도, 목표도, 성공/실패도 정의할 수 없다.

Google이 정한 3가지 핵심 숫자가 Core Web Vitals (CWV):

지표측정 대상좋은 값
LCP핵심 콘텐츠 나타나는 시간≤ 2.5s
INP클릭/입력 후 반응 시간≤ 200ms
CLS레이아웃이 얼마나 흔들리나≤ 0.1

이 셋이 SEO 랭킹에도 영향을 준다. 성능이 검색 노출과 직결 되므로, 비즈니스 지표이기도 하다.

0-2. 성능 문제는 두 종류

종류언제 나타남증상
로드 성능페이지 처음 열 때흰 화면, 늦게 보임, 글자 점프
런타임 성능상호작용 중버튼 눌러도 반응 느림, 스크롤 끊김

이 장은 둘 다 다룬다.

0-3. 실생활 체감 예시

  • Amazon: 100ms 지연 = 매출 1% 감소
  • Google: 로드 시간 1초 → 3초 = 이탈률 32% 증가
  • Pinterest: 대기 시간 40% 감소 = SEO 트래픽 15% 증가

"0.1초의 돈 가치" 가 생각보다 크다. 개발자가 왜 이걸 신경 써야 하는지 근거가 확실해진다.

0-4. 이 장에서 다루는 도구들

도구역할
Lighthouse로드 성능 점수화 (개발 중 측정)
WebPageTest실제 네트워크·디바이스 시뮬레이션
Chrome DevTools Performance런타임 프로파일링
web-vitals 라이브러리실사용자 지표 수집 (RUM)
PageSpeed InsightsGoogle의 실사용자 데이터 (CrUX)

0-5. "최적화의 순서"

  1. 측정 먼저 (예: Lighthouse 돌려서 현재 점수)
  2. 가장 큰 병목 식별 (LCP 이미지인지, JS 블로킹인지)
  3. 한 가지 고치고 다시 측정
  4. 반복

"감으로 고치지 말고 숫자로 고친다." 이 장의 제일 큰 원칙.

0-6. 선행 장 연결

가져오는 것
2-5렌더링 파이프라인 이해 — 어디를 건드리면 빨라지나
2-6메인 스레드 블로킹 개념
2-9리소스 캐싱·CDN
5-5렌더링 전략별 성능 특성

이 장은 지금까지 배운 CS 지식을 프로덕션 웹 성능에 실제로 적용하는 장이다.


1. 성능은 왜 비즈니스 지표인가

  • Amazon: 100ms 지연 = 매출 1% 감소
  • Google: 페이지 로드 1초 → 3초, 이탈률 32% 증가
  • Walmart: 1초 개선 = 전환율 2% 증가

성능은 단순 엔지니어링 품질이 아니라 전환율·검색 노출·유지율에 직접 영향을 준다.


2. Core Web Vitals

Google이 제시한 사용자 경험 핵심 지표. 검색 랭킹 요소이기도 하다.

2-1. LCP (Largest Contentful Paint)

뷰포트 내에서 가장 큰 콘텐츠 요소가 그려지기까지 걸린 시간.

등급기준
Good≤ 2.5s
Needs Improvement≤ 4.0s
Poor> 4.0s

LCP 후보: <img>, 배경 이미지, <video> 포스터, 블록 레벨 텍스트 중 가장 큰 것.

LCP를 나쁘게 만드는 주범:

  1. 느린 서버 응답 (TTFB)
  2. 블로킹 JS/CSS
  3. 리소스 로드 지연 (LCP 이미지가 늦게 발견됨)
  4. 클라이언트 사이드 렌더링

개선 전략:

  • 서버 응답 속도 개선 (CDN, 캐시, ISR)
  • Critical CSS 인라인 + 비동기 CSS 로드
  • LCP 이미지에 fetchpriority="high" + preload
  • 이미지 포맷(AVIF/WebP) + 반응형 srcset
  • 서드파티 스크립트 async/defer
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<img src="/hero.avif" alt="Hero" fetchpriority="high" width="1200" height="600">

2-2. INP (Interaction to Next Paint) — FID 후속

사용자 상호작용 후 다음 프레임이 그려지기까지 걸린 시간.

등급기준
Good≤ 200ms
Needs Improvement≤ 500ms
Poor> 500ms

2024년 3월 FID를 대체했다. FID는 입력만 측정했지만 INP는 페이지 수명 전체의 모든 입력을 측정한다.

INP를 망치는 주범: 긴 JS 실행(Long Task), 무거운 이벤트 핸들러, 리렌더 폭주.

개선 전략:

  • 메인 스레드 블로킹 제거 (50ms 이상 Task 쪼개기)
  • scheduler.yield() / setTimeout(fn, 0) / React startTransition
  • 이벤트 핸들러에서 debounce/throttle
  • 무거운 계산은 Web Worker로
// ❌ 클릭 한 번에 10,000개 아이템 필터링 + 렌더
button.addEventListener('click', () => {
  const filtered = hugeArray.filter(/* ... */);
  render(filtered); // 긴 Task
});

// ✅ startTransition으로 급하지 않은 업데이트 양보
button.addEventListener('click', () => {
  startTransition(() => {
    const filtered = hugeArray.filter(/* ... */);
    render(filtered);
  });
});

2-3. CLS (Cumulative Layout Shift)

페이지 수명 동안 발생한 예상치 못한 레이아웃 이동의 누적 점수.

등급기준
Good≤ 0.1
Needs Improvement≤ 0.25
Poor> 0.25

계산: impact fraction × distance fraction.

주범:

  1. 크기 명시 없는 이미지/비디오
  2. 크기 없는 iframe/광고
  3. 동적으로 삽입되는 배너
  4. 폰트 로드 후 FOUT/FOIT

개선 전략:

  • 이미지·비디오에 width·height 속성 (CSS aspect-ratio도 OK)
  • 동적 콘텐츠는 자리를 미리 확보 (스켈레톤/placeholder)
  • font-display: optional 또는 swap + 크기 매칭된 폴백 폰트
  • 스크롤 상단에 새 콘텐츠 삽입 금지 (아래에 추가)
<!-- ✅ width/height로 브라우저가 공간 예약 -->
<img src="/photo.jpg" width="800" height="600" alt="...">
/* 폰트 로드 전후 크기 차이 최소화 */
@font-face {
  font-family: 'Pretendard';
  font-display: swap;
  size-adjust: 105%;
}

2-4. 기타 보조 지표

  • TTFB (Time To First Byte): 첫 바이트 수신까지. ≤ 800ms
  • FCP (First Contentful Paint): 첫 콘텐츠. ≤ 1.8s
  • TBT (Total Blocking Time): Long Task 누적. 랩 측정 지표 (INP는 필드)

3. Critical Rendering Path

브라우저가 HTML을 받은 뒤 픽셀을 그릴 때까지의 경로.

HTMLDOM 트리
CSSCSSOM 트리
DOM + CSSOM → Render Tree → Layout → Paint → Composite

3-1. 렌더링 블로킹 리소스

  • CSS는 기본적으로 렌더링 블로킹. CSSOM이 완성되기 전까지 Paint 안 함.
  • <script>는 기본적으로 파싱 블로킹. <head>에 있으면 HTML 파싱이 멈춤.

3-2. 스크립트 로딩 전략

<!-- 파싱 블로킹 -->
<script src="app.js"></script>

<!-- 병렬 다운로드, 다운로드 완료 즉시 실행 (HTML 파싱 멈춤) -->
<script src="app.js" async></script>

<!-- 병렬 다운로드, HTML 파싱 완료 후 순서대로 실행 -->
<script src="app.js" defer></script>

<!-- ESM은 기본 defer -->
<script type="module" src="app.js"></script>
전략언제
async독립적인 서드파티 (분석, 광고)
defer앱 코드, DOM 의존
type="module"최신 브라우저 타깃

3-3. 리소스 힌트

<!-- DNS 조회만 미리 -->
<link rel="dns-prefetch" href="https://api.example.com">

<!-- DNS + TCP + TLS 핸드셰이크까지 미리 -->
<link rel="preconnect" href="https://api.example.com">

<!-- 현재 페이지에 곧 필요한 리소스 (높은 우선순위 fetch) -->
<link rel="preload" href="/hero.avif" as="image">
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin>

<!-- 다음 페이지에 필요할 것 같은 리소스 (idle 시 fetch) -->
<link rel="prefetch" href="/next-page.html">

<!-- 확실히 다음에 갈 페이지 전체 렌더링까지 (최근엔 prerender-2 Speculation Rules API) -->
<script type="speculationrules">
{ "prerender": [{ "urls": ["/next-page"] }] }
</script>

⚠️ preload 남용 금지. 진짜 지금 당장 필요한 것(LCP 이미지, 핵심 폰트)에만. 우선순위 경쟁이 일어나 오히려 느려진다.


4. 이미지 최적화

4-1. 포맷 선택

포맷장점단점
JPEG호환성 최고큰 용량, 투명도 불가
PNG무손실, 투명도용량 큼
WebPJPEG 대비 ~30% 감소-
AVIFWebP 대비 ~20% 감소인코딩 느림, iOS 16+ 필요
SVG벡터, 무한 확대래스터 부적합
<picture>
  <source type="image/avif" srcset="hero.avif">
  <source type="image/webp" srcset="hero.webp">
  <img src="hero.jpg" alt="Hero" width="1200" height="600">
</picture>

4-2. 반응형 이미지

<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
  alt="..."
  width="800" height="600">

브라우저가 뷰포트·DPR에 맞는 이미지를 선택.

4-3. Lazy Loading

<!-- 뷰포트 밖 이미지는 네이티브 lazy load -->
<img src="below-fold.jpg" loading="lazy" alt="...">

<!-- LCP 이미지에는 절대 쓰지 말 것 -->
<img src="hero.jpg" fetchpriority="high" alt="...">

⚠️ LCP 이미지에 loading="lazy"는 재앙이다. 우선순위를 낮춰 LCP를 악화시킨다.

4-4. Next.js <Image>

자동 반응형 + AVIF/WebP 변환 + width/height 강제 + blurDataURL placeholder.


5. 폰트 최적화

5-1. 웹폰트가 주는 영향

  • FOIT (Flash of Invisible Text): 텍스트 안 보임 → 로딩 지연
  • FOUT (Flash of Unstyled Text): 폴백 폰트 → 웹폰트 깜빡임 → CLS

5-2. 전략

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard.woff2') format('woff2');
  font-display: swap; /* 기본: auto, 권장: swap 또는 optional */
  unicode-range: U+AC00-D7A3; /* 한글만 */
  size-adjust: 100%;
  ascent-override: 90%;
}
  • woff2만 사용 (woff, ttf, eot 삭제 — 2024년 IE 죽음)
  • Subsetting: 사용하는 글자만 포함 (한글은 특히 큼)
  • font-display: swap 또는 optional
  • preload 핵심 폰트
  • **size-adjust + ascent-override**로 폴백 폰트 크기 맞춰 CLS 제거
<link rel="preload" href="/fonts/pretendard.woff2" as="font" type="font/woff2" crossorigin>

5-3. 가변 폰트 (Variable Fonts)

하나의 파일에 여러 weight를 담아 용량 절약.

@font-face {
  font-family: 'Inter';
  src: url('Inter.var.woff2') format('woff2-variations');
  font-weight: 100 900;
}

6. JavaScript 최적화

6-1. 번들 크기 줄이기

  • Tree Shaking (ESM + sideEffects: false)
  • Code Splitting (route-based, dynamic import)
  • 서드파티 감사import moment from 'moment'date-fns 또는 Intl
  • Polyfill: core-js 필요한 타깃에만
// ❌ lodash 전체
import _ from 'lodash';

// ✅ 개별 함수
import debounce from 'lodash/debounce';

// ✅✅ 네이티브로 충분하면 네이티브
const debounced = debounce(fn, 200);

6-2. Long Task 쪼개기

// ❌ 메인 스레드 1초 블로킹
function processAll(items) {
  items.forEach(expensive);
}

// ✅ yield로 쪼개기 (Chrome 129+)
async function processAll(items) {
  for (const item of items) {
    expensive(item);
    await scheduler.yield();
  }
}

// 폴백
function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

6-3. Web Worker

메인 스레드를 막는 무거운 작업(이미지 처리, 대용량 파싱, 암호화)은 Worker로.

const worker = new Worker('/worker.js');
worker.postMessage({ data: hugeArray });
worker.onmessage = (e) => render(e.data);

6-4. React 특화

  • React.memo, useMemo, useCallback프로파일링 후 적용
  • List virtualization (react-window, TanStack Virtual)
  • useTransition — 긴급하지 않은 업데이트
  • useDeferredValue — 검색 입력 같은 파생 값

7. 런타임 성능 (Rendering Performance)

7-1. Reflow vs Repaint

  • Reflow (Layout): 요소 크기/위치 변경 → 전체 트리 재계산 (매우 비쌈)
  • Repaint: 색상 등 시각적 변경 (Reflow 없음)
  • Composite: transform, opacity — GPU 레이어로 처리 (가장 쌈)
/* ❌ 매 프레임 Reflow */
.ball { left: 0; }
.ball:hover { left: 100px; }

/* ✅ Composite만 */
.ball { transform: translateX(0); }
.ball:hover { transform: translateX(100px); }

7-2. Layout Thrashing

읽기 → 쓰기 → 읽기가 반복되면 매번 강제 동기 레이아웃.

// ❌ Layout Thrashing
for (const el of elements) {
  el.style.width = (el.offsetWidth + 10) + 'px'; // 읽기·쓰기 반복
}

// ✅ 읽기 → 쓰기 분리
const widths = elements.map(el => el.offsetWidth);
elements.forEach((el, i) => {
  el.style.width = (widths[i] + 10) + 'px';
});

7-3. content-visibility / contain

뷰포트 밖 렌더 건너뛰기.

.card {
  content-visibility: auto;
  contain-intrinsic-size: 300px 400px;
}

7-4. CSS will-change

브라우저에게 미리 레이어 분리 힌트. 남용하면 메모리 낭비.

.modal { will-change: transform, opacity; }

8. 네트워크 최적화

  • HTTP/2, HTTP/3 — multiplexing, 0-RTT
  • Brotli 압축 (gzip보다 ~20% 작음)
  • Cache-Control — immutable (빌드 해시된 자산)
  • CDN — 엣지 캐싱
  • Service Worker로 오프라인/precache
Cache-Control: public, max-age=31536000, immutable

9. 측정·진단 도구

9-1. 랩 (Lab) — 제어된 환경

  • Lighthouse (Chrome DevTools / PageSpeed Insights)
  • WebPageTest (네트워크 조건 다양)
  • DevTools Performance 패널 — Flame chart, Long Task 분석

9-2. 필드 (Field / RUM) — 실제 사용자

  • Chrome UX Report (CrUX) — Google 공식 RUM
  • web-vitals.js 라이브러리로 직접 수집
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(metric => sendToAnalytics(metric));
onINP(metric => sendToAnalytics(metric));
onCLS(metric => sendToAnalytics(metric));

9-3. 실무 팁

  • p75 값으로 판단 — 상위 25% 사용자 경험
  • 디바이스·네트워크별로 세분화 — 저사양 기기가 병목
  • 랩 점수만 보지 말 것 — Lighthouse 90점이어도 필드는 Poor일 수 있음

10. 성능 예산 (Performance Budget)

초과하면 배포 실패시키는 임계값 설정.

// lighthouse-budget.json
[{
  "resourceSizes": [
    { "resourceType": "script", "budget": 170 },
    { "resourceType": "total", "budget": 500 }
  ],
  "timings": [
    { "metric": "largest-contentful-paint", "budget": 2500 },
    { "metric": "interactive", "budget": 3500 }
  ]
}]

CI에서 Lighthouse 실행 + budget 검증 → 회귀 방지.


11. 실무 체크리스트

  • Core Web Vitals(LCP/INP/CLS)를 필드에서 측정하고 있는가
  • LCP 이미지에 fetchpriority="high" 또는 preload가 있는가
  • 모든 <img>width/height 또는 aspect-ratio가 있는가
  • 웹폰트는 woff2 + font-display: swap + preload인가
  • 서드파티 스크립트는 async/defer 또는 facade 패턴인가
  • Long Task(>50ms)를 프로파일에서 확인했는가
  • 번들 분석(rollup-plugin-visualizer 등)을 정기적으로 하는가
  • Cache-Control과 CDN이 제대로 설정되어 있는가
  • 성능 회귀를 잡는 CI(Lighthouse CI)가 있는가

12. 연습 문제

Q1. LCP가 4.5초로 측정되었다. 가장 먼저 확인해야 할 4가지를 순서대로 나열하라.

정답
  1. TTFB — 서버 응답 자체가 느리면 LCP 절대 못 잡음. 2.5초 이상이면 CDN/캐시/DB 쿼리부터.
  2. LCP 요소 식별 — DevTools Performance에서 LCP 엘리먼트 확인. 이미지인가 텍스트인가.
  3. 리소스 발견 시점 — LCP 이미지가 JS로 동적 삽입되면 preload 스캐너가 놓침 → HTML에 <img> 또는 <link rel="preload"> 추가.
  4. 블로킹 리소스 — 렌더링 블로킹 CSS/JS 최소화(critical CSS 인라인, 비필수 JS에 async/defer).

Q2. FID와 INP의 차이를 설명하라.

정답
  • FID: 페이지 사용자 입력의 **지연(입력 시작 → 핸들러 시작)**만 측정. 처리 시간·렌더 시간 제외.
  • INP: 페이지 수명 전체의 모든 입력을 대상으로, 입력 시작 → 다음 프레임 paint까지 전체 구간을 측정. p98 값 사용.

따라서 INP는 실제 체감 반응성에 훨씬 가깝고, 여러 번 나쁜 상호작용이 있으면 모두 반영된다.

Q3. CLS를 0.3에서 0.05 이하로 낮추려면 어떤 조치를 취해야 하는가?

정답
  1. 모든 <img>, <video>, <iframe>width·height 또는 CSS aspect-ratio 설정.
  2. 광고·배너·임베드의 자리를 먼저 확보(min-height 또는 placeholder).
  3. 웹폰트 font-display: swap + **size-adjust/ascent-override**로 폴백 폰트와 크기 매칭.
  4. 스크롤 상단에 동적 콘텐츠 삽입 금지. 새 알림은 사용자 제스처 기반으로만.
  5. 스켈레톤 UI로 로딩 중 레이아웃을 미리 고정.

Q4. preload, prefetch, preconnect의 차이와 각각의 사용 예를 설명하라.

정답
  • preload: 현재 페이지에 곧 필요한 리소스. 높은 우선순위로 즉시 fetch. → LCP 이미지, 핵심 폰트, 중요한 CSS.
  • prefetch: 다음 페이지에서 쓸 리소스. idle 시 낮은 우선순위 fetch. → 사용자가 다음에 클릭할 것 같은 라우트 청크.
  • preconnect: DNS + TCP + TLS 핸드셰이크만 미리. → 다른 오리진의 API/CDN/폰트 서버.

Q5. 클릭 시 10,000개 아이템을 필터링·렌더링하는 버튼이 있다. INP가 800ms가 나왔다. 개선 방법을 3가지 제시하라.

정답
  1. startTransition으로 양보 — 필터링·렌더는 startTransition에 감싸고, 즉각적인 UI 피드백(스피너/비활성화)은 바깥에. React가 긴급 업데이트 먼저 처리.
  2. Virtualization — 10,000개 DOM을 전부 만들지 않고 뷰포트에 보이는 것만 렌더 (react-window, TanStack Virtual).
  3. Web Worker로 필터링 이동 — 메인 스레드는 UI만, 필터 계산은 Worker에서. 결과만 postMessage.

추가: 입력 기반이면 useDeferredValue, 긴 Task는 scheduler.yield()로 쪼개기.

Q6. transform: translateX(100px)left: 100px 중 애니메이션에서 어느 쪽이 성능상 우수한가?

정답

**transform**이 우수하다. left 변경은 Layout(Reflow) → Paint → Composite 전 단계를 다시 거치지만, transformopacityComposite 단계만 발생한다. 브라우저가 해당 요소를 GPU 레이어로 승격시켜 메인 스레드와 독립적으로 합성하기 때문에 60fps 유지가 쉽다.

Q7. Lighthouse 점수는 95점인데 CrUX(필드) LCP는 Poor로 나온다. 왜일까?

정답

Lighthouse는 제어된 환경(특정 CPU 스로틀링, 느린 3G 시뮬레이션)에서 한 번 측정한 결과다. 실제 사용자는 다양한 디바이스·네트워크·지역·캐시 상태에 있다. 예를 들어:

  • 실제 사용자 중 다수가 구형 안드로이드 + 느린 LTE
  • Lighthouse는 빈 캐시지만 실제로는 세션 중 다른 무거운 페이지를 거친 후 이 페이지에 도달
  • 특정 지리적 지역의 서버 응답이 느림 (엣지 누락)

해결: 필드 데이터를 p75·디바이스별로 쪼개 병목을 찾고, 해당 환경을 Lighthouse로 재현해 검증.


13. 정리

  • Core Web Vitals(LCP/INP/CLS)는 사용자 경험과 검색 랭킹의 표준이다.
  • LCP는 TTFB + 리소스 발견 + 블로킹 자원으로 결정된다.
  • INP는 긴 Task와 무거운 이벤트 핸들러로 악화된다.
  • CLS는 크기 명시와 공간 예약으로 해결한다.
  • Critical Rendering Path 이해 없이는 최적화가 어렵다.
  • 이미지·폰트·JS 각각에 고유한 최적화 기법이 있다.
  • 측정 없이 최적화 없다 — 랩과 필드 데이터를 모두 본다.
  • 성능 예산으로 회귀를 막는다.

← 5-5. 렌더링 패턴 | 5-7. 프레임워크 내부 동작 →

진도 체크시작 전
NEXT · 5-7

프레임워크 내부 동작

Virtual DOM, Fiber, Reactivity 시스템.

이어서 학습하기 →