문제 상황
Notion 기반 블로그를 만들면서, Notion Database의 커버 이미지를 가끔 못불러오는 문제가 있었다.
에러 로그는 다음과 같다.
GET /next/image?url=https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com … mode%3DENABLED%26x-id%3DGetObject&w=1080&q=75 500 in 31ms
이것은 Presigned URL 만료 문제라고 한다…
X-Amz-Expires=3600
로그를 분석해보니 presigned URL이 1시간짜리다.
dev 서버는 SSR + on-demand 로 이미지를 가져오는데, URL이 이미 만료되면 Next.js가 S3에서 다운로드 못 하고 곧바로 500을 띄운다.
Vercel 같은 배포 환경은 캐시/CDN을 잘 쓰니까 한 번 가져오면 곧바로 캐시에서 서빙돼서 잘 되는 경우가 많고, dev는 캐싱이 거의 없다. → 그래서 더 잘 터진다.
- Next.js Image Optimization의 제한
- dev 서버에서는 /_next/image가
proxy역할을 해서 원본을 직접 가져와야 하는데, presigned URL에 붙은 긴 query string이나 region mismatch 때문에 실패할 때가 있다. - 특히 X-Amz-Security-Token이 붙은 경우, 로컬 dev 환경에서 프록시가 제대로 처리 못 하는 경우 보고된 적이 있다고 한다.
- CORS / HTTPS 이슈
- dev 서버는 보통 http://localhost:3000에서 돌지만, presigned URL은 https라서 리다이렉션이나 CORS 헤더 문제 생기면 Next.js 쪽에서 500 던져버린다.
[Error [TimeoutError]: The operation was aborted due to timeout]
{ code: 23 … }
fetch, XMLHttpRequest, IndexedDB, WebSocket 같은 API에서 응답이 지정된 시간 안에 안 오면 발생한다.
즉, 위의 문제 때문에 이미지를 늦게 불러와서 생기는 오류다.
해결방법
- 개발 중에는 presigned URL을 바로 쓰고, 프로덕션만 next/image 최적화 거치게 설정할 수도 있었다.
- 또는 dev 서버에선 이미지 최적화 끄기
// next.config.js module.exports = { images: { unoptimized: process.env.NODE_ENV === "development", remotePatterns: [ { protocol: "https", hostname: "prod-files-secure.s3.us-west-2.amazonaws.com", }, ], }, };
이렇게 하면 로컬 개발에서는 Next.js가 _next/image로 프록시하지 않고, 원본 URL을 직접 불러온다.
→ presigned URL 문제가 회피 가능해진다.
이걸 켜면 Next.js는 dev 모드에서 _next/image 프록시 경로를 쓰지 않고,
그냥 <img src="https://s3.../image.png" /> 로 HTML에 넣어버린다.
즉, 프록시를 거치지 않고 브라우저가 직접 presigned URL에 접근하는 것이다..
Proxy란,
기본적으로 Next.js next/image 가 하는 일
<Image src="https://s3.../image.png" /> 라고 하면, 브라우저는 바로 S3로 요청하지 않는다.
대신 브라우저는 Next.js 서버에 다음과 같이 요청한다.
GET http://localhost:3000/_next/image?url=https://s3.../image.png&w=750&q=75
Next.js dev 서버는 이 요청을 받아서
- 원래 이미지(S3 presigned URL)를 다운로드 (fetch)
- 크기/품질 최적화 (sharp 라이브러리로 리사이즈 등)
- 결과를 브라우저에 응답
→ 이게 바로
proxy 역할 (중간에서 대신 받아서 전달)이다.문제가 되는 이유
- dev 서버에서는 캐시가 거의 없어서, 매번 presigned URL로 S3에 fetch를 시도한다.
- 그런데 presigned URL이 만료되거나 query string이 복잡하면 이 fetch에서 실패 → 500 발생.
- 반면 <img src="https://s3.../image.png" /> 로 직접 쓰면 브라우저가 바로 S3에 요청하므로 Next.js 프록시 과정이 아예 없다.
Proxy하면 무엇이 좋을까?
Proxy란, 브라우저가 원본 이미지를 직접 가져오지 않고, Next.js 서버가 대신 가져와서 최적화 후 전달하는 것1. 자동 최적화 (리사이즈 & 포맷 변환)
예를 들어 <Image src="/photo.png" width={300} height={200} />라고 하면,
원본이 2000x1500 PNG라도 Next.js 서버가
- 300x200으로 리사이즈
- WebP/AVIF 같은 최신 포맷으로 변환
그래서 브라우저가 훨씬 가볍고 빠르게 받음.
→ 브라우저가 원본 URL 직접 가져오면 이런 최적화가 불가능하다.
2. 반응형 이미지 (srcset 자동 생성)
- <Image>는 뷰포트 크기에 따라 적절한 크기의 이미지를 자동으로 요청하도록 srcset을 생성해준다.
- 예) 레티나 디스플레이에서는 2x 크기, 일반 화면에서는 1x 크기.
모바일, 데스크탑 환경 모두 효율적이다.
3. 캐싱 & CDN 친화적
- /_next/image?... 요청은 Next.js 서버 또는 Vercel CDN에서 캐싱된다.
- 같은 이미지 요청이 오면 S3에 또 안 가고, 캐시에서 빠르게 서빙 → 트래픽 비용도 줄고, 속도도 빨라짐.
4. Lazy Loading + Placeholder
- next/image는 loading="lazy"와 blur placeholder(blurDataURL)을 지원함.
- 프록시를 통해 이미지를 처리하니까 이런 기능을 쉽게 제공할 수 있음.
문득 이런 생각이 들었다. Prod 환경에서 똑같은 에러가 나오면 어떻게 해야하지?
실제로 Prod 환경에서 큰 문제가 생겼다.
신기하게도 이미지가 잘 불러와지는데 꼭 개발자 모드로 검사만 키면 이미지를 불러올 수 없고,
아래와 같은 에러가 발생했다.
.png?table=block&id=25860608-ac17-8048-bea9-d31b48d8690d&cache=v2)
.png?table=block&id=25860608-ac17-80e6-9bba-d7b8bfa68726&cache=v2)
image:1 GET https://notion-blog-steel-rho.vercel.app/_next/image?url=https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F3015db7a…(생략)…mode%3DENABLED%26x-id%3DGetObject&w=384&q=75 502 (Bad Gateway)
근데 정말 신기하게도 Vercel 로그에서는 위와 같은 에러를 찾을 수 없었다..!

알아보니 이건 보통 두 가지 원인 중 하나라고 한다.
- Presigned URL + CORS(교차 출처 문제)
- AWS S3 presigned URL은 짧은 TTL(1시간) + 정확한 요청 헤더/조건이 필요함.
- 개발자 도구를 열면 브라우저가 Disable cache 옵션을 자동으로 켜는 경우가 많음. → 원래 CDN/브라우저 캐시에서 가져올 수 있는 이미지를 다시 원본 presigned URL로 요청해버림.
- 그런데 presigned URL이 이미 만료됐거나 조건이 달라지면 → 403 또는 500 에러 발생.
👉 즉, 평소엔 캐시 덕분에 정상, 개발자 모드(Disable cache)에서는 새로 요청하다가 깨짐.
- Next.js Image Optimization의 재요청 문제
- Next.js의 <Image />는 _next/image 경유로 이미지를 최적화해서 가져오는데,
- 개발자 도구 + “Disable cache”가 켜지면 Next.js 서버가 매번 원본 presigned URL로 다시 fetch 시도함.
- presigned URL 만료 → fetch 실패 → 이미지 깨짐.
👉 그래서 개발자 모드 ON = 강제 캐시 무효화 → 만료된 presigned URL 재요청 → 실패
위 문제와 유사하게 또 웃긴 이슈가 있었다.
내 로컬에서의 배포 환경에서도 이미지 로딩이 깨지는 경우는 있는데,
Vercel에서의 배포 환경에서는 이미지 로딩이 대부분 잘 된다.
이 문제의 원인은
CDN의 차이였다.먼저
CDN이란, AWS 공식 홈페이지에서 다음과 같이 정의되었다.
그래서 내 상황으로 추가 설명을 해보면,
- Vercel은 CDN(Edge) 캐시가 있음
- Vercel에 배포하면 Next.js Image Optimization이 Vercel의 Edge CDN 위에서 동작.
- presigned URL(1시간짜리)로 한 번 이미지를 가져오면, CDN이 최적화된 이미지를 캐시함.
- 이후 요청은 presigned URL이 만료돼도 CDN 캐시에서 바로 서빙 → 에러 없음.
👉 반면, 로컬 next start에는 캐시 계층이 없어서 매번 presigned URL로 fetch 시도 → 만료 시 무조건 500.
- 로컬 next start는 단일 서버 프로세스
- 로컬에서는 이미지 최적화 요청이 올 때마다 서버 프로세스가 S3 presigned URL로 fetch를 수행.
- presigned URL이 만료됐거나 권한 문제 있으면 바로 500 에러.
- 즉, “캐싱이 없는 원본 서버”만 돌리는 거라 문제가 그대로 드러나는 것.
정리
- Next.js는 클라이언트에서 이미지를 바로 불러오는 것이 아니라 서버를 한 번 거침 (proxy).
- Notion이 AWS S3를 사용하면 presigned url을 제공.
- 이 presigned url은 유효 시간이 있음.
- Local 서버에서는 캐싱이 없어서 presigned url을 매 번 호출함 → 느림 + 못 가져오는 경우도 존재.
- Vercel 같은 곳에서 배포를 하면 CDN이 있어 캐싱이 되며 최적화 된 이미지를 제공함.

