안녕하세요. J4J입니다.
이번 포스팅은 Next.js 16으로 업그레이드하면서 next/image의 여러 기본값이 어떻게 조용히 달라졌는지 알아보는 시간을 가져보려고 합니다.
next/image quality 기본값 변경, qualities 배열로 제한되는 화질
Next.js 16으로 프로젝트를 올린 뒤에도 quality prop을 예전과 똑같이 쓰고 있는데, 이미지가 이전과 다른 화질로 렌더링되는 경우를 마주치게 됩니다.
이 변화의 원인은 next/image의 images.qualities 설정이 15와 16 사이에 달라졌기 때문입니다.
images.qualities는 quality prop으로 지정할 수 있는 값을 제한하는 배열이며, next/image 컴포넌트에서 이 배열에 없는 값을 지정하면 가장 가까운 값으로 자동 보정됩니다.
Next.js 15에서는 quality prop에 1부터 100 사이 어떤 정수든 자유롭게 사용할 수 있었습니다.
하지만 Next.js 16부터는 images.qualities의 기본값이 [75] 하나로 제한되어, quality prop을 생략하면 항상 75가 사용되고 다른 값을 넣어도 결국 75로 수렴합니다.
공식 문서는 이 변경 이유를 제한 없이 값을 허용하면 의도한 것보다 많은 화질 조합으로 이미지가 최적화될 수 있기 때문이라고 설명하고 있습니다.
기본값인 [75] 하나만으로는 여러 값 사이에서 어떻게 보정되는지 확인하기 어려우므로, 이 포스팅에서는 qualities를 [50, 75, 100]으로 직접 넓혀서 살펴보겠습니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
qualities: [50, 75, 100],
},
}
export default nextConfig
이 상태에서 quality prop에 50, 80, 90, 100을 각각 지정한 이미지를 렌더링해 실제로 어떻게 보정되는지 확인해 보겠습니다.
// app/image-quality/page.tsx
import Image from 'next/image'
const qualityPresets = [50, 80, 90, 100]
export default function ImageQualityPage() {
return (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-lg font-medium">quality 보정 확인</h1>
<p className="text-sm text-gray-500">
qualities가 [50, 75, 100]으로 설정된 상태에서 quality prop을 다르게 지정했을 때 실제로 어떻게
보정되는지 개발자 도구의 img 태그 src에서 확인할 수 있습니다.
</p>
<div className="grid grid-cols-2 gap-4">
{qualityPresets.map((quality) => (
<div key={quality} className="flex flex-col gap-2">
<span className="text-sm">quality prop: {quality}</span>
<Image src="/sample.png" alt={`quality ${quality} 예시`} width={200} height={100} quality={quality} />
</div>
))}
</div>
</div>
)
}
50과 100처럼 qualities 배열에 이미 있는 값은 그대로 사용되지만, 80은 75와의 차이(5)가 100과의 차이(20)보다 작아 75로 보정됩니다.
반면 90은 100과의 차이(10)가 75와의 차이(15)보다 작기 때문에 오히려 100으로 보정되므로, 높은 값은 항상 낮아질 것이라고 단순하게 예상하면 틀리기 쉽습니다.
체감할 수 있는 것 중 하나는, 브라우저 개발자 도구에서 실제 img 태그의 src 쿼리스트링에 담긴 q 값을 확인해 보면 quality prop에 넣은 숫자가 아니라 보정된 숫자가 그대로 찍혀 있다는 점입니다.
다만 qualities 배열을 직접 정해두지 않은 상태에서 quality prop에 75가 아닌 값을 넘기면 그 값은 예외 없이 75로 보정됩니다.
개발 모드에서는 images.qualities에 없는 값이라는 콘솔 경고가 남지만 프로덕션 빌드에서는 이 경고가 제거되므로, 실제 화질이 달라졌다는 사실을 인지하지 못하고 넘어가기 쉽습니다.
next/image 컴포넌트를 거치지 않고 /_next/image 최적화 엔드포인트를 직접 호출하는 경우에는 동작이 다릅니다.
qualities 배열에 없는 값을 q 쿼리스트링으로 직접 넘기면 자동 보정 대신 400 Bad Request가 반환됩니다.
// terminal
$ curl -I "http://localhost:3000/_next/image?url=%2Fsample.png&w=256&q=60"
qualities가 [50, 75, 100]으로 설정된 상태에서 60은 어디에도 해당하지 않으므로, 이 요청은 HTTP 400 응답과 함께 실패합니다.
next/image 컴포넌트를 거치는 일반적인 사용에서는 이런 에러를 마주칠 일이 없지만, 이미지 최적화 URL을 직접 조립해 쓰는 경우라면 qualities 배열에 있는 값만 사용하는 것이 좋습니다.
다음으로는 이미지 화질만큼이나 자주 헷갈리는 캐시 정책 변화를 살펴보겠습니다.
next/image, minimumCacheTTL 4시간으로 바뀐 이유
Next.js 16으로 올린 뒤 이미지를 교체했는데도 브라우저에 예전 이미지가 한동안 남아 있는 것처럼 보이는 경우가 있습니다.
images.minimumCacheTTL은 Next.js가 최적화한 이미지 응답을 캐시에 최소 몇 초 동안 유지할지 지정하는 설정입니다.
Next.js 15에서 이 값의 기본값은 60초였지만, Next.js 16부터는 14400초, 즉 4시간으로 늘어났습니다.
이 변경 이유는 업스트림 이미지 응답에 별도의 캐시 헤더가 없으면 60초마다 다시 최적화를 수행하게 되어 서버 자원 소모가 커진다는 점 때문입니다.
실제로 응답에 적용되는 Cache-Control의 max-age 값은 minimumCacheTTL과 업스트림 응답의 max-age 중 더 큰 값이 사용됩니다.
이 이미지 캐시는 Cache Components의 use cache가 다루는 데이터 캐시와는 별개의 계층이므로, 두 설정을 같은 캐시로 혼동하지 않는 것이 좋습니다.
use cache를 포함한 Cache Components의 캐시 무효화 방법은 Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리를 참고해 주시길 바랍니다.
Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리
Next.js 16의 새로운 캐싱 모델인 Cache Components를 통해 use cache, cacheTag, revalidateTag, updateTag, refresh 등의 기능으로 컴포넌트 단위의 캐시 관리를 통합하는 방법을 설명합니다.
jforj.tistory.com
직접 헤더를 확인하며 이 값을 커스터마이징해 보겠습니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
minimumCacheTTL: 14400,
},
}
export default nextConfig
이 캐시 헤더는 개발 모드(next dev)에서는 항상 max-age=0으로 강제되므로, 실제 값은 프로덕션 모드로 실행했을 때 확인할 수 있습니다.
// terminal
$ npm run build
$ npm start
서버가 프로덕션 모드로 올라온 뒤 이미지 최적화 URL에 curl로 요청을 보내 응답 헤더를 확인해 보겠습니다.
// terminal
$ curl -I "http://localhost:3000/_next/image?url=%2Fsample.png&w=256&q=75"
응답의 Cache-Control 헤더에서 public, max-age=14400, must-revalidate 값을 확인할 수 있으며, minimumCacheTTL에 지정한 14400초가 그대로 반영된 결과입니다.
다음으로는 이미지 크기 후보 자체가 달라진 imageSizes 변경을 살펴보겠습니다.
next/image srcset 크기 후보에서 16이 빠진 이유, imageSizes 변경
images.imageSizes는 deviceSizes와 합쳐져 srcset 크기 후보를 구성하는 배열입니다.
Next.js 15의 imageSizes 기본값은 [16, 32, 48, 64, 96, 128, 256, 384]였지만, Next.js 16부터는 16이 빠진 [32, 48, 64, 96, 128, 256, 384]로 바뀌었습니다.
Next.js 팀은 16px 크기를 실제로 사용하는 프로젝트가 전체의 4.2%에 불과했다는 점을 이 변경의 근거로 공식적으로 밝히고 있습니다.
sizes prop을 지정하지 않고 width만 16처럼 작게 고정한 이미지에서 이 변화가 특히 눈에 띕니다.
// Next.js 15, width=16 이미지의 srcset 후보
16px 1x, 32px 2x
// Next.js 16, width=16 이미지의 srcset 후보
32px 1x
width를 16처럼 작게 고정하고 sizes prop을 쓰지 않는 이미지라면, Next.js 16에서는 1x 후보와 2x 후보가 모두 32px로 계산되면서 중복이 제거되어 srcset에 32px 후보 하나만 남습니다.
그러므로 16px 수준의 작은 이미지를 다룬다면 imageSizes에 16을 다시 추가하거나 sizes prop으로 크기를 명시하는 것이 좋습니다.
공식 문서는 imageSizes 변경이 sizes prop을 사용하는 이미지에만 영향을 준다고 설명하지만, 실제로는 sizes prop 없이 width만 고정한 이미지의 srcset 후보에도 동일하게 적용됩니다.
다음으로는 로컬 이미지에 쿼리스트링을 쓰면 갑자기 막히는 이유, localPatterns의 기본 동작을 살펴보겠습니다.
로컬 이미지에 쿼리스트링을 쓰면 막히는 이유, localPatterns 기본 동작
저의 경우 로컬 이미지에 캐시 무효화를 위해 쿼리스트링을 붙여 두었다가, Next.js 16으로 올린 뒤 해당 이미지만 갑자기 로드되지 않는 상황을 마주친 적이 있습니다.
images.localPatterns는 public 폴더 등 로컬 경로에서 최적화를 허용할 이미지의 pathname과 쿼리스트링을 화이트리스트로 제한하는 설정이며, Next.js 14.2.15부터 존재해 온 옵션입니다.
다만 Next.js 16에서 실제로 바뀐 것은 이 옵션의 존재 여부가 아니라, localPatterns를 설정하지 않았을 때의 기본 동작입니다.
localPatterns를 아예 설정하지 않아도 내부적으로는 모든 pathname을 허용하되 search는 빈 문자열만 허용하는 값으로 채워지기 때문에, 로컬 이미지 경로 자체는 자유롭게 쓸 수 있지만 쿼리스트링이 붙는 순간 최적화 요청을 만들기 전에 막힙니다.
실제로 쿼리스트링이 있는 로컬 이미지와 없는 로컬 이미지를 비교해 보겠습니다.
// ❌ 쿼리스트링이 붙은 로컬 이미지, localPatterns 미설정 상태에서 차단됨
<Image src="/sample.png?v=1" alt="예시" width={120} height={60} />
// ✅ 쿼리스트링 없는 로컬 이미지, 기본 상태에서도 허용됨
<Image src="/sample.png" alt="예시" width={120} height={60} />
이렇게 작성하면 쿼리스트링이 없는 두 번째 이미지는 정상적으로 최적화되지만, 첫 번째 이미지는 최적화 요청이 나가기도 전에 next/image가 렌더링 시점에 에러를 던집니다.
이 에러는 개발 모드 전용 검사가 아니라 프로덕션 모드에서도 그대로 발생하므로, 이미지 한 장이 깨지는 수준이 아니라 해당 화면 렌더링 자체가 실패합니다.
쿼리스트링이 붙은 로컬 이미지를 계속 쓰고 싶다면, localPatterns에 해당 경로와 쿼리스트링을 명시적으로 등록하는 것이 좋습니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
localPatterns: [
{ pathname: '/**', search: '' },
{ pathname: '/sample.png', search: '?v=1' },
],
},
}
export default nextConfig
localPatterns를 한 번이라도 직접 설정하면 기본으로 채워지던 pathname '**', search '' 규칙이 더 이상 자동으로 적용되지 않으므로, 다른 로컬 이미지도 계속 쓰려면 이 규칙을 배열에 함께 포함시켜야 합니다.
search 필드는 부분 일치나 와일드카드를 지원하지 않고 쿼리스트링 전체가 정확히 일치해야 하므로, ?v=1로 등록해 두면 ?v=2 같은 다른 값은 여전히 차단됩니다.
localPatterns의 허용·차단 동작을 직접 버튼으로 전환하며 확인해 보겠습니다.
// app/local-image-security/page.tsx
'use client'
import Image from 'next/image'
import { useState } from 'react'
const variants = [
{ label: '쿼리스트링 없음 (허용)', src: '/sample.png' },
{ label: '허용된 쿼리스트링 (?v=1)', src: '/sample.png?v=1' },
{ label: '허용되지 않은 쿼리스트링 (?v=2)', src: '/sample.png?v=2' },
]
export default function LocalImageSecurityPage() {
const [index, setIndex] = useState(0)
const [blocked, setBlocked] = useState(false)
const variant = variants[index]
return (
<div className="flex flex-col gap-4 p-6">
<h1 className="text-lg font-medium">localPatterns 동작 확인</h1>
<div className="flex gap-2">
{variants.map((item, i) => (
<button
key={item.src}
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => {
setIndex(i)
setBlocked(false)
}}
>
{item.label}
</button>
))}
</div>
<Image
key={variant.src}
src={variant.src}
alt={variant.label}
width={120}
height={60}
onError={() => setBlocked(true)}
/>
{blocked && (
<p className="text-sm text-red-600">이미지 최적화 요청이 localPatterns에 막혀 로드되지 않았습니다.</p>
)}
</div>
)
}
"쿼리스트링 없음 (허용)" 버튼과 "허용된 쿼리스트링 (?v=1)" 버튼을 누르면 이미지가 정상적으로 표시되지만, "허용되지 않은 쿼리스트링 (?v=2)" 버튼을 누르면 이미지 로드가 실패하며 안내 문구가 나타납니다.
다만 개발 모드에서는 허용되지 않은 src가 onError 대신 렌더링 에러로 처리되므로, 위 안내 문구를 확인하려면 프로덕션 모드로 실행해야 합니다.
다음으로는 로컬 이미지가 아니라 원격 이미지를 대상으로 한 SSRF 방어 강화를 살펴보겠습니다.
원격 이미지 SSRF 방어 강화, dangerouslyAllowLocalIP
원격 이미지를 최적화하는 /_next/image 엔드포인트는 서버가 대신 그 URL로 요청을 보내는 구조라서, 공격자가 이 URL을 조작해 서버 내부망에 접근하는 SSRF에 노출될 수 있습니다.
Next.js 16은 이 위험을 줄이기 위해 dangerouslyAllowLocalIP와 maximumRedirects라는 두 옵션을 새로 추가했습니다.
dangerouslyAllowLocalIP는 원격 이미지 hostname이 사설 IP로 해석되는 경우에도 최적화를 허용할지 지정하는 옵션이며, 기본값은 false입니다.
이 검사는 hostname 문자열 자체가 아니라 실제 DNS로 해석한 IP 주소를 기준으로 이루어지므로, remotePatterns에 등록해 둔 공개 도메인이라도 사설 IP로 해석되면 요청이 차단됩니다.
기본값 상태에서 사설 IP로 해석되는 이미지를 최적화하려 하면 서버 로그에 해석된 IP가 남고 HTTP 400 응답이 반환됩니다.
maximumRedirects는 원격 이미지 요청이 리다이렉트를 몇 번까지 따라갈지 지정하는 옵션이며, Next.js 15에서는 제한이 없었지만 16부터는 기본값이 3으로 줄었습니다.
리다이렉트로 도착한 최종 URL은 remotePatterns 검증을 다시 거치지 않기 때문에, 리다이렉트를 무제한으로 따라가도록 두면 remotePatterns로 걸어둔 원격 이미지 허용 범위를 우회하는 경로로 악용될 수 있습니다.
기본값 상태에서 리다이렉트가 4번 이상 발생하는 이미지 URL을 최적화하려 하면, 다른 이미지 관련 에러 대부분이 400인 것과 달리 HTTP 508 응답이 반환됩니다.
두 옵션 모두 기본값 자체가 안전한 방향으로 설정되어 있으므로 별도로 손댈 필요는 없지만, 사내망 이미지 서버처럼 의도적으로 사설 IP를 허용해야 하는 특수한 경우라면 다음과 같이 명시적으로 켤 수 있습니다.
// next.config.ts, 사내망 이미지 서버를 의도적으로 허용하는 설정
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [{ protocol: 'https', hostname: 'internal-assets.example.com' }],
dangerouslyAllowLocalIP: true,
},
}
export default nextConfig
옵션 이름에 dangerously가 붙어 있는 만큼, 신뢰할 수 있는 사내망 호스트임을 확실히 확인한 뒤에만 이 값을 true로 바꾸는 것이 좋습니다.
지금까지 살펴본 qualities, minimumCacheTTL, imageSizes, localPatterns 설정을 하나의 next.config.ts로 합쳐보면 다음과 같습니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
qualities: [50, 75, 100],
minimumCacheTTL: 14400,
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
localPatterns: [
{ pathname: '/**', search: '' },
{ pathname: '/sample.png', search: '?v=1' },
],
remotePatterns: [{ protocol: 'https', hostname: 'assets.example.com' }],
},
}
export default nextConfig
next/image의 기본값들은 대부분 화면에서 바로 티가 나지 않고 화질, 캐시, 보안처럼 조용히 동작에 스며드는 부분이라, Next.js 16으로 업그레이드한 뒤에는 한 번쯤 next.config.ts의 images 설정을 점검해 보는 것이 좋습니다.
이상으로 Next.js 16에서 next/image의 기본값이 어떻게 달라졌는지에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > Next' 카테고리의 다른 글
| Next.js 16 App Router에서 Activity, useEffectEvent, View Transitions 통합하기 (0) | 2026.08.11 |
|---|---|
| Next.js 16, Layout Deduplication과 Incremental Prefetching 동작 원리 (0) | 2026.08.06 |
| React Compiler와 Next.js 16: useMemo/useCallback이 사라지는 이유 (0) | 2026.08.02 |
| Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션 (0) | 2026.08.01 |
| Next.js 16 proxy.ts, middleware.ts를 대체하는 네트워크 경계 재정의 (1) | 2026.07.27 |
댓글