문제 상황

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는 캐싱이 거의 없다. → 그래서 더 잘 터진다.
 
  1. Next.js Image Optimization의 제한
      • dev 서버에서는 /_next/image가 proxy 역할을 해서 원본을 직접 가져와야 하는데, presigned URL에 붙은 긴 query string이나 region mismatch 때문에 실패할 때가 있다.
      • 특히 X-Amz-Security-Token이 붙은 경우, 로컬 dev 환경에서 프록시가 제대로 처리 못 하는 경우 보고된 적이 있다고 한다.
  1. 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 서버는 이 요청을 받아서
  1. 원래 이미지(S3 presigned URL)를 다운로드 (fetch)
  1. 크기/품질 최적화 (sharp 라이브러리로 리사이즈 등)
  1. 결과를 브라우저에 응답
→ 이게 바로 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 환경에서 큰 문제가 생겼다.
신기하게도 이미지가 잘 불러와지는데 꼭 개발자 모드로 검사만 키면 이미지를 불러올 수 없고,
아래와 같은 에러가 발생했다.
notion image
notion image
🚫
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 로그에서는 위와 같은 에러를 찾을 수 없었다..!
notion image
 
알아보니 이건 보통 두 가지 원인 중 하나라고 한다.
 
  1. Presigned URL + CORS(교차 출처 문제)
    1. AWS S3 presigned URL은 짧은 TTL(1시간) + 정확한 요청 헤더/조건이 필요함.
    2. 개발자 도구를 열면 브라우저가 Disable cache 옵션을 자동으로 켜는 경우가 많음. → 원래 CDN/브라우저 캐시에서 가져올 수 있는 이미지를 다시 원본 presigned URL로 요청해버림.
    3. 그런데 presigned URL이 이미 만료됐거나 조건이 달라지면 → 403 또는 500 에러 발생.
    4. 👉 즉, 평소엔 캐시 덕분에 정상, 개발자 모드(Disable cache)에서는 새로 요청하다가 깨짐.
 
  1. Next.js Image Optimization의 재요청 문제
    1. Next.js의 <Image />는 _next/image 경유로 이미지를 최적화해서 가져오는데,
    2. 개발자 도구 + “Disable cache”가 켜지면 Next.js 서버가 매번 원본 presigned URL로 다시 fetch 시도함.
    3. presigned URL 만료 → fetch 실패 → 이미지 깨짐.
    4. 👉 그래서 개발자 모드 ON = 강제 캐시 무효화 → 만료된 presigned URL 재요청 → 실패
       
💡
위 문제와 유사하게 또 웃긴 이슈가 있었다. 내 로컬에서의 배포 환경에서도 이미지 로딩이 깨지는 경우는 있는데, Vercel에서의 배포 환경에서는 이미지 로딩이 대부분 잘 된다.
이 문제의 원인은 CDN의 차이였다.
먼저 CDN이란, AWS 공식 홈페이지에서 다음과 같이 정의되었다.
notion image
 
그래서 내 상황으로 추가 설명을 해보면,
  1. Vercel은 CDN(Edge) 캐시가 있음
    1. Vercel에 배포하면 Next.js Image Optimization이 Vercel의 Edge CDN 위에서 동작.
    2. presigned URL(1시간짜리)로 한 번 이미지를 가져오면, CDN이 최적화된 이미지를 캐시함.
    3. 이후 요청은 presigned URL이 만료돼도 CDN 캐시에서 바로 서빙 → 에러 없음.
👉 반면, 로컬 next start에는 캐시 계층이 없어서 매번 presigned URL로 fetch 시도 → 만료 시 무조건 500.
 
  1. 로컬 next start는 단일 서버 프로세스
    1. 로컬에서는 이미지 최적화 요청이 올 때마다 서버 프로세스가 S3 presigned URL로 fetch를 수행.
    2. presigned URL이 만료됐거나 권한 문제 있으면 바로 500 에러.
    3. 즉, “캐싱이 없는 원본 서버”만 돌리는 거라 문제가 그대로 드러나는 것.
 

정리

  1. Next.js는 클라이언트에서 이미지를 바로 불러오는 것이 아니라 서버를 한 번 거침 (proxy).
  1. Notion이 AWS S3를 사용하면 presigned url을 제공.
  1. 이 presigned url은 유효 시간이 있음.
  1. Local 서버에서는 캐싱이 없어서 presigned url을 매 번 호출함 → 느림 + 못 가져오는 경우도 존재.
  1. Vercel 같은 곳에서 배포를 하면 CDN이 있어 캐싱이 되며 최적화 된 이미지를 제공함.