[JPA] 파일업로드 Presigned URL 리펙토링
[JPA] 파일업로드 Presigned URL 리펙토링
기존 방식의 문제점
일반적인 파일 업로드 방식
1
2
3
4
5
6
@PostMapping("/product/new")
public String createProduct(MultipartFile image) {
// 서버가 파일을 받아서 S3에 업로드
String url = s3Service.upload(image);
productService.save(url);
}
서버 메모리 부담
- 파일이 서버 메모리에 로드됨
- 대용량 파일 시 Out of Memory 위험
트랜잭션 문제
- S3 업로드 성공 → DB 저장 실패 시 쓰레기 파일 발생
- 외부 시스템 호출이 트랜잭션 안에 포함됨
성능 문제
- 브라우저 → 서버 → S3 (2번 전송)
- 서버 대역폭 낭비
기존 방식 흐름도
1
2
3
[사용자] → [HTML Form] → [Controller] → [Service] → [AWS S3]
| | | | |
파일선택 파일전송 파일받기 처리로직 실제저장
구글링을 하다 Presigned URL 방식을 알게되었다.
Presigned URL 방식이란?
- 임시로 S3 접근 권한을 부여하는 URL
- 만료 시간 설정 가능 (예: 5분, 1시간)
- 서버를 거치지 않고 클라이언트가 S3에 직접 업로드
동작흐름
1
2
3
4
5
1. Client → Server: "이미지 올릴 URL 요청"
2. Server → Client: Presigned URL 생성 후 전달
3. Client → S3: URL로 직접 업로드
4. Client → Server: 업로드 완료, CloudFront URL 전달
5. Server: DB에 URL만 저장
구현
yml 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
cloud:
aws:
s3:
bucket: aws-s3-bucket-devbid
region:
static: eu-north-1
cloudfront:
domain: dqtqfyr92dz08.cloudfront.net
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
stack:
auto: false
AwsProperties 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@ConfigurationProperties(prefix = "cloud.aws")
@Getter
@Setter
public class AwsProperties {
private S3 s3;
private CloudFront cloudfront;
private Region region;
private Credentials credentials;
private Stack stack;
@Getter
@Setter
public static class S3 {
private String bucket;
}
@Getter
@Setter
public static class CloudFront {
private String domain;
}
}
Presigned URL 생성 API
1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/product/presigned-url")
@ResponseBody
public Map<String, String> getPresignedUrl(
@RequestParam("filename") String filename,
@RequestParam("contentType") String contentType
) {
//pre-signed URL 생성
PresignedUrlData data = s3Service.generatePresignedUrl(filename, contentType);
return Map.of(
"uploadUrl", data.uploadUrl(),
"key", data.key()
);
}
S3Service
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
public PresignedUrlData generatePresignedUrl(String fileName, String contentType) {
nameAndTypeValidation(fileName, contentType);
validateImageContentType(contentType);
String key = makeKey(fileName);
//PutObjectRequest 생성
PutObjectRequest putObjectRequest = getPutObjectRequest(contentType, key);
//PutObjectPresignRequest 생성
PutObjectPresignRequest presignRequest = getPutObjectPresignRequest(putObjectRequest);
//pre-signed URL 생성
PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject(presignRequest);
String uploadUrl = presignedPutObjectRequest.url().toString();
//URL과 key 반환
return new PresignedUrlData(uploadUrl, key);
}
private static void nameAndTypeValidation(String fileName, String contentType) {
if(fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("fileName is null or empty");
}
if(contentType == null || contentType.isEmpty()) {
throw new IllegalArgumentException("contentType is null or empty");
}
}
private static void validateImageContentType(String contentType) {
if (!contentType.equals("image/jpeg") &&
!contentType.equals("image/png") &&
!contentType.equals("image/gif") &&
!contentType.equals("image/webp")) {
throw new IllegalArgumentException(
"지원하지 않는 이미지 형식입니다. (지원: JPEG, PNG, GIF, WebP)"
);
}
}
private static String makeKey(String fileName) {
//key 생성
String extension = fileName.substring(fileName.lastIndexOf('.'));
return "products/" + UUID.randomUUID().toString() + extension;
}
private PutObjectRequest getPutObjectRequest(String contentType, String key) {
return PutObjectRequest.builder()
.bucket(awsProperties.getS3().getBucket())
.key(key)
.contentType(contentType)
.build();
}
private PutObjectPresignRequest getPutObjectPresignRequest(PutObjectRequest putObjectRequest) {
return PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(5))
.putObjectRequest(putObjectRequest).build();
}
public String buildPublicUrl(String key) {
return String.format("https://%s/%s",
awsProperties.getCloudfront().getDomain(),
key
);
}
프론트엔드
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
// 1. Presigned URL 요청
async function getPresignedUrl(filename, contentType) {
const formData = new FormData();
formData.append('filename', filename);
formData.append('contentType', contentType);
const response = await fetch('/product/presigned-url', {
method: 'POST',
body: formData
});
return await response.json();
}
// 2. S3에 직접 업로드
async function uploadToS3(uploadUrl, file, contentType) {
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': contentType }
});
}
// 3. 이미지 선택 시 처리
document.getElementById('mainImage').addEventListener('change', async (e) => {
const file = e.target.files[0];
// Presigned URL 받기
const data = await getPresignedUrl(file.name, file.type);
// S3에 업로드
await uploadToS3(data.uploadUrl, file, file.type);
// CloudFront URL을 hidden input에 저장
document.querySelector('input[name="mainImageKey"]').value = data.key;
alert('업로드 완료!');
});
백엔드에서 Key 저장
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
@Override
public void registerProduct(ProductRegistrationRequest request) {
Category category = getCategoryWithId(request);
User seller = getSellerWithId(request);
Product product = ProductFactory.createFromPrimitives(
request.productName(),
request.description(),
request.price(),
category,
request.condition(),
seller
);
productRepository.save(product);
mainImageValidationAndSave(request, product);
subImagesValidationAndSave(request, product);
}
private void mainImageValidationAndSave(ProductRegistrationRequest request, Product product) {
if(request.mainImageKey() != null && !request.mainImageKey().isEmpty()) {
saveProductImage(product, request.mainImageKey(), 1);
}
}
private void subImagesValidationAndSave(ProductRegistrationRequest request, Product product) {
if(request.subImageKeys() != null && !request.subImageKeys().isEmpty()) {
int sortOrd = 2;
for (String key : request.subImageKeys()) {
saveProductImage(product, key, sortOrd++);
}
}
}
private void saveProductImage(Product product, String imageKey, int sortOrder) {
ProductImage productImage = ProductImage.create(product, imageKey, sortOrder);
productImageRepository.save(productImage);
}
왜 URL이 아닌 Key를 저장하는지
URL저장 방식의 문제점
- 도메인 변경 시 DB의 모든 레코드 수정필요.
- URL 형식 변경 시 마이그레이션 필요
Key 저장 방식의 장점
- 유연성 : 도메인만 변경하면 끝
- 환경 독립성 : 같은 Key로 개발/운영 구분 가능
- 확장성 : 추후 리사이징 URL도 같은 Key로 생성가능!
효과
트랜잭션 분리
- S3 업로드: 프론트엔드에서 직접
- DB 저장: 백엔드 @Transctional (트랜잭션 안)
- 외부 시스템과 DB 작업을 분리
보안
- S3 버킷은 private 유지
- CloudFront만 접근 가능
- Presigned URL은 5분만 유효
성능
- 서버 메모리 사용 없음
- 대용량 파일도 문제 없음
- CDN으로 빠른 이미지 제공
배운점
- 외부 시스템과 트랜잭션 분리의 중요성
- Presigned URL의 활용도
- CloudFront CDN의 필요성
추후 개선 방향
- 업로드 진행률 표시
- 실패한 업로드 파일 정리 (S3 Lifecycle)
참고자료
This post is licensed under
CC BY 4.0
by the author.