🔥 Presigned URL: 임시 접근 링크의 원리
강의 목차

이 문제 앞에서 한참 헤맸다. 비공개 S3 버킷에 들어 있는 영수증 PDF를 한 번만 사용자에게 보여주고 싶다. 백엔드가 객체를 읽어서 다시 흘려 보내면 되긴 한다. 그런데 객체가 수백 MB짜리 영상이면? 백엔드는 사실 한 일도 없는데 대역폭만 두 번 보낸 셈이다. 반대로 클라이언트가 S3에 직접 다녀오게 하려면 자격증명을 줘야 하는데, 자격증명을 클라이언트에 넣는 건 어떤 상황에서도 답이 아니다. 그렇다고 버킷을 통째로 공개하면 버킷 정책과 ACL: 공개와 비공개의 경계에서 본 네 겹 검사가 의미가 없다.
S3는 이 모순을 풀려고 한 가지 길을 내놓았다. URL 안에 서명을 담는다. 클라이언트는 자격증명 없이 그 URL 한 장만 들고 S3에 직접 갈 수 있고, S3는 그 URL이 진짜 자격증명을 가진 누군가가 만든 것임을 query string만 보고 검증한다. 이 한 장짜리 URL이 presigned URL이다.
SigV4 서명을 query로 옮긴 형식
AWS는 거의 모든 API 요청에 Signature Version 4(SigV4)라는 서명 방식을 쓴다. 평소에는 Authorization 헤더에 서명을 담아 보내지만, presigned URL은 그 서명을 query string으로 옮긴 형식일 뿐이다.
URL 끝에 다섯 개의 query parameter가 붙는다. 외워 둘 만하다.
X-Amz-Algorithm. 서명 알고리즘. 항상AWS4-HMAC-SHA256이다.X-Amz-Credential.AccessKeyId/yyyymmdd/region/service/aws4_request형식. 누가 어느 날에 어느 리전의 어느 서비스용으로 만들었는지를 한 줄 안에 담는다. 서울 리전이면region파라미터에ap-northeast-2.X-Amz-Date. 서명을 만든 시점. ISO 8601 형식 (yyyymmddTHHMMSSZ).X-Amz-Expires. 만든 시점부터 몇 초 동안 유효한지. 1초부터 604800초(=7일)까지 가능하다.X-Amz-SignedHeaders. 서명에 포함된 헤더 목록. presigned URL은 보통host하나뿐이다.
마지막에 X-Amz-Signature가 붙는다. 위 다섯 개를 포함한 canonical request로부터 HMAC-SHA256으로 계산한 hex 문자열. S3가 검증할 때는 이 값을 빼고 나머지로 같은 계산을 다시 해서, 둘이 같은지만 본다.
서명한 자격증명이 STS 임시 자격증명(IAM Role의 AssumeRole 결과 등)이라면, 위 다섯 개에 더해
X-Amz-Security-Token한 칸이 더 붙는다. URL을 받은 S3는 이 토큰으로 임시 세션을 다시 검증한다.
그림 1. 발급은 자격증명을 가진 백엔드만 할 수 있는데, 검증은 S3가 URL만 보고 한다. 그 비대칭이 presigned URL의 핵심.
서명이 만들어지는 4단계
내가 이 부분을 처음 봤을 때 가장 혼란스러웠던 게 왜 한 번에 HMAC을 안 하고 4번을 거쳐서 키를 만들지? 였다. 답은 Secret Access Key를 그대로 노출하지 않기 위함이다.
kDate = HMAC("AWS4" + SecretKey, yyyymmdd)
kRegion = HMAC(kDate, region)
kService = HMAC(kRegion, service)
kSigning = HMAC(kService, "aws4_request")kDate = HMAC("AWS4" + SecretKey, yyyymmdd)
kRegion = HMAC(kDate, region)
kService = HMAC(kRegion, service)
kSigning = HMAC(kService, "aws4_request")이 kSigning은 그 날, 그 리전, 그 서비스에 한정된 키다. 만약 어딘가에서 새어 나가도 다른 날짜·다른 리전에서는 무용지물이라, 노출 반경이 좁다. AWS의 어떤 SDK든 백엔드에서 presigned URL을 만들 때 이 4단계를 자동으로 돌린다.
서명할 대상은 string-to-sign이라는 4줄짜리 문자열이다.
AWS4-HMAC-SHA256
20260430T080000Z
20260430/ap-northeast-2/s3/aws4_request
<canonical request의 SHA256 hex>AWS4-HMAC-SHA256
20260430T080000Z
20260430/ap-northeast-2/s3/aws4_request
<canonical request의 SHA256 hex>마지막 줄의 canonical request는 HTTP 메서드·경로·query string·signed headers·payload 해시를 한 줄로 정렬해서 이어 붙인 형식이다. 그런데 presigned URL을 만들 때는 클라이언트가 실제로 무슨 payload를 올릴지 백엔드가 모른다. 그래서 payload 해시 부분에 UNSIGNED-PAYLOAD라는 상수를 넣는다. 이게 SigV4의 다른 호출과 presigned URL이 달라지는 한 곳이다.
string-to-sign의 SHA256을 kSigning으로 HMAC하면 그게 X-Amz-Signature다.
그림 2. 위쪽은 매일 새로 만드는 좁은 키 (kSigning) 합성, 아래쪽은 그 키로 서명할 4줄짜리 string-to-sign. 두 갈래가 합쳐져 X-Amz-Signature가 된다.
누가 만들고, 누가 검증하나
발급 시점에 백엔드는 자기 자격증명(IAM User의 Access Key, 혹은 Role이 가정한 임시 자격증명)으로 위 4단계를 돌려 서명을 만든다. URL 한 장이 응답으로 클라이언트에게 간다. 그 다음부터 백엔드는 이 거래에 참여하지 않는다.
클라이언트는 그 URL 그대로 S3에 GET이나 PUT을 친다. S3는 query string에 들어 있는 X-Amz-Credential로 누가 만든 서명인지를 알고, 같은 SecretKey로 같은 4단계를 돌려 같은 서명이 나오는지를 비교한다. 만든 시점(X-Amz-Date)에 만료 시간(X-Amz-Expires)을 더한 시각이 지났으면 거부한다.
검증의 비대칭이 이 그림의 핵심이다. 발급은 자격증명을 가진 백엔드만 할 수 있는데, 검증은 S3가 URL만 보고 한다. 클라이언트는 양쪽 모두에 자격증명 없이 끼어 있을 수 있다.

그림 3. 한 장의 티켓. 발급 후엔 백엔드가 더 이상 거래에 끼지 않는다. 회수 도장도 따로 없다.
PUT presigned vs POST presigned
같은 SigV4 메커니즘으로 두 가지 다른 패턴이 나온다.
PUT presigned는 단순하다. 백엔드가 PutObject 호출을 sign해서 URL을 만든다. 클라이언트는 그 URL에 그대로 PUT을 친다. body에 들어가는 바이트가 무엇이든 S3는 받아 준다. 코드 한 줄로 끝나서 빠르게 뽑기 좋지만, 조건을 강제할 수 없다는 약점이 한 줄로 따라온다. 사용자가 100MB짜리 zip을 올리든 1GB짜리 ISO를 올리든 PUT은 받아 준다. 회사 정책상 50MB 위는 안 받기로 했다면, 클라이언트 자바스크립트의 양심에만 의존하게 된다. 양심은 우회하기 너무 쉽다.
POST presigned는 한 단계 위에서 푼다. 백엔드가 POST policy라는 작은 JSON을 만들어 서명한다.
{
"expiration": "2026-04-30T09:00:00Z",
"conditions": [
{ "bucket": "uploads-prod" },
[ "starts-with", "$key", "user-uploads/" ],
[ "content-length-range", 0, 52428800 ],
{ "Content-Type": "image/jpeg" }
]
}{
"expiration": "2026-04-30T09:00:00Z",
"conditions": [
{ "bucket": "uploads-prod" },
[ "starts-with", "$key", "user-uploads/" ],
[ "content-length-range", 0, 52428800 ],
{ "Content-Type": "image/jpeg" }
]
}이 JSON 자체를 base64 인코딩해서 form field로 클라이언트에게 보낸다. 클라이언트는 multipart/form-data로 S3에 POST하면서 policy + 위 conditions에 맞는 form field들을 같이 보낸다. S3는 받은 form field가 conditions를 모두 만족하는지를 검증하고, 한 줄이라도 어긋나면 업로드를 통째로 거절한다. content-length-range로 50MB 상한(52,428,800바이트)을 걸어 두었다면, 클라이언트가 1GB를 보내도 S3가 잘라 낸다. 키 prefix·MIME type·메타데이터까지 같은 식으로 조건을 걸 수 있다.
PUT presigned는 내가 백엔드라 클라이언트를 신뢰할 수 있을 때, POST presigned는 클라이언트가 사용자의 브라우저고 양심에 기댈 수 없을 때. 그게 두 패턴의 경계다.
그림 4. PUT은 빠르게 뽑기 좋은 단순한 형식, POST는 policy로 조건을 강제해 양심을 안 믿어도 되는 형식.
만료의 두 시계
이 부분이 운영에서 가장 자주 사고를 만든다. X-Amz-Expires는 7일이 상한이지만, 실제 유효 시간은 두 시계 중 짧은 쪽이 결정한다.
첫 번째 시계는 URL 자체의 만료, X-Amz-Date + X-Amz-Expires. 두 번째 시계는 서명에 사용된 자격증명의 잔여 수명이다. AWS는 서명이 자격증명보다 오래 살지 못하게 설계했다. 그래서 User, Group, Role: 세 가지 주체의 차이에서 본 STS 임시 자격증명(AssumeRole 기준 default 1시간, role의 MaxSessionDuration 설정에 따라 최대 12시간까지)으로 서명한 presigned URL은 7일을 적었어도 실제로는 길어야 그 세션 길이만큼이다. role chaining(역할이 다른 역할을 또 가정하는 경우)은 1시간 cap이 따로 걸려 있어 더 짧다. 자격증명이 만료된 순간 URL도 같이 죽는다.
7일 한도를 끝까지 쓰려면 IAM User의 장기 Access Key로 서명해야 한다. 그런데 장기 키는 그 자체가 운영 부담(키 회전·노출·감사)이라 보통은 STS 세션을 받아 쓰는 길로 간다. 결과적으로 우리가 실제로 쓰는 presigned URL은 12시간 안쪽이 대부분이다. 7일이 가능하다는 docs 한 줄을 보고 일주일짜리 다운로드 링크를 만들었다가, IAM Role 잔여 1시간에 잘린 사고를 본 적이 있다. 그땐 나도 그게 왜 안 되는지 한참 몰랐다.
그림 5. 시계 두 개가 겹치는 곳에서 짧은 쪽이 실제 만료를 정한다. STS 세션 1시간이면 7일을 적었어도 1시간 후 URL이 죽는다.
세부 한도(콘솔 12시간 / SDK 7일)와 흔한 운영 사고(만료 너무 길게 잡아서 생기는 노출)는 버킷 정책과 ACL: 공개와 비공개의 경계에서 한 번 정리했다. 여기서는 왜 두 시계가 잘리는지만 짚고 넘어간다.

그림 6. 두 시계가 한 화면에. 어느 쪽이 짧든 그쪽이 진짜 만료다.
언제 쓰지 말아야 하는가
presigned URL이 풀려는 문제가 분명한 만큼, 풀지 못하는 문제도 분명하다.
취소 API가 없다. 한 번 발급된 URL을 개별로 무효화할 방법이 AWS에 없다. URL이 외부로 새 나갔다면 만료를 기다리거나, 그게 너무 오래라면 서명한 IAM User의 Access Key를 회전(또는 deactivate)해서 같은 키로 sign한 모든 URL을 한꺼번에 죽이는 식으로만 막는다. 한 명의 사고를 막으려고 무관한 수십·수백 개의 URL이 같이 죽는다는 뜻이다. 개별 사용자에게 살아 있는 다운로드 링크가 중요한 서비스라면 이 비용이 만만치 않다.
감사 흔적은 있긴 한데 거리가 있다. presigned URL의 사용은 S3 server access log나 CloudTrail S3 data events를 켜 두면 그 시점에 남길 수 있다(둘 다 default off라 의식적으로 활성화해 두지 않으면 흔적이 비어 있다). 다만 IAM Access Analyzer: 과권한을 탐지하는 법이 보는 정적 권한 분석은 bucket policy·ACL·BPA·access point/MRAP policy를 분석 대상으로 명시하는데, 그 목록에 presigned URL은 없다. 그래서 정적 분석으로는 보이지 않고 위의 사후 로그로만 흔적을 추적한다.
개별 권한 평가가 없다. 한 번 sign되면 누가 그 URL을 들고 와도 통과다. 이 사용자는 이 객체에 권한이 있는가는 sign 시점에 백엔드가 한 번 확인하는 것이지, S3가 GET·PUT 시점에 다시 확인하지 않는다. 게이트는 백엔드 안에 있다. 백엔드가 잘못 발급하면 S3가 막아 주지 않는다.
그림 7. 세 항목이 모두 가벼우면 presigned URL, 하나라도 무거우면 백엔드를 통한 streaming.
위 셋 중 어느 하나라도 무거운 곳에는 (의료 기록·결제 영수증·권한 변경이 잦은 워크스페이스 파일) presigned URL은 답이 아니다. 그쪽은 백엔드를 통한 streaming(클라이언트 ↔ 백엔드 ↔ S3)이 더 정직하다. 대역폭 두 번 보내는 비용을 감수하는 대신 권한·취소·감사를 백엔드 코드 안에서 다룰 수 있다.
한 줄로
presigned URL은 S3 권한을 푸는 게 아니라, S3 권한을 가진 누군가의 서명을 일회성 카드 형식으로 옮겨 둔 것이다. 백엔드가 카드를 발급하고, S3가 카드의 진위만 검증한다. 그 사이에 백엔드가 끼지 않아도 되는 거래를 만든다.
대신 카드는 발급 후 회수가 어렵고, 이 사용자에게 정말 권한이 있나를 매번 다시 묻지 않는다. 그래서 권한·감사·취소가 가벼운 곳(미리보기·일회성 다운로드·간단한 업로드)에 잘 어울리고, 무거운 곳에는 답이 아니다. 두 갈래를 잘 가르는 게 운영의 절반이다.
이어서 Lifecycle 규칙 (객체가 시간에 따라 자동으로 이동·삭제되는 길) 을 짚는다. 이 글에서 본 키 단위의 권한·접근을 lifecycle에서는 prefix·tag·age 단위로 어떻게 다시 짜는지를 짚는다.











