이미지 프리로드
웹 앱에서 이미지가 필요한 순간에 로드를 시작하면 사용자는 빈 공간이나 깜빡임을 경험한다. 특히 캐러셀, 모달, 페이지 전환처럼 이미지가 핵심 콘텐츠인 UI에서는 이 지연이 치명적이다. 이미지 프리로드는 이미지가 실제로 화면에 보여지기 전에 미리 브라우저 캐시에 올려두는 기법이다. 사용자가 해당 이미지를 만나는 순간 네트워크 요청 없이 즉시 렌더링되기 때문에 체감 성능이 크게 향상된다.
프리로드가 필요한 상황
모든 이미지를 프리로드할 필요는 없다. 오히려 무분별한 프리로드는 네트워크 대역폭을 낭비하고 초기 로딩을 느리게 만든다. 프리로드가 의미 있는 케이스를 명확히 구분해야 한다.
프리로드해야 하는 경우:
- 이미지 캐러셀에서 다음 슬라이드 이미지
- 모달/다이얼로그를 열 때 보여줄 이미지
- 라우트 전환 후 메인 히어로 이미지
- 호버 시 나타나는 이미지 (상품 상세 등)
- 포토부스 같은 앱에서 프레임/오버레이 이미지
프리로드하면 안 되는 경우:
- 스크롤해야 볼 수 있는 아래쪽 이미지 → lazy loading이 적합
- 사용자가 방문하지 않을 수도 있는 페이지의 이미지
- 수십 장 이상의 갤러리 이미지 전체
핵심은 "곧 보게 될 가능성이 높은 이미지"만 선별적으로 프리로드하는 것이다.
JavaScript new Image()를 이용한 프리로드
가장 기본적이고 널리 사용되는 방법이다. Image 생성자로 HTMLImageElement 인스턴스를 만들고 src를 설정하면 브라우저가 즉시 해당 URL의 이미지를 다운로드한다. DOM에 추가하지 않아도 브라우저 캐시에 저장되기 때문에, 나중에 같은 URL의 <img>를 렌더링하면 캐시에서 즉시 가져온다.
function preloadImage(src) {
const img = new Image();
img.src = src;
}
// 단순 호출
preloadImage('/images/hero.webp');
이 코드가 실행되는 순간 브라우저는 /images/hero.webp를 다운로드하기 시작한다. img 변수가 스코프를 벗어나더라도 브라우저가 다운로드를 완료할 때까지 내부적으로 참조를 유지한다.
로드 완료를 감지해야 할 때
단순히 프리로드만 하면 되는 경우도 있지만, "이미지가 다 준비됐을 때" 무언가를 해야 하는 경우도 있다. 이때 onload와 onerror 이벤트를 활용한다.
function preloadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
img.src = src;
});
}
// 사용
const img = await preloadImage('/images/hero.webp');
console.log(`${img.naturalWidth}x${img.naturalHeight} 로드 완료`);
Promise로 감싸면 async/await이나 Promise.all과 자연스럽게 조합할 수 있다.
여러 이미지를 병렬로 프리로드
캐러셀의 다음 몇 장, 또는 포토부스의 프레임 세트처럼 여러 이미지를 한 번에 프리로드해야 할 때는 Promise.all을 사용한다.
async function preloadImages(srcList) {
const promises = srcList.map((src) => preloadImage(src));
return Promise.all(promises);
}
// 캐러셀의 다음 3장을 미리 로드
await preloadImages([
'/images/slide-2.webp',
'/images/slide-3.webp',
'/images/slide-4.webp',
]);
Promise.all은 모든 이미지가 로드될 때까지 기다린다. 하나라도 실패하면 전체가 reject되므로, 실패를 허용하려면 Promise.allSettled를 사용한다.
async function preloadImages(srcList) {
const results = await Promise.allSettled(
srcList.map((src) => preloadImage(src))
);
const loaded = results
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
const failed = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason);
if (failed.length > 0) {
console.warn('일부 이미지 로드 실패:', failed);
}
return loaded;
}
HTML <link rel="preload">
JavaScript 없이 HTML만으로 프리로드할 수 있다. 브라우저가 HTML을 파싱하는 초기 단계에서 이미지 다운로드를 시작하기 때문에, JavaScript 프리로드보다 더 이른 시점에 로드가 시작된다.
<link rel="preload" as="image" href="/images/hero.webp" />
as="image"를 반드시 지정해야 한다. 생략하면 브라우저가 리소스 타입을 알 수 없어서 우선순위를 올바르게 설정하지 못한다.
반응형 이미지 프리로드
imagesrcset과 imagesizes를 사용하면 반응형 이미지도 프리로드할 수 있다.
<link
rel="preload"
as="image"
imagesrcset="/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w"
imagesizes="(max-width: 600px) 400px, 800px"
/>
브라우저가 현재 뷰포트 크기에 맞는 이미지만 골라서 프리로드한다. 불필요한 큰 이미지를 받지 않으므로 대역폭을 절약할 수 있다.
React에서 <link rel="preload"> 사용
React 18.3+에서는 react-dom의 preload 함수를 직접 사용할 수 있다.
import { preload } from 'react-dom';
function HeroSection() {
preload('/images/hero.webp', { as: 'image' });
return <img src="/images/hero.webp" alt="hero" />;
}
이 함수는 렌더링 중에 호출해도 안전하며, 내부적으로 <link rel="preload">를 <head>에 삽입한다. 같은 URL에 대해 여러 번 호출해도 중복 삽입하지 않는다.
Next.js를 사용한다면 next/image 컴포넌트가 priority prop으로 이 기능을 제공한다.
import Image from 'next/image';
// priority가 true이면 자동으로 preload link를 삽입
<Image src="/images/hero.webp" alt="hero" priority />
CSS 기반 프리로드
JavaScript도 HTML link도 아닌, CSS를 이용한 프리로드 트릭도 있다. 숨겨진 요소의 background-image로 이미지를 지정하면 브라우저가 미리 다운로드한다.
.preload-images::after {
content: '';
position: absolute;
width: 0;
height: 0;
overflow: hidden;
background-image: url('/images/hover-state.webp'),
url('/images/modal-bg.webp');
}
다만 이 방식은 요소가 렌더링 트리에 포함돼야 작동하고(display: none이면 다운로드하지 않음), 프리로드 시점을 제어하기 어렵다. 현대 웹에서는 <link rel="preload">나 JavaScript 방식이 더 권장된다.
React 커스텀 훅: useImagePreloader
React 앱에서 프리로드 상태를 관리하려면 커스텀 훅으로 추상화하는 것이 깔끔하다.
import { useState, useEffect } from 'react';
interface PreloadState {
isLoaded: boolean;
isError: boolean;
progress: number; // 0 ~ 1
}
function useImagePreloader(srcList: string[]): PreloadState {
const [state, setState] = useState<PreloadState>({
isLoaded: false,
isError: false,
progress: 0,
});
useEffect(() => {
if (srcList.length === 0) {
setState({ isLoaded: true, isError: false, progress: 1 });
return;
}
let loadedCount = 0;
let hasError = false;
let cancelled = false;
const handleComplete = (success: boolean) => {
if (cancelled) return;
if (!success) hasError = true;
loadedCount++;
const progress = loadedCount / srcList.length;
const isLoaded = loadedCount === srcList.length;
setState({
isLoaded,
isError: hasError,
progress,
});
};
srcList.forEach((src) => {
const img = new Image();
img.onload = () => handleComplete(true);
img.onerror = () => handleComplete(false);
img.src = src;
});
return () => {
cancelled = true;
};
}, [srcList]);
return state;
}
사용 예시:
function PhotoFrameSelector({ frames }: { frames: string[] }) {
const { isLoaded, progress } = useImagePreloader(frames);
if (!isLoaded) {
return <ProgressBar value={progress} />;
}
return (
<div className="frame-grid">
{frames.map((src) => (
<img key={src} src={src} alt="frame" />
))}
</div>
);
}
프레임 이미지가 모두 준비될 때까지 프로그레스 바를 보여주고, 준비되면 즉시 모든 이미지를 렌더링한다. 캐시에 이미 있으므로 깜빡임 없이 한 번에 표시된다.
주의: srcList 참조 안정성
useEffect의 의존성 배열에 srcList가 들어가므로, 렌더링할 때마다 새 배열을 만들면 무한 루프에 빠질 수 있다. 반드시 useMemo로 안정화하거나 컴포넌트 외부에서 정의해야 한다.
// ❌ 렌더링마다 새 배열 → useEffect 무한 실행
function App() {
const { isLoaded } = useImagePreloader(['/a.webp', '/b.webp']);
}
// ✅ useMemo로 참조 안정화
function App() {
const srcs = useMemo(() => ['/a.webp', '/b.webp'], []);
const { isLoaded } = useImagePreloader(srcs);
}
캐러셀에서의 프리로드 전략
캐러셀은 프리로드의 가장 대표적인 활용 사례다. 전체 이미지를 한 번에 로드하는 대신, 현재 슬라이드 기준으로 앞뒤 N장만 프리로드하는 "슬라이딩 윈도우" 전략이 효과적이다.
function useCarouselPreload(
images: string[],
currentIndex: number,
windowSize: number = 2
) {
const preloadTargets = useMemo(() => {
const targets = new Set<string>();
for (let i = -windowSize; i <= windowSize; i++) {
const idx = currentIndex + i;
if (idx >= 0 && idx < images.length) {
targets.add(images[idx]);
}
}
return Array.from(targets);
}, [images, currentIndex, windowSize]);
useEffect(() => {
preloadTargets.forEach((src) => {
const img = new Image();
img.src = src;
});
}, [preloadTargets]);
}
currentIndex가 바뀔 때마다 앞뒤 2장을 프리로드한다. 이미 캐시에 있는 이미지는 브라우저가 알아서 스킵하므로 중복 요청은 걱정하지 않아도 된다.
fetchpriority로 우선순위 제어
같은 페이지에서 여러 이미지가 로드될 때, 어떤 이미지가 더 중요한지 브라우저에 알려줄 수 있다.
<!-- 히어로 이미지: 최우선 -->
<img src="/hero.webp" fetchpriority="high" alt="hero" />
<!-- 아래쪽 장식 이미지: 낮은 우선순위 -->
<img src="/decoration.webp" fetchpriority="low" alt="" />
<link rel="preload">에도 사용할 수 있다.
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
fetchpriority는 세 가지 값을 가진다:
high— 다른 이미지보다 먼저 다운로드low— 중요한 리소스가 먼저 다운로드된 후에 가져옴auto(기본값) — 브라우저가 자체적으로 판단
LCP(Largest Contentful Paint) 이미지에 fetchpriority="high"를 지정하면 Core Web Vitals 점수가 개선될 수 있다.
프리로드와 캐시의 관계
프리로드의 핵심 원리는 결국 브라우저 캐시에 의존한다는 점이다. new Image()로 로드한 이미지가 나중에 <img src>로 렌더링될 때 캐시에서 가져오는 이유는 HTTP 캐싱 메커니즘 때문이다.
1차 요청 (프리로드):
GET /hero.webp → 200 OK
Cache-Control: max-age=86400
→ 브라우저 캐시에 저장
2차 요청 (렌더링):
GET /hero.webp → 캐시 히트 (네트워크 요청 없음)
→ 즉시 렌더링
그래서 서버의 캐시 헤더가 중요하다. Cache-Control: no-store가 설정된 이미지는 프리로드해도 두 번째 요청에서 다시 네트워크를 탄다. 프리로드 효과를 보려면 적절한 캐시 정책이 서버에 설정되어 있어야 한다.
또한 new Image()로 프리로드할 때 crossOrigin 속성에 주의해야 한다. 프리로드 시 crossOrigin을 설정하지 않고, 렌더링 시 <img crossorigin>을 사용하면 캐시가 분리되어 이중 다운로드가 발생할 수 있다.
// 프리로드할 때와 렌더링할 때 crossOrigin 설정을 일치시켜야 함
const img = new Image();
img.crossOrigin = 'anonymous'; // 렌더링할 <img>에도 동일하게 설정
img.src = 'https://cdn.example.com/hero.webp';
<link rel="prefetch"> vs <link rel="preload">
혼동하기 쉬운 두 속성의 차이를 명확히 해두자.
| 속성 | 시점 | 우선순위 | 용도 |
|---|---|---|---|
preload | 현재 페이지에서 곧 필요한 리소스 | 높음 | 히어로 이미지, 폰트, 핵심 스크립트 |
prefetch | 다음 페이지에서 필요할 수 있는 리소스 | 매우 낮음 | 다음 페이지 이미지, 아직 안 간 라우트의 번들 |
<!-- 현재 페이지의 히어로 이미지 → preload -->
<link rel="preload" as="image" href="/hero.webp" />
<!-- 다음 페이지에서 쓸 이미지 → prefetch -->
<link rel="prefetch" as="image" href="/next-page/banner.webp" />
preload는 현재 네비게이션에 필요한 리소스를 높은 우선순위로 가져온다. 3초 내에 사용되지 않으면 브라우저 콘솔에 경고가 뜬다. prefetch는 브라우저가 유휴 시간에 낮은 우선순위로 가져오며, 사용되지 않아도 경고가 뜨지 않는다.
실전 패턴: 조건부 프리로드
모든 이미지를 무조건 프리로드하는 것은 비효율적이다. 사용자의 행동에 따라 조건부로 프리로드하면 대역폭을 절약하면서도 체감 성능을 높일 수 있다.
호버 시 프리로드
상품 목록에서 마우스를 올리면 상세 이미지를 프리로드한다. 클릭해서 상세 페이지에 진입하면 이미지가 즉시 표시된다.
function ProductCard({ product }: { product: Product }) {
const handleMouseEnter = () => {
product.detailImages.forEach((src) => {
const img = new Image();
img.src = src;
});
};
return (
<Link
to={`/product/${product.id}`}
onMouseEnter={handleMouseEnter}
>
<img src={product.thumbnail} alt={product.name} />
<span>{product.name}</span>
</Link>
);
}
Intersection Observer와 결합
뷰포트에 가까워지면 프리로드를 시작하는 패턴. 스크롤하면서 자연스럽게 아래쪽 이미지가 미리 로드된다.
function LazyPreloadImage({ src, alt }: { src: string; alt: string }) {
const [isNear, setIsNear] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsNear(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // 뷰포트 200px 전에 트리거
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
{isNear && <img src={src} alt={alt} />}
</div>
);
}
rootMargin: '200px'으로 실제 뷰포트보다 200px 아래까지 감시 영역을 확장한다. 사용자가 스크롤하면서 요소에 가까워지면 미리 이미지 로드가 시작된다.
성능 고려사항
프리로드는 양날의 검이다. 잘못 사용하면 오히려 성능을 해친다.
동시 연결 수 제한: 브라우저는 같은 도메인에 대해 동시 HTTP 연결을 6개 정도로 제한한다(HTTP/1.1 기준). 프리로드할 이미지가 너무 많으면 정작 현재 화면에 필요한 리소스의 다운로드가 지연된다.
대역폭 경쟁: 프리로드 이미지가 네트워크 대역폭을 차지하면서 JS 번들이나 API 응답의 다운로드가 느려질 수 있다. 특히 모바일 환경에서 주의해야 한다.
메모리 사용: 프리로드된 이미지는 메모리에 디코딩된 상태로 유지될 수 있다. 고해상도 이미지 수십 장을 한 번에 프리로드하면 메모리 압박이 발생한다.
절제의 원칙:
- 한 번에 프리로드하는 이미지는 3~5장 이내로 제한
- 모바일에서는 더 보수적으로 (1~2장)
- HTTP/2 환경에서는 동시 연결 제한이 사실상 없으므로 좀 더 여유롭게 가능
Save-Data헤더를 확인해서 데이터 절약 모드에서는 프리로드를 비활성화
// 데이터 절약 모드 확인
const saveData = navigator.connection?.saveData;
if (!saveData) {
preloadImages(nextSlides);
}
정리
| 방법 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
new Image() | 타이밍 완전 제어, Promise 조합 가능 | JS 실행 후에야 시작 | 동적 프리로드, 조건부 로드 |
<link rel="preload"> | 가장 빠른 시작 시점 (HTML 파싱 단계) | 정적, 동적 URL에 부적합 | LCP 이미지, 히어로 배너 |
react-dom preload() | React와 자연스러운 통합 | React 18.3+ 필요 | React 앱의 핵심 이미지 |
| CSS background | JS 불필요 | 제어 어려움, display:none 시 무효 | 레거시 환경 |
핵심은 "언제, 무엇을, 얼마나" 프리로드할지 판단하는 것이다. 사용자가 곧 보게 될 소수의 중요한 이미지만 선별적으로 프리로드하면, 최소한의 비용으로 최대한의 체감 성능 향상을 얻을 수 있다.
관련 문서
- Canvas drawImage - 프리로드된 이미지를 Canvas에 그리는 API
- Intersection Observer - 뷰포트 진입 감지를 활용한 조건부 프리로드
- Next.js Image Optimization - Next.js의 이미지 최적화와 priority 프리로드