본문으로 건너뛰기
Tech Blog

preload 와 그 형제들

글 복사 완료!

preload, prefetch, modulepreload 가 비슷해 보이지만 처리 시점이 정말 달라요.

·11분·

성능을 손볼 때 head 에 박는 link 태그가 다섯 가지 있어요. <link rel="dns-prefetch">, <link rel="preconnect">, <link rel="preload">, <link rel="prefetch">, <link rel="modulepreload">. 앞 둘은 연결을 미리 해두고, 뒤 셋은 자원을 미리 받아두는 쪽이에요. 이름이 다 비슷해서 한참 헷갈렸거든요. 다섯 다 "미리 한다" 는 결인데, 막상 박아보면 어떤 글에서는 LCP 가 줄어들고 어떤 글에서는 콘솔에 unused 경고만 뜨고요. 미리 하는 깊이가 다 다르더라고요. 한 번에 정리하고 가볼게요.

dns-prefetch 와 preconnect, 연결을 미리

새 origin 에 처음 요청을 보내려면 DNS 해석, TCP 핸드셰이크, TLS 협상을 거쳐야 해요. 합치면 한 origin 마다 100ms 에서 500ms 가 들어요. 외부 CDN 이 본문 중간에 발견되면 그만큼 늦어지죠. head 에 hint 를 박아두면 이 연결 단계가 본문 파싱과 병렬로 진행돼요.

dns-prefetch 는 1단계, 도메인 이름을 IP 로 바꾸는 DNS 해석만 미리 해둬요. 비용이 거의 안 들어서 망설일 필요가 없는 hint 죠.

preconnect 는 거기에 TCP 와 TLS 까지 미리 끝내요. 그래서 가장 비싸지만 효과도 가장 커요. 비싼 만큼 가장 critical 한 origin 한두 개에만 박는 게 권고예요. 그 외엔 dns-prefetch 가 더 안전한 선택이고요.

<!-- Google Fonts 표준 패턴, 두 origin 연결 + CSS 파일 로드 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
 
<!-- 분석이나 광고처럼 가벼운 외부 origin, dns-prefetch 만 -->
<link rel="dns-prefetch" href="https://www.google-analytics.com">

위 패턴에서 fonts.gstatic.comcrossorigin 이 붙은 게 보일 거예요. 그쪽에서 실제 폰트 파일을 받기 때문이고, fonts.googleapis.com 은 CSS 한 줄을 받아요.

자기 도메인은 어차피 이미 연결돼 있으니 preconnect 가 무의미하고, 외부 origin 을 너무 많이 preconnect 하면 critical 자원 대역폭이 깎여요. 폰트 fetch 는 항상 anonymous CORS 모드라 crossorigin 도 같이 박지 않으면 효과가 DNS 수준으로 떨어지고요.

preload, 지금 페이지를 위한 받아두기

preload현재 내비게이션 에 쓸 자원을 미리 받아두는 hint 예요. 브라우저는 HTML 을 받자마자 preload scanner 라는 빠른 파서로 본문을 한 번 훑어서 외부 자원을 미리 받기 시작해요. 그런데 scanner 는 CSS @font-face 안의 폰트 URL, JS 가 런타임에 만드는 fetch URL 같은 건 못 봐요. 본문 파싱이 거기까지 가야 발견되거든요. preload 가 푸는 게 정확히 이 "늦은 발견" 문제예요.

WHATWG HTML 명세는 preload 만 다른 hint 와 다르게 적어요. "must preemptively fetch and cache" 라고요. 다른 hint 가 "should" 인 것과 달리 강제 표현이죠. 그래서 받아왔는데 안 쓰면 콘솔이 unused preload 경고를 띄워요. 명세 상 "받아왔어야 한다" 인데 안 쓴다는 게 어색하니까요.

<!-- 위 fold hero 이미지 (LCP 후보), fetchpriority 로 우선순위까지 끌어올림 -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
 
<!-- 본문 핵심 폰트, woff2 한 weight 만 -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-400.woff2" crossorigin>
 
<!-- CSS 의 @import 안에 숨어 있어 scanner 가 못 찾는 자원 -->
<link rel="preload" as="script" href="/critical.js">

이 link 태그는 바닐라 HTML 이든 React 든 그대로 <head> 에 박아요. fontsource 같은 폰트 패키지나 Next.js 의 next/font 같은 도구를 쓰면 빌드 시 자동으로 박기도 해요. 어느 쪽이든 결과적으로 head 에 들어가는 link 태그 모양은 같아요.

as 는 옵션이 아니에요. 브라우저가 캐시 분류, CSP 적용, Accept 헤더, 우선순위 할당에 모두 as 를 쓰거든요. as 가 빠지면 XHR 우선순위로 받아져서 효과가 반감돼요. 폰트는 한 가지 더 있어요. 폰트 fetch 는 항상 anonymous CORS 모드라 crossorigin 도 같이 박지 않으면 같은 폰트가 두 번 다운로드돼요.

prefetch, 다음 페이지를 위한 받아두기

prefetch 는 같은 "받아둔다" 인데 적용 시점이 달라요. 다음 내비게이션 을 위한 힌트거든요. 결과는 메모리가 아니라 디스크 캐시에 저장되어 페이지 이동 후에도 남아요. 사용자가 다음에 갈 가능성이 큰 페이지의 자원을 미리 받아두는 용도죠.

<!-- 다음 라우트 (예: 상품 상세에서 결제 페이지로) -->
<link rel="prefetch" as="document" href="/checkout">
 
<!-- 그 페이지에서 쓰는 JS chunk -->
<link rel="prefetch" as="script" href="/static/checkout-a3f4.js">

바닐라 HTML 이나 정적 사이트라면 이런 식으로 head 에 직접 박아요. SPA 라면 라우터 라이브러리가 비슷한 일을 자동으로 해주는 게 보통이에요. React Router 의 <Link prefetch>, Next.js <Link> 가 viewport 진입 시 알아서 박는 식이죠. 직접 hover 나 viewport 이벤트에서 동적으로 link 태그를 추가하는 패턴도 있고요.

Chrome 에서 prefetch 의 우선순위는 명시적으로 "Lowest" 예요. 현재 페이지의 critical 자원이 다 받아질 때까지 기다리고, 그 다음에야 한가하게 받기 시작해요. 그래서 현재 페이지 성능을 잠식하지 않죠. preload 가 "지금 당장" 인 반면 prefetch 는 "여유 시간에" 인 셈이에요.

한 가지 함정. Cache-Control: no-store 같은 응답 헤더가 붙은 자원은 prefetch 결과를 사용자에게 다시 줄 때 무효화될 수 있어요. 정적 자산이 아닌 동적 페이지를 prefetch 할 때는 캐시 정책을 한 번 더 확인하세요.

modulepreload, 받아서 실행 준비까지

modulepreload 는 ES 모듈 전용이에요. preload 가 "받아만" 두는 거라면, modulepreload 는 한 단계 더 가요. fetch + parse + compile + module map 등록 까지 마쳐요.

"The main difference is that preload just downloads the file and stores it in the cache, while modulepreload gets the module, parses and compiles it, and puts the results into the module map so that it is ready to execute." - MDN

<script type="module"> 로 로드되는 ES 모듈은 module map 에 등록돼야 즉시 실행 가능해요. preload 로 받아두면 디스크엔 있지만 module map 엔 없어서 실행 시점에 다시 파싱하고 컴파일해야 해요.

<!-- 번들러 빌드 결과 head 에 자동으로 박히는 형태 -->
<link rel="modulepreload" href="/assets/index-a3f4.js">
<link rel="modulepreload" href="/assets/router-b2c1.js">
<link rel="modulepreload" href="/assets/vendor-d5e6.js">

바닐라로 ES 모듈을 직접 쓸 때도 이렇게 박을 수 있고, Vite, Rollup, esbuild 같은 번들러가 빌드 결과 head 에 자동으로 채워주기도 해요.

함정도 하나 알아두면 좋아요. 브라우저가 modulepreload 된 모듈의 import 의존성을 자동으로 따라 받을 수도 있는데, 이건 "구현 최적화" 라서 모든 브라우저에서 보장되지 않아요. 의존 모듈이 확실히 미리 받아지길 원하면 각각 명시해야 해요. 번들러가 modulepreload 태그를 줄줄이 박는 게 그 이유예요.

다섯이 어떻게 다른가

같은 link 태그인데 미리 하는 깊이가 다 달라요. dns-prefetchpreconnect 는 연결 단계만 미리 해두는데, 앞은 DNS 까지, 뒤는 TCP+TLS 까지예요. preloadprefetch 는 자원을 받아두는데, preload 는 지금 페이지용, prefetch 는 다음 페이지용이고요. modulepreload 는 자원을 받아두고도 한 단계 더 가서 실행 준비까지 마쳐요. 다섯이 비슷해 보이는 건 다 "미리 한다" 는 결을 공유하기 때문이고, 다른 건 "어디까지" 와 "언제" 가 따로 있기 때문이에요.

2편 에서는 다섯을 한 표 위에 올려놓고 자기 자리에 맞게 박는 법을 정리해요. preload 만 박았는데 LCP 가 안 빨라지는 이유, preconnect 를 너무 많이 박았을 때 어떻게 되는지 같은 함정도 같이 봐요.

참고 자료

관련 글