JavaScript 이미지 프리로딩 — 5가지 방법 비교

11 min read
JavaScript성능이미지프리로드브라우저 캐시
JavaScript 이미지 프리로딩 — 5가지 방법 비교

한 줄 결론부터

이미지 프리로딩 방법은 여러 가지가 있는데, 상황에 따라 정답이 다르다. 급하게 선택해야 한다면 이렇게 보면 된다.

  • 이미지 크기나 로드 콜백이 필요해요new Image()
  • 서버 캐시 제어와 무관하게 확실히 받아두고 싶다<link rel="preload">
  • 요청 헤더를 세밀하게 제어하고 오래 들고 있어야 한다Cache API
  • 짧게 메모리에만 들고 있을 거다fetch() + blob URL

왜 이 비교가 의미 있나

이미지를 미리 받아두면 사용자가 그 이미지가 필요한 순간에 즉시 렌더할 수 있다. 문제는 "미리 받아두기"가 생각보다 애매하다는 점이다. HTTP 캐시가 서버 정책에 좌우되고, JS로 만든 <link>는 우선순위가 낮게 잡히며, Cache API는 직접 정리까지 해줘야 한다. 각 방법이 어떤 캐시에 들어가는지 이해해야 원하는 동작을 얻는다.

[💡 잠깐! 이 용어는?] 프리로드(Preload): 사용자가 그 리소스를 보기 전에 브라우저가 미리 내려받아 두는 것. "필요할 때 바로 쓸 수 있게" 준비해두는 동작이다.

[💡 잠깐! 이 용어는?] HTTP 캐시 vs 프리로드 캐시: HTTP 캐시는 일반적인 브라우저 캐시로, 서버가 Cache-Control: no-store를 보내면 저장되지 않는다. 반면 프리로드 캐시(Preload Cache)는 브라우저 내부의 별도 공간으로 <link rel="preload">가 사용하며, 서버 정책과 무관하게 동작한다.


핵심 비교표

방식저장 위치서버 캐시 제어에 영향 받음재사용정리
new Image()HTTP 캐시자동자동
<link rel="preload">프리로드 캐시아니오자동자동
hidden <div>HTTP 캐시자동자동
Cache APICacheStorage아니오수동수동
fetch() + blob메모리수동URL.revokeObjectURL

굵게 표시한 <link rel="preload">가 대부분의 경우 가장 무난한 선택이다. 서버가 캐시를 막아도 동작하고, 정리도 자동이다.


방법 1 — new Image()

가장 오래된 방법이다. 이미지 객체를 만들고 src를 할당하면 그 순간부터 브라우저가 이미지를 받기 시작한다.

src/utils/preloadImage.js
export function preloadImage(url) {
  const img = new Image()
  img.src = url
 
  return new Promise((resolve, reject) => {
    img.onload = () => {
      resolve({
        width: img.naturalWidth,
        height: img.naturalHeight,
      })
    }
    img.onerror = () => {
      reject(new Error(`이미지 로드 실패: ${url}`))
    }
  })
}

장단점

강점: 호환성이 넓고, 이미지 크기를 미리 알 수 있다. onload/onerror 콜백으로 로드 완료 시점을 잡을 수 있어서 "이미지가 준비됐을 때만 보여주기" 같은 UX에 적합하다.

약점: HTTP 캐시를 타기 때문에 서버가 Cache-Control: no-store를 주면 두 번 다운로드된다. 한 번은 프리로드용, 한 번은 실제 렌더용이다.


표준 프리로드 훅이다. JS로 <link> 엘리먼트를 만들어 <head>에 붙이면 된다.

src/utils/preloadImage.js
export function preloadImageLink(url) {
  const link = document.createElement('link')
  link.rel = 'preload'
  link.as = 'image'
  link.href = url
  link.fetchPriority = 'high'
  document.head.appendChild(link)
}

장단점

강점: 브라우저의 프리로드 캐시라는 별도 공간에 저장되기 때문에 Cache-Control: no-store 같은 서버 정책의 영향을 받지 않는다. 중복 요청도 브라우저가 알아서 막는다. 이미지가 프리로드 중인데 렌더가 시작되면 새 요청을 만들지 않고 이미 진행 중인 요청을 기다린다.

약점: JS로 동적으로 주입한 <link rel="preload">의 기본 우선순위는 "low"다. 이미지를 빨리 받고 싶다면 fetchPriority = 'high'를 명시해야 한다.

포인트: fetchPriority를 생략하면 "미리 받는 의미"가 퇴색된다. JS로 주입할 때는 거의 항상 명시하는 게 맞다.

[💡 잠깐! 이 용어는?] fetchPriority: 브라우저에게 리소스 우선순위를 힌트로 주는 속성. high, low, auto 세 값을 가지며, <link rel="preload"> JS 주입 시 기본값이 low로 잡히는 함정이 있다.


방법 3 — hidden <div> + background-image

CSS의 background-image로 숨겨진 요소에 이미지를 할당해 받는 꼼수다.

src/utils/preloadImage.js
export function preloadImageDiv(url) {
  const div = document.createElement('div')
  div.style.backgroundImage = `url('${url}')`
  div.style.visibility = 'hidden'
  div.style.position = 'absolute'
  div.style.width = '1px'
  div.style.height = '1px'
  document.body.appendChild(div)
}

주의할 점

display: none을 쓰면 이미지가 아예 로드되지 않는다. 브라우저는 display: none 요소를 "렌더 안 함"으로 판단해서 배경 이미지까지 받지 않는다. visibility: hidden이나 화면 밖으로 빼는 방식을 써야 한다.

솔직한 평가: 실무에서 이 방법을 쓸 일은 거의 없다. <link rel="preload">가 모든 면에서 낫다.


방법 4 — Cache API

Service Worker에서 쓰는 CacheStorage를 직접 호출해 리소스를 저장한다.

src/utils/preloadImage.js
const CACHE_NAME = 'images-v1'
 
export async function preloadImageCache(url) {
  const cache = await caches.open(CACHE_NAME)
  await cache.add(url)
}
 
export async function getCachedImage(url) {
  const cache = await caches.open(CACHE_NAME)
  const response = await cache.match(url)
  if (!response) {
    return null
  }
  const blob = await response.blob()
  return URL.createObjectURL(blob)
}
 
export async function clearImageCache() {
  await caches.delete(CACHE_NAME)
}

장단점

강점: 브라우저 HTTP 캐시와 완전히 독립이다. 페이지 리로드 후에도, 네트워크가 끊긴 뒤에도 접근할 수 있다. Promise 기반이라 로직 순서를 명확하게 잡을 수 있다.

약점: 정리가 수동이다. caches.delete()를 안 부르면 저장된 이미지가 계속 남는다. 타임아웃이나 에러 흐름에서 정리 로직을 깜빡하면 디스크에 고아 데이터가 쌓인다. 관리 부담이 있다.


방법 5 — fetch() + blob URL

가장 유연한 방법이다. fetch로 받아서 blob으로 변환하고, URL.createObjectURL로 사용할 URL을 만든다.

src/utils/preloadImage.js
export async function preloadImageFetch(url, options = {}) {
  const response = await fetch(url, {
    headers: options.headers ?? {},
    credentials: options.credentials ?? 'same-origin',
  })
  if (!response.ok) {
    throw new Error(`이미지 로드 실패: ${response.status}`)
  }
  const blob = await response.blob()
  const objectUrl = URL.createObjectURL(blob)
 
  return {
    url: objectUrl,
    revoke: () => URL.revokeObjectURL(objectUrl),
  }
}

장단점

강점: 요청 헤더를 완전히 제어할 수 있다. 인증 토큰을 붙이거나 CORS 옵션을 세밀하게 조절해야 할 때 유용하다.

약점: 여전히 서버의 Cache-Control을 탄다. 그리고 blob URL은 revokeObjectURL 호출 전까지 메모리에 남는다. 정리를 잊으면 누수가 생긴다.


선택 체크리스트

상황
이미지 크기나 로드 시점을 JS에서 다뤄야 한다히어로 이미지 페이드 인new Image()
페이지 이동 전 미리 받아두고 싶다next page의 배경 이미지<link rel="preload">
서버가 HTTP 캐시를 막아놓았다일회성 서명 URL<link rel="preload"> 또는 Cache API
오프라인에서도 접근해야 한다PWA 갤러리Cache API
인증 헤더가 필요하다보호된 자산fetch() + blob

**"뭘 써야 할지 모르겠다"**면 <link rel="preload">로 시작하는 게 안전하다. 가장 표준에 가깝고, 브라우저가 중복 요청 방지와 정리를 알아서 해준다.


마무리

프리로딩은 어느 캐시에 들어가는지를 이해해야 원하는 동작이 나온다. 방법마다 저장 위치와 정리 방식이 달라서, 겉보기에 같아 보이는 코드가 전혀 다르게 동작하기도 한다.

  • 콜백·치수가 필요하면 new Image()
  • 대부분의 경우 <link rel="preload"> + fetchPriority: 'high'
  • 오프라인 지속이 필요하면 Cache API (정리 책임 따라옴)
  • 헤더 제어가 필요하면 fetch() + blob (정리 책임 따라옴)

제일 좋은 방법은, 작은 테스트 페이지에서 각각 DevTools Network 탭을 열고 확인해보는 것이다. 어느 방법이 몇 번 요청을 날리는지, 우선순위는 어떻게 잡히는지 눈으로 보면 선택이 쉬워진다.


참고:

관심 있을 만한 포스트

HTML Minifier 벤치마크 — 48개 사이트에서 어떤 도구가 이겼나

minify-html, htmlnano, HTML Minifier Next, @swc/html 등 주요 HTML 압축 도구를 실제 사이트로 비교한 벤치마크 결과와 선택 기준을 정리한다.

HTMLMinifier

JavaScript 물리 엔진 만들기 — 100줄로 구현하는 2D 물리 시뮬레이션

벡터 연산, 원 충돌 감지, 충격량 기반 응답까지 순수 JavaScript로 2D 물리 엔진을 직접 만든다.

Physics EngineCanvas

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler

Native JSON Modules — 번들러 없이 JSON을 import하는 시대

Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.

JavaScriptESM

Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1

2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.

BabelJavaScript

Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다

Bun으로 바꿔도 p99가 개선되지 않는 이유. 런타임 선택보다 먼저 봐야 할 진짜 병목 지점들.

BunNode.js

Error.isError() — realm을 넘나드는 안전한 에러 검사 API

instanceof Error가 iframe과 worker에서 실패하는 이유, 그리고 이를 근본적으로 해결하는 Error.isError()의 동작 원리를 정리한다.

JavaScriptError.isError