웹 이미지 최적화: AVIF/WebP와 Next.js Image 컴포넌트 완전 활용

프론트엔드

이미지 최적화Next.jsAVIFWebP웹 성능

이 글은 누구를 위한 것인가

  • LCP(Largest Contentful Paint)를 개선하려는 팀
  • Next.js <Image> 컴포넌트를 제대로 활용하고 싶은 개발자
  • AVIF/WebP 변환으로 이미지 크기를 줄이고 싶은 팀

들어가며

이미지는 대부분 웹페이지에서 가장 무거운 리소스다. AVIF는 JPEG 대비 50% 작고, Next.js <Image>는 자동으로 포맷 변환, 크기 최적화, lazy loading을 처리한다. 올바르게 설정하면 LCP를 0.5-1초 단축할 수 있다.

이 글은 bluefoxdev.kr의 웹 이미지 최적화 가이드 를 참고하여 작성했습니다.


1. 이미지 포맷 선택 가이드

[포맷 비교 (동일 품질 기준)]

AVIF:
  압축률: PNG 대비 80%, JPEG 대비 50%
  브라우저: Chrome 85+, Firefox 93+, Safari 16+
  인코딩: 느림 (서버 부하 있음)
  적합: 사진, 복잡한 이미지

WebP:
  압축률: PNG 대비 30%, JPEG 대비 25-35%
  브라우저: 모든 현대 브라우저
  인코딩: 빠름
  적합: 사진, 일러스트 모두

PNG:
  압축: 무손실
  투명: 지원
  적합: 아이콘, 스크린샷, 텍스트 포함 이미지

SVG:
  크기: 매우 작음 (벡터)
  확대: 무제한 품질
  적합: 로고, 아이콘, 다이어그램

[Next.js Image 자동 최적화]
  요청 이미지 → 브라우저 지원 포맷으로 변환
  AVIF 지원: AVIF 서빙
  WebP 지원: WebP 서빙
  나머지: 원본 포맷
  크기: 요청된 sizes에 맞게 리사이즈
  캐싱: 생성된 이미지 캐시 (Vercel Edge Cache)

2. Next.js Image 최적화 구현

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    // 외부 이미지 도메인 허용
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/products/**',
      },
      {
        protocol: 'https',
        hostname: '**.cloudinary.com',
      },
    ],
    
    // 지원 포맷 (우선순위 순)
    formats: ['image/avif', 'image/webp'],
    
    // 이미지 크기 힌트 (srcset 생성에 사용)
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    
    // 최적화 품질
    minimumCacheTTL: 60 * 60 * 24 * 7,  // 7일
  },
};

export default nextConfig;
// 히어로 이미지 - LCP 최적화
import Image from 'next/image';

export function HeroSection() {
  return (
    <section className="relative h-screen">
      <Image
        src="/hero.jpg"
        alt="쇼핑몰 메인 배너 - 봄 신상품 컬렉션"
        fill                      // 부모 기준 채우기
        sizes="100vw"             // 뷰포트 전체 너비
        priority                  // LCP: preload 적용
        quality={85}              // 품질 (기본 75)
        className="object-cover"
        placeholder="blur"        // 블러 플레이스홀더
        blurDataURL="data:image/jpeg;base64,/9j..."  // 작은 블러 이미지
      />
      <div className="absolute inset-0 flex items-center justify-center">
        <h1 className="text-white text-5xl font-bold">봄 신상품</h1>
      </div>
    </section>
  );
}

// 상품 카드 그리드 - 반응형 이미지
export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="relative">
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={400}
        // sizes: 각 미디어 쿼리에서 이미지가 차지하는 너비 명시
        // → 브라우저가 올바른 srcset 선택
        sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
        className="rounded-lg object-cover"
        // lazy loading (기본값, 뷰포트 밖 이미지)
        loading="lazy"
      />
    </div>
  );
}

// 썸네일 - 작은 고정 크기
export function Avatar({ user }: { user: User }) {
  return (
    <Image
      src={user.avatarUrl ?? '/default-avatar.png'}
      alt={`${user.name} 프로필`}
      width={40}
      height={40}
      // 고정 크기 → sizes 불필요
      className="rounded-full"
    />
  );
}

// 동적 블러 플레이스홀더 생성
import { getPlaiceholder } from 'plaiceholder';

async function getImageProps(src: string) {
  const buffer = await fetch(src).then(r => r.arrayBuffer());
  const { base64 } = await getPlaiceholder(Buffer.from(buffer));
  return { src, blurDataURL: base64, placeholder: 'blur' as const };
}

// 서버 컴포넌트에서 사용
export async function ProductImage({ src, alt }: { src: string; alt: string }) {
  const imageProps = await getImageProps(src);
  return (
    <Image
      {...imageProps}
      alt={alt}
      width={800}
      height={600}
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}
// 커스텀 이미지 로더 (Cloudinary, Imgix 등)
const cloudinaryLoader = ({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) => {
  const params = [
    'f_auto',           // 자동 포맷 (AVIF/WebP)
    'c_limit',          // 최대 크기 제한
    `w_${width}`,       // 너비
    `q_${quality || 75}`, // 품질
  ].join(',');
  
  return `https://res.cloudinary.com/mycloud/image/upload/${params}/${src}`;
};

// next.config.ts
// images: { loader: 'custom', loaderFile: './src/utils/cloudinary-loader.ts' }

// 사용
<Image
  loader={cloudinaryLoader}
  src="products/shoe-001.jpg"
  alt="상품 이미지"
  width={400}
  height={400}
  sizes="(max-width: 768px) 100vw, 400px"
/>
// 이미지 최적화 분석 유틸리티
export function analyzeImageUsage() {
  // 개발 환경에서 sizes 누락 감지
  if (process.env.NODE_ENV !== 'development') return;
  
  const images = document.querySelectorAll('img');
  images.forEach(img => {
    if (!img.sizes && img.naturalWidth > 500) {
      console.warn(
        `큰 이미지에 sizes 속성이 없습니다: ${img.src}\n`,
        '예시: sizes="(max-width: 768px) 100vw, 50vw"'
      );
    }
  });
}

interface Product { name: string; imageUrl: string }
interface User { name: string; avatarUrl: string | null }

마무리

Next.js <Image>에서 가장 자주 실수하는 것은 sizes 속성 누락이다. sizes 없으면 브라우저가 100vw로 가정해 불필요하게 큰 이미지를 다운로드한다. LCP 이미지에는 반드시 priority를 추가해 preload를 트리거한다. 외부 이미지 소스(Cloudinary, Imgix)를 쓸 때는 커스텀 로더로 자동 AVIF 변환을 활성화하면 별도 Next.js 서버 최적화 없이도 최소 크기를 유지할 수 있다.