Post

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

참고자료

AWS S3 Presigned URL 문서 AWS CloudFront 시작하기

This post is licensed under CC BY 4.0 by the author.