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

3247자
32분

16:9 가로 표지 일러스트레이션. 흰 배경. 화면 가운데를 가로지르는 종이 티켓 한 장. 티켓 가운데에 작은 빨간 밀랍 봉인 위에 sky-blue SigV4 글자. 티켓 왼쪽 끝은 sky-blue 백엔드 서버 박스에서 뻗어 나오고, 오른쪽 끝은 AWS 오렌지(#ff9900) S3 버킷 큐브에 닿는다. 티켓 위쪽으로 점선 화살표가 가운데 작은 회색 백엔드 서버 아이콘을 우회해 위로 호를 그린다. 좌상단 다크 슬레이트 Amazon S3 Presigned URL 산세리프 라벨. 모던 플랫 에디토리얼 디자인. 절제된 sans-serif. 프로페셔널 IT 표지 스타일.

이 문제 앞에서 한참 헤맸다. 비공개 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는 이 토큰으로 임시 세션을 다시 검증한다.

Presigned URL의 발급 → 사용 → 검증Client (Browser)Backend (Signer)Amazon S31 "이 객체 다운로드 링크 줘"2 SigV4 4단계 → kSigning → HMAC3 presigned URL (X-Amz-Signature 포함)4 GET / PUT (자격증명 없이, URL 그대로)5 객체 (서명·만료 검증 통과 시)백엔드는 2까지만 참여, 4 5 거래에는 끼지 않는다.

그림 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다.

서명이 만들어지는 두 갈래, kSigning 합성과 string-to-sign 합성A · KSIGNING 합성 (4단계 HMAC)"AWS4" + SecretKeyroot materialkDateHMAC(., yyyymmdd)kRegionHMAC(., region)kServiceHMAC(., service)kSigningHMAC(., "aws4_request")B · STRING-TO-SIGN 합성 (4줄)AWS4-HMAC-SHA25620260430T080000Z20260430/ap-northeast-2/s3/aws4_requestSHA256(canonical request) ← payload 칸에는 UNSIGNED-PAYLOADX-Amz-Signature= HMAC(kSigning, string-to-sign)A→B로 운반날짜·리전·서비스가 다르면 서로 다른 kSigning. 한 키가 새도 노출 반경이 좁다.

그림 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만 보고 한다. 클라이언트는 양쪽 모두에 자격증명 없이 끼어 있을 수 있다.

검정과 베이지 톤의 일러스트레이션. 가운데에 종이 티켓 한 장이 떠 있고 그 가운데 빨간 밀랍 봉인이 찍혀 있다. 봉인 위에 작은 SigV4 글자. 티켓의 왼쪽 모서리는 슬레이트 배경의 백엔드 서버 큐브에서 이어지고, 오른쪽 모서리는 AWS 오렌지 색 S3 버킷 큐브를 향해 점선 화살표로 뻗는다. 티켓 아래 회색으로 no revoke 한 줄. 모던 플랫 에디토리얼 스타일.

그림 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을 만들어 서명한다.

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" }
  ]
}
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" }
  ]
}

이 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는 클라이언트가 사용자의 브라우저고 양심에 기댈 수 없을 때. 그게 두 패턴의 경계다.

PUT presigned vs POST presigned, 풀려는 문제가 다르다PUT PRESIGNEDquery string에 서명만PUT https://bucket.s3.ap-northeast-2.amazonaws.com/key? X-Amz-Algorithm=AWS4-HMAC-SHA256& X-Amz-Expires=900&…&X-Amz-Signature=… body = (어떤 바이트든)강점· 코드 한 줄, 빠르게 뽑을 수 있다· GET·PUT 어느 쪽이든 동일한 형태약점· 파일 크기·MIME·키 prefix 강제 불가· 클라이언트 자바스크립트 양심에 의존POST PRESIGNEDform policy로 조건 강제POST https://bucket.s3.ap-northeast-2.amazonaws.com/Content-Type: multipart/form-data policy = base64({expiration, conditions[…]}) x-amz-signature, key, bucket, file …강점· content-length-range 로 크기 상한 강제· starts-with 로 키 prefix 강제트레이드오프· 폼·policy JSON 합성이 PUT보다 복잡· 일부 SDK는 native 지원이 부족클라이언트가 사용자의 브라우저면 POST, 내부 신뢰 환경이면 PUT.

그림 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시간에 잘린 사고를 본 적이 있다. 그땐 나도 그게 왜 안 되는지 한참 몰랐다.

만료의 두 시계, 짧은 쪽이 실제 만료를 정한다SCENARIO · IAM ROLE STS 1H 세션으로 X-AMZ-EXPIRES=604800(7일) 서명시계 1 · X-Amz-Expires0h7d (=168h)서명에 적힌 만료, 클라이언트가 쥔 종이의 글자.시계 2 · 자격증명 잔여 수명0h1h (STS default)자격증명이 죽으면 URL도 같이 죽는다, 서명 검증이 깨지므로.cut here · 잘리는 지점실제 만료 = min(시계 1, 시계 2)→ 7일을 적었어도 1시간 후 죽는다. 7일 한도를 끝까지 쓰려면 IAM User 장기 키.

그림 5. 시계 두 개가 겹치는 곳에서 짧은 쪽이 실제 만료를 정한다. STS 세션 1시간이면 7일을 적었어도 1시간 후 URL이 죽는다.

세부 한도(콘솔 12시간 / SDK 7일)와 흔한 운영 사고(만료 너무 길게 잡아서 생기는 노출)는 버킷 정책과 ACL: 공개와 비공개의 경계에서 한 번 정리했다. 여기서는 왜 두 시계가 잘리는지만 짚고 넘어간다.

에디토리얼 일러스트레이션. 흰 배경 위에 아날로그 시계 두 개가 나란히 있다. 왼쪽 시계 다이얼에는 슬레이트 블루 색의 큰 7d 글자, 오른쪽 시계 다이얼에는 AWS 오렌지 색의 큰 12h 글자. 두 시계 사이를 가는 점선이 연결한다. 시계 아래쪽으로 작은 회색 글자 min(URL expires, credentials remaining) 한 줄. 모던 플랫 에디토리얼 스타일.

그림 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가 막아 주지 않는다.

presigned URL이 풀지 못하는 세 항목, 무거운 케이스에 답이 아닌 이유FAILURE 1취소 API가 없다한 URL을 개별 무효화할 방법이 없다.막으려면 Access Key 회전·deactivate→ 같은 키로 sign된 모든 URL이 한꺼번에 죽는다.관계 없는 URL이 같이 죽는 비용→ 살아 있는 다운로드 링크가 많은 서비스에서 매우 무겁다.FAILURE 2정적 분석 사각지대Access Analyzer 분석 범위:bucket policy · ACL · BPA · accesspoint/MRAP policy↓ 이 안에 presigned URL은 없다.사후 로그로만 흔적이 남는다→ S3 server access log, CloudTrail data events.FAILURE 3개별 권한 평가가 없다sign 시점에 백엔드가 한 번 확인한다.GET·PUT 시점에 S3는 다시 묻지 않는다.URL을 누가 들고 와도 통과.게이트가 백엔드 안에 있다→ 백엔드가 잘못 발급하면 S3가 막아 주지 않는다.의료 기록·결제 영수증·권한 변경이 잦은 파일 → 백엔드를 통한 streaming이 더 정직하다.

그림 7. 세 항목이 모두 가벼우면 presigned URL, 하나라도 무거우면 백엔드를 통한 streaming.

위 셋 중 어느 하나라도 무거운 곳에는 (의료 기록·결제 영수증·권한 변경이 잦은 워크스페이스 파일) presigned URL은 답이 아니다. 그쪽은 백엔드를 통한 streaming(클라이언트 ↔ 백엔드 ↔ S3)이 더 정직하다. 대역폭 두 번 보내는 비용을 감수하는 대신 권한·취소·감사를 백엔드 코드 안에서 다룰 수 있다.

한 줄로

presigned URL은 S3 권한을 푸는 게 아니라, S3 권한을 가진 누군가의 서명을 일회성 카드 형식으로 옮겨 둔 것이다. 백엔드가 카드를 발급하고, S3가 카드의 진위만 검증한다. 그 사이에 백엔드가 끼지 않아도 되는 거래를 만든다.

대신 카드는 발급 후 회수가 어렵고, 이 사용자에게 정말 권한이 있나를 매번 다시 묻지 않는다. 그래서 권한·감사·취소가 가벼운 곳(미리보기·일회성 다운로드·간단한 업로드)에 잘 어울리고, 무거운 곳에는 답이 아니다. 두 갈래를 잘 가르는 게 운영의 절반이다.

이어서 Lifecycle 규칙 (객체가 시간에 따라 자동으로 이동·삭제되는 길) 을 짚는다. 이 글에서 본 키 단위의 권한·접근을 lifecycle에서는 prefix·tag·age 단위로 어떻게 다시 짜는지를 짚는다.

YouTube 영상

채널 보기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
마지막편, 트라이 노드를 50% 이상 줄이는 방법? 압축 트라이 성능 분석 | Trie 자료구조 이야기
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기
트라이(Trie) 자료구조: 파이썬으로 삽입(Insert) 연산 구현하기 | Trie 자료구조 이야기
AI는 데이터를 어떻게 분류할까? 벡터의 거리와 KNN 알고리즘 | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기
내적의 기하학적 의미와 코사인 유사도 원리 | 선형대수학
투영과 예측, 그리고 선형 결합 | 선형대수학