개요
- Presigned URL은 AWS 서명 V4 알고리즘으로 서명된 URL임
- 서버의 비밀 키로 생성된 보안 토큰으로 클라이언트가 직접 S3에 접근할 수 있게 해줌
- 서버 부하 없이 대용량 파일을 안전하게 업로드하거나 다운로드할 수 있음
개념
Presigned URL
- AWS 서명 V4로 서명된 URL로 다음 요소들을 조합해 만든 보안 토큰임
- 클라이언트 요청 정보 (HTTP 메서드, 버킷, 키, 헤더)
- 서버의 비밀 키 (Secret Access Key)
- 타임스탬프 및 만료 시간
- 암호화 서명 (HMAC-SHA256)
Presigned URL 구조
-
Presigned URL = Base URL + Query Parameters (서명)
1 2 3 4 5 6 7
https://mybucket.s3.amazonaws.com/myfile.jpg? X-Amz-Algorithm=AWS4-HMAC-SHA256 &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F... &X-Amz-Date=20240101T000000Z &X-Amz-Expires=3600 &X-Amz-SignedHeaders=host &X-Amz-Signature=fe5f80f77d5fa3beca5917323676bcd2...
-
S3가 요청을 받으면 서명을 검증하여 AWS 인증 정보를 가진 서버가 생성한 URL인지 확인함
요청-응답 흐름
전체 흐름

- 클라이언트 → 서버
- Presigned URL 요청
- 서버 내부 처리
- GeneratePresignedUrlRequest 생성
- 버킷, 키, HTTP 메서드, 만료 시간 설정
- AWS SDK로 URL 생성 (암호화 서명 포함)
- 서버 → 클라이언트
- Presigned URL과 만료 시간 반환
- 클라이언트 → S3
- Presigned URL로 직접 파일 업로드
- S3 검증 및 저장
- 서명 검증
- 만료 시간 확인
- 검증 통과 시 파일 저장
파라미터 의미
- X-Amz-Algorithm
- 서명 알고리즘 (고정값: AWS4-HMAC-SHA256)
- X-Amz-Credential
- 자격증명 (접근 키 + 날짜 + 리전)
- X-Amz-Date
- 요청 생성 시각 (UTC)
- X-Amz-Expires
- 유효 기간 (초 단위)
- X-Amz-SignedHeaders
- 서명에 포함된 헤더들
- X-Amz-Signature
- HMAC-SHA256 서명값
보안 메커니즘
서명 검증 과정
- S3가 요청을 받았을 때
- 요청에서 X-Amz-Signature 값 추출
- S3가 같은 정보로 서명 재계산
- 두 서명 비교
- 일치 시 요청 진행
- 불일치 시 403 Forbidden
서명에 포함되는 정보
- HTTP 메서드 (GET, PUT, DELETE)
- 요청 경로 (버킷 + 키)
- 요청 헤더 (Content-Type 등)
-
타임스탬프
- URL을 조금이라도 변조하면 서명이 일치하지 않아 접근이 거부됨
시간 기반 보안
- 서버에서 생성 시
- 현재 시간 + 만료 시간 = 타임스탬프 저장
- S3에서 요청 받을 때
- 현재 시간 > 요청 시간 + 만료 시간이면 거부
- ex)
- 5분 유효 기간이라면 만료 시각 이후 요청은 무조건 실패함
Spring Boot 구현
기본 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@Profile("local")
public class LocalStackS3Config {
@Bean
public AmazonS3 amazonS3(
@Value("${aws.s3.endpoint}") String endpoint,
@Value("${aws.s3.region}") String region,
@Value("${aws.credentials.access-key}") String accessKey,
@Value("${aws.credentials.secret-key}") String secretKey
) {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(new EndpointConfiguration(endpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withPathStyleAccessEnabled(true)
.build();
}
}
Presigned URL 생성 서비스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Service
@RequiredArgsConstructor
public class S3PresignedService {
private final AmazonS3 amazonS3;
@Value("${aws.s3.bucket}")
private String bucketName;
/**
* PUT (업로드) Presigned URL 생성
*
* @param fileName 저장될 파일명
* @param expirationMinutes 유효 기간 (분)
* @param contentType 파일 MIME 타입 (서명에 포함)
* @return Presigned URL과 만료 시간을 포함한 결과 객체
*/
public PresignedUrlResult generatePresignedUploadUrl(String fileName, int expirationMinutes, String contentType) {
Date expiration = new Date();
long expirationTimeInMillis = expiration.getTime() + (expirationMinutes * 60 * 1000L);
expiration.setTime(expirationTimeInMillis);
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(bucketName, fileName)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration)
.withContentType(contentType);
URL url = amazonS3.generatePresignedUrl(request);
return new PresignedUrlResult(url.toString(), expirationTimeInMillis);
}
/**
* Presigned URL 결과 객체
*/
public record PresignedUrlResult(String url, long expiresAt) {}
/**
* GET (다운로드) Presigned URL 생성
*/
public PresignedUrlResult generatePresignedDownloadUrl(String fileName, int expirationMinutes) {
Date expiration = new Date();
long expirationTimeInMillis = expiration.getTime() + (expirationMinutes * 60 * 1000L);
expiration.setTime(expirationTimeInMillis);
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(bucketName, fileName)
.withMethod(HttpMethod.GET)
.withExpiration(expiration);
URL url = amazonS3.generatePresignedUrl(request);
return new PresignedUrlResult(url.toString(), expirationTimeInMillis);
}
/**
* DELETE (삭제) Presigned URL 생성
*/
public String generatePresignedDeleteUrl(String fileName, int expirationMinutes) {
Date expiration = new Date();
long expirationTimeInMillis = expiration.getTime() + (expirationMinutes * 60 * 1000L);
expiration.setTime(expirationTimeInMillis);
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(bucketName, fileName)
.withMethod(HttpMethod.DELETE)
.withExpiration(expiration);
URL url = amazonS3.generatePresignedUrl(request);
return url.toString();
}
}
Controller 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FilePresignedController {
private final S3PresignedService presignedService;
@PostMapping("/presigned-upload-url")
public ResponseEntity<PresignedUrlResponse> getUploadUrl(@RequestBody PresignedUrlRequest request) {
String fileName = generateUniqueFileName(request.fileName());
S3PresignedService.PresignedUrlResult result =
presignedService.generatePresignedUploadUrl(
fileName,
5,
request.contentType()
);
return ResponseEntity.ok(new PresignedUrlResponse(
result.url(),
fileName,
result.expiresAt()
));
}
@GetMapping("/presigned-download-url")
public ResponseEntity<PresignedUrlResponse> getDownloadUrl(@RequestParam String fileName) {
S3PresignedService.PresignedUrlResult result = presignedService.generatePresignedDownloadUrl(fileName, 15);
return ResponseEntity.ok(new PresignedUrlResponse(
result.url(),
fileName,
result.expiresAt()a
));
}
@PostMapping("/upload-complete")
public ResponseEntity<Void> notifyUploadComplete(@RequestBody UploadCompleteRequest requesta) {
// 파일이 실제로 S3에 저장됐는지 확인
// DB에 파일 메타데이터 저장
return ResponseEntity.ok().build();
}
private String generateUniqueFileName(String originalFileName) {
return java.util.UUID.randomUUID().toString() + "_" + originalFileName;
}
}
// DTO
record PresignedUrlRequest(String fileName, String contentType) {
}
record PresignedUrlResponse(String presignedUrl, String fileName, long expiresAt) {}
record UploadCompleteRequest(String fileName) {}
주의 사항
Content-Type 서명 문제
- 가장 흔한 오류 중 하나임
-
서버에서 생성한 Presigned URL의 Content-Type과 클라이언트가 보내는 Content-Type이 일치해야 함
1 2 3 4 5 6 7 8 9 10 11 12 13
String contentType = "image/jpeg"; GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, key) .withMethod(HttpMethod.PUT) .withContentType(contentType) .withExpiration(expiration); // 클라이언트는 같은 Content-Type 사용 fetch(presignedUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: file });
- Content-Type이 서명에 포함되지 않으면 클라이언트가 다른 Content-Type을 보낼 수 있어 보안 위험이 있음
- Content-Type이 서명에 포함되면 반드시 일치해야 하므로 서명 검증 실패 시 403 Forbidden 발생
시간 동기화 문제
- S3와 서버의 시간이 다르면 만료 시간 검증이 실패할 수 있음
- 해결 방법
- NTP로 시스템 시간 동기화
- 만료 시간에 여유 두기 (10분 → 15분으로 설정)
- LocalStack은 Path-style만 지원하므로 반드시
.withPathStyleAccessEnabled(true)설정 필요
CORS (Cross-Origin Resource Sharing) 설정
- 가장 많이 겪는 오류 중 하나임
- 브라우저에서 S3로 직접 요청을 보낼 때, S3 버킷에 CORS 설정이 없으면 브라우저가 요청을 차단함
- 브라우저 콘솔에서
Access to fetch at '...' from origin '...' has been blocked by CORS policy오류 발생
AWS S3 CORS 설정
-
AWS 콘솔에서 버킷 → 권한 → CORS 구성으로 설정 가능
1 2 3 4 5 6 7 8 9
[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "DELETE"], "AllowedOrigins": ["http://localhost:3000", "https://example.com"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ]
LocalStack CORS 설정
-
AWS CLI 또는 초기화 스크립트로 설정 가능
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
# AWS CLI로 CORS 설정 awslocal s3api put-bucket-cors \ --bucket my-bucket \ --cors-configuration file://cors-config.json # cors-config.json { "CORSRules": [ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "DELETE"], "AllowedOrigins": ["*"], "ExposeHeaders": ["ETag"] } ] }
-
또는 초기화 스크립트에 포함
1 2 3 4 5 6 7 8 9 10 11 12 13
#!/bin/bash # init-scripts/setup-cors.sh awslocal s3api put-bucket-cors \ --bucket my-bucket \ --cors-configuration '{ "CORSRules": [{ "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "DELETE"], "AllowedOrigins": ["*"], "ExposeHeaders": ["ETag"] }] }'
-
주의 사항
AllowedOrigins에"*"를 사용하면 모든 도메인에서 접근 가능하므로 프로덕션 환경에서는 특정 도메인만 명시하는 것이 보안상 안전함AllowedMethods에는 실제로 사용하는 HTTP 메서드만 포함하는 것이 좋음ExposeHeaders는 클라이언트에서 읽을 수 있는 응답 헤더를 지정함
결론
- Presigned URL은 서버 부하 없이 대용량 파일을 안전하게 처리할 수 있는 기능임
- AWS 서명 V4 알고리즘을 통해 보안을 보장하며 시간 기반 만료로 추가 보안을 제공함
- 서버와 클라이언트 간 Content-Type 일치가 중요하며 시간 동기화도 주의해야 함
- LocalStack에서도 동일하게 동작하므로 로컬 개발 환경에서도 테스트 가능함
- 올바르게 사용하면 성능을 크게 향상시킬 수 있음