Next Image 컴포넌트와 Preload를 활용한 이미지 최적화
TL;DR
Next.js Image 컴포넌트는 srcset을 통해 디바이스에 맞는 이미지를 제공하지만, 동적 src의 경우 체감 속도 향상이 크지 않다. 사용자 환경(뷰포트, DPR)에 맞는 정확한 이미지 URL을 계산해서 preload하면 브라우저 캐시 히트를 통해 실질적인 로딩 속도 개선을 얻을 수 있다.
개요
이미지 최적화의 가장 좋은 방법은 업로드 시점에 sharp 등으로 avif/webp 변환과 적절한 압축을 적용하는 것이다. 하지만 이미 외부 스토리지(S3, Supabase 등)에 저장된 대용량 이미지를 다뤄야 하는 경우, Next.js Image 컴포넌트의 최적화 기능과 preload 전략을 조합하면 사용자 체감 속도를 개선할 수 있다.
이 글에서는 Next.js Image 컴포넌트가 생성하는 srcset의 원리를 이해하고, 이를 활용해 효과적인 preload를 구현하는 방법을 다룬다.
목표
- Next.js Image의 srcset 동작 원리 이해
- 사용자 디바이스 환경에 맞는 이미지 URL 계산
- 계산된 URL로 preload하여 캐시 히트 달성
- 모달, 캐러셀 등에서 이미지 로딩 지연 최소화
방법
문제 인식: Next.js Image만으로는 부족한 이유
Next.js Image 컴포넌트를 사용한다고 해서 무조건 빨라지는 것은 아니다.
- 동적 src: src 값이 런타임에 결정되면 최적화 효과가 제한적
- 외부 이미지: 원본이 S3 등 외부에 있고 크기가 크면 첫 요청 시 warm-up 지연 발생
- 정적 src:
public폴더의 정적 이미지라면 효과적
핵심 아이디어: srcset과 동일한 URL로 preload
Next.js Image는 srcset을 통해 다양한 해상도 옵션을 제공한다:
/_next/image?url=원본URL&w=3840&q=75
브라우저는 뷰포트와 DPR을 고려해 적절한 w 값을 선택한다. preload 시에도 동일한 URL을 사용해야 캐시 히트가 발생한다.
구현
1. 상수 정의
// Next.js 기본 deviceSizes
export const NEXT_IMAGE_DEVICE_SIZES = [
640, 750, 828, 1080, 1200, 1920, 2048, 3840,
] as const;
/**
* 프리로드 시 사용할 기본 품질 (next.config.ts 의 images.qualities 범위 내)
*/
export const PRELOAD_DEFAULT_QUALITY = 75;
/**
* sizes 프롭을 모든 이미지에 대해 고정할 경우 사용
*/
export const IMAGE_SIZES = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw";
2. 적정 width 계산 함수
type PreloadParamsOptions = {
viewportWidth: number;
dpr?: number;
scale?: number; // 모달=1.0, 썸네일=더 낮게
quality?: number;
};
export function getPreloadImageParams({
viewportWidth,
dpr = 1,
scale = 1,
quality = PRELOAD_DEFAULT_QUALITY,
}: PreloadParamsOptions) {
const safeViewportWidth = Math.max(1, viewportWidth);
const safeDpr = clamp(dpr, 1, 3); // DPR 3 이상은 대역폭 낭비 방지
const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);
const width = pickClosestGreaterOrEqual(targetWidth, NEXT_IMAGE_DEVICE_SIZES);
return { width, quality };
}
function pickClosestGreaterOrEqual(
targetWidth: number,
candidates: readonly number[],
) {
for (const candidateW of candidates) {
if (candidateW >= targetWidth) return candidateW;
}
return candidates[candidates.length - 1] ?? targetWidth;
}
function clamp(number: number, min: number, max: number) {
return Math.min(max, Math.max(min, number));
}
3. Next.js Image Optimization URL 생성
export function buildImageUrl(src: string, width: number, quality: number) {
const encodedUrl = encodeURIComponent(src);
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: "http://localhost:3000";
return `${baseUrl}/_next/image?url=${encodedUrl}&w=${width}&q=${quality}`;
}
4. Preload 실행 함수
export async function preloadImages(srcs: string[]): Promise<number> {
if (srcs.length === 0) return 0;
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1200;
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
const { width, quality } = getPreloadImageParams({
viewportWidth,
dpr,
scale: 1,
});
const promises = srcs.map((src) => {
const url = buildImageUrl(src, width, quality);
return imagePromise(url);
});
const results = await Promise.allSettled(promises);
return results.filter((r) => r.status === "fulfilled").length;
}
function imagePromise(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => resolve();
img.onerror = () => reject();
});
}
사용 예시
onMouseEnter로 preload
const handlePreloadImage = async () => {
const successCount = await preloadImages(IMAGE_SRCS.slice(9, 12));
console.log(`Preloaded: ${successCount} images`);
};
<a
href="/gallery"
onMouseEnter={handlePreloadImage}
>
갤러리 보기
</a>
Hook으로 사용
export function usePreloadImage({
srcs,
isActive,
}: {
srcs: string[];
isActive: boolean;
}) {
const [successCount, setSuccessCount] = useState(0);
useEffect(() => {
if (!isActive) return;
preloadImages(srcs).then(setSuccessCount);
}, [srcs, isActive]);
return successCount;
}
// 사용
const successCount = usePreloadImage({
srcs: IMAGE_SRCS.slice(3, 6),
isActive: true,
});
결과
- 캐시 히트 달성: 사용자 환경에 맞는 정확한 URL을 preload하므로 Next.js Image가 요청할 이미지와 일치
- 체감 속도 향상: 모달 열기, 캐러셀 넘기기 등에서 이미지가 즉시 표시됨
- 대역폭 효율: 모든 srcset을 preload하지 않고 필요한 해상도만 preload
배포 후 여러 번 테스트하면 /_next/image CDN 캐시가 이미 워밍되어 차이가 거의 안 보일 수 있습니다.
참고: DPR(Device Pixel Ratio)
DPR이란?
DPR은 CSS 픽셀 1개가 실제 물리적 픽셀 몇 개에 해당하는지를 나타내는 비율이다.
| 기기 | DPR | 설명 |
|---|---|---|
| 일반 모니터 | 1 | CSS 1px = 물리 1px |
| Retina 디스플레이 | 2 | CSS 1px = 물리 4px (2×2) |
| iPhone Pro Max 등 | 3 | CSS 1px = 물리 9px (3×3) |
코드에서의 역할
const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);
예시: iPhone 14 Pro (뷰포트 393px, DPR 3)
- DPR 미적용:
393px→ 후보 중640선택 - DPR 적용:
393 × 3 = 1179px→ 후보 중1200선택
왜 DPR을 고려해야 하나?
1. 선명한 이미지를 위해
DPR 3인 기기에서 393px 이미지를 보여주면, 물리적으로 1179개의 픽셀에 393개의 정보만 있어서 이미지가 흐릿하게 보인다.
2. next/image가 실제로 요청하는 이미지와 일치시키기 위해
브라우저는 srcset에서 이미지를 고를 때 자동으로 DPR을 고려한다:
<img srcset="/_next/image?w=640 640w, /_next/image?w=750 750w, ..." />
뷰포트 393px + DPR 3인 경우 브라우저는 자동으로 1200w 이미지를 요청한다.
3. 프리로드 효과를 얻기 위해
DPR을 고려하지 않으면:
- 프리로드: 640px 이미지
- 실제 요청: 1200px 이미지
→ 캐시 히트 실패로 프리로드가 무의미해진다.
DPR 클램프 (1~3 제한)
const safeDpr = clamp(dpr, 1, 3);
일부 기기는 DPR이 3.5나 4인 경우도 있는데, 너무 큰 이미지를 프리로드하면 대역폭 낭비가 될 수 있어서 3으로 상한선을 둔다.
요약: DPR을 고려해야 next/image가 실제로 요청할 이미지와 동일한 이미지를 프리로드할 수 있고, 그래야 브라우저 캐시 히트가 발생해서 프리로드의 효과를 볼 수 있다.
전체 코드 예시
type PreloadParamsOptions = {
// CSS 픽셀 기준 뷰포트 폭 (window.innerWidth)
viewportWidth: number;
// 디바이스 픽셀 비율 (window.devicePixelRatio)
dpr?: number;
// 기본 1.0. 모달처럼 거의 풀폭인 경우 1.0, 썸네일/그리드처럼 작으면 더 낮춰도 됨.
scale?: number;
// next/image 품질(0-100). next.config.ts images.qualities 에 포함되어야 함.
quality?: number;
};
/**
* preloadImages 함수의 옵션 타입
*
* @property sizes - CSS sizes 속성. 미디어 조건에 따라 scale을 자동 계산합니다.
* 예: "(max-width: 768px) 100vw, 50vw"
* @property scale - 이미지가 차지하는 뷰포트 비율 (0~1). sizes보다 우선 적용됩니다.
* @property quality - 이미지 품질 (0-100). 기본값: 75
* @property viewportWidth - 뷰포트 너비 (CSS 픽셀). 기본값: window.innerWidth
* @property dpr - 디바이스 픽셀 비율. 기본값: window.devicePixelRatio
*/
type PreloadImagesOptions = {
sizes?: string;
scale?: number;
quality?: number;
viewportWidth?: number;
dpr?: number;
};
/**
* 주어진 숫자를 최소값과 최대값 사이로 제한합니다.
*
* @param number - 제한할 숫자
* @param min - 허용되는 최소값
* @param max - 허용되는 최대값
* @returns min과 max 사이로 제한된 숫자
*
* @example
* clamp(5, 0, 10) // => 5 (범위 내)
* clamp(-5, 0, 10) // => 0 (최소값으로 제한)
* clamp(15, 0, 10) // => 10 (최대값으로 제한)
*/
function clamp(number: number, min: number, max: number) {
return Math.min(max, Math.max(min, number));
}
/**
* CSS sizes 속성 문자열을 개별 조건-값 쌍으로 분리합니다.
*
* sizes 속성은 쉼표로 구분된 미디어 조건과 크기 값의 목록입니다.
* 이 함수는 각 항목을 트림하고 빈 항목을 필터링합니다.
*
* @param sizes - CSS sizes 속성 문자열 (예: "(max-width: 768px) 100vw, 50vw")
* @returns 분리된 sizes 항목 배열
*
* @example
* splitSizes("(max-width: 768px) 100vw, 50vw")
* // => ["(max-width: 768px) 100vw", "50vw"]
*/
function splitSizes(sizes: string) {
return sizes
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
/**
* 주어진 미디어 조건이 현재 뷰포트 너비에 맞는지 확인합니다.
*
* 현재 지원하는 미디어 조건:
* - `(max-width: Npx)`: 뷰포트가 N 이하일 때 true
* - `(min-width: Npx)`: 뷰포트가 N 이상일 때 true
*
* @param media - 미디어 조건 문자열 (예: "(max-width: 768px)")
* @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀)
* @returns 미디어 조건이 일치하면 true, 아니면 false
*
* @example
* matchesMediaCondition("(max-width: 768px)", 500) // => true (500 <= 768)
* matchesMediaCondition("(max-width: 768px)", 1024) // => false (1024 > 768)
* matchesMediaCondition("(min-width: 768px)", 1024) // => true (1024 >= 768)
*/
function matchesMediaCondition(media: string, viewportWidth: number) {
const normalized = media.trim().replace(/^\(/, "").replace(/\)$/, "");
const match = normalized.match(/^(max|min)-width\s*:\s*(\d+)px$/);
if (!match) return false;
const [, type, value] = match;
const width = Number(value);
if (Number.isNaN(width)) return false;
return type === "max" ? viewportWidth <= width : viewportWidth >= width;
}
/**
* CSS 크기 값을 뷰포트 대비 비율(scale)로 변환합니다.
*
* 지원하는 단위:
* - `vw`: 뷰포트 너비의 백분율 (예: "50vw" → 0.5)
* - `px`: 고정 픽셀 값을 뷰포트 대비 비율로 변환 (예: 뷰포트 1000px에서 "500px" → 0.5)
* - `100%`: 전체 너비 (→ 1)
*
* @param size - CSS 크기 값 문자열 (예: "50vw", "500px", "100%")
* @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀, px 단위 계산에 사용)
* @returns 뷰포트 대비 비율 (0~1). 파싱 실패 시 null
*
* @example
* parseSizeToScale("50vw", 1000) // => 0.5
* parseSizeToScale("500px", 1000) // => 0.5 (500 / 1000)
* parseSizeToScale("100%", 1000) // => 1
* parseSizeToScale("invalid", 1000) // => null
*/
function parseSizeToScale(size: string, viewportWidth: number) {
const trimmed = size.trim();
if (trimmed.endsWith("vw")) {
const value = Number(trimmed.replace("vw", ""));
if (Number.isNaN(value)) return null;
return value / 100;
}
if (trimmed.endsWith("px")) {
const value = Number(trimmed.replace("px", ""));
if (Number.isNaN(value)) return null;
return value / viewportWidth;
}
if (trimmed === "100%") return 1;
return null;
}
/**
* CSS sizes 속성을 파싱하여 현재 뷰포트에 맞는 scale 값을 계산합니다.
*
* next/image의 sizes 속성과 동일한 로직으로, 현재 뷰포트 너비에 맞는
* 미디어 조건을 찾아 해당하는 크기 값을 scale로 변환합니다.
*
* 동작 방식:
* 1. sizes 문자열을 쉼표로 분리
* 2. 각 항목에 대해 미디어 조건이 있으면 조건 매칭 확인
* 3. 조건이 맞는 첫 번째 항목의 크기 값을 scale로 변환
* 4. 미디어 조건 없는 항목은 기본값으로 사용
*
* @param sizes - CSS sizes 속성 문자열 (예: "(max-width: 768px) 100vw, 50vw")
* @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀)
* @returns 뷰포트 대비 비율 (0.05~1, 기본값 1)
*
* @example
* // 뷰포트 500px에서 "(max-width: 768px) 100vw, 50vw"
* getScaleFromSizes("(max-width: 768px) 100vw, 50vw", 500)
* // => 1 (500 <= 768이므로 "100vw" 적용 → 1.0)
*
* @example
* // 뷰포트 1024px에서 "(max-width: 768px) 100vw, 50vw"
* getScaleFromSizes("(max-width: 768px) 100vw, 50vw", 1024)
* // => 0.5 (1024 > 768이므로 "50vw" 적용 → 0.5)
*/
export function getScaleFromSizes(
sizes: string | undefined,
viewportWidth: number,
) {
if (!sizes) return 1;
const safeViewportWidth = Math.max(1, viewportWidth);
for (const part of splitSizes(sizes)) {
const match = part.match(/^\(([^)]+)\)\s+(.+)$/);
if (match) {
const [, media, size] = match;
if (!matchesMediaCondition(`(${media})`, safeViewportWidth)) continue;
const scale = parseSizeToScale(size, safeViewportWidth);
if (scale !== null) return clamp(scale, 0.05, 1);
continue;
}
const scale = parseSizeToScale(part, safeViewportWidth);
if (scale !== null) return clamp(scale, 0.05, 1);
}
return 1;
}
/**
* 정렬된 후보 배열에서 targetWidth보다 크거나 같은 가장 작은 값을 반환합니다.
*
* @param targetWidth - 필요한 최소 너비 (viewportWidth × DPR × scale)
* @param candidates - 오름차순으로 정렬된 후보 너비 배열 (예: NEXT_IMAGE_DEVICE_SIZES)
* @returns targetWidth 이상인 가장 작은 후보 값. 모든 후보보다 크면 마지막(가장 큰) 후보 반환.
*
* @example
* // targetWidth가 1179일 때
* pickClosestGreaterOrEqual(1179, [640, 750, 828, 1080, 1200, 1920, 2048, 3840])
* // => 1200 (1179보다 크거나 같은 가장 작은 값)
*
* @example
* // targetWidth가 5000일 때 (모든 후보보다 큼)
* pickClosestGreaterOrEqual(5000, [640, 750, 828, 1080, 1200, 1920, 2048, 3840])
* // => 3840 (가장 큰 후보 반환)
*/
function pickClosestGreaterOrEqual(
targetWidth: number,
candidates: readonly number[],
) {
for (const w of candidates) {
if (w >= targetWidth) return w;
}
return candidates[candidates.length - 1] ?? targetWidth;
}
/**
* 현재 뷰포트와 기기 특성을 기반으로 next/image가 선택할 이미지 파라미터를 계산합니다.
*
* next/image는 srcset에서 이미지를 선택할 때 뷰포트 너비 × DPR을 기준으로 합니다.
* 이 함수는 동일한 로직으로 프리로드할 이미지의 width를 결정하여,
* 프리로드한 이미지가 실제 next/image 요청과 일치하도록 합니다.
*
* 계산 과정:
* 1. targetWidth = viewportWidth × DPR × scale
* 2. NEXT_IMAGE_DEVICE_SIZES 중 targetWidth 이상인 가장 작은 값 선택
*
* @param options - 프리로드 파라미터 옵션
* @param options.viewportWidth - CSS 픽셀 기준 뷰포트 너비 (window.innerWidth)
* @param options.dpr - 디바이스 픽셀 비율 (window.devicePixelRatio). 1~3으로 제한됨. 기본값: 1
* @param options.scale - 이미지가 차지하는 뷰포트 비율 (0~1). 기본값: 1
* @param options.quality - 이미지 품질 (0-100). 기본값: 75
* @returns { width, quality } - next/image API에 전달할 파라미터
*
* @example
* // iPhone 14 Pro: 뷰포트 393px, DPR 3, 풀스크린 이미지
* getPreloadImageParams({ viewportWidth: 393, dpr: 3, scale: 1 })
* // => { width: 1200, quality: 75 }
* // 계산: 393 × 3 × 1 = 1179 → 1200 선택
*
* @example
* // 데스크톱: 뷰포트 1920px, DPR 1, 50% 너비 이미지
* getPreloadImageParams({ viewportWidth: 1920, dpr: 1, scale: 0.5 })
* // => { width: 1080, quality: 75 }
* // 계산: 1920 × 1 × 0.5 = 960 → 1080 선택
*/
export function getPreloadImageParams({
viewportWidth,
dpr = 1,
scale = 1,
quality = PRELOAD_DEFAULT_QUALITY,
}: PreloadParamsOptions) {
const safeViewportWidth = Math.max(1, viewportWidth);
const safeDpr = clamp(dpr, 1, 3);
const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);
const width = pickClosestGreaterOrEqual(targetWidth, NEXT_IMAGE_DEVICE_SIZES);
return { width, quality };
}
/**
* Next.js Image Optimization API URL을 생성합니다.
* @param src - 원본 이미지 URL
* @param width - 이미지 너비 (px)
* @param quality - 이미지 품질 (0-100)
* @returns Next.js Image Optimization API URL
* @example
* buildImageUrl("https://example.com/image.jpg", 750, 50)
* => "https://yourdomain.com/_next/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg&w=750&q=50"
*/
export function buildImageUrl(src: string, width: number, quality: number) {
const encodedUrl = encodeURIComponent(src);
// 클라이언트 사이드에서는 현재 origin을 사용하여 정확한 도메인을 가져옵니다
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: "http://localhost:3000";
const url = `${baseUrl}/_next/image?url=${encodedUrl}&w=${width}&q=${quality}`;
return url;
}
/**
* 이미지 로딩을 Promise로 래핑하여 비동기적으로 처리합니다.
*
* 브라우저의 Image 객체를 사용하여 이미지를 로드하고,
* 로딩 완료/실패를 Promise로 반환합니다.
* 이를 통해 이미지 프리로드 시 async/await 또는 Promise.all 등을 사용할 수 있습니다.
*
* @param src - 로드할 이미지의 URL
* @returns 이미지 로딩 완료 시 resolve, 실패 시 reject되는 Promise
*
* @example
* // 단일 이미지 로드
* await imagePromise("https://example.com/image.jpg");
*
* @example
* // 여러 이미지 병렬 로드
* await Promise.all([
* imagePromise("https://example.com/image1.jpg"),
* imagePromise("https://example.com/image2.jpg"),
* ]);
*/
export function imagePromise(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => resolve();
img.onerror = () => reject();
});
}
/**
* 여러 이미지를 Next.js Image Optimization API를 통해 프리로드합니다.
*
* 이 함수는 다음 과정을 수행합니다:
* 1. 현재 뷰포트와 DPR을 기반으로 최적의 이미지 크기 결정
* 2. sizes 속성이 제공되면 해당 scale 값 계산
* 3. Next.js Image Optimization API URL 생성
* 4. 모든 이미지를 병렬로 로드
* 5. 성공한 이미지 개수 반환 (실패한 이미지는 무시)
*
* 프리로드된 이미지는 브라우저 캐시에 저장되어,
* 이후 next/image가 동일한 URL을 요청할 때 캐시 히트됩니다.
*
* @param srcs - 프리로드할 원본 이미지 URL 배열
* @param options - 프리로드 옵션
* @param options.sizes - CSS sizes 속성 (예: "(max-width: 768px) 100vw, 50vw")
* @param options.scale - 이미지가 차지하는 뷰포트 비율 (sizes보다 우선)
* @param options.quality - 이미지 품질 (0-100)
* @param options.viewportWidth - 뷰포트 너비 (기본값: window.innerWidth)
* @param options.dpr - 디바이스 픽셀 비율 (기본값: window.devicePixelRatio)
* @returns 성공적으로 프리로드된 이미지 개수
*
* @example
* // 기본 사용 (현재 기기 설정 자동 감지)
* const count = await preloadImages([
* "https://example.com/image1.jpg",
* "https://example.com/image2.jpg",
* ]);
* console.log(`${count}개 이미지 프리로드 완료`);
*
* @example
* // sizes 속성과 함께 사용
* await preloadImages(imageUrls, {
* sizes: "(max-width: 768px) 100vw, 50vw",
* quality: 80,
* });
*
* @example
* // 명시적 scale 지정 (썸네일 그리드 등)
* await preloadImages(thumbnailUrls, {
* scale: 0.25, // 뷰포트의 25%
* });
*/
export async function preloadImages(
srcs: string[],
options: PreloadImagesOptions = {},
): Promise<number> {
if (srcs.length === 0) return 0;
const viewportWidth =
options.viewportWidth ??
(typeof window !== "undefined" ? window.innerWidth : 1200);
const dpr =
options.dpr ??
(typeof window !== "undefined" ? window.devicePixelRatio : 1);
const scale =
options.scale ?? getScaleFromSizes(options.sizes, viewportWidth);
const { width, quality } = getPreloadImageParams({
viewportWidth,
dpr,
scale,
quality: options.quality,
});
const promises = srcs.map((src) => {
const url = buildImageUrl(src, width, quality);
return imagePromise(url);
});
const results = await Promise.allSettled(promises);
return results.filter((r) => r.status === "fulfilled").length;
}'TIL' 카테고리의 다른 글
| [251227 TIL] RSC 에서 redirect 사용시 주의점 (0) | 2025.12.27 |
|---|---|
| [251214 TIL] React2Shell 사건 정리 (0) | 2025.12.14 |
| [251127 TIL] Next.js 이미지 최적화 정리 (0) | 2025.11.27 |
| [251124 TIL] 간접 의존성 관련 업데이트 (0) | 2025.11.24 |
| [251123 TIL] Turbopack 간접 의존성 경고 상황 (0) | 2025.11.23 |