9osari.log
← All posts

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

📚 파일 업로드 시리즈:

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

기존 방식의 문제점

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

이 글에서는 이러한 문제를 Presigned URL 방식으로 해결한 과정을 다룬다.

일반적인 파일 업로드 방식

@PostMapping("/product/new")
public String createProduct(MultipartFile image) {
    // 서버가 파일을 받아서 S3에 업로드
    String url = s3Service.upload(image);
    productService.save(url);
}

서버 메모리 부담

트랜잭션 문제

성능 문제

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


Presigned URL 이란?

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

Before vs After

기존 방식:

브라우저 → 서버(메모리 로드) → S3

         DB 저장

Presigned URL 방식:

브라우저 ────→ S3 (직접 업로드)

서버 (Key만 저장)

DB

전체 동작 흐름

[브라우저]              [Spring Boot]           [AWS S3]
    │                       │                      │
    │ ──① URL 요청 ───────→ │                      │
    │   (filename, type)     │                      │
    │                       │                      │
    │                       │  ② Presigned URL     │
    │                       │     생성              │
    │                       │                      │
    │ ←─③ URL 응답 ─────── │                      │
    │   (uploadUrl, key)     │                      │
    │                       │                      │
    │ ──────④ PUT ──────────────────────────────→ │
    │       (파일 직접 업로드)                      │
    │                       │                      │
    │ ←─────⑤ 200 OK ────────────────────────────│
    │                       │                      │
    │ ──⑥ Key 전송 ────────→│                      │
    │   (상품 등록 요청)      │                      │
    │                       │                      │
    │                       │  ⑦ DB 저장          │
    │                       │     (Key만)          │
    │                       │                      │
    │ ←─⑧ 성공 응답 ────── │                      │

핵심 차이점


구현

1단계: AWS 설정

application.yml

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 클래스

@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;
    }
}

2단계: S3Presigner 빈 등록

@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();
    }
}

3단계: Presigned URL 생성 API

@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

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. 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 저장

@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: 도메인 변경 시

-- S3 → CloudFront 전환 시
UPDATE product_images 
SET image_url = REPLACE(
    image_url, 
    'bucket.s3.region.amazonaws.com', 
    'dqtqfyr92dz08.cloudfront.net'
);

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

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

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

Key 저장 방식의 장점

DB에는 products/abc.jpg만 저장

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

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

장점 2: 환경별 분리

// 개발 환경
dev.cloudfront.net/products/abc.jpg

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

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

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

// 원본
https://cdn.example.com/products/abc.jpg

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

효과

트랜잭션 분리

보안

성능


배운점

추후 개선 방향


참고자료

···
← PREV [DevBid - AWS] S3 파일업로드 NEXT → [쿼리튜닝] 공통쿼리 개선