Post

[JPA] AWS S3 파일업로드

[JPA] AWS S3 파일업로드

개요

중고거래 플랫폼 프로젝트에서 상품 이미지를 업로드하는 기능을 구현했다. 사용자가 상품 등록 시 메인 이미지 1장과 서브 이미지 최대 4장을 업로드할 수 있도록 했다.

파일 업로드의 흐름

1
2
3
[사용자]  [HTML Form]  [Controller]  [Service]  [AWS S3]
   |          |             |            |           |
 파일선택     파일전송        파일받기        처리로직      실제저장

구현

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>

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번 전송)
  • 서버 대역폭 낭비
This post is licensed under CC BY 4.0 by the author.