2025 . 오은

repo

Next.js 이미지 작동 방식

2025.11.27

1. Next.js Image 최적화의 동작 원리

빌드 타임이 아닌 런타임 최적화

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. 이후 동일 요청 → 캐시에서 즉시 응답

동작 방식

  • 동적 src도 최적화 가능: 런타임 처리이므로 동적 이미지 URL도 혜택을 받음
  • 정적 페이지 필수 아님: SSR, ISR, CSR 모두에서 동작
  • 캐시 기반: 첫 요청만 느리고, 이후는 캐시 히트로 빠름

2. 제가 오해한 부분

오해: 이미 webp면 최적화를 건너뛴다

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

3. 상황별 최적화 전략

케이스 1: /public에 이미 최적화된 이미지

증상: webp로 직접 최적화한 이미지인데 Next Image가 오히려 느림 원인: 불필요한 재처리 오버헤드 해결: unoptimized={true} 사용

// 이미 최적화된 로컬 이미지
<Image 
  src="/images/optimized-hero.webp"
  alt="Hero"
  width={1200}
  height={600}
  unoptimized
/>

케이스 2: 외부 이미지 (CDN, 사용자 업로드 등)

증상: 첫 로드가 느리고, 두 번째부터 빠름 원인: 정상 동작. 첫 요청 시 서버에서 최적화 작업 수행 해결: 서버 캐시 워밍업 (아래 섹션 참고)

케이스 3: LCP (Largest Contentful Paint) 이미지

해결: 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}
    />
  );
}

4. 서버 캐시 워밍업으로 첫 방문자도 빠르게

문제 상황

외부 이미지의 경우 첫 방문자는 항상 최적화 대기 시간을 겪습니다. 브라우저 캐시는 같은 유저의 재방문에만 효과가 있고, 서버 캐시가 비어있으면 모든 신규 방문자가 느린 첫 로드를 경험합니다.

해결: 배포 후 캐시 워밍업

워밍업 스크립트

// 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"
  }
}

5. 배포 환경별 설정

Vercel

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

Docker (Cloud Run, ECS, etc.)

컨테이너 환경에서는 추가 고려사항이 있습니다.

1. 캐시 영속성 문제

컨테이너 재시작 시 .next/cache/images/ 캐시가 사라집니다.

해결: 볼륨 마운트 또는 외부 캐시

# docker-compose.yml
services:
  web:
    image: your-nextjs-app
    volumes:
      - image-cache:/app/.next/cache/images

volumes:
  image-cache:

2. 스케일아웃 시 캐시 분산

여러 인스턴스가 각자 캐시를 갖게 됩니다.

해결: CDN을 앞에 배치

사용자 → CloudFront/Cloud CDN → Container
         (/_next/image/* 캐시)

3. Dockerfile에 워밍업 포함

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 + 볼륨 마운트 + 엔트리포인트 워밍업

참고 자료

  • Next.js Image Optimization 공식 문서
  • next/image API Reference
  • Vercel Image Optimization