이미지 최적화
next/image의 크기·우선순위·반응형 속성과 외부 호스트 설정으로 레이아웃 이동과 이미지 전송량을 줄입니다.
웹 애플리케이션의 성능 최적화는 사용자 경험과 SEO에 모두 영향을 주는 중요한 작업입니다. 특히 최적화하지 않은 큰 이미지는 로딩 속도를 늦추므로, 표시 크기와 전송량을 함께 살펴야 합니다. Next.js는 내장 컴포넌트 next/image로 효율적인 이미지 처리를 쉽게 구현하게 해 줍니다.
12장에서는 이미지, 폰트, 코드 분할과 지연 로딩, 메모이제이션과 다시 렌더링 최적화를 차례로 다루며 Next.js 애플리케이션의 성능을 높이는 방법을 살펴봅니다. 이 글에서는 이미지 최적화가 왜 중요한지부터 next/image 컴포넌트의 사용법과 여러 최적화 기법까지 알아봅니다.
왜 이미지를 최적화해야 하나요?
이미지는 웹 페이지에 시각적인 매력을 더하고 정보를 전달하는 데 꼭 필요하지만, 동시에 페이지 로딩을 늦추는 주된 원인이 되기도 합니다. 용량이 큰 이미지는 네트워크로 전송되는 데 오래 걸려 페이지 로딩을 늦추고, 이는 사용자 이탈로 이어질 수 있습니다. 모바일 환경에서는 사용자의 데이터 요금 부담도 늘립니다.
이미지가 늦게 도착하거나 공간이 밀리는 것은 LCP(Largest Contentful Paint)와 CLS(Cumulative Layout Shift) 같은 Core Web Vitals와 페이지 경험에 영향을 줄 수 있습니다. 다만 이 지표만으로 검색 순위가 정해지지는 않습니다. 사용자 기기에 맞지 않는 고해상도 이미지를 보내는 것도 불필요한 자원 낭비입니다. 이미지 최적화는 이런 문제를 해결해 웹 성능을 크게 개선합니다.
next/image 컴포넌트 사용하기
Next.js는 이미지 최적화를 위해 특별히 설계한 <Image> 컴포넌트를 next/image에서 제공합니다. 기본 이미지 로더는 요청에 맞춰 크기, 품질, 형식을 변환합니다. 정적 import에서 얻는 크기 정보와 blur 데이터 준비는 이 요청 시점의 변환과 구분됩니다.
next/image는 기기 크기에 따라 최적화된 크기의 이미지를 자동으로 제공하는 반응형 이미지를 지원합니다. 브라우저가 정한 뷰포트 주변 거리에 이를 때까지 이미지 로딩을 미루는 지연 로딩(lazy loading)도 기본으로 동작합니다. 형식은 기본 설정에서 브라우저가 지원하면 WebP로 변환하며, AVIF까지 제공하려면 images.formats에 image/avif를 추가합니다. 지정한 품질 설정에 따라 이미지를 압축하고, 고유 크기나 부모 영역을 지정해 이미지가 도착할 자리를 미리 확보합니다. 다만 주변 CSS 때문에 생기는 이동까지 모두 없애지는 않습니다. 이미지는 Next.js 서버나 CDN을 거쳐 효율적으로 제공됩니다.
App Router 프로젝트라면 next/image는 별도로 설치하지 않고 바로 쓸 수 있습니다.
기본 사용법
<Image> 컴포넌트에는 src와 의미에 맞는 alt가 필요합니다. public 폴더의 문자열 경로나 원격 URL을 쓰면 width와 height로 비율을 알려야 하지만, 정적 import가 크기를 제공하거나 fill로 부모 영역을 채우는 경우에는 두 속성을 생략합니다.
public/logo.png를 준비하고, 코드 끝의 주석에 있는 CSS는 별도의 src/app/page.module.css 파일에 저장합니다. 원격 이미지의 크기 속성은 실제로 쓰는 이미지의 비율에 맞춥니다.
import Image from 'next/image';
import styles from './page.module.css'; // CSS 모듈 사용 예시
export default function HomePage() {
return (
<div className={styles.container}>
<h1>Next.js 이미지 최적화</h1>
{/* 로컬 이미지 사용 */}
<div className={styles.imageWrapper}>
<h2>로컬 이미지</h2>
<Image
src="/logo.png"
alt="Next.js 로고"
width={500}
height={300}
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
style={{ maxWidth: '100%', height: 'auto' }}
/>
<p>이 이미지는 Next.js 로고입니다.</p>
</div>
{/* 외부 이미지 사용 */}
<div className={styles.imageWrapper}>
<h2>외부 이미지</h2>
<Image
src="https://images.unsplash.com/photo-1617424699564-90f7a77e9b40?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="아름다운 자연 풍경"
width={800}
height={600}
preload
style={{ maxWidth: '100%', height: 'auto' }}
/>
<p>이 이미지는 Unsplash에서 가져온 풍경 이미지입니다.</p>
</div>
{/* 레이아웃 채우기 (fill) 예시 */}
<div className={styles.fillImageWrapper}>
<h2>레이아웃 채우기 (fill)</h2>
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
<Image
src="https://images.unsplash.com/photo-1610214644596-f94d3f572a8c?q=80&w=2835&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="도시 야경"
fill
sizes="(max-width: 900px) 100vw, 900px"
style={{ objectFit: 'cover' }}
/>
</div>
<p>이 이미지는 부모 요소의 공간을 채웁니다.</p>
</div>
</div>
);
}
// src/app/page.module.css
/*
.container {
padding: 2rem;
max-width: 900px;
margin: 0 auto;
font-family: Arial, sans-serif;
}
.imageWrapper {
margin-bottom: 3rem;
border: 1px solid #ddd;
padding: 1rem;
text-align: center;
background-color: #f9f9f9;
}
.imageWrapper h2 {
margin-top: 0;
color: #333;
}
.imageWrapper p {
font-size: 0.9em;
color: #666;
margin-top: 1rem;
}
.fillImageWrapper {
margin-bottom: 3rem;
border: 1px solid #007bff;
padding: 1rem;
text-align: center;
background-color: #eaf6ff;
}
.fillImageWrapper h2 {
margin-top: 0;
color: #007bff;
}
.fillImageWrapper p {
font-size: 0.9em;
color: #666;
margin-top: 1rem;
}
*/실습의 fill 이미지는 한 열 전체를 채웁니다. sizes는 컨테이너의 최대 폭 900px을 기준으로 잡은 넉넉한 상한이므로, padding과 테두리를 뺀 실제 표시 폭보다 클 수 있습니다. 배포 화면의 폭에 맞춰 더 정확히 좁힐 수 있습니다. 둘째 이미지의 preload는 사용법을 보여 주려는 설정이므로, 이 이미지가 실제 LCP 요소인지는 화면 크기와 콘텐츠로 확인해야 합니다.
<Image> 컴포넌트의 주요 속성
src는 로컬이나 외부 URL의 이미지 경로이고, alt는 접근성과 SEO에 꼭 필요한 대체 텍스트입니다. width와 height는 이미지의 고유한 너비와 높이(픽셀)로, 정적 import나 fill을 쓰지 않을 때 필수이며 브라우저가 비율을 미리 확보해 CLS를 막도록 돕습니다. fill은 부모 요소의 크기에 맞춰 이미지를 채우는 불리언 속성으로, 이때는 width와 height가 필요 없고 부모 요소에 position: relative 같은 스타일이 있어야 합니다.
sizes는 fill을 쓰거나 CSS로 반응형 크기를 정할 때 실제 표시 폭을 브라우저에 알려, 알맞은 srcset 후보를 고르게 합니다. 이 값은 CSS의 표시 폭 자체를 바꾸지 않습니다. quality는 1부터 100 사이의 숫자로 압축 품질을 지정합니다. Next.js 16은 next.config의 images.qualities 허용 목록을 적용하며 기본 허용값은 75이므로, 다른 값을 쓰려면 허용 목록에도 추가합니다.
preload는 LCP 후보가 분명할 때 <Image preload />로 이미지를 발견하는 시점을 앞당깁니다. Next.js 16에서는 기존의 priority 대신 이 속성을 씁니다. 일반적인 상단 이미지에는 loading="eager"나 fetchPriority="high"가 더 알맞을 수 있습니다. loading은 기본값인 lazy(지연 로딩)나 eager(즉시 로딩)를 지정하며, preload와 함께 지정하지 않습니다.
placeholder는 이미지가 로딩되는 동안 보여 줄 자리표시자입니다. blur는 작은 blurDataURL로 흐릿한 미리보기를 보여 주고, empty는 자리표시자를 따로 보여 주지 않습니다. 지원 형식의 이미지를 정적으로 import하면 blur 데이터가 자동으로 만들어지지만, 이 효과는 placeholder="blur"로 직접 골라야 합니다. blurDataURL은 placeholder="blur"와 함께 쓸 작은 Base64 데이터 URL로, import hero from "./hero.jpg"처럼 지원 형식의 이미지를 정적으로 import하면 Next.js가 자동으로 만듭니다. src="/logo.png" 같은 public 문자열 경로나 외부 이미지는 직접 제공해야 합니다. unoptimized는 크기, 품질, 형식 변환 없이 src의 원본을 제공하는 불리언 속성이며, 레이아웃이나 다른 이미지 속성까지 없애지는 않습니다.
NEXT · 12.1
이미지 공간·파일 후보·로딩 시점은 서로 다른 설정입니다
본문의 세 Image 예제를 같은 질문으로 비교합니다. 이 설정들은 독립된 역할을 하므로 순서대로 실행하는 세 단계로 읽지 않습니다.
본문의 세 예제
- 로컬 로고 · 500 × 300
- width·height로 비율을 알리고 CSS의 max-width와 height: auto로 반응형 표시합니다. blurDataURL은 직접 제공한 미리보기입니다.
- 외부 사진 · 800 × 600
- 같은 방식으로 비율과 CSS 표시 크기를 구분합니다. preload는 발견 시점을 앞당기는 예시이며 실제 LCP 요소인지는 별도로 확인합니다.
- fill 사진 · 부모 높이 300px
- relative인 부모를 채웁니다. sizes는 최대 900px을 기준으로 한 후보 선택 힌트이며 padding을 뺀 실제 폭보다 클 수 있습니다.
설정이 답하는 세 질문
- 어디에 얼마나 표시하나요?
- width·height의 비율 또는 fill의 부모 크기와 CSS가 레이아웃 공간을 정합니다.
- 어느 파일 후보를 고르나요?
- sizes가 예상 표시 폭을 알립니다. 브라우저가 srcset 중 후보를 선택하며 sizes 자체는 CSS 폭을 바꾸지 않습니다.
- 언제 발견하고 요청하나요?
- preload·loading은 발견과 요청 시점에 관여합니다. lazy의 요청 기준은 브라우저가 정하며 화면에 닿는 순간으로 고정되지 않습니다.
blur 미리보기는 원본 다운로드를 대체하지 않습니다. 포맷·quality·표시 폭을 조정해도 전송량 감소와 LCP 개선은 실제 이미지와 화면에서 측정해야 합니다. 외부 URL은 remotePatterns 허용 범위도 확인합니다.
위 그림은 이미지의 공간, 파일 후보, 로딩 시점이 서로 다른 설정이라는 점을 보여 줍니다. width와 height의 비율, 그리고 CSS는 크기와 cover 잘림 같은 표시 영역을 정합니다. sizes와 srcset은 표시 폭의 힌트로 브라우저가 내려받을 파일 후보를 고르게 하고, preload와 loading은 미리 요청할지 지연할지 같은 요청 시점을 정합니다. 세 화살표는 각각 따로 영향을 주는 관계이며, 차례로 실행해야 하는 세 단계가 아닙니다. 그림의 설명처럼 sizes는 CSS 크기를 바꾸지 않습니다.
아래쪽 표는 본문의 세 예제에 같은 질문을 적용합니다. 500 × 300 로컬 로고는 width와 height로 비율을 알리고 CSS의 max-width와 height: auto로 반응형으로 표시하며, blurDataURL은 직접 제공한 미리보기입니다. 800 × 600 외부 사진도 같은 방식으로 비율과 CSS 표시 크기를 구분하며, preload는 발견 시점을 앞당기는 예시일 뿐 실제 LCP 요소인지는 따로 확인합니다. 높이 300px인 부모를 채우는 fill 사진의 sizes는 최대 900px을 기준으로 한 후보 선택 힌트입니다. 그림의 아래 설명처럼 blur 미리보기가 원본 다운로드를 대신하지는 않으며, 전송량 감소와 LCP 개선은 실제 이미지와 화면에서 측정해야 합니다.
외부 이미지 도메인 설정
외부 이미지 URL을 쓰는 경우, Next.js가 그 이미지를 최적화할 수 있도록 next.config.ts에 프로토콜, 호스트, 경로를 구체적으로 허용합니다. 이미지 최적화는 Next.js 서버가 원본을 대신 내려받아 변환하는 방식이라, 허용 목록이 없으면 누구나 이 서버에 임의의 외부 이미지를 변환시킬 수 있기 때문입니다. 그래서 remotePatterns에 맞지 않는 외부 URL을 <Image>의 src에 넣으면 오류가 납니다. 아래 코드의 pathname 끝에 붙은 별표 두 개는 그 아래의 경로를 깊이와 상관없이 모두 뜻하므로, 둘째 항목은 www.example.com의 /images/ 아래만 허용합니다.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'www.example.com',
pathname: '/images/**',
},
],
},
};
export default nextConfig;이 예제는 Unsplash의 모든 경로를 허용하고, search를 생략해 URL의 쿼리도 허용합니다. 실제 서비스에서는 필요한 출처 범위로 제한합니다. next.config.ts를 고치면 개발 서버가 Found a change in next.config.ts로 시작하는 줄을 출력하며 저절로 다시 시작하므로, 그 줄이 나온 뒤에 이미지를 다시 확인합니다.
표시 크기는 이렇게 계산해 볼 수 있습니다. 로고의 원본 비율이 500:300이고 CSS로 너비를 300px, 높이를 auto로 정했다면, 표시 높이는 300 × 300 ÷ 500 = 180px입니다. 반면 800 × 600 이미지를 300 × 300 부모에 fill과 objectFit: 'cover'로 넣으면, 비율을 유지한 크기는 400 × 300이 되어 가로 100px이 잘립니다. 가운데 정렬이라면 양쪽에서 50px씩 잘립니다. 이는 표시 영역의 계산이며, sizes가 이 CSS 크기나 잘림을 바꾸지는 않습니다.
sizes는 브라우저가 내려받을 후보를 고르는 힌트입니다. 화면에서 필요한 폭을 지나치게 크게 적으면 더 큰 파일을 고를 수 있지만, 힌트를 작게 쓴다고 부모의 레이아웃까지 작아지지는 않습니다. 먼저 실제 표시 영역을 정하고, 그 폭에 맞는 힌트를 줍니다.
더 깊이: 최적화 서버가 이미지를 캐시하고 원본을 가져오는 규칙
기본 이미지 최적화 로더는 /_next/image 요청이 들어올 때 원본을 변환하고, 그 결과를 디스크 캐시에 써서 다음 요청에 다시 씁니다. 최적화한 이미지의 캐시 수명은 images.minimumCacheTTL과 원본 이미지가 보낸 Cache-Control 헤더 가운데 더 긴 쪽으로 정해집니다. Next.js 16은 minimumCacheTTL의 기본값을 60초에서 4시간(14400초)으로 늘렸는데, 원본이 Cache-Control 헤더를 보내지 않아 60초마다 재검증이 일어나 CPU 사용량과 비용이 늘던 문제를 줄이려는 것입니다. 지금은 이 캐시를 무효화하는 방법이 없으므로, 같은 주소의 원본을 바꿨다면 src를 바꾸거나 <distDir>/cache/images의 캐시 파일을 지워야 합니다. 반면 정적으로 import한 이미지는 파일 내용의 해시가 이름에 들어가므로 오래 유지되는 immutable 캐시로 제공됩니다(Next.js 문서의 Image).
원본을 가져오는 단계에도 Next.js 16에서 강화된 기본 제한이 있습니다. 로컬 IP 주소의 이미지는 기본적으로 최적화하지 않으며, 사설 네트워크에서만 images.dangerouslyAllowLocalIP를 true로 켜되 서버 측 요청 위조(SSRF)의 위험을 이해한 뒤에 켜야 합니다. 원격 이미지의 리다이렉트는 최대 3번까지만 따라가고(images.maximumRedirects), 쿼리 문자열이 붙은 로컬 이미지는 images.localPatterns의 search 조건에 맞아야 합니다. 또 여러 출력 형식을 켜면 형식마다 따로 캐시하므로 저장 공간이 늘어나며, 16.1.7에 추가된 images.maximumDiskCacheSize를 정하지 않으면 시작할 때 확인한 사용 가능한 디스크 공간의 50%를 상한으로 씁니다.
이미지 최적화를 위한 추가 팁
이미지는 올리기 전에 쓰일 최대 크기에 맞춰 알맞게 줄이는 것이 좋습니다. next/image가 반응형 이미지를 처리해 주지만, 지나치게 큰 원본을 올리는 것은 여전히 비효율적일 수 있습니다.
파일 형식도 용도에 맞게 고릅니다. JPEG는 사진처럼 색상이 풍부한 이미지에 알맞고 손실 압축으로 파일 크기를 줄입니다. PNG는 투명도가 필요하거나, 로고나 아이콘처럼 색상 수가 적고 선명도가 중요한 이미지에 알맞은 비손실 압축 형식입니다. WebP와 AVIF는 JPEG나 PNG보다 효율적인 압축을 제공할 수 있는 최신 형식입니다. 기본 형식 목록은 WebP이며 브라우저의 Accept 헤더와 원본 조건에 따라 적용되고, AVIF를 함께 쓰려면 next.config의 images.formats를 ['image/avif', 'image/webp']처럼 설정합니다. SVG는 로고나 아이콘 같은 벡터 그래픽에 알맞으며, 확대해도 선명하지만 복잡도에 따라 파일 크기가 달라집니다. 일반 <img>나 <Image unoptimized>로 표시할 수 있고, SVG를 React 컴포넌트로 import하려면 별도의 로더 설정이 필요합니다.
placeholder="blur"나 placeholder="empty"로 이미지가 로딩되는 동안의 사용자 경험을 개선할 수 있습니다. LCP 이미지가 분명하다면 preload와 함께 쓸 수 있지만, 작은 Base64 자리표시자가 실제 전송 비용을 늘리지 않는지도 확인합니다. CDN(Content Delivery Network)을 쓰면 사용자에게 물리적으로 가장 가까운 서버에서 이미지를 제공해 로딩 시간을 줄일 수 있으며, Vercel에 배포하면 내장된 이미지 최적화 서비스를 씁니다. 지연 로딩의 기본값은 lazy이며, 브라우저가 뷰포트 주변의 이미지를 미리 가져올 수 있으므로 화면 밖에 있다는 이유만으로 아직 요청되지 않았다고 단정하지 않습니다.
이미지 최적화는 이미지를 웹 페이지에 넣는 것을 넘어, 사용자 경험과 웹 성능을 높이기 위한 필수 과정입니다. next/image를 올바르게 쓰면 복잡한 최적화 기법을 직접 구현하지 않고도 좋은 이미지 성능을 얻을 수 있습니다.
정리
next/image의<Image>는 반응형 크기, 지연 로딩, 형식 변환, 공간 예약을 제공하며,src와 의미에 맞는alt가 필요합니다.- 문자열 경로나 원격 URL은
width와height로 비율을 알리고, 정적 import나fill을 쓰면 두 속성을 생략합니다. - 표시 영역은 비율과 CSS가, 내려받을 파일 후보는
sizes가, 요청 시점은preload와loading이 정하며 서로 대신하지 않습니다. - Next.js 16에서는
priority대신preload를 쓰고,quality는images.qualities허용 목록(기본75)을 따릅니다. - 외부 이미지는
next.config.ts의remotePatterns에 프로토콜, 호스트, 경로를 필요한 범위만 허용합니다.
연습 문제
1. 표시 높이와 품질 조건 확인하기
로고의 CSS 너비만 250px로 바꾸면 높이는 얼마가 될까요? 또 허용 품질을 [50, 75, 100]으로 설정한 상태에서 <Image quality={80}>를 쓰는 경우와, 이미지 최적화 API에 허용되지 않은 q=80을 직접 요청하는 경우를 구별해 보세요.
해설 보기
높이는 250 × 300 ÷ 500 = 150px입니다. 지금의 next/image 컴포넌트는 허용 목록에서 가까운 75를 쓰지만, 최적화 API에 직접 보낸 요청은 허용되지 않은 값이면 400 응답 대상입니다. 이 설정 차이를 외부 원본 이미지 서버의 응답이나 CSS 크기와 섞지 않습니다. 자세한 조건은 공식 Image 문서의 품질 조건에서 확인할 수 있습니다.
2. cover로 잘리는 양
1200 × 600 이미지를 300 × 300 부모에 fill과 objectFit: 'cover'로 넣었습니다. 비율을 유지한 크기와 가로로 잘리는 양을 계산해 보세요. 가운데 정렬이라면 한쪽에서 얼마나 잘릴까요?
해설 보기
cover는 부모를 빈틈없이 덮도록 비율을 유지해 늘이므로, 높이를 300px에 맞춥니다. 그러면 너비는 1200 × 300 ÷ 600 = 600px이 되어 비율을 유지한 크기는 600 × 300이고, 가로로 600 - 300 = 300px이 잘립니다. 가운데 정렬이라면 양쪽에서 150px씩 잘립니다. sizes를 바꿔도 이 잘림은 달라지지 않습니다.
3. 모든 이미지에 preload 붙이기
페이지를 빨리 보이게 하려고 화면의 모든 <Image>에 preload를 붙였습니다. 이 방식에는 어떤 문제가 있을까요?
해설 보기
preload는 브라우저가 이미지를 일찍 발견하고 요청하게 만들므로, 모든 이미지에 붙이면 화면 아래의 이미지까지 처음부터 앞다투어 내려받아 정작 중요한 LCP 이미지와 다른 자원의 로딩이 늦어질 수 있습니다. 지연 로딩의 이점도 사라집니다. preload는 실제 LCP 요소로 확인된 이미지 하나 정도에만 쓰고, 나머지는 기본 lazy를 유지합니다.
다음 글 폰트 최적화에서는 next/font로 웹 폰트를 최적화해 레이아웃 이동과 외부 요청을 줄이는 방법을 알아봅니다.