개요
- 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 구현
- 참고 본 예제는 AWS SDK for Java v1 (
com.amazonaws.services.s3)을 기준으로 작성됨 - AWS는 현재 SDK v2 (
software.amazon.awssdk) 사용을 권장하지만 v1도 여전히 널리 사용되고 있음 - SDK v2의 경우
S3Presigner클래스를 사용하며 API가 다소 상이함
기본 설정
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@Service
@RequiredArgsConstructor
public class S3PresignedService {
private final AmazonS3 amazonS3;
@Value("${aws.s3.bucket}")
private String bucketName;
/**
* PUT (업로드) Presigned URL 생성
*
* 이 방식은 PUT 메서드를 사용하여 단일 파일을 덮어쓰거나 생성할 때 사용함
* Presigned POST와는 다르며 PUT은 단일 파일 업로드에 최적화됨
*
* @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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@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()
));
}
@PostMapping("/upload-complete")
public ResponseEntity<Void> notifyUploadComplete(
@RequestBody UploadCompleteRequest request
) {
// 파일이 실제로 S3에 저장됐는지 확인
// DB에 파일 메타데이터 저장
return ResponseEntity.ok().build();
}
private String generateUniqueFileName(String originalFileName) {
return System.currentTimeMillis() + "_" + originalFileName;
}
}
// DTO
record PresignedUrlRequest(
String fileName,
String contentType
) {}
record PresignedUrlResponse(
String presignedUrl,
String fileName,
long expiresAt
) {}
record UploadCompleteRequest(
String fileName
) {}
클라이언트 코드
JavaScript 기본 업로드
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
// 올바른 방식
async function uploadWithPresignedUrl(file) {
// 서버에서 Presigned URL 받기
const response = await fetch('/api/files/presigned-upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: file.type
})
});
const { presignedUrl, fileName, expiresAt } = await response.json();
// 유효성 확인
if (Date.now() > expiresAt) {
console.error('URL 이미 만료됨');
return;
}
// Presigned URL로 직접 S3 업로드
const s3Response = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type
},
body: file
});
if (!s3Response.ok) {
console.error('S3 업로드 실패:', s3Response.status);
return;
}
// 서버에 업로드 완료 알림
await fetch('/api/files/upload-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName })
});
console.log('업로드 완료');
}
React Hook
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
71
72
73
74
75
76
77
import { useState } from 'react';
function FileUpload() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const handleUpload = async (file) => {
setLoading(true);
try {
// Presigned URL 요청
const urlResponse = await fetch('/api/files/presigned-upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: file.type
})
});
const { presignedUrl, fileName } = await urlResponse.json();
// XMLHttpRequest로 진행률 추적
await uploadToS3WithProgress(presignedUrl, file);
// 완료 알림
await fetch('/api/files/upload-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName })
});
console.log('업로드 성공');
} catch (error) {
console.error('업로드 실패:', error);
} finally {
setLoading(false);
}
};
const uploadToS3WithProgress = (url, file) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
const percent = (event.loaded / event.total) * 100;
setProgress(percent);
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
};
return (
<div>
<input
type="file"
onChange={(e) => handleUpload(e.target.files[0])}
disabled={loading}
/>
{loading && <p>진행률: {progress.toFixed(0)}%</p>}
</div>
);
}
export default FileUpload;
주의 사항
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분으로 설정)
Path-style vs Virtual-hosted-style
- Path-style (LocalStack/호환 서비스)
1
https://localhost:4566/mybucket/myfile.jpg
- Virtual-hosted-style (AWS 기본)
1
https://mybucket.s3.amazonaws.com/myfile.jpg
- 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는 클라이언트에서 읽을 수 있는 응답 헤더를 지정함
성능 최적화
URL 재사용 vs 매번 생성
- 매번 생성하는 방식은 불필요한 오버헤드가 발생함
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
@Service public class S3CachedPresignedService { private final S3PresignedService presignedService; private final Map<String, CachedUrl> urlCache = new ConcurrentHashMap<>(); public String getOrGenerateUrl(String key, int expirationMinutes) { CachedUrl cached = urlCache.get(key); // 캐시된 URL이 아직 유효한가? if (cached != null && cached.expiresAt > System.currentTimeMillis()) { return cached.url; } // 새로 생성 S3PresignedService.PresignedUrlResult result = presignedService.generatePresignedUploadUrl( key, expirationMinutes, "image/jpeg" ); urlCache.put(key, new CachedUrl( result.url(), result.expiresAt() )); return result.url(); } record CachedUrl(String url, long expiresAt) {} }
배치 URL 생성
여러 파일에 대한 Presigned URL을 한 번에 생성할 때 병렬 처리 고려
1 2 3 4 5 6 7 8 9 10 11 12 13 14
@PostMapping("/batch-presigned-urls") public ResponseEntity<Map<String, String>> getBatchUrls( @RequestBody List<String> fileNames ) { Map<String, String> urls = fileNames.parallelStream() .collect(Collectors.toMap( fileName -> fileName, fileName -> presignedService.generatePresignedUploadUrl( fileName, 5, "image/jpeg" ).url() )); return ResponseEntity.ok(urls); }
LocalStack에서의 Presigned URL
- LocalStack도 동일한 방식으로 동작함
시간 기반 검증을 완전히 지원함
1 2 3 4 5 6 7
# docker-compose.yml services: localstack: environment: - SERVICES=s3 - AWS_ACCESS_KEY_ID=test - AWS_SECRET_ACCESS_KEY=test
1 2 3 4 5 6 7 8 9 10 11 12
@Bean public AmazonS3 amazonS3() { return AmazonS3ClientBuilder.standard() .withEndpointConfiguration(new EndpointConfiguration( "http://localhost:4566", "us-east-1" )) .withCredentials(new AWSStaticCredentialsProvider( new BasicAWSCredentials("test", "test") )) .withPathStyleAccessEnabled(true) .build(); }
- 생성된 URL
http://localhost:4566/mybucket/file.jpg?X-Amz-Signature=...
- 생성된 URL
결론
- Presigned URL은 서버 부하 없이 대용량 파일을 안전하게 처리할 수 있는 기능임
- AWS 서명 V4 알고리즘을 통해 보안을 보장하며 시간 기반 만료로 추가 보안을 제공함
- 서버와 클라이언트 간 Content-Type 일치가 중요하며 시간 동기화도 주의해야 함
- LocalStack에서도 동일하게 동작하므로 로컬 개발 환경에서도 테스트 가능함
- 올바르게 사용하면 성능을 크게 향상시킬 수 있음