S3 Presigned URL
파일 업로드 기능을 구현할 때 가장 단순한 방식은 클라이언트가 파일을 서버로 보내고, 서버가 그 파일을 S3에 업로드하는 것이다.
클라이언트 → [파일 전송] → 서버 → [파일 업로드] → S3
이 방식은 동작은 하지만 문제가 있다. 파일이 서버를 거쳐가기 때문에 서버의 네트워크 대역폭과 메모리를 소비한다. 10MB짜리 이미지를 100명이 동시에 업로드하면 서버가 1GB의 트래픽을 중계하는 셈이다. 영상 파일처럼 크기가 큰 경우에는 서버가 병목이 되어 응답 속도가 느려지고, Lambda 같은 서버리스 환경에서는 페이로드 크기 제한(6MB)에 걸리기도 한다.
Presigned URL은 이 문제를 해결한다. 서버가 파일을 중계하는 대신, 클라이언트가 S3에 직접 업로드할 수 있는 "서명된 URL"을 발급해주는 방식이다.
클라이언트 → [URL 요청] → 서버 → [Presigned URL 생성] → 클라이언트
클라이언트 → [파일 직접 업로드] → S3
서버는 URL만 생성하고 실제 파일 전송에는 관여하지 않는다. 서버 대역폭 소비가 거의 없고, 클라이언트와 S3 간 직접 통신이라 전송 속도도 빠르다.
Presigned URL이란
Presigned URL은 AWS 자격 증명 없이도 S3 객체에 접근할 수 있게 해주는 임시 URL이다. URL 자체에 인증 정보가 쿼리 파라미터로 포함되어 있어서, 이 URL을 가진 누구든 지정된 시간 내에 지정된 작업(업로드 또는 다운로드)을 수행할 수 있다.
실제 Presigned URL은 이런 형태다:
https://bucket-name.s3.ap-northeast-2.amazonaws.com/uploads/photo.jpg
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE/20251210/ap-northeast-2/s3/aws4_request
&X-Amz-Date=20251210T100000Z
&X-Amz-Expires=300
&X-Amz-Signature=abcdef1234567890...
&X-Amz-SignedHeaders=host;content-type
각 파라미터의 역할:
| 파라미터 | 설명 |
|---|---|
X-Amz-Algorithm | 서명 알고리즘 (항상 AWS4-HMAC-SHA256) |
X-Amz-Credential | 서명에 사용된 자격 증명 (Access Key + 날짜 + 리전 + 서비스) |
X-Amz-Date | URL이 생성된 시각 |
X-Amz-Expires | URL 유효 시간 (초 단위) |
X-Amz-Signature | 요청 파라미터들을 Secret Key로 서명한 해시값 |
X-Amz-SignedHeaders | 서명에 포함된 헤더 목록 |
핵심은 X-Amz-Signature다. 이 값은 버킷 이름, 객체 키, Content-Type, 만료 시간 등을 조합해서 Secret Access Key로 HMAC-SHA256 서명한 결과다. S3는 요청을 받으면 같은 방식으로 서명을 재계산해서 일치하는지 검증한다. 서명에 포함된 파라미터를 하나라도 변조하면 서명 불일치로 403 에러가 발생한다.
서명 과정 내부 동작
Presigned URL의 서명은 AWS Signature Version 4(SigV4) 프로토콜을 따른다. 단순히 Secret Key로 한 번 해싱하는 게 아니라 4단계 키 파생 과정을 거친다.
SigningKey = HMAC-SHA256(
HMAC-SHA256(
HMAC-SHA256(
HMAC-SHA256("AWS4" + SecretKey, Date),
Region
),
Service
),
"aws4_request"
)
이렇게 파생된 키로 "Canonical Request"를 서명한다. Canonical Request에는 HTTP 메서드, 객체 경로, 쿼리 파라미터, 헤더 등이 정규화된 형태로 포함된다. 이 과정 덕분에 URL에 포함된 어떤 값이든 변조하면 서명 검증에 실패한다.
중요한 점은 Presigned URL 생성 자체는 AWS에 네트워크 요청을 보내지 않는다는 것이다. 서명은 순수하게 로컬에서 계산되고, AWS는 클라이언트가 실제로 URL을 사용할 때만 서명을 검증한다. 그래서 URL 생성은 매우 빠르고, AWS API 호출 비용도 발생하지 않는다.
업로드 Presigned URL 생성
AWS SDK v3에서 Presigned URL을 생성하려면 @aws-sdk/s3-request-presigner 패키지의 getSignedUrl 함수를 사용한다.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3Client = new S3Client({ region: 'ap-northeast-2' });
async function generateUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300, // 5분
});
return uploadUrl;
}
PutObjectCommand에 넣는 옵션들이 서명에 포함된다. ContentType을 image/jpeg로 지정하면 클라이언트도 반드시 Content-Type: image/jpeg 헤더로 업로드해야 한다. 다른 Content-Type으로 보내면 서명 불일치로 실패한다.
주요 옵션
Key (객체 경로)
S3에서 파일이 저장될 경로다. 보통 충돌을 방지하기 위해 UUID나 타임스탬프를 포함시킨다.
// 경로 구조 예시
const key = `uploads/photos/${sessionId}/${uuid()}.jpg`;
const key = `uploads/videos/${Date.now()}_${originalName}`;
ContentType
업로드할 파일의 MIME 타입이다. 서명에 포함되기 때문에 클라이언트가 실제 업로드 시 보내는 Content-Type 헤더와 정확히 일치해야 한다.
ContentType: 'image/jpeg' // 이미지
ContentType: 'video/mp4' // 영상
ContentType: 'application/pdf' // PDF
ContentLength
파일 크기 제한이 필요할 때 사용한다. 지정하면 정확히 그 크기의 파일만 업로드할 수 있다.
// 파일 크기를 알고 있을 때
ContentLength: fileSize
다만 ContentLength는 정확한 바이트 수를 지정해야 하기 때문에, 최대 크기를 제한하는 용도로는 적합하지 않다. 최대 업로드 크기를 제한하려면 S3 버킷 정책이나 Lambda@Edge에서 처리하는 게 낫다.
expiresIn
URL의 유효 시간(초)이다. 기본값은 900초(15분)이고, IAM 사용자의 경우 최대 7일(604,800초)까지 설정할 수 있다. 임시 자격 증명(STS, IAM Role)을 사용하는 경우에는 토큰 만료 시간이 상한이 된다.
expiresIn: 300 // 5분 - 이미지 업로드에 적절
expiresIn: 600 // 10분 - 영상 업로드에 적절
expiresIn: 3600 // 1시간 - 대용량 파일
유효 시간은 필요한 만큼만 짧게 설정하는 게 보안상 좋다. URL이 유출되면 누구든 업로드할 수 있기 때문이다.
다운로드 Presigned URL 생성
다운로드용 URL은 GetObjectCommand를 사용한다. 비공개 버킷의 파일을 임시로 공개할 때 유용하다.
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async function generateDownloadUrl(key: string) {
const command = new GetObjectCommand({
Bucket: 'my-bucket',
Key: key,
});
const downloadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300,
});
return downloadUrl;
}
다운로드 URL의 활용 사례:
- 비공개 미디어 제공: 인증된 사용자에게만 이미지/영상 접근 허용
- 임시 공유 링크: 일정 시간만 유효한 파일 공유 (Google Drive 공유 링크와 유사)
- 결제 후 다운로드: 결제 완료 후 디지털 콘텐츠 다운로드 링크 제공
2단계 업로드 플로우
실제 서비스에서는 Presigned URL을 2단계로 나눠서 사용한다.
1단계: URL 발급
클라이언트가 서버에 "파일을 업로드하고 싶다"고 요청하면, 서버가 Presigned URL과 S3 키를 응답한다.
// 서버 - NestJS 예시
@Post('upload-url')
async getUploadUrl(@Body() dto: RequestUploadDto) {
const key = `uploads/${dto.category}/${uuid()}.${dto.extension}`;
const { uploadUrl, s3Key, expiresIn } =
await this.s3PresignService.generateUploadUrl({
key,
contentType: dto.contentType,
});
return { uploadUrl, s3Key, expiresIn };
}
// 클라이언트
const { uploadUrl, s3Key } = await api.post('/upload-url', {
contentType: file.type,
extension: 'jpg',
category: 'photos',
});
2단계: 직접 업로드
클라이언트가 받은 Presigned URL로 S3에 직접 파일을 업로드한다.
// 클라이언트 - fetch로 S3에 직접 업로드
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});
여기서 주의할 점이 있다. Content-Type 헤더는 서버가 Presigned URL을 생성할 때 지정한 값과 정확히 일치해야 한다. 불일치하면 SignatureDoesNotMatch 에러가 발생한다. 클라이언트에서 file.type을 그대로 서버에 전달하고, 서버가 그 값으로 URL을 생성하면 불일치 문제를 방지할 수 있다.
3단계: 업로드 완료 알림 (선택)
파일이 S3에 올라간 후, 서버에 S3 키를 전달해서 DB에 기록한다.
// 클라이언트
await api.post('/photos', {
s3Key: s3Key,
// ... 기타 메타데이터
});
// 서버
@Post('photos')
async createPhoto(@Body() dto: CreatePhotoDto) {
// s3Key를 DB에 저장 (URL이 아닌 key만 저장)
return this.photoService.create({ s3Key: dto.s3Key });
}
DB에는 전체 URL이 아닌 S3 키만 저장하는 게 좋다. CloudFront CDN 도메인이 바뀌거나 리전을 옮길 때 DB의 URL을 전부 수정할 필요가 없기 때문이다. 클라이언트에 URL을 내려줄 때 CDN 도메인을 붙여서 생성하면 된다.
// CDN URL 생성
const cdnUrl = `${this.config.assetsCdnUrl}/${s3Key}`;
CORS 설정
클라이언트가 브라우저에서 S3로 직접 업로드하려면 S3 버킷에 CORS 설정이 필요하다. 브라우저의 Same-Origin Policy 때문에 다른 도메인(S3)으로의 PUT 요청이 기본적으로 차단되기 때문이다.
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["https://your-domain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
| 필드 | 설명 |
|---|---|
AllowedHeaders | 클라이언트가 보낼 수 있는 헤더. Content-Type 등을 포함해야 하므로 * 권장 |
AllowedMethods | 허용할 HTTP 메서드. 업로드에는 PUT, 다운로드에는 GET |
AllowedOrigins | 요청을 허용할 도메인. 프로덕션에서는 정확한 도메인 지정 |
ExposeHeaders | 클라이언트에서 읽을 수 있는 응답 헤더. ETag은 멀티파트 업로드 확인에 필요 |
MaxAgeSeconds | preflight 응답 캐시 시간. 매 업로드마다 OPTIONS 요청을 보내지 않도록 설정 |
개발 환경에서는 AllowedOrigins를 *로 설정해도 되지만, 프로덕션에서는 반드시 구체적인 도메인을 지정해야 한다. 그렇지 않으면 아무 사이트에서나 버킷에 파일을 업로드할 수 있게 된다.
콘텐츠 유형별 만료 시간 전략
모든 파일에 같은 만료 시간을 적용할 수도 있지만, 파일 크기에 따라 다르게 설정하는 게 현실적이다. 작은 이미지는 몇 초면 업로드되지만, 큰 영상은 수 분이 걸릴 수 있다.
getDefaultExpires(contentType: string): number {
if (contentType.startsWith('video/')) {
return 600; // 영상: 10분
}
return 300; // 기본: 5분
}
이렇게 콘텐츠 타입별로 분리하면 이미지는 짧은 유효 시간으로 보안을 강화하면서, 영상은 충분한 시간을 확보할 수 있다.
보안 고려사항
URL 유출 위험
Presigned URL을 가진 사람은 누구든 해당 작업을 수행할 수 있다. URL이 브라우저 히스토리, 로그, 리퍼러 헤더 등을 통해 유출될 수 있으므로 주의해야 한다.
- 유효 시간을 최소한으로 설정
- HTTPS만 사용 (HTTP는 중간자 공격에 취약)
- 서버 로그에 Presigned URL을 남기지 않도록 주의
- 민감한 파일은 다운로드 URL도 짧은 만료 시간 적용
업로드 제한
Presigned URL 자체로는 파일 크기를 유연하게 제한하기 어렵다. ContentLength는 정확한 바이트 수만 허용하기 때문이다. 실무에서는 다음 방법을 조합한다.
1. 클라이언트에서 파일 크기 사전 검증 (UX 용도)
2. S3 버킷 정책으로 최대 크기 제한
3. Lambda@Edge나 CloudFront Functions로 요청 필터링
4. 업로드 완료 후 서버에서 S3 객체 메타데이터 확인
IAM 권한 최소화
Presigned URL을 생성하는 서버(또는 Lambda)의 IAM 역할에는 필요한 최소 권한만 부여한다.
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::my-bucket/uploads/*"
}
s3:*처럼 와일드카드 권한을 주면 Presigned URL로 버킷의 모든 객체를 삭제하는 것도 가능해진다. 업로드 전용이면 PutObject만, 다운로드 전용이면 GetObject만 허용하고, Resource도 특정 경로로 제한해야 한다.
멀티파트 업로드
파일이 100MB를 넘어가면 단일 PUT 요청으로는 불안정할 수 있다. 네트워크가 중간에 끊기면 처음부터 다시 업로드해야 하기 때문이다. 이런 경우 멀티파트 업로드를 사용한다.
1. CreateMultipartUpload → UploadId 발급
2. 파일을 5MB+ 청크로 분할
3. 각 청크마다 UploadPart용 Presigned URL 생성
4. 클라이언트가 각 청크를 병렬 업로드
5. CompleteMultipartUpload로 조립 완료
각 파트마다 별도의 Presigned URL이 필요하고, 모든 파트가 업로드된 후 서버가 CompleteMultipartUpload를 호출해야 한다. 구현이 복잡한 대신, 실패한 파트만 재업로드할 수 있고 병렬 전송으로 속도도 빠르다.
실무에서는 직접 구현하기보다 @aws-sdk/lib-storage의 Upload 클래스를 사용하면 자동으로 멀티파트 처리를 해준다.
import { Upload } from '@aws-sdk/lib-storage';
const upload = new Upload({
client: s3Client,
params: {
Bucket: 'my-bucket',
Key: key,
Body: readableStream,
ContentType: contentType,
},
partSize: 10 * 1024 * 1024, // 10MB
queueSize: 4, // 동시 업로드 파트 수
});
upload.on('httpUploadProgress', (progress) => {
console.log(`${progress.loaded} / ${progress.total}`);
});
await upload.done();
SDK v2 vs v3 차이
AWS SDK v2에서는 s3.getSignedUrl() 메서드를 동기적으로 호출했지만, v3에서는 별도 패키지의 getSignedUrl() 함수를 비동기로 호출한다.
// SDK v2 (레거시)
const url = s3.getSignedUrl('putObject', {
Bucket: 'my-bucket',
Key: key,
Expires: 300,
ContentType: contentType,
});
// SDK v3 (현재)
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const url = await getSignedUrl(s3Client, new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
ContentType: contentType,
}), { expiresIn: 300 });
v3의 장점은 모듈화다. S3 관련 패키지만 설치하면 되기 때문에 번들 크기가 작다. v2는 aws-sdk 전체를 설치해야 해서 Lambda 함수에서 cold start가 느려지는 원인이 되기도 했다.
# v3: 필요한 패키지만 설치
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# v2: 전체 SDK 설치
npm install aws-sdk
정리
- Presigned URL은 서버가 파일을 중계하지 않고 클라이언트→S3 직접 업로드를 가능하게 하며, URL 생성은 로컬 서명이라 네트워크 호출이 없다
- Content-Type 일치, CORS 설정, expiresIn 최소화가 실무에서 가장 자주 빠뜨리는 세 가지 포인트다
- 100MB 이상은 멀티파트 업로드로 전환하고, DB에는 전체 URL 대신 S3 key만 저장해서 CDN 도메인 변경에 대응한다
관련 개념
- S3 Key 저장 패턴: URL 대신 key만 DB에 저장하고 CDN 도메인을 분리하는 전략
- CloudFront Cache Invalidation: CDN 캐시 무효화로 업데이트된 파일 즉시 반영
- AbortSignal.timeout: fetch 요청에 타임아웃을 걸어 업로드 실패 시 빠르게 감지