[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.