Post

[DevBid - AWS] S3 파일업로드

[DevBid - AWS] S3 파일업로드

📚 파일 업로드 시리즈:

  1. AWS S3 파일업로드 (현재 글)
  2. [DevBid - AWS] 파일업로드 Presigned URL 리펙토링

개요

개인 프로젝트 DevBid 경매 플랫폼에서 상품 이미지 업로드 기능을 구현했다. 판매자가 경매 등록 시 대표 이미지 1장과 추가 이미지 최대 4장까지 업로드할 수 있도록 했다.

초기에는 서버를 경유하는 방식으로 구현했고, 이후 Presigned URL 방식으로 개선했다. 이 글에서는 기본 구현 방식과 그 과정에서 발견한 문제점들을 다룬다.

왜 AWS S3인가?

파일 저장 방식을 선택할 때 몇 가지 옵션이 있었다:

1. 로컬 파일 시스템

1
2
장점: 추가 비용 없음, 구현 간단
단점: 서버 용량 제한, 백업 어려움, 확장 불가

2. Database BLOB

1
2
장점: 트랜잭션 보장, 별도 인프라 불필요
단점: DB 부하, 용량 한계, 성능 저하

3. AWS S3

1
2
3
4
5
6
7
8
9
장점: 
- 무제한 용량
- 99.999999999% 내구성
- CDN 연동 가능 (CloudFront)
- 객체 스토리지 최적화

단점:
- 추가 비용 발생
- AWS 종속성

DevBid는 이미지가 핵심 자산이므로 안정성과 확장성이 중요했다. 따라서 AWS S3를 선택했다.

파일 업로드의 흐름

전체 아키텍처

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[브라우저]                [Spring Boot]              [AWS S3]
    │                         │                         │
    │  1. 파일 선택             │                         │
    │  (multipart/form-data)  │                         │
    │                         │                         │
    │ ──2. HTTP POST ────────→│                         │
    │    (이미지 파일 전송)   │                          │
    │                         │                         │
    │                         │  3. MultipartFile 처리   │
    │                         │     (메모리 로드)        │
    │                         │                         │
    │                         │ ──4. S3 업로드 ────────→ │
    │                         │    (PutObjectRequest)   │
    │                         │                         │
    │                         │ ←─5. URL 반환 ────────── │
    │                         │                         │
    │                         │  6. DB 저장              │
    │                         │     (파일 URL)          │
    │                         │                         │
    │ ←─7. 응답 ────────────── │                         │
    │    (성공/실패)          │                          │

주요 처리 흐름

  1. 사용자가 파일 선택 및 업로드
  2. Spring이 MultipartFile로 파일 수신 (메모리 로드)
  3. S3에 파일 업로드
  4. S3로부터 파일 URL 획득
  5. DB에 파일 메타데이터 저장
  6. 사용자에게 결과 응답

구현

1단계 - AWS 설정

application.yml

1
2
3
4
5
6
7
8
9
10
11
cloud:
  aws:
    s3:
      bucket: ${AWS_S3_BUCKET:}
    credentials:
      access-key: ${AWS_ACCESS_KEY:}
      secret-key: ${AWS_SECRET_KEY:}
    region:
      static: ${AWS_REGION:ap-northeast-2}
    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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Getter
@ConfigurationProperties(prefix = "cloud.aws")
public class AwsProperties {
    private final S3 s3;
    private final Credentials credentials;
    private final Region region;

    public AwsProperties(S3 s3, Credentials credentials, Region region) {
        this.s3 = s3;
        this.credentials = credentials;
        this.region = region;
    }

    @Getter
    public static class S3 {
        private final String bucket;

        public S3(String bucket) {
            this.bucket = bucket;
        }
    }

    @Getter
    public static class Credentials {
        private final String accessKey;
        private final String secretKey;

        public Credentials(String accessKey, String secretKey) {
            this.accessKey = accessKey;
            this.secretKey = secretKey;
        }
    }

    @Getter
    public static class Region {
        private final String staticRegion;

        public Region(String staticRegion) {
            this.staticRegion = staticRegion;
        }
    }
}
  • @ConfigurationProperties를 사용해 타입 안전하게 설정값을 관리

S3Config 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableConfigurationProperties({AwsProperties.class})
public class S3Config {
    @Bean
    public S3Client s3Client(AwsProperties awsProperties) {
        // AWS SDK v2의 S3Client는 Builder 패턴을 사용
        return S3Client.builder()
                .region(Region.of(awsProperties.getRegion().getStaticRegion()))
                .credentialsProvider(StaticCredentialsProvider.create(
                        AwsBasicCredentials.create(
                                awsProperties.getCredentials().getAccessKey(),
                                awsProperties.getCredentials().getSecretKey()
                        )
                ))
                .build();
    }
}
  • AWS SDK v2의 S3Client를 Spring Bean으로 등록한다.
  • Region.of(): String을 Region 객체로 변환
  • StaticCredentialsProvider: 고정된 Access Key/Secret Key 제공
  • AwsBasicCredentials.create(): Access Key와 Secret Key를 담는 객체

2단계 - HTML Form 설정

일반 텍스트가 아닌 파일을 전송하려면 enctype="multipart/form-data" 속성이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
<form action="/product/new" method="post" enctype="multipart/form-data">
    <input type="file" id="mainImage" name="mainImage" accept="image/*">
    <div style="font-size:11px; color:#666;">
        * 선택사항. JPG, PNG, GIF (최대 10MB)
    </div>

    <input type="file" id="subImages" name="subImages" accept="image/*" multiple>
    <div style="font-size:11px; color:#666;">
        * 선택사항. 최대 4장 (sortOrder: 2,3,4,5)
    </div>
</form>
  • enctype="multipart/form-data": 파일 전송을 위한 필수 속성
  • accept="image/*": 이미지 파일만 선택 가능
  • multiple: 여러 파일 선택 가능

3단계 - S3 업로드 서비스

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
@Service
@RequiredArgsConstructor
public class S3Service {
    private final S3Client s3Client;
    private final AwsProperties awsProperties;

    public String upload(MultipartFile mainImage) {
        if (mainImage == null || mainImage.isEmpty()) {
            return null;
        }

        try {
            // 1. 파일명 생성 (UUID로 중복 방지)
            String fileName = UUID.randomUUID().toString() + extractExtension(mainImage);
            String key = "products/" + fileName;

            //2. S3에 업로드 요청 생성
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(awsProperties.getS3().getBucket())
                    .key(key)
                    .contentType(mainImage.getContentType())
                    .build();

            // 3. 서버가 파일을 S3에 업로드 (메모리 경유)
            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(
                    mainImage.getInputStream(),
                    mainImage.getSize()
            ));

            // 4. S3 URL 반환
            return buildURL(key);
        } catch (Exception e) {
            throw new RuntimeException("파일 업로드 실패 " + mainImage.getOriginalFilename(), e);
        }
    }

    private String buildURL(String key) {
        return String.format("https://%s.s3.%s.amazonaws.com/%s",
            awsProperties.getS3().getBucket(),
            awsProperties.getS3().getRegion(),
            key
        );
    }

    private String extractExtension(MultipartFile mainImage) {
        String fileName = mainImage.getOriginalFilename();
        return fileName.substring(fileName.lastIndexOf('.'));
    }

    public List<String> uploadMultiple(List<MultipartFile> subImages) {
        return subImages.stream()
                .map(this::upload)
                .collect(Collectors.toList());
    }
}

핵심 흐름:

  1. MultipartFile을 서버가 받음 (메모리 로드)
  2. UUID로 고유한 파일명 생성
  3. S3Client.putObject()로 S3에 업로드
  4. S3 URL 생성 및 반환

4단계 - DTO 설계

1
2
3
4
5
6
7
8
9
10
public record ProductRegistrationRequest(
        String productName,
        String description,
        BigDecimal price,
        Long categoryId,
        String condition,
        Long sellerId,
        String mainImageUrl,    // 파일이 아닌 URL
        List<String> subImageUrls // 파일이 아닌 URL
) {}

왜 MultipartFile을 DTO에 넣지 않았나?

  • DTO는 데이터 전송/저장을 위한 데이터 객체
  • MultipartFile은 임시 파일로, 레이어 간 전달 시 비효율적
  • 파일은 S3 URL로 변환된 후 DTO에 포함
  • Controller 에서 분리해서 처리 (관심사의 분리)

5단계: Controller 구현

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
@PostMapping("/product/new")
public String createProduct(
        ProductRegistrationRequest request,
        @RequestParam("mainImage") MultipartFile mainImage,
        @RequestParam("subImages") List<MultipartFile> subImages,
        @AuthenticationPrincipal AuthUser authUser,
        BindingResult result,
        RedirectAttributes ra
) {
    if(result.hasErrors()) {
        return "product/productRegister";
    }
    
    // 파일을 S3에 업로드
    String mainImageUrl = s3Service.upload(mainImage);
    List<String> subImageUrls = s3Service.uploadMultiple(subImages);

    ProductRegistrationRequest registrationRequest = new ProductRegistrationRequest(
            request.productName(),
            request.description(),
            request.price(),
            request.categoryId(),
            request.condition(),
            authUser.getId(),
            mainImageUrl,
            subImageUrls
    );

    productService.registerProduct(registrationRequest);

    ra.addFlashAttribute("productName", request.productName());
    return "redirect:/productRegisterSuccess";
}
  • HTML의 name="mainImage"와 매칭하기 위해 @RequestParam 사용
  • multiple 속성이 있으면 List<MultipartFile>로 받음
  • Controller에서 파일을 S3에 업로드한 후 URL을 DTO에 담아(관심사의 분리) Service로 전달

처리 순서:

  1. Controller에서 파일 업로드 처리
  2. S3 URL을 DTO에 담아 Service로 전달
  3. Service는 순수 비즈니스 로직만 처리

정리

ConfigurationProperties 패턴

1
2
@ConfigurationProperties(prefix = "cloud.aws")
// application.yml의 cloud.aws.* 값들을 타입 안전하게 매핑

S3 업로드 과정

1
MultipartFile → getInputStream() → PutObjectRequest → S3 업로드 → URL 생성

관심사의 분리

1
2
Controller: 파일 처리 (MultipartFile → S3 URL)
Service: 비즈니스 로직 (URL 기반 데이터 처리)

발견된 문제점

기본 구현은 완료했지만, 실제 운영 환경을 고려하면 몇 가지 문제가 있었다:

서버 메모리 부담

  • 모든 파일이 서버 메모리에 로드됨
  • 대용량 파일 업로드 시 Out of Memory 위험

트랜잭션 문제

  • S3 업로드 성공 → DB 저장 실패 시 쓰레기 파일 발생
  • 외부 시스템(S3) 호출이 트랜잭션 안에 포함됨

성능 문제

  • 브라우저 → 서버 → S3 (2번 전송)
  • 서버 대역폭 낭비

특히 경매 이미지처럼 대용량 파일이 빈번하게 업로드되는 서비스에서는 이러한 문제들이 치명적일 수 있다.

이를 해결하기 위해 Presigned URL 방식을 도입했고, 그 과정에서 마주한 문제와 해결 방법은 다음 글에서 다룬다.

📌 관련 글:
[DevBid - AWS] 파일업로드 Presigned URL 리펙토링

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