2025.11.27
Next.js Image가 빌드 타임에 .next 폴더에 webp 이미지를 미리 생성해둔다고 생각하는 경우가 있습니다.
그게 바로 저입니다... 실제로는 런타임에 on-demand로 최적화가 이루어 진다고 하네요.
요청 흐름:
1. 브라우저 → /_next/image?url=...&w=800&q=75 요청
2. Next.js 서버 → 원본 이미지 fetch
3. Next.js 서버 → 리사이즈 + webp/avif 변환
4. .next/cache/images/ 에 캐시 저장
5. 이후 동일 요청 → 캐시에서 즉시 응답
NO! Next.js는 원본 포맷과 관계없이 무조건 최적화 거침. 이미 최적화된 webp 이미지도 재처리됩니다.
new Image()로 프리로드하면 Next Image도 빨라진다아님. URL이 다르기 때문에 브라우저 캐시가 공유되지 않습니다.
프리로드 URL: https://example.com/photo.jpg
Next Image URL: /_next/image?url=https://example.com/photo.jpg&w=800&q=75
/public에 이미 최적화된 이미지증상: webp로 직접 최적화한 이미지인데 Next Image가 오히려 느림
원인: 불필요한 재처리 오버헤드
해결: unoptimized={true} 사용
// 이미 최적화된 로컬 이미지
<Image
src="/images/optimized-hero.webp"
alt="Hero"
width={1200}
height={600}
unoptimized
/>
증상: 첫 로드가 느리고, 두 번째부터 빠름 원인: 정상 동작. 첫 요청 시 서버에서 최적화 작업 수행 해결: 서버 캐시 워밍업 (아래 섹션 참고)
해결: priority prop 필수
<Image
src={heroImage}
alt="Hero"
priority // preload 힌트 + fetchpriority="high"
/>
상황별 자동 분기 처리:
import Image, { ImageProps } from 'next/image';
interface SmartImageProps extends Omit<ImageProps, 'unoptimized'> {
src: string;
}
export function SmartImage({ src, width, ...props }: SmartImageProps) {
const isAlreadyOptimized =
src.endsWith('.webp') ||
src.endsWith('.avif') ||
src.includes('imagecdn.com') ||
src.includes('cloudinary.com');
const isSmallImage = typeof width === 'number' && width < 100;
const isSvg = src.endsWith('.svg');
const skipOptimization = isAlreadyOptimized || isSmallImage || isSvg;
return (
<Image
src={src}
width={width}
unoptimized={skipOptimization}
{...props}
/>
);
}
외부 이미지의 경우 첫 방문자는 항상 최적화 대기 시간을 겪습니다. 브라우저 캐시는 같은 유저의 재방문에만 효과가 있고, 서버 캐시가 비어있으면 모든 신규 방문자가 느린 첫 로드를 경험합니다.
// scripts/warmup-images.ts
const BASE_URL = process.env.SITE_URL || 'http://localhost:3000';
// 워밍업할 이미지 목록
const IMAGES_TO_WARM = [
'https://external-cdn.com/hero-image.jpg',
'https://external-cdn.com/product-1.jpg',
'https://external-cdn.com/product-2.jpg',
// 동적으로 가져올 수도 있음
];
// Next.js 기본 deviceSizes + imageSizes
const WIDTHS = [640, 750, 828, 1080, 1200, 1920];
const QUALITY = 75;
async function warmupImage(src: string, width: number): Promise<void> {
const url = `${BASE_URL}/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${QUALITY}`;
try {
const response = await fetch(url);
if (response.ok) {
console.log(`✓ Warmed: ${src} @ ${width}w`);
} else {
console.error(`✗ Failed: ${src} @ ${width}w - ${response.status}`);
}
} catch (error) {
console.error(`✗ Error: ${src} @ ${width}w -`, error);
}
}
async function warmup(): Promise<void> {
console.log('🔥 Starting image cache warmup...\n');
const tasks: Promise<void>[] = [];
for (const src of IMAGES_TO_WARM) {
for (const width of WIDTHS) {
tasks.push(warmupImage(src, width));
}
}
// 동시 요청 제한 (서버 부하 방지)
const CONCURRENCY = 5;
for (let i = 0; i < tasks.length; i += CONCURRENCY) {
await Promise.all(tasks.slice(i, i + CONCURRENCY));
}
console.log('\n✅ Warmup complete!');
}
warmup();
# 로컬 테스트
pnpm build && pnpm start &
sleep 5 # 서버 시작 대기
npx tsx scripts/warmup-images.ts
# 또는 package.json에 추가
{
"scripts": {
"warmup": "tsx scripts/warmup-images.ts",
"start:warmed": "next start & sleep 5 && npm run warmup"
}
}
Vercel은 이미지 최적화가 Edge에서 자동 처리되고 글로벌 CDN 캐시가 포함됨. 별도 설정 없이 최적의 성능을 얻을 수 있음.
필요한 경우 수동으로 배포시 워밍업 로직을 실행하게 할 수는 있음...
GitHub Actions
# .github/workflows/warmup.yml
name: Image Cache Warmup
on:
deployment_status:
jobs:
warmup:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Wait for deployment to stabilize
run: sleep 30
- name: Run warmup script
env:
SITE_URL: ${{ github.event.deployment_status.target_url }}
run: npx tsx scripts/warmup-images.ts
컨테이너 환경에서는 추가 고려사항이 있습니다.
컨테이너 재시작 시 .next/cache/images/ 캐시가 사라집니다.
해결: 볼륨 마운트 또는 외부 캐시
# docker-compose.yml
services:
web:
image: your-nextjs-app
volumes:
- image-cache:/app/.next/cache/images
volumes:
image-cache:
여러 인스턴스가 각자 캐시를 갖게 됩니다.
해결: CDN을 앞에 배치
사용자 → CloudFront/Cloud CDN → Container
(/_next/image/* 캐시)
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY scripts/warmup-images.ts ./scripts/
# 헬스체크 + 워밍업 엔트리포인트
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
#!/bin/bash
# docker-entrypoint.sh
# Next.js 서버 시작 (백그라운드)
node server.js &
# 서버 준비 대기
until curl -s http://localhost:3000/api/health > /dev/null; do
sleep 1
done
# 캐시 워밍업
npx tsx scripts/warmup-images.ts
# 포그라운드로 전환
wait
Next.js 내장 최적화 대신 전문 서비스를 사용하면 인프라 복잡도를 줄일 수 있다는데,, 아직 해보질 못했습니다
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.ts',
},
};
export default config;
// lib/image-loader.ts
interface ImageLoaderParams {
src: string;
width: number;
quality?: number;
}
export default function cloudinaryLoader({
src,
width,
quality = 75
}: ImageLoaderParams): string {
// Cloudinary 예시
const params = [
'f_auto',
'c_limit',
`w_${width}`,
`q_${quality}`,
];
return `https://res.cloudinary.com/your-cloud/image/fetch/${params.join(',')}/${encodeURIComponent(src)}`;
}
| 상황 | 권장 설정 |
|---|---|
/public 폴더의 이미 최적화된 이미지 | unoptimized={true} |
| 외부 이미지, 성능 중요 | 서버 캐시 워밍업 적용 |
| LCP 이미지 (Hero, 메인 배너 등) | priority prop 추가 |
| 아이콘, 작은 이미지 (< 100px) | unoptimized={true} |
| SVG 이미지 | unoptimized={true} |
| Vercel 배포 | GitHub Actions 워밍업 |
| Docker 배포 | CDN + 볼륨 마운트 + 엔트리포인트 워밍업 |