Post

[DevBid - AWS] 파일업로드 Presigned URL 리펙토링

[DevBid - AWS] 파일업로드 Presigned URL 리펙토링

📚 파일 업로드 시리즈:

  1. [DevBid - AWS] S3 파일업로드 - 기본 구현과 발견된 문제점
  2. 파일업로드 Presigned URL 리팩토링 (현재 글)

기존 방식의 문제점

이전글 S3 파일업로드에서 서버를 경유하는 방식으로 S3 파일 업로드를 구현했다. 하지만 실제 운영을 생각해보면 몇 가지 문제가 있었다.

  • 서버 메모리 부담
  • 트랜잭션 문제
  • 성능 저하

이 글에서는 이러한 문제를 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번 전송)
  • 서버 대역폭 낭비

파일 업로드 아키텍처 개선 방법을 탐색하다가 Presigned URL 방식을 확인했다.


Presigned URL 이란?

임시로 S3 접근 권한을 부여하는 URL

  • 만료 시간 설정 가능 (예: 5분, 1시간)
  • 서버를 거치지 않고 클라이언트가 S3에 직접 업로드
  • AWS SDK가 서명된 URL을 생성

Before vs After

기존 방식:

1
2
3
브라우저 → 서버(메모리 로드) → S3
          ↓
         DB 저장

Presigned URL 방식:

1
2
3
4
5
브라우저 ────→ S3 (직접 업로드)
  ↓
서버 (Key만 저장)
  ↓
DB

전체 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[브라우저]              [Spring Boot]           [AWS S3]
    │                       │                      │
    │ ──① URL 요청 ───────→ │                      │
    │   (filename, type)     │                      │
    │                       │                      │
    │                       │  ② Presigned URL     │
    │                       │     생성              │
    │                       │                      │
    │ ←─③ URL 응답 ─────── │                      │
    │   (uploadUrl, key)     │                      │
    │                       │                      │
    │ ──────④ PUT ──────────────────────────────→ │
    │       (파일 직접 업로드)                      │
    │                       │                      │
    │ ←─────⑤ 200 OK ────────────────────────────│
    │                       │                      │
    │ ──⑥ Key 전송 ────────→│                      │
    │   (상품 등록 요청)      │                      │
    │                       │                      │
    │                       │  ⑦ DB 저장          │
    │                       │     (Key만)          │
    │                       │                      │
    │ ←─⑧ 성공 응답 ────── │                      │

핵심 차이점

  • 기존: 파일 데이터가 서버 메모리를 거쳐감
  • 개선: 파일은 S3로 직접, 서버는 Key만 처리

구현

1단계: AWS 설정

application.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;
    }
}
  • CloudFront 도메인 설정이 추가되었다.

2단계: S3Presigner 빈 등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class S3Config {
    
    @Bean
    public S3Presigner s3Presigner(AwsProperties awsProperties) {
        return S3Presigner.builder()
                .region(Region.of(awsProperties.getRegion().getStatic()))
                .credentialsProvider(StaticCredentialsProvider.create(
                        AwsBasicCredentials.create(
                                awsProperties.getCredentials().getAccessKey(),
                                awsProperties.getCredentials().getSecretKey()
                        )
                ))
                .build();
    }
}
  • 기존 S3Client 외에 S3Presigner가 추가로 필요하다.

3단계: 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. 파일명과 ContentType 검증
  2. UUID로 고유한 Key 생성
  3. Presigned URL 생성 (5분 유효)
  4. URL과 Key 반환

4단계: 프론트엔드 구현

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('업로드 완료!');
});

5단계: 백엔드에서 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저장 방식의 문제점

예시: https://bucket.s3.region.amazonaws.com/products/abc.jpg

문제 1: 도메인 변경 시

1
2
3
4
5
6
7
-- S3 → CloudFront 전환 시
UPDATE product_images 
SET image_url = REPLACE(
    image_url, 
    'bucket.s3.region.amazonaws.com', 
    'dqtqfyr92dz08.cloudfront.net'
);

수천, 수만 개의 레코드를 업데이트해야 함.

문제 2: 리사이징 URL 생성 불가

1
2
원본: https://cdn.example.com/products/abc.jpg
썸네일: ??? (새로 만들 방법 없음)

Key 저장 방식의 장점

DB에는 products/abc.jpg만 저장

장점 1: 도메인 변경이 자유로움

1
2
3
4
// 코드에서만 변경
public String getImageUrl(String key) {
    return "https://" + cloudfrontDomain + "/" + key;
}

장점 2: 환경별 분리

1
2
3
4
5
6
7
// 개발 환경
dev.cloudfront.net/products/abc.jpg

// 운영 환경
prod.cloudfront.net/products/abc.jpg

// Key는 동일: products/abc.jpg

장점 3: 리사이징 URL 동적 생성

1
2
3
4
5
// 원본
https://cdn.example.com/products/abc.jpg

// 썸네일 (Lambda@Edge 활용)
https://cdn.example.com/products/abc.jpg?w=300&h=200

효과

트랜잭션 분리

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