⚡ 02. HTML 성능 최적화 심화: Lighthouse 90점을 향해

2026년 3월 5일 수정됨

📋 개요

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 영역이니까 다음에 따로 파봐야겠다.


🔗 더 알아보기