The Problem Presigned URLs Solve
한국어 원문으로 표시합니다.
한 줄 요약
프리사인드 URL 은 "이 사람이 이 객체를 이 시간 동안 이 동작만 할 수 있다"를 서명된 URL 하나로 표현한 것이다. 바이트가 앱 서버를 통과할 필요가 없어진다.
왜 이게 필요했나
사용자가 올린 파일을 다운로드하게 해 주는 가장 소박한 구현은 이렇습니다. 애플리케이션이 권한을 확인하고, 스토리지에서 객체를 읽어, 응답 본문으로 흘려보냅니다.
이 방식의 문제는 규모에서 나타납니다. 500MB 동영상 100개가 동시에 다운로드되면 앱 서버가 50GB 를 중계합니다. 워커가 그동안 묶이고, 대역폭이 앱 서버 스펙에 갇히고, 메모리 버퍼링을 잘못하면 OOM 이 납니다. 앱 서버는 로직을 처리해야지 바이트를 나르는 파이프가 되면 안 됩니다.
프리사인드 URL 은 역할을 나눕니다. 앱은 권한을 판단하고 URL 을 서명해서 돌려줍니다. 실제 바이트 전송은 클라이언트와 스토리지 사이에서 직접 일어납니다. 앱은 몇 바이트짜리 URL 만 만들면 됩니다.
어떻게 동작하나
URL 에는 서명과 함께 제약이 담깁니다. 어떤 동작(GET/PUT), 어떤 객체, 언제까지(X-Amz-Expires), 어떤 자격 증명으로(X-Amz-Credential), 어떤 헤더를 서명에 포함했는지(X-Amz-SignedHeaders). 스토리지는 이 서명을 검증해 요청을 허용합니다. 서명이 자격 증명으로 만들어졌지만 시크릿 키 자체는 URL 에 없다는 점이 핵심입니다.
업로드에도 씁니다. 프리사인드 PUT URL 을 주면 클라이언트가 스토리지에 직접 올립니다. 여기서 주의할 점은 클라이언트가 무엇을 얼마나 올릴지 제한해야 한다는 것입니다. 프리사인드 POST 정책을 쓰면 Content-Length 범위와 Content-Type 을 서명에 묶을 수 있습니다.
만료 시간은 짧을수록 좋습니다. 다운로드용은 몇 분, 업로드용은 몇십 분이면 대개 충분합니다. URL 이 로그나 리퍼러 헤더로 새어 나갈 수 있기 때문입니다. 반대로 너무 짧으면 느린 회선의 사용자가 업로드를 끝내지 못합니다.
정책은 다른 층위입니다. 프리사인드가 임시 위임이라면 정책은 상시 권한입니다. IAM 스타일 JSON 으로 어떤 주체가 어떤 리소스에 어떤 동작을 할 수 있는지 적습니다. 여기서 자주 하는 실수가 Resource 를 버킷 전체로 잡는 것입니다. 접두어 단위로 좁히면 서비스 하나가 털려도 피해가 그 접두어 안에 갇힙니다.
현장에서 만나는 모습
버킷을 익명 읽기로 열어 두는 것은 대부분의 데이터 유출 사고의 시작점입니다. "이미지만 있으니까 괜찮다"고 열었다가 그 아래 백업이나 사용자 업로드가 섞여 들어가는 일이 흔합니다. 익명 공개가 정말 필요하면 버킷 전체가 아니라 public/ 같은 특정 접두어만 열고, 그 접두어에는 공개해도 되는 것만 넣는 규칙을 코드로 강제합니다.
그리고 MinIO 의 CVE-2025-62506 처럼 세션 정책 우회로 권한이 상승하는 취약점이 실제로 나옵니다. 정책을 좁게 쓰는 것은 구현 버그에 대한 방어이기도 합니다.
브라우저에서 직접 올릴 때 걸리는 것들
프리사인드 URL 을 처음 붙이면 대개 같은 자리에서 막힌다. 순서대로 짚어 둔다.
CORS. 브라우저가 다른 출처인 스토리지로 PUT 을 보내려면 먼저 OPTIONS 로 사전 요청을 보내고, 스토리지가 허용 헤더로 답해야 한다. 이 설정은 버킷에 따로 넣어야 하며, 없으면 서명은 멀쩡한데 브라우저 콘솔에만 CORS 오류가 뜬다. 서버에서 curl 로 시험하면 잘 되기 때문에 원인을 엉뚱한 곳에서 찾기 쉽다.
서명한 헤더는 반드시 그대로 보내야 한다. Content-Type 을 서명에 포함했다면 클라이언트가 정확히 같은 값을 보내야 하고, 하나라도 다르면 SignatureDoesNotMatch 가 난다. 브라우저나 HTTP 라이브러리가 헤더를 임의로 추가하거나 대소문자를 바꾸는 경우가 있어서, 서명에 넣는 헤더는 꼭 필요한 것만으로 줄이는 편이 안전하다.
시계. 서명에는 발급 시각이 들어가고 스토리지는 자기 시계로 검증한다. 두 시계가 몇 분 이상 어긋나면 방금 만든 URL 이 "이미 만료" 로 거절된다. 컨테이너에서 NTP 가 없거나 노트북이 절전에서 깨어난 직후에 실제로 겪게 된다.
업로드가 끝난 뒤의 처리도 미리 정해 두어야 한다. 클라이언트가 스토리지에 직접 올렸으므로 애플리케이션은 업로드가 끝났다는 사실을 스스로 알 수 없다. 클라이언트가 "다 올렸다" 고 알려 주는 방식은 그 요청이 오지 않으면 그대로 끊긴다. 그래서 실무에서는 두 가지를 함께 쓴다. 스토리지의 이벤트 알림으로 객체 생성을 받아 처리하고, 동시에 일정 시간이 지나도 완결되지 않은 항목을 주기적으로 훑어 정리한다.
큰 파일은 한 번에 올리지 않는다. 멀티파트 업로드는 파일을 조각으로 나눠 병렬로 올리고 마지막에 합치는 방식이라, 중간에 끊겨도 그 조각만 다시 올리면 된다. 대신 합치기를 호출하지 않은 조각들은 그대로 남아 요금만 나간다. 눈에 보이는 객체 목록에는 나타나지 않아서 몇 달 뒤 용량이 안 맞는 것으로 발견되는 일이 잦다. 미완성 업로드를 며칠 뒤 자동으로 지우는 수명 주기 규칙을 버킷을 만들 때 함께 넣어 두는 것이 정석이다.
다음 실습에서 할 것
익명 접근이 거부되는 것을 확인한 뒤 프리사인드 GET 과 PUT 을 만들어 쓰고 만료를 검증합니다. 그다음 별도 실습에서 사용자를 만들고 읽기 전용 정책과 접두어 한정 정책을 작성해 부착합니다.