[DevBid - AWS] S3 파일업로드
[DevBid - AWS] S3 파일업로드
📚 파일 업로드 시리즈:
- AWS S3 파일업로드 (현재 글)
- [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. 응답 ────────────── │ │
│ (성공/실패) │ │
주요 처리 흐름
- 사용자가 파일 선택 및 업로드
- Spring이
MultipartFile로 파일 수신 (메모리 로드) - S3에 파일 업로드
- S3로부터 파일 URL 획득
- DB에 파일 메타데이터 저장
- 사용자에게 결과 응답
구현
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());
}
}
핵심 흐름:
MultipartFile을 서버가 받음 (메모리 로드)- UUID로 고유한 파일명 생성
S3Client.putObject()로 S3에 업로드- 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로 전달
처리 순서:
- Controller에서 파일 업로드 처리
- S3 URL을 DTO에 담아 Service로 전달
- 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 방식을 도입했고, 그 과정에서 마주한 문제와 해결 방법은 다음 글에서 다룬다.
This post is licensed under
CC BY 4.0
by the author.