S3 Presigned URL 직접 업로드: 보안과 검증 기준
브라우저가 S3로 직접 업로드하는 구조와 5분짜리 Presigned PUT URL의 권한, 재사용, 덮어쓰기와 파일 검증 경계를 설명한다.
이 글의 목차직접 업로드와 서버 중계는 목적에 따라 고른다5
이미지 업로드 기능을 만들 때 브라우저는 파일을 애플리케이션 서버로 보냈고, 서버가 다시 Amazon S3에 올렸다.
S3는 파일을 객체로 저장하는 서비스다.
저장 공간을 버킷, 그 안에서 객체를 찾는 주소를 객체 키라고 부른다.
이 흐름은 이해하기 쉽지만 파일의 모든 바이트가 서버를 한 번 거친다.
이미지가 커지거나 동시 업로드가 늘면 서버가 택배 중계소 역할까지 맡는다.
서버 중계를 없애기 위해 브라우저가 S3로 직접 업로드하는 구조로 바꿨다.
프로필 사진 몇 장처럼 작고 드문 업로드라면 서버 중계도 충분히 단순한 선택이다.
반대로 판매자가 여러 장의 고해상도 상품 이미지를 올리거나 사용자가 큰 동영상을 보내는 서비스에서는 파일 본문을 애플리케이션 서버가 전부 운반하는 비용이 빠르게 커진다.
직접 업로드와 서버 중계는 목적에 따라 고른다
Presigned URL이 언제나 더 좋은 것은 아니다.
| 상황 | 우선 검토할 구조 | 이유 |
|---|---|---|
| 큰 이미지·동영상이 많고 동시 업로드가 잦음 | S3 직접 업로드 | 애플리케이션 서버가 파일 본문을 중계하지 않는다 |
| 작은 파일이 드물고 구현 단순성이 중요함 | 서버 중계 업로드 | 인증·검사·저장을 한 요청 안에서 다루기 쉽다 |
| 게시 전에 바이러스·실제 형식 검사가 필수 | 격리 버킷으로 직접 업로드 후 검사 | 전송 부담은 줄이면서 미검사 파일을 공개하지 않는다 |
| 서버가 업로드 바이트를 실시간으로 해석해야 함 | 서버 중계 또는 별도 스트리밍 처리 | Presigned URL만으로는 서버가 본문을 보지 않는다 |
이번에는 이미지 본문을 운반하는 부하를 줄이는 것이 목적이어서 직접 업로드를 선택했다.
게시 가능 여부는 업로드 이후 별도 검사 단계에서 결정해야 했다.
장기 키 대신 5분짜리 권한을 건넸다
브라우저에 AWS 액세스 키를 넣을 수는 없었다.
대신 서버가 로그인 사용자와 소속 tenant(회사나 팀 같은 조직 단위)의 업로드 권한, 남은 할당량과 허용할 파일 종류를 확인한 뒤 특정 버킷과 객체 키에 PUT할 수 있는 Presigned URL을 발급했다.
Presigned URL은 서버의 AWS 권한으로 미리 서명한 주소다.
주소를 가진 쪽은 AWS 계정 키 없이도 서명된 작업을 수행할 수 있으므로 주소 자체를 비밀번호처럼 다뤄야 한다.
당시 회사 코드에는 AWS SDK for JavaScript v2의 getSignedUrl()이 사용되고 있었다.
다만 v2는 2025년 9월 지원이 끝났기 때문에, 아래 예시는 같은 동작을 하는 AWS SDK v3 기준으로 바꿨다.
import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
const command = new PutObjectCommand({
Bucket: uploadBucket,
Key: objectKey,
ContentType: requestedContentType,
IfNoneMatch: "*",
});
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 60 * 5,
signableHeaders: new Set([
"content-type",
"if-none-match",
]),
});Bucket과 Key가 업로드 위치를 정하고, expiresIn이 새 업로드 요청을 허용할 시간을 초로 정한다.
ContentType은 image/png처럼 HTTP가 데이터 종류를 표현하는 값이다.
이 값은 클라이언트 문자열을 그대로 믿지 않고 서버의 허용 목록에서 골라야 한다.
AWS SDK v3의 signableHeaders는 content-type과 if-none-match를 SigV4 서명에 포함해, URL을 가진 쪽이 두 헤더를 빼거나 다른 값으로 바꾸면 서명 검증이 실패하게 만든다.
두 헤더를 명령에만 적고 서명 대상으로 강제하지 않으면 브라우저가 전송한 메타데이터와 덮어쓰기 방지 조건을 URL의 권한 경계로 믿을 수 없다.
브라우저는 발급 때 정한 Content-Type과 If-None-Match: *를 그대로 넣어 PUT해야 하며, S3 CORS의 허용 헤더에도 두 이름을 포함해야 한다.
IfNoneMatch: "*"는 같은 키의 객체가 이미 있으면 덮어쓰지 말라는 조건이다.
충돌하면 S3는 412 Precondition Failed 또는 경합 상황의 409 Conflict를 돌려줄 수 있으므로, 클라이언트는 새 키를 발급받아 다시 시작해야 한다.
키는 원본 파일명 대신 서버가 업로드 요청마다 만든 고유값을 사용하고 사용자·tenant·업로드 상태 레코드와 묶는다.
expiresIn: 60 * 5의 5분은 AWS가 정한 보안 정답이 아니라 당시 업로드를 시작하기에 충분하면서 주소 노출 시간을 짧게 잡으려 선택한 값이다.
모바일 네트워크에서 파일 선택 뒤 전송을 시작하기까지 시간이 더 필요하다면 사용자 경험을 확인해 조정하되, 단순히 실패가 불편하다는 이유로 수 시간짜리 주소를 발급하지 않는다.
이 URL은 일회용이 아니다.
유효한 동안 다시 사용할 수 있고, 조건부 쓰기를 걸지 않은 같은 키로 다시 PUT하면 현재 객체가 새 내용으로 바뀐다.
버저닝을 켠 버킷이라면 이전 버전은 남지만 최신 버전은 바뀐다.
URL 전체에는 서명 정보가 들어 있으므로 애플리케이션 로그, 분석 도구, 오류 추적 서비스나 채팅에 남기지 않는 것도 중요했다.
S3는 요청을 시작할 때 만료 여부를 확인한다.
만료 직전에 시작한 전송은 만료 시각을 지나 완료될 수 있고, 연결이 끊긴 뒤 만료된 URL로 새 요청을 보내면 실패할 수 있다.
URL의 실제 유효기간은 서명에 사용한 자격 증명이 먼저 만료되거나 취소되면 5분보다 짧아질 수도 있다.
이 변경으로 해결한 범위
적용 뒤 브라우저는 애플리케이션 서버를 거치지 않고 S3로 파일을 보냈다.
- 장기 AWS 자격 증명을 브라우저에 전달하지 않았다.
- 애플리케이션 서버가 이미지 본문을 받아 다시 전송하지 않게 됐다.
- 업로드 권한의 유효시간을 5분으로 제한했다.
Presigned URL이 파일을 검사해 주는 것은 아니다.
Content-Type: image/png가 서명과 일치해도 실제 바이트가 PNG라는 보장은 없다.
서버가 업로드 전에 신뢰할 수 있는 SHA-256 같은 체크섬을 알고 있다면 그 체크섬 헤더까지 서명해 전송 중 바이트가 바뀐 업로드를 S3가 거부하게 할 수 있다.
그래도 체크섬은 악성 파일이나 위장 형식을 판별하지 않는다.
URL이 허용하는 작업, 바이트가 손상되지 않았다는 사실, 업로드된 파일을 게시해도 된다는 판단은 서로 다른 문제다.
파일 확장자도 같은 한계가 있다.
이름을 photo.png로 바꾸는 일은 누구나 할 수 있으므로 검사 워커는 파일의 시작 바이트인 시그니처와 실제 디코딩 성공 여부를 확인해야 한다.
이 구분이 뒤늦게 중요해졌다.
당시 만든 것은 “정해진 위치로 직접 업로드하는 길”이었다.
파일 크기, 실제 형식, 무결성, 중복 이벤트를 확인하고 공개 위치로 옮기는 과정은 별도 파이프라인이 필요했다.
그 후속 설계는 S3 업로드를 격리·검사·게시로 나누는 설계에 분리했다.
직접 업로드는 서버의 운반 일을 없앨 때 쓴다
확인된 결과는 세 가지다.
브라우저에 장기 AWS 자격 증명을 넣지 않았고, 업로드 권한은 5분으로 제한했으며, 파일 본문은 애플리케이션 서버를 통과하지 않고 S3로 전송됐다.
해결한 문제는 서버가 권한 발급과 파일 운반을 동시에 맡던 구조였다.
파일 한 개의 크기를 S, 업로드 수를 N이라고 하면 이전 서버는 브라우저에서 약 N × S바이트를 받고 S3로 다시 같은 양을 보내 파일 본문 기준 약 2 × N × S바이트를 처리했다.
직접 업로드에서는 이 본문이 서버를 지나지 않는다.
예를 들어 10MiB 파일 1만 개라면 계산상 약 195GiB의 서버 데이터 경로가 빠진다.
재시도와 HTTP 부가 데이터는 뺀 구조 계산이며, 네트워크 요금이나 처리시간의 실측값은 아니다.
이 방식은 사용자가 올린 큰 파일이나 업로드 빈도가 높아 서버 중계가 병목이 될 때 적합하다.
대신 URL마다 예측하기 어려운 고유 키, 필요한 경로에만 한정한 서명 권한, 짧은 만료시간을 함께 적용해야 한다.
Presigned URL은 만료 전까지 주소를 가진 사람이 다시 사용할 수 있는 임시 권한이므로 일회용 권한이나 파일 검사가 필요하면 이것만으로 끝내지 않는다.
공개하거나 후속 처리할 파일이라면 격리 경로에 먼저 올리고, 크기·실제 형식·무결성을 확인한 뒤 게시하는 파이프라인까지 이어 붙여야 한다.
실서비스에서는 다음 경계를 한 묶음으로 적용한다.
- 서버가 로그인 사용자, tenant 권한, 허용 목적·종류·크기와 남은 할당량을 확인한다.
- 객체 키는 사용자가 정한 파일명을 그대로 쓰지 않고 서버가 업로드마다 고유하게 만들며 기존 키 덮어쓰기를 막는다.
- 서명 역할은 해당 격리 경로의
PutObject만 허용하고 목록·읽기·삭제·ACL 변경·게시 경로 쓰기를 주지 않는다. - CORS는 실제 웹 앱의 origin,
PUT메서드와 필요한 헤더만 허용한다.
CORS는 브라우저 규칙일 뿐 인증을 대신하지 않는다. - 발급 횟수와 실제 업로드 바이트에 사용자·tenant별 한도를 두고, 발급됐지만 완료되지 않은 요청도 센다.
- 브라우저의 성공 응답만 믿지 않고 서버 또는 이벤트 워커가 정확한 bucket·key·version을 다시 조회한다.
- 업로드 뒤 실제 크기, 파일 시그니처, 디코딩 여부와 무결성을 검사한다.
- 검사를 통과한 객체만 게시 경로로 옮기고, 실패하거나 완료되지 않은 격리 객체는 정책에 따라 만료시킨다.