- Published on
Next.js 이미지 최적화 - next/image 400·캐시 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 렌더링과 클라이언트 렌더링이 섞인 Next.js 앱에서 이미지 최적화는 성능에 직결됩니다. next/image는 자동 리사이징, 포맷 변환(WebP/AVIF), 지연 로딩, 캐시까지 한 번에 제공하지만, 운영 환경에서는 종종 400 오류나 “이미지 바뀌었는데 계속 예전 이미지가 보이는” 캐시 문제가 발생합니다.
이 글은 next/image 최적화 파이프라인이 실제로 어떻게 동작하는지부터, /_next/image에서 발생하는 400의 대표 원인과 재현 방법, 그리고 CDN/프록시/스토리지(S3 등)와 결합했을 때의 캐시 설계까지 한 번에 정리합니다.
next/image 최적화가 동작하는 방식
next/image는 단순히 img 태그를 감싼 컴포넌트가 아닙니다. 기본 로더를 쓰면 브라우저는 원본 URL로 직접 요청하지 않고, Next.js가 제공하는 이미지 최적화 엔드포인트로 요청합니다.
- 브라우저 요청:
/_next/image?url=...&w=...&q=... - Next.js 서버(또는 Edge/런타임): 원본 이미지를 가져옴(fetch)
- 리사이즈/재인코딩 후 응답
- 결과를 디스크(또는 캐시 계층)에 저장하고 재사용
즉, 문제의 대부분은 아래 중 하나입니다.
url파라미터로 전달되는 원본 URL이 허용되지 않음(도메인/패턴)- 원본 서버가
next/image의 fetch를 거부(403/401/리다이렉트/핫링크 차단) - 프록시나 CDN이 쿼리스트링을 잘못 처리하거나 캐시 정책이 충돌
- 파일이 바뀌었는데 URL이 동일해서 캐시가 정상적으로 동작(하지만 기대와 다름)
next/image 400 오류의 핵심 원인 6가지
400은 “Next.js 이미지 최적화 서버가 요청을 처리하지 않겠다”는 의미로, 보통 설정/요청 형태가 잘못된 경우가 많습니다. 아래는 현장에서 가장 자주 보는 패턴입니다.
1) 허용되지 않은 외부 도메인(가장 흔함)
외부 이미지를 최적화하려면 next.config.js에서 도메인을 허용해야 합니다. Next.js는 보안상 기본적으로 외부 도메인을 막습니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/**',
},
],
},
}
module.exports = nextConfig
- 과거 버전의
images.domains만 쓰던 프로젝트라면,remotePatterns로 마이그레이션하면서 경로 패턴 누락이 자주 발생합니다. http와https혼용도 흔한 함정입니다.
2) url 파라미터가 잘못 인코딩됨
/_next/image는 url을 쿼리로 받기 때문에, 원본 URL에 ?나 &가 들어가면 인코딩 문제로 깨질 수 있습니다.
- 원본:
https://cdn.example.com/a.png?ver=123&sig=abc - 안전한 형태:
encodeURIComponent로 인코딩된 값
직접 구성하는 경우가 아니라면 보통 next/image가 처리하지만, 커스텀 로더나 프록시에서 URL을 재조합하면서 문제가 생깁니다.
3) width(w)나 quality(q)가 허용 범위 밖
Next.js는 w를 임의의 값으로 받지 않습니다. images.deviceSizes와 images.imageSizes에 정의된 값만 허용합니다. 정의되지 않은 폭으로 요청하면 400이 날 수 있습니다.
// next.config.js
const nextConfig = {
images: {
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
},
}
module.exports = nextConfig
운영에서만 터지는 이유는, 특정 뷰포트/특정 컴포넌트에서만 특정 sizes 조합이 발생해서입니다.
4) 원본 서버가 Next.js 서버의 요청을 차단(403 유발 후 400으로 보일 때)
next/image는 서버에서 원본 이미지를 가져옵니다. 원본 서버가 아래 정책을 쓰면 실패합니다.
- User-Agent 또는 헤더 기반 차단
- Referer 없으면 차단(핫링크 방지)
- 사설 네트워크/방화벽에서만 접근 가능
- 서명 URL 만료
이때 Next.js는 내부적으로 fetch 실패를 처리하면서 최종 응답이 400 또는 502로 보이기도 합니다(버전/호스팅 환경에 따라 상이).
해결은 “원본 서버가 Next.js 런타임에서 접근 가능한가”를 먼저 확인하는 것입니다. 서버/컨테이너에서 curl로 테스트하세요.
curl -I "https://images.example.com/path/to/img.jpg"
S3/CloudFront 조합에서 권한 문제가 자주 나오는데, 접근 제어를 점검할 때는 아래 글도 같이 보면 좋습니다.
5) next.config.js 변경 후 재시작 누락
이미지 설정(images.*)은 런타임에서 동적으로 반영되지 않습니다. 로컬에서는 dev 서버가 알아서 재시작되는 것처럼 보여도, 배포 환경에서는 빌드 산출물/서버 재기동이 필요합니다.
- Vercel: 재배포 필요
- 자체 서버: 프로세스 재시작 필요
6) 프록시/CDN이 /_next/image 쿼리스트링을 무시
/_next/image는 쿼리스트링이 핵심입니다. 그런데 일부 CDN/리버스 프록시 설정이 쿼리를 제거하거나, 쿼리를 캐시 키에 포함하지 않으면 문제가 발생합니다.
- 폭/품질이 다른 요청이 같은 캐시로 합쳐짐
- 잘못된 콘텐츠가 반환되며, 상황에 따라
400처럼 보이는 오류가 연쇄 발생
해결은 CDN 캐시 키에 w, q, url을 포함시키거나, 최소한 전체 쿼리스트링을 캐시 키에 반영하는 것입니다.
캐시 문제: “이미지 바꿨는데 왜 안 바뀌지?”
캐시 문제는 크게 두 종류입니다.
- 정상 동작인데 기대가 다른 경우(같은 URL이면 캐시되는 게 정상)
- 캐시 계층이 꼬여서 잘못된 리소스가 반환되는 경우
1) 같은 URL로 덮어쓰면 캐시는 당연히 남는다
예를 들어 https://cdn.example.com/logo.png를 같은 경로로 덮어쓰면, 브라우저 캐시, CDN 캐시, Next.js 이미지 캐시가 모두 “같은 리소스”로 판단하고 계속 예전 것을 줄 수 있습니다.
가장 확실한 해결은 URL 버저닝입니다.
- 파일명에 해시를 포함:
logo.8f3a1c.png - 또는 쿼리 버전:
logo.png?v=20260224
Next.js에서 정적 임포트를 쓰면 자동으로 해시 파일명이 붙어 캐시 무효화가 쉬워집니다.
import Image from 'next/image'
import logo from '@/public/logo.png'
export function HeaderLogo() {
return (
<Image
src={logo}
alt="Brand"
priority
/>
)
}
2) /_next/image의 캐시 계층 이해하기
이미지 최적화 응답은 보통 다음 헤더를 가집니다.
Cache-Control: CDN/브라우저 캐시 정책ETag또는Last-Modified: 재검증을 위한 식별자
운영에서 “특정 사용자만” 오래된 이미지를 보는 경우, 대개 아래 중 하나입니다.
- 브라우저가 강하게 캐시하고 재검증을 안 함
- CDN이 오래된 오브젝트를 잡고 있음
- Next.js 서버의 이미지 캐시가 남아 있음(특히 자체 호스팅)
3) 자체 호스팅에서 이미지 캐시 위치와 삭제
자체 서버에서 기본 로더를 사용하면 최적화된 이미지는 .next/cache/images에 저장됩니다(버전/환경에 따라 경로는 달라질 수 있음).
배포 방식이 “서버 위에서 빌드 후 그대로 실행”이라면, 이전 배포의 캐시가 남아 예전 이미지가 계속 제공될 수 있습니다.
- 해결 1: 배포 시
.next를 완전히 새로 생성 - 해결 2: 이미지 캐시 디렉터리 정리
- 해결 3: 무중단 배포에서는 릴리즈 디렉터리를 분리해 캐시를 릴리즈 단위로 격리
4) CDN 앞단에서의 올바른 캐시 키
CDN을 /_next/image 앞에 두는 경우, 캐시 키는 최소한 다음을 포함해야 합니다.
- Path:
/_next/image - Query:
url,w,q
쿼리 무시 설정이 켜져 있으면, 작은 썸네일 요청이 큰 이미지 응답으로 캐시되는 등 치명적인 혼선이 생깁니다.
실전 해결 레시피: 400과 캐시를 한 번에 잡는 체크리스트
1) 외부 이미지 허용 설정부터 고정
// next.config.js
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'images.example2.com',
pathname: '/assets/**',
},
],
formats: ['image/avif', 'image/webp'],
},
}
module.exports = nextConfig
- 운영에서 도메인이 여러 개로 늘어나는 경우가 많으니, “현재/미래” 도메인까지 정리해두는 게 좋습니다.
2) 원본 서버 응답 헤더 점검(특히 캐시)
원본이 지나치게 강한 캐시 헤더를 주거나, 반대로 매번 다운로드가 발생하면 비용과 지연이 커집니다.
curl -I "https://cdn.example.com/assets/banner.jpg"
확인 포인트
Cache-Control: public, max-age=...가 합리적인지ETag가 안정적으로 바뀌는지(동일 내용이면 동일 ETag)Vary가 불필요하게 커서 캐시 적중률을 떨어뜨리지 않는지
3) URL 버저닝을 팀 규칙으로 강제
- “같은 경로 덮어쓰기 금지”를 규칙으로 두면 캐시 이슈의 80%가 사라집니다.
- 정적 리소스는 해시 파일명, 동적 리소스는 버전 쿼리 또는 리비전 경로를 권장합니다.
4) 프록시/CDN에서 쿼리 포함 캐시 키 강제
Nginx를 앞단에 둔 경우, 최소한 /_next/image는 쿼리를 포함해 캐시되도록 정책을 분리하세요.
# nginx.conf (개념 예시)
location /_next/image {
proxy_pass http://next_upstream;
proxy_set_header Host $host;
# 쿼리스트링이 핵심이므로 캐시를 쓸 경우 키에 포함되도록 설계
# (구체 설정은 사용하는 캐시 모듈/구성에 따라 달라집니다)
}
CloudFront라면 Cache Policy에서 Query strings를 All로 포함하거나, 최소 url, w, q만 화이트리스트로 포함하세요.
5) 문제 재현: 최적화 엔드포인트 직접 호출
브라우저에서 이미지가 깨질 때는 실제로 어떤 요청이 나가는지 확인한 뒤, 같은 URL을 직접 호출해보면 원인 분리가 빨라집니다.
예시 형태는 다음과 같습니다(실제 값은 환경에 맞게).
/_next/image?url=https%3A%2F%2Fcdn.example.com%2Fa.jpg&w=1080&q=75
여기서 400이 나오는지, 응답 본문에 어떤 메시지가 있는지(환경에 따라 다름), 그리고 서버 로그에 “허용되지 않은 도메인” 같은 힌트가 찍히는지 확인하세요.
운영 환경별 팁: Vercel, 자체 서버, S3/CloudFront
Vercel
- 기본적으로 이미지 최적화와 캐시가 잘 구성되어 있지만, 외부 도메인 허용 누락이 여전히 1순위 이슈입니다.
- 빌드 산출물만 바뀌고 원본 URL이 동일하면, 사용자는 여전히 캐시된 이미지를 보게 됩니다. URL 버저닝이 정답입니다.
자체 서버(Docker, VM)
.next/cache/images가 남아있어 “배포했는데 안 바뀜”이 자주 발생합니다.- 무중단 배포라면 릴리즈 디렉터리 분리 또는 배포 훅에서 캐시 정리를 고려하세요.
S3/CloudFront
- 원본 접근 제어(서명 URL, OAC/OAI, 버킷 정책)로 인해 Next.js 서버가 원본을 못 읽는 케이스가 많습니다.
- CloudFront 캐시 키에 쿼리가 포함되지 않으면
/_next/image는 거의 확정적으로 사고가 납니다.
대안: 최적화를 포기하거나(또는 위임)해야 할 때
모든 이미지가 next/image에 적합한 것은 아닙니다.
- 외부 서비스가 강한 핫링크 방지 정책을 사용한다
- 원본이 매우 빈번하게 바뀌며 URL 버저닝이 불가능하다
- 이미지 변환을 별도 이미지 CDN(Cloudinary, Imgix 등)으로 위임하고 싶다
이 경우에는 다음을 고려합니다.
unoptimized옵션으로 Next.js 최적화를 끄고 원본을 직접 제공
import Image from 'next/image'
export function ExternalImage() {
return (
<Image
src="https://images.example.com/photo.jpg"
alt="photo"
width={1200}
height={800}
unoptimized
/>
)
}
다만 이 선택은 LCP/대역폭/포맷 최적화 이점을 일부 포기하는 것이므로, 원인(권한/캐시 키/도메인 설정)을 먼저 해결 가능한지 검토한 뒤 결정하는 편이 좋습니다.
마무리: 400은 설정, 캐시는 설계 문제다
next/image의 400은 대부분 “허용되지 않은 입력”에서 발생합니다. 따라서 remotePatterns, 허용 폭(deviceSizes, imageSizes), 그리고 원본 서버 접근 가능성을 먼저 고정하면 해결이 빠릅니다.
캐시 문제는 버그가 아니라 설계 이슈인 경우가 많습니다. 같은 URL을 덮어쓰는 운영 습관을 버리고, URL 버저닝과 CDN 캐시 키 정책을 정리하면 “안 바뀌는 이미지” 문제는 구조적으로 사라집니다.
추가로, 성능 최적화 관점에서 프론트 지표를 함께 추적하고 싶다면 아래 글도 도움이 됩니다.