개요
중고거래 플랫폼 프로젝트에서 상품 이미지를 업로드하는 기능을 구현했다. 사용자가 상품 등록 시 메인 이미지 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번 전송)
- 서버 대역폭 낭비