개요
중고거래 플랫폼 프로젝트에서 상품 이미지를 업로드하는 기능을 구현했다. 사용자가 상품 등록 시 메인 이미지 1장과 서브 이미지 최대 4장을 업로드할 수 있도록 했다.
파일 업로드의 흐름
| 1
2
3
 | [사용자] → [HTML Form] → [Controller] → [Service] → [AWS S3]
   |          |             |            |           |
 파일선택     파일전송        파일받기        처리로직      실제저장
 | 
구현
일반 텍스트가 아닌 파일을 전송하려면 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>
 | 
Controller에서 파일 받기
Spring은 MultipartFile 타입으로 파일을 쉽게 처리할 수 있다.
| 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로 전달
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,
        List<String> subImageUrls
) {}
 | 
왜 MultipartFile을 DTO에 넣지 않았나?
  - DTO는 데이터 전송/저장을 위한 순수 데이터 객체
- MultipartFile은 임시 파일로, 레이어 간 전달 시 비효율적
- 파일은 S3 URL로 변환된 후 DTO에 포함
- Controller 에서 분리해서 처리
관심사의 분리: 파일 처리 로직과 비즈니스 로직을 분리하고, DTO는 순수 데이터만 담는다.
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;
        }
    }
}
 | 
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();
    }
}
 | 
  - Region.of(): String을 Region 객체로 변환
- StaticCredentialsProvider: 고정된 Access Key/Secret Key 제공
- AwsBasicCredentials.create(): Access Key와 Secret Key를 담는 객체
정리
ConfigurationProperties 패턴
| 1
2
3
4
 | @ConfigurationProperties(prefix = "cloud.aws")
public class AwsProperties {
    private final S3 s3;  // cloud.aws.s3.//
}
 | 
S3 업로드
| 1
2
3
4
 | S3Client.putObject(
    PutObjectRequest, // 어디에, 무슨 이름으로
    RequestBody       // 실제 파일 데이터
)
 | 
MultipartFile → S3 → URL
| 1
 | MultipartFile → getInputStream() → S3 업로드 → URL 생성
 | 
발견된 문제점
기본 구현은 완료했지만, 실제 운영 환경을 고려하면 몇 가지 문제가 있었다:
서버 메모리 부담
  - 모든 파일이 서버 메모리에 로드됨
- 대용량 파일 업로드 시 Out of Memory 위험
트랜잭션 문제
  - S3 업로드 성공 → DB 저장 실패 시 쓰레기 파일 발생
- 외부 시스템(S3) 호출이 트랜잭션 안에 포함됨
성능 문제
  - 브라우저 → 서버 → S3 (2번 전송)
- 서버 대역폭 낭비