⚡ 02. HTML 성능 최적화 심화: Lighthouse 90점을 향해
📋 개요
Resource Hints (preload/prefetch/preconnect), Critical CSS, Font Loading, web.dev Core Web Vitals — HTML 수준에서 가능한 성능 최적화 기법을 집중 분석합니다.
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 20분 / 핵심 파트만: 12분
🎯 이 문서의 위치
HTML guide 01번(HTML 멘탈 모델)과 06번(이미지 최적화)을 먼저 읽어보세요.
🗺️ 이 문서의 흐름
[Core Web Vitals 개요] → [Resource Hints] → [폰트 최적화] → [Critical CSS] → [Next.js App의 성능 설정]
🎯 이 문서를 다 읽으면 할 수 있는 것
- LCP, CLS, INP(FID) 가 무엇인지, 어떤 HTML 요소가 영향을 주는지 설명할 수 있다.
-
<link rel="preload/prefetch/preconnect">를 상황에 따라 올바르게 사용할 수 있다. - 폰트 FOUT/FOIT 를
font-display로 제어할 수 있다. - Critical CSS와 Non-critical CSS의 차이를 이해한다.
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 🐣 영철 ( 신입 ): "영호 님, Lighthouse 점수가 58점인데... 어디서부터 손봐야 하는 건지 모르겠어요. 이미지 최적화는 했는데 더 할 게 있나요?"
- 🦁 영호 ( 리드 ): "Lighthouse 점수는 이미지 말고도 폰트, CSS, JS 로딩 순서 전부 영향을 줘요. 특히 폰트 로딩 최적화 안 하면 웹 폰트 로드 전까지 텍스트가 안 보이거나(FOIT) 폰트가 갑자기 바뀌는(FOUT) 현상이 생겨요. HTML
<head>에 힌트 몇 줄 추가하는 것만으로 점수가 확 달라져요."
📊 1. Core Web Vitals — 구글의 성능 기준
LCP (Largest Contentful Paint): 가장 큰 콘텐츠 요소가 로드되는 시간
→ 목표: 2.5초 이내
→ 주요 요인: 히어로 이미지, H1 텍스트, 배경 이미지
CLS (Cumulative Layout Shift): 예상치 못한 레이아웃 이동 합계
→ 목표: 0.1 이하
→ 주요 원인: width/height 없는 img, 동적으로 삽입되는 광고/배너
INP (Interaction to Next Paint): 사용자 입력 → 시각 응답 시간
→ 목표: 200ms 이내
→ 주요 원인: 무거운 JS, 긴 태스크
HTML이 직접 영향을 주는 지표: LCP, CLS
🔗 2. Resource Hints — 브라우저에게 미리 힌트 주기
<head>
<!-- preconnect: 외부 서버와 TCP 연결 미리 수립 (DNS + 소켓) -->
<!-- CDN, 폰트 서버, API 서버 등 꼭 필요한 외부 origin에만 사용 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- preload: 현재 페이지에 반드시 필요한 리소스를 높은 우선순위로 미리 요청 -->
<!-- LCP 이미지, 웹폰트에 사용 -->
<link
rel="preload"
href="/fonts/Pretendard-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
as="image"
href="/images/hero.webp"
imagesrcset="/images/hero-480.webp 480w, /images/hero-800.webp 800w"
/>
<!-- prefetch: 다음 페이지에서 필요할 것 같은 리소스를 유휴 시간에 미리 캐시 -->
<!-- 현재 페이지 로드에 영향 없음 (낮은 우선순위) -->
<link rel="prefetch" href="/posts/2" as="document" />
<!-- dns-prefetch: DNS 조회만 미리 수행 (preconnect보다 가벼운 버전) -->
<link rel="dns-prefetch" href="https://api.ysdeveloper.community" />
</head>언제 무엇을 쓰는가:
| Resource Hint | 사용 시점 | 우선순위 |
|---|---|---|
preconnect | 필수 외부 서버 (폰트, CDN) | 높음 |
preload | 현재 페이지 LCP 이미지, 폰트 | 매우 높음 |
prefetch | 다음 이동할 페이지 JS/데이터 | 낮음 |
dns-prefetch | 여러 서드파티 도메인 | 낮음 |
🔤 3. 폰트 최적화 — FOUT와 FOIT 제어
FOIT (Flash of Invisible Text): 폰트 로드 전 텍스트 보이지 않음
FOUT (Flash of Unstyled Text): 폰트 로드 전 시스템 폰트로 보이다가 교체
/* font-display 속성으로 FOIT/FOUT 제어 */
@font-face {
font-family: "Pretendard";
src: url("/fonts/Pretendard-Regular.woff2") format("woff2");
font-display: swap;
/* swap: 시스템 폰트 먼저 표시, 웹폰트 로드되면 교체 (FOUT 발생하지만 텍스트는 항상 표시) */
/* block: 폰트 로드 전 텍스트 숨김 (FOIT, CLS 없음) */
/* optional: 빠른 캐시에서만 폰트 사용, 없으면 시스템 폰트 */
}Google Fonts 최적화 패턴:
<head>
<!-- 1. preconnect: TCP 연결 미리 수립 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- 2. Google Fonts CSS (display=swap 파라미터로 FOIT 방지) -->
<link
href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;700&display=swap"
rel="stylesheet"
/>
</head>Next.js에서의 폰트 최적화 (next/font):
import { Pretendard } from "next/font/google";
// next/font는 빌드 시 폰트를 다운로드하고 CSS를 자동 최적화
// → 외부 네트워크 요청 없음, 자동 font-display: swap 적용
const pretendard = Pretendard({
subsets: ["latin"],
display: "swap",
variable: "--font-pretendard",
});🎨 4. Critical CSS — 초기 렌더링에 필요한 CSS만 먼저
<head>
<!-- Critical CSS: above-the-fold 콘텐츠에 필요한 최소 스타일을 인라인으로 -->
<!-- 별도 파일 요청 없이 즉시 적용 → FCP 개선 -->
<style>
/* 히어로 섹션, 헤더 등 첫 화면에 보이는 요소의 핵심 스타일만 */
body { margin: 0; font-family: system-ui; }
header { background: #1a1a2e; color: white; padding: 1rem; }
.hero { min-height: 60vh; display: flex; align-items: center; }
</style>
<!-- Non-critical CSS: 나머지는 비동기로 로드 -->
<!-- media="print" 트릭: 파싱 블로킹 없이 로드, onload에서 all로 전환 -->
<link
rel="stylesheet"
href="/styles/full.css"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="/styles/full.css" /></noscript>
</head>🔗 Next.js에서는 자동 처리
Next.js는 페이지별 Critical CSS를 자동으로 추출해서<style>태그로 인라인 삽입해줘요. CSS-in-JS (Styled Components, Emotion) 도 SSR 시 Critical CSS만 추출해서 보내줘요. 수동으로 할 필요 없어요.
📝 마무리 퀴즈
Q1. LCP(Largest Contentful Paint)를 개선하기 위한 HTML 수준의 가장 효과적인 방법 두 가지는?
✅ 정답: ① 히어로 이미지에 <link rel="preload"> 적용 + loading="eager", ② 이미지에 width/height 속성 명시 (CLS 방지)
💡 상세 해설:
- LCP는 가장 큰 콘텐츠 요소(보통 히어로 이미지)가 로드되는 시간이에요.
rel="preload"로 브라우저에게 "이 이미지가 최우선이야" 라는 힌트를 주면 조기에 요청해요.loading="lazy"를 히어로 이미지에 쓰면 오히려 LCP가 나빠지므로 주의하세요.
Q2. 웹폰트의 FOIT(Flash of Invisible Text) 를 방지하기 위한 CSS 설정은?
- A)
font-display: block - B)
font-display: swap - 가)
font-display: optional - 라)
font-display: fallback
✅ 정답: B (실용적으로는 swap 또는 fallback)
💡 상세 해설:
swap은 웹폰트 로드 전 시스템 폰트를 즉시 표시하고, 로드 완료 후 교체(FOUT 발생하지만 텍스트는 항상 표시).block은 폰트 로드 전 텍스트를 숨기는 FOIT 방식이에요.optional은 빠른 로드만 사용,fallback은 swap과 block의 중간이에요. FOIT 방지 =swap또는fallback.
Q3. 영철이의 테스트 타임
Lighthouse 보고서에서 "Eliminate render-blocking resources" 경고가 뜨고 특정 CSS 파일이 지목됐다. 이 CSS 파일이 렌더링을 블로킹하지 않도록 하면서도 결국 적용이 되게 하려면?
✅ 정답: media="print" + onload="this.media='all'" 패턴 사용, 또는 동적 import
💡 상세 해설:
<link rel="stylesheet">는 기본적으로 파싱 블로킹이에요.media="print"로 설정하면 프린트용 스타일로 인식해 블로킹 없이 다운로드하고,onload에서all로 바꿔 적용해요.<noscript>폴백으로 JS 비활성화 환경도 커버해요.
🐣 영철이의 퇴근 일기
<link rel="preload"> 로 폰트 미리 로드하고, font-display: swap 추가했더니 텍스트 FOIT 현상이 사라졌다. 전에는 폰트 로딩 전 텍스트가 안 보이는 게 대수롭지 않게 봤는데, 그게 사용자 경험을 얼마나 해치는 건지 이제 알겠다.
💡 "성능 최적화는 DevTools Network 탭을 보면 보인다. 무엇이 렌더링을 막고 있는지, 어떤 리소스가 늦게 로드되는지 — HTML head의 로딩 순서 하나가 Core Web Vitals 점수를 좌우한다."
Lighthouse 점수가 58점에서 81점으로 올라갔다. 이미지 최적화(6번)랑 이번 Resource Hints + 폰트 최적화 합쳐서 30점 가까이 올린 거다. 남은 건 INP 개선인데, 이건 JS 영역이니까 다음에 따로 파봐야겠다.
🔗 더 알아보기