HTML link 태그 성능 최적화 가이드: preload, prefetch, preconnect 완벽 정리
웹 성능을 극대화하는 리소스 힌트를 알아봅니다. preload로 필수 리소스 우선 로딩, preconnect로 외부 서버 사전 연결, prefetch로 다음 페이지 미리 준비하는 방법을 다이어그램과 함께 상세히 설명합니다.
HTML <link> 태그 성능 최적화 가이드
브라우저에게 리소스 로딩을 최적화하도록 힌트를 제공하는 <link> 태그들을 다룹니다. preload, prefetch, preconnect, dns-prefetch 4가지 리소스 힌트의 동작 원리와 실무 사용법을 알아봅니다.
관련 가이드
| 가이드 | 설명 |
|---|---|
| HTML link 태그 완벽 가이드 | link 태그 기본 문법, stylesheet, 파비콘, SEO |
목차
- 리소스 힌트 개요
- preconnect - 외부 서버 사전 연결
- dns-prefetch - DNS 조회
- preload - 리소스 프리로드
- prefetch - 사전 가져오기
- 리소스 힌트 종합 비교
- 성능 최적화 전략
리소스 힌트 개요
4가지 리소스 힌트는 각각 다른 범위와 우선순위로 동작합니다.
| 힌트 | 하는 일 | 우선순위 | 실행 방식 |
|---|---|---|---|
dns-prefetch | DNS 조회만 | 매우 낮음 | 힌트 |
preconnect | DNS + TCP + TLS 전체 연결 | 낮음 | 힌트 |
preload | 현재 페이지 필수 리소스 즉시 다운로드 | 높음 | 명령 |
prefetch | 다음 페이지 리소스 미리 다운로드 | 최저 | 힌트 |
preconnect - 외부 서버 사전 연결
외부 서버와의 연결을 미리 수립합니다 (DNS + TCP + TLS).
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
동작 원리
외부 서버에 리소스를 요청하려면 DNS → TCP → TLS 3단계 연결 과정이 필요합니다. 이 과정만으로 70~320ms가 소요됩니다.
브라우저 서버
│ │
│ ① DNS 조회 (20~120ms) │
│──── "fonts.gstatic.com의 IP?" ──────→ DNS 서버 │
│◀─── "142.250.196.106" ──────────── DNS 서버 │
│ │
│ ② TCP 3-way Handshake (20~100ms) │
│──── SYN ─────────────────────────────────────→ │
│ (연결 요청 + 시퀀스 번호) │
│◀─── SYN-ACK ──────────────────────────────── │
│ (요청 수락 + 서버 시퀀스 번호) │
│──── ACK ─────────────────────────────────────→ │
│ (확인 완료 → TCP 연결 수립!) │
│ │
│ ③ TLS 핸드셰이크 (30~100ms) │
│──── ClientHello ────────────────────────────→ │
│◀─── ServerHello + 인증서 ────────────────── │
│──── 키 교환 + Finished ────────────────────→ │
│◀─── Finished ────────────────────────────── │
│ │
│ ✅ 연결 완료! (총 70~320ms) │
각 단계 소요 시간:
┌──────────────┬──────────────┬──────────────┐
│ DNS 조회 │ TCP 연결 │ TLS 협상 │
│ 20~120ms │ 20~100ms │ 30~100ms │
├──────────────┴──────────────┴──────────────┤
│ 합계: 70~320ms │
└─────────────────────────────────────────────┘
preconnect 사용 전후 비교
preconnect는 이 연결 과정을 HTML 파싱과 병렬로 미리 수행합니다.
시간(ms) 0 50 100 150 200 250 300 350 400 450
│────│────│─────│─────│─────│─────│─────│─────│─────│
[preconnect 없음] — 리소스 요청 시점에 연결 시작
HTML 파싱 ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
CSS 발견 ░░████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
DNS 조회 ░░░███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
TCP 연결 ░░██░░░░░░░░░░░░░░░░░░░░░░░░░░
TLS 협상 ░░░███░░░░░░░░░░░░░░░░░░░░
리소스 전송 ░░░░██████░░░░░░░░░░
렌더링 ░░████████
~450ms
[preconnect 사용] — HTML 파싱과 연결을 병렬 처리
HTML 파싱 ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
preconnect ░████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ← 병렬!
CSS 발견 ░░████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
리소스 전송 ░░░░██████░░░░░░░░░░░░░░░░░░░░░░░░ ← 즉시!
렌더링 ░░████████░░░░░░░░░░░░░░░
~300ms
절약 시간: 약 100~200ms
연결 풀 관리와 4-6개 제한 이유
preconnect로 수립된 연결은 브라우저 **연결 풀(connection pool)**에 보관됩니다. 사용하지 않는 연결은 리소스를 낭비합니다.
브라우저 연결 풀:
┌──────────────────────────────────────────────┐
│ 도메인당 최대 동시 연결: 6개 (HTTP/1.1) │
│ 미사용 연결 자동 해제: ~10초 후 │
└──────────────────────────────────────────────┘
[적절한 사용: 4개] ✅
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ fonts │ │ cdn │ │ api │ │analytics│
│ 사용 ✅ │ │ 사용 ✅ │ │ 사용 ✅ │ │ 사용 ✅ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
→ 모든 연결이 실제 사용됨
[과도한 사용: 10개] ❌
→ 미사용 연결이 해제될 때까지 CPU, 메모리, 소켓 점유
→ 중요 리소스 다운로드의 대역폭과 경쟁
적합한 사용처
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- CDN -->
<link rel="preconnect" href="https://cdn.example.com">
<!-- API 서버 -->
<link rel="preconnect" href="https://api.example.com">
핵심:
preconnect는 "이 서버와 곧 통신할 테니 미리 연결해 둬라"는 지시입니다. 최대 4-6개, 가장 중요한 외부 도메인에만 사용하세요.
dns-prefetch - DNS 조회
DNS 조회만 미리 수행합니다 (preconnect보다 가벼움).
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//analytics.example.com">
DNS 조회 과정
도메인 이름을 IP 주소로 변환하는 DNS 조회는 계층적 캐시 구조를 따릅니다.
브라우저: "cdn.example.com의 IP 주소가 뭐야?"
│
▼
┌──────────────────────────────────────────────────┐
│ 1단계: 브라우저 DNS 캐시 확인 (~0ms) │
│ → 최근 방문한 도메인이면 즉시 반환 │
└──────────────┬───────────────────────────────────┘
캐시 미스 │
▼
┌──────────────────────────────────────────────────┐
│ 2단계: OS DNS 캐시 확인 (~1ms) │
│ → /etc/hosts 파일 및 OS 레벨 캐시 │
└──────────────┬───────────────────────────────────┘
캐시 미스 │
▼
┌──────────────────────────────────────────────────┐
│ 3단계: ISP DNS 서버 (리커시브 리졸버) (~5-30ms) │
└──────────────┬───────────────────────────────────┘
캐시 미스 │
▼
┌──────────────────────────────────────────────────┐
│ 4단계: 재귀 DNS 조회 (~30-120ms) │
│ 루트 DNS → TLD 서버 → 권한 서버 → IP 반환 │
└──────────────────────────────────────────────────┘
dns-prefetch vs preconnect 커버 범위
DNS 조회 TCP 연결 TLS 협상 리소스 전송
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 20~120ms │ │ 20~100ms │ │ 30~100ms │ │ 가변 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
◀─ dns-prefetch ▶
◀────────────── preconnect ──────────────────────▶
◀────────────────────────── preload ─────────────────────────────▶
리소스 비용:
dns-prefetch │ ▓░░░░░░░░░░░░░░░░░░░ │ 매우 적음 (DNS 패킷만)
preconnect │ ▓▓▓▓▓▓▓░░░░░░░░░░░░░ │ 중간 (소켓 + 메모리)
preload │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░ │ 높음 (전체 다운로드)
조합 사용 (권장)
<!-- 중요한 도메인: preconnect + dns-prefetch (폴백) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<!-- 덜 중요한 도메인: dns-prefetch만 -->
<link rel="dns-prefetch" href="//www.google-analytics.com">
<link rel="dns-prefetch" href="//www.googletagmanager.com">
| 구분 | preconnect | dns-prefetch |
|---|---|---|
| 수행 작업 | DNS + TCP + TLS | DNS만 |
| 리소스 비용 | 높음 | 낮음 |
| 사용처 | 중요한 외부 도메인 | 덜 중요한 도메인 |
| 연결 수 | 4-6개 제한 | 많아도 괜찮음 |
핵심:
dns-prefetch는preconnect의 가벼운 대안입니다. 중요 도메인에는preconnect를, 나머지에는dns-prefetch를 사용하세요.
preload - 리소스 프리로드
현재 페이지에서 즉시 필요한 리소스를 우선 로드합니다.
<link rel="preload" href="리소스URL" as="타입">
동작 원리
브라우저는 HTML을 위에서 아래로 파싱하면서 리소스를 발견합니다. 폰트처럼 CSS 안에 선언된 리소스는 HTML → CSS 파싱 → 리소스 발견 순서를 거쳐야 하므로 다운로드가 늦어집니다. preload는 이 순차 발견 체인을 우회하여 HTML 파싱 단계에서 즉시 다운로드를 시작합니다.
시간 ──────────────────────────────────────────────────────────────>
[일반 로딩] 리소스 발견이 늦어지는 문제
HTML ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
CSS ░░░░██████░░░░░░░░░░░░░░░░░░░░░░░░░
폰트 ░░░░░░░░██████████░░░░░░░░░░░ ← CSS 파싱 후에야 발견
렌더링 ░░░░████████
[preload 사용] HTML 단계에서 즉시 요청
HTML ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
CSS ░░░░██████░░░░░░░░░░░░░░░░░░░░░░░░░
폰트 ░░░░██████████░░░░░░░░░░░░░░░░░░░░░░░░░ ← HTML에서 즉시 시작!
렌더링 ░░░░████████░░░░░░░░░░░░░░░
↑
렌더링이 더 빨라짐!
as 속성 값 (필수)
as 속성은 브라우저 캐시의 **키(key)**를 결정합니다. 없으면 리소스가 두 번 다운로드됩니다.
| as 값 | 리소스 타입 | 우선순위 |
|---|---|---|
style | CSS | Highest |
script | JavaScript | Highest |
font | 폰트 파일 | High |
fetch | XHR/Fetch 요청 | Medium |
image | 이미지 | Low |
document | HTML 문서 | - |
audio | 오디오 | - |
video | 비디오 | - |
as 속성의 역할: 이중 다운로드 방지
[as 속성이 있는 경우] ✅ 1회만 다운로드
<link rel="preload" href="font.woff2" as="font" crossorigin>
preload 요청 @font-face 요청 (CSS)
┌──────────────────┐ ┌──────────────────┐
│ as="font" │ │ 타입: font │
│ CORS: anonymous │ │ CORS: anonymous │
└────────┬─────────┘ └────────┬─────────┘
│ 캐시 키 일치! │
▼ ▼
┌─────────────────────────────────────────────┐
│ 브라우저 캐시 (1회 다운로드) │
└─────────────────────────────────────────────┘
[as 속성이 없는 경우] ❌ 2회 다운로드 (낭비)
<link rel="preload" href="font.woff2"> ← as 없음!
preload 요청 @font-face 요청 (CSS)
┌──────────────────┐ ┌──────────────────┐
│ 타입: 불명 │ │ 타입: font │
│ CORS: no-cors │ │ CORS: anonymous │
└────────┬─────────┘ └────────┬─────────┘
│ 캐시 키 불일치! │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 캐시 A (사용 안됨) │ │ 캐시 B (실제 사용) │
└──────────────────┘ └──────────────────┘
→ 동일 파일을 2번 다운로드하게 됨!
폰트 preload 시 crossorigin이 필수인 이유
CSS @font-face 스펙에 따라 모든 폰트 요청은 CORS anonymous 모드로 전송됩니다. preload도 이 모드와 일치해야 캐시를 공유합니다.
CSS @font-face 규격:
┌──────────────────────────────────────────────┐
│ 모든 폰트 요청은 CORS anonymous 모드로 전송 │
└─────────────────────┬────────────────────────┘
│
┌────────────┴─────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ preload + │ │ preload │
│ crossorigin │ │ (crossorigin 없음) │
│ → CORS anonymous │ │ → no-cors 모드 │
│ → 캐시 키 일치 ✅ │ │ → 캐시 키 불일치 ❌ │
│ → 1회 다운로드 │ │ → 2회 다운로드 │
└──────────────────┘ └──────────────────┘
결론: <link rel="preload" as="font" crossorigin> ← 반드시 3개 세트!
사용 예시
<!-- 폰트 프리로드 (crossorigin 필수!) -->
<link rel="preload"
href="/fonts/noto-sans-kr.woff2"
as="font"
type="font/woff2"
crossorigin>
<!-- 주요 CSS 프리로드 -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="stylesheet" href="/css/critical.css">
<!-- LCP 이미지 프리로드 -->
<link rel="preload" as="image" href="/images/hero-banner.webp">
<!-- 반응형 이미지 프리로드 -->
<link rel="preload" as="image"
href="/images/hero-mobile.webp"
media="(max-width: 600px)">
<link rel="preload" as="image"
href="/images/hero-desktop.webp"
media="(min-width: 601px)">
<!-- fetchpriority로 우선순위 조절 -->
<link rel="preload" href="/critical.js" as="script" fetchpriority="high">
<link rel="preload" href="/analytics.js" as="script" fetchpriority="low">
핵심:
preload는 "지금 당장 필요한 리소스를 미리 받아라"는 명령입니다. 브라우저는 반드시 실행하며, 3초 내에 사용하지 않으면 콘솔에 경고를 출력합니다.
주의사항
- as 속성 필수: 없으면 리소스가 두 번 다운로드될 수 있음
- 필요한 리소스만: 불필요한 preload는 다른 중요 리소스의 대역폭을 빼앗음
- crossorigin 주의: CORS 리소스(폰트 등)는 crossorigin 속성 필수
prefetch - 사전 가져오기
미래 탐색에서 필요할 수 있는 리소스를 낮은 우선순위로 미리 로드합니다.
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="/js/checkout.js" as="script">
동작 원리
prefetch는 현재 페이지의 모든 리소스가 로딩된 후, 브라우저가 유휴(idle) 상태일 때 실행됩니다.
시간 ──────────────────────────────────────────────────────────────>
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌──────────────┐
│ HTML │→│ CSS │→│ JS │→│ 이미지 │→│ 유휴 시간 │
│ 파싱 │ │ 다운로드 │ │ 실행 │ │ 로드 │ │ (idle) │
└─────────┘ └──────────┘ └──────────┘ └────────┘ └──────┬───────┘
◀──────── 현재 페이지 필수 리소스 (높은 우선순위) ────────▶ │
▼
┌─────────────────┐
│ prefetch 실행 │
│ (최저 우선순위) │
└─────────────────┘
※ 브라우저가 "한가할 때" = 현재 페이지의 모든 리소스 로딩 완료 후
※ 네트워크가 바쁘면 prefetch는 무시될 수 있음
캐시 저장 방식: preload vs prefetch
┌───────────────────────┐ ┌───────────────────────┐
│ preload 캐시 │ │ prefetch 캐시 │
├───────────────────────┤ ├───────────────────────┤
│ 메모리 캐시 (빠름) │ │ 디스크 캐시 (지속성) │
│ 현재 페이지에서 사용 │ │ 다음 탐색에서 사용 │
│ 페이지 이동 시 소멸 │ │ 브라우저 닫아도 유지 │
│ 유효 기간: ~10-20초 │ │ 유효 기간: 수분~수시간 │
└───────────────────────┘ └───────────────────────┘
필수(preload) vs 선택(prefetch) 동작 비교
preload (필수 실행 - 명령)
──────────────────────────
브라우저 → "이 리소스를 반드시 다운로드하라"
│
├─ 네트워크 바쁨? → 그래도 다운로드 (높은 우선순위)
├─ 배터리 부족? → 그래도 다운로드
└─ 3초 내 미사용? → 콘솔에 경고 메시지 출력
prefetch (선택 실행 - 힌트)
──────────────────────────
브라우저 → "여유 있으면 이것도 받아두면 좋겠다"
│
├─ 네트워크 바쁨? → 건너뜀
├─ 배터리 부족? → 건너뜀
├─ 데이터 세이버 ON? → 건너뜀
└─ Safari? → 미지원 (완전 무시)
preload vs prefetch
| 구분 | preload | prefetch |
|---|---|---|
| 용도 | 현재 페이지 필수 리소스 | 미래 페이지 리소스 |
| 우선순위 | 높음 (강제) | 낮음 (힌트) |
| 브라우저 준수 | 반드시 실행 | 무시 가능 |
| Safari 지원 | O | X |
| 사용 시점 | 즉시 필요한 리소스 | 다음 페이지 리소스 |
사용 예시
<!-- 다음 페이지 HTML -->
<link rel="prefetch" href="/checkout.html">
<!-- 다음 페이지에서 필요한 스크립트 -->
<link rel="prefetch" href="/js/payment.js" as="script">
<!-- 사용자가 클릭할 가능성이 높은 리소스 -->
<link rel="prefetch" href="/images/product-detail.webp" as="image">
주의사항
- Safari 미지원: Safari는 prefetch를 지원하지 않음 (배터리/데이터 절약 정책)
- 저우선순위: 다른 모든 리소스 로드 후 실행
- 확실하지 않은 예측은 피하기: 사용되지 않을 리소스는 대역폭 낭비
리소스 힌트 종합 비교
페이지 로드 중 4가지 힌트의 동작 타임라인
시간 ──────────────────────────────────────────────────────────────>
0ms 100ms 200ms 500ms 1000ms+
HTML ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
파싱 (link 태그 발견)
│ │ │ │
│ │ │ └──── prefetch ──────────────────────────────────
│ │ │ ░░░░░░████████
│ │ │ (유휴 시간에 실행)
│ │ │
│ │ └─── preload ─────────────────────>
│ │ ██████████████████ (즉시 고우선순위)
│ │
│ └──── preconnect ────>
│ ███[DNS]███[TCP]███[TLS] (연결만 수립)
│
└───── dns-prefetch ──>
███[DNS] (DNS만 조회)
네트워크 레벨 비교
| DNS 조회 | TCP 연결 | TLS 협상 | 리소스 다운로드 | |
|---|---|---|---|---|
| dns-prefetch | ✅ 미리 | - | - | - |
| preconnect | ✅ 미리 | ✅ 미리 | ✅ 미리 | - |
| preload | ✅ 미리 | ✅ 미리 | ✅ 미리 | ✅ 즉시 전송 |
| prefetch | ✅ 유휴 | ✅ 유휴 | ✅ 유휴 | ✅ 유휴 시 전송 |
종합 비교 테이블
| 절약 시간 | 실행 방식 | 리소스 비용 | 개수 제한 | 브라우저 지원 | |
|---|---|---|---|---|---|
| dns-prefetch | ~50ms | 힌트 | 매우 낮음 | 거의 없음 | 매우 좋음 |
| preconnect | ~130ms | 힌트 | 중간 | 4-6개 | 좋음 |
| preload | ~200ms | 명령 | 높음 | 필요한 만큼 | 좋음 |
| prefetch | 가변 | 힌트 | 중간 | 적당히 | Safari 미지원 |
리소스 힌트 선택 가이드
┌─────────────────────┐
│ 외부 리소스가 │
│ 필요한가? │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ 현재 페이지에서 │
┌─YES─┤ 즉시 필요한가? ├─NO─┐
│ └─────────────────────┘ │
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ preload │ │ 다음 페이지에서 │
│ as 속성 필수 │ ┌─YES─┤ 필요한가? ├─NO─┐
│ 폰트→crossorigin │ │ └─────────────────────┘ │
└──────────────────┘ ▼ ▼
┌──────────┐ ┌──────────────────┐
│ prefetch │ │ 외부 도메인인가? │
└──────────┘ ┌─YES─┤ ├─NO─┐
│ └──────────────────┘ │
▼ ▼
┌──────────────────┐ ┌────────────┐
│ 중요한 도메인? │ │ 힌트 불필요 │
┌YES─┤ (4-6개 이내) ├NO─┐ └────────────┘
│ └──────────────────┘ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ preconnect │ │ dns-prefetch │
│ DNS+TCP+TLS │ │ DNS만 조회 │
└─────────────────┘ └─────────────────┘
성능 최적화 전략
권장 head 태그 순서
<head>
<!-- 1. 메타 태그 -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 2. 리소스 힌트 (연결 관련, 가장 먼저) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="//cdn.example.com">
<!-- 3. 프리로드 (현재 페이지 필수) -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<!-- 4. 스타일시트 -->
<link rel="stylesheet" href="/css/critical.css">
<link rel="stylesheet" href="/css/main.css">
<!-- 5. 프리페치 (다음 페이지, 마지막) -->
<link rel="prefetch" href="/js/checkout.js" as="script">
</head>
순서가 중요합니다: 연결 힌트 → 프리로드 → 스타일시트 → 프리페치
주의사항 요약
| 힌트 | 주의사항 |
|---|---|
preconnect | 4-6개 제한, 중요한 도메인만 |
dns-prefetch | 많이 사용해도 OK, preconnect 폴백으로 |
preload | as 속성 필수, 폰트는 crossorigin 필수 |
prefetch | Safari 미지원, 확실한 경우만 사용 |