---
title: "서명 URL로 보내는 302를 캐시하면 만료된 403이 계속 재생된다"
description: "S3 presigned URL 로 리다이렉트하는 302 를 프록시가 캐시하면 몇 분 뒤에도 만료된 Location 이 재생됩니다. Date 헤더로 캐시 층을 가려내고 no-store 로 푸는 과정을 정리했습니다."
date: 2026-10-19
updated: 2026-10-19T08:15:43.688Z
tags: [s3, presigned-url, http-cache, redirect, debugging]
canonical: https://blog.wooncloud.com/posts/cached-302-expired-presigned-url
---

이미지 주소 `/img/<key>.png`를 서버가 받으면 S3 presigned URL(유효 60초)을 새로 만들어 302로 보내는 구조였습니다. 그런데 어떤 이미지는 처음부터 썸네일이 깨져 있었습니다.

같은 주소를 몇 분 뒤 다시 받아 보니 302의 `Date` 헤더와 `Location`의 `X-Amz-Date`가 처음 요청한 시각에 고정돼 있었습니다. 그 Location을 열면 S3가 `403 Request has expired`를 돌려줬고요.

![프록시가 서명 URL로 가는 302를 캐시하면 몇 분 뒤 만료된 Location이 재생되어 S3가 403을 돌려주지만, no-store를 붙이면 요청마다 새로 서명된 URL이 나간다.](/images/posts/cached-302-expired-presigned-url/57c48ed6-fe7a-4a67-906a-f7148393ac24.webp)

## 누가 302를 저장해 둘까

서버 코드는 요청마다 새로 서명합니다. 그런데도 첫 302가 그대로 재생된다면 누군가 응답을 저장해 두고 내주고 있다는 뜻입니다.

단서는 `Date` 헤더입니다. 앱이 서명 URL만 캐시한다면 `Date`는 매번 새 값이어야 합니다. `Date`까지 고정이면 앱이 아니라 HTTP 캐시 층, 그러니까 앞단 프록시 캐시입니다.

캐시가 범인인지는 같은 요청을 오리진(인스턴스)에 직접 보내 `Date`가 바뀌는지로 가립니다.

![서명 URL로 보내는 302를 프록시가 캐시하면 몇 분 뒤 만료된 Location이 재생되어 S3가 403을 돌려주고, no-store 를 붙이면 요청마다 새 서명이 쓰인다.](/images/posts/cached-302-expired-presigned-url/42978a91-5e78-4416-9f0d-b854364ea3d9.svg)

## 콘솔에는 왜 만료가 안 보일까

브라우저에서는 S3 오류 본문(`application/xml`)이 `<img>` 응답으로 오니 `ERR_BLOCKED_BY_ORB`로만 보입니다. 원인이 만료라는 게 콘솔에 드러나지 않습니다. 그래서 더 헷갈렸습니다.

## 403이면 객체는 있는 걸까

아닙니다. 진단하다가 가장 빠지기 쉬운 오판이 이겁니다.

S3는 presigned 요청의 서명과 만료를 객체 조회보다 먼저 검사합니다. 그러니 만료된 URL은 키가 없어도 403입니다. 게다가 버킷에 `ListBucket` 권한이 없으면 키가 없을 때도 404 `NoSuchKey` 대신 403 `AccessDenied`가 옵니다.

그래서 "만료 403을 봤으니 객체는 있다"는 결론은 성립하지 않습니다. 객체 유무를 보려면 캐시 버스터 쿼리(`?cb=<now>`)로 새 서명을 받고, **서명 직후 만료 전에** S3 응답 코드를 봐야 합니다.

## 302에는 no-store

수명이 짧은 서명 URL로 가는 리다이렉트에는 `Cache-Control: no-store`를 붙입니다.

```http
HTTP/1.1 302 Found
Location: https://bucket.s3.amazonaws.com/a.png?X-Amz-Expires=60&...
Cache-Control: no-store
```

캐시가 원본을 받아 가는 구조라면 다른 방법도 있습니다. 302 대신 바이트를 직접 내려서, 캐시가 리다이렉트가 아니라 이미지 자체를 저장하게 하는 겁니다.

서명 URL이 60초짜리면 그걸 가리키는 302도 60초짜리 응답이라는 걸 기억해 두기로 했습니다.

## 참고

- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
- https://www.rfc-editor.org/rfc/rfc9111#section-3
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
