초기 코드는 “S3Client Bean만 만들어 두고, 업로드 로직을 각 도메인 서비스 메서드에 직접 작성하는 방식”이다.
개선된 코드는 “S3Client Bean은 그대로 두되, 업로드 책임을 S3Service로 분리하고 업로드 결과(key/url)를 반환하는 방식”이다.
초기 세팅의 역할은 단순하다. AWS 자격증명과 리전을 설정해 S3Client를 스프링 Bean으로 만들어 주입 가능하게 만드는 단계다.
즉, 이 설정만으로는 “업로드가 어디서 어떻게 이뤄지는지”에 대한 정책이 정해지지 않는다. 사용 위치(서비스/컨트롤러)에서 매번 putObject 로직을 작성하게 되는 구조다.
초기 설정 코드(그대로 두는 영역)다.
@Configuration
public class AwsS3Config {
@Value("${aws.credentials.access-key}")
private String accessKey;
@Value("${aws.credentials.secret-key}")
private String secretKey;
@Value("${aws.region}")
private String region;
@Bean
public S3Client client() {
AwsBasicCredentials basicCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(basicCredentials))
.build();
}
}
개선된 코드에서 추가된 것은 새로운 Config가 아니라, 업로드를 담당하는 S3ServiceImpl의 도입이다. 한마디로 “S3Client를 사용하는 위치를 서비스로 이동시킨 것”이다.
이 서비스는 PutObjectRequest와 RequestBody.fromInputStream(...)을 사용해 스트림 기반 업로드를 수행하고, key와 url을 묶은 결과를 반환한다.
추가된 세팅(서비스 계층 도입) 코드다.
@Service
public class S3ServiceImpl implements S3Service {
private final S3Client s3Client;
@Value("${aws.s3.bucket}")
private String bucket;
@Value("${aws.region}")
private String region;
public S3ServiceImpl(S3Client s3Client) {
this.s3Client = s3Client;
}
@Override
public S3UploadResult upload(MultipartFile file, String keyPrefix) {
String original = file.getOriginalFilename();
String ext = (original != null && original.contains(".")) ? original.substring(original.lastIndexOf('.')) : "";
String key = keyPrefix + "/" + UUID.randomUUID() + ext;
try {
PutObjectRequest req = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(file.getContentType())
.build();
s3Client.putObject(req, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
} catch (IOException e) {
throw new IllegalStateException("S3 업로드 실패", e);
}
String url = "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key;
return new S3UploadResult(key, url);
}
}
여기서 RequestBody.fromInputStream(inputStream, contentLength)처럼 스트림 업로드는 content length가 필요하며, 스트림 업로드는 별도 베스트 프랙티스로 다뤄지는 영역이다.
초기 스타일은 도메인 서비스(예: AuthorService, ProductService)에서 putObject, URL 생성, 예외 처리까지 전부 직접 들고 있는 구조다. 같은 업로드 로직이 여러 곳에 복제되기 쉬운 형태다.
개선 스타일은 도메인 서비스가 “업로드 요청”만 하고, 실제 업로드/키 생성/예외 정책은 S3Service가 담당한다. 결과로 받은 key/url만 도메인 엔티티에 반영하는 구조다.
아래는 “사용 위치”를 코드 블록으로 비교한 예시다.
public void saveAuthor(AuthorCreateDto dto, MultipartFile profileImg) {
Author author = dto.toEntity(...);
if (profileImg != null && !profileImg.isEmpty()) {
String fileName = "user-" + author.getId() + "-profile-" + profileImg.getOriginalFilename();
PutObjectRequest req = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(profileImg.getContentType())
.build();
s3Client.putObject(req, RequestBody.fromBytes(profileImg.getBytes()));
String url = s3Client.utilities()
.getUrl(b -> b.bucket(bucket).key(fileName))
.toExternalForm();
author.updateProfileImageUrl(url);
}
authorRepository.save(author);
}
이 방식은 동작 자체는 단순하지만, “키 생성 규칙, 예외 메시지, URL 정책”이 도메인 서비스마다 달라지기 쉬운 구조다.
@Transactional
public ProductDetailResDto create(ProductCreateReqDto dto, List<MultipartFile> productImages, String principal) {
Long memberId = Long.valueOf(principal);
Product product = Product.builder()
.member(em.getReference(Member.class, memberId))
.name(dto.getName())
.price(dto.getPrice())
.category(dto.getCategory())
.stockQuantity(dto.getStockQuantity())
.build();
Product saved = productRepository.save(product);
for (MultipartFile file : productImages) {
if (file == null || file.isEmpty()) continue;
S3UploadResult uploaded = s3Service.upload(file, "products/" + saved.getId());
saved.addImage(ProductImage.builder()
.s3Key(uploaded.getKey())
.url(uploaded.getUrl())
.build());
}
return ProductDetailResDto.fromEntity(saved);
}
초기 스타일에서 흔한 문제는 “엔티티 저장 전인데 ID를 키에 포함하려는 것”이다. 이 경우 ID가 아직 확정되지 않아 key에 null이 들어가거나 규칙이 깨질 수 있다.
개선 스타일은 먼저 Product를 저장해 saved.getId()를 확보한 뒤 "products/{id}/..."로 key prefix를 만들기 때문에 키 네이밍이 일관되고 추적이 쉬워지는 장점이 있다.
또 하나의 개선 포인트는 “운영에서 고정 키를 들고 가지 않는 설정”이다. AWS SDK for Java 2.x는 기본 자격증명 체인(Default credentials provider chain)을 제공하므로, 운영에서는 IAM Role을 붙이고 코드에서는 credentialsProvider를 생략하거나 DefaultCredentialsProvider를 쓰는 방식이 가능하다.
@Configuration
public class AwsS3Config {
@Value("${aws.region}")
private String region;
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
// 기본 자격증명 체인 사용(명시적으로 쓰려면 DefaultCredentialsProvider.create())
.build();
}
}
이 방식은 로컬에서는 AWS CLI profile/환경변수로, 운영에서는 IAM Role로 자격증명을 해결하는 방향이다.
프로필 분리의 목표는 간단하다. 공통 설정(리전, 버킷 키 이름)은 유지하고, 환경별로 버킷명 또는 자격증명 방식을 분리하는 구성이다.
spring:
profiles:
default: local
aws:
region: ap-northeast-2
s3:
bucket: your-bucket-name
로컬에서는 기본 자격증명 체인으로도 충분한 경우가 많아, 보통 버킷만 분리하는 형태가 된다.
aws:
s3:
bucket: your-bucket-name-local
로컬 실행 시 강제하고 싶다면 -Dspring.profiles.active=local 형태로 실행 옵션을 주면 된다.
운영에서는 버킷을 운영 버킷으로 바꾸고, 자격증명은 IAM Role로 해결하는 구조가 된다.
aws:
region: ap-northeast-2
s3:
bucket: your-bucket-name-prod
운영 실행 시 SPRING_PROFILES_ACTIVE=prod 또는 -Dspring.profiles.active=prod로 활성화하면 된다.
원하는 방향이 “URL 생성도 SDK 유틸로 통일”하는 것이라면, S3ServiceImpl의 URL 생성은 문자열 조합 대신 s3Client.utilities().getUrl(...)로 바꾸는 편이 안정적인 선택이다.