Spring에서 S3 파일 업로드란?
파일 업로드는 대부분의 웹 애플리케이션에서 필수 기능입니다. AWS S3(또는 MinIO 같은 S3 호환 스토리지)와 Spring Boot를 결합하면, Multipart 업로드부터 Presigned URL, 대용량 멀티파트 분할 업로드까지 다양한 시나리오를 처리할 수 있습니다. 이 글에서는 Spring Boot 3.x + AWS SDK v2 기반으로 파일 업로드·다운로드·삭제의 운영 레벨 구현을 심화 분석합니다.
프로젝트 설정
// build.gradle.kts (Spring Boot 3.x + AWS SDK v2)
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("software.amazon.awssdk:s3:2.25.0")
implementation("software.amazon.awssdk:s3-transfer-manager:2.25.0")
implementation("software.amazon.awssdk:apache-client:2.25.0")
}
S3 클라이언트 설정
@Configuration
public class S3Config {
@Value("${cloud.aws.s3.endpoint:}")
private String endpoint;
@Value("${cloud.aws.s3.region}")
private String region;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Bean
public S3Client s3Client() {
var builder = S3Client.builder()
.region(Region.of(region))
.httpClientBuilder(ApacheHttpClient.builder()
.maxConnections(50)
.connectionTimeout(Duration.ofSeconds(5))
.socketTimeout(Duration.ofSeconds(30)));
// MinIO 등 S3 호환 스토리지 지원
if (!endpoint.isBlank()) {
builder.endpointOverride(URI.create(endpoint))
.forcePathStyle(true);
}
return builder.build();
}
@Bean
public S3Presigner s3Presigner() {
var builder = S3Presigner.builder()
.region(Region.of(region));
if (!endpoint.isBlank()) {
builder.endpointOverride(URI.create(endpoint));
}
return builder.build();
}
@Bean
public S3TransferManager transferManager(S3Client s3Client) {
return S3TransferManager.builder()
.s3Client(s3Client)
.build();
}
}
application.yml
cloud:
aws:
s3:
region: ap-northeast-2
bucket: my-app-uploads
endpoint: "" # MinIO: http://minio:9000
spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
file-size-threshold: 2MB # 이 크기 이상은 디스크 임시 저장
app:
upload:
allowed-types: image/jpeg,image/png,image/webp,application/pdf
max-file-size: 52428800 # 50MB
presigned-url-expiry: 3600 # 1시간
파일 업로드 서비스
핵심 업로드 로직을 서비스로 캡슐화합니다. 파일 검증, 키 생성, 메타데이터 관리를 포함합니다.
@Service
@RequiredArgsConstructor
public class FileUploadService {
private final S3Client s3Client;
private final S3Presigner presigner;
private final FileMetadataRepository metadataRepo;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Value("${app.upload.allowed-types}")
private List<String> allowedTypes;
@Value("${app.upload.max-file-size}")
private long maxFileSize;
// ─── 직접 업로드 (서버 경유) ───
public FileMetadata upload(MultipartFile file, String directory,
Long uploaderId) {
validate(file);
String key = generateKey(directory, file.getOriginalFilename());
String contentType = file.getContentType();
try {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.contentLength(file.getSize())
.metadata(Map.of(
"uploader-id", uploaderId.toString(),
"original-name", file.getOriginalFilename()))
.build();
s3Client.putObject(request,
RequestBody.fromInputStream(
file.getInputStream(), file.getSize()));
// 메타데이터 DB 저장
FileMetadata metadata = FileMetadata.builder()
.s3Key(key)
.originalName(file.getOriginalFilename())
.contentType(contentType)
.size(file.getSize())
.uploaderId(uploaderId)
.uploadedAt(Instant.now())
.build();
return metadataRepo.save(metadata);
} catch (S3Exception e) {
throw new FileUploadException(
"S3 업로드 실패: " + e.getMessage(), e);
} catch (IOException e) {
throw new FileUploadException(
"파일 읽기 실패: " + e.getMessage(), e);
}
}
// ─── 파일 검증 ───
private void validate(MultipartFile file) {
if (file.isEmpty()) {
throw new InvalidFileException("빈 파일");
}
if (file.getSize() > maxFileSize) {
throw new InvalidFileException(
"파일 크기 초과: " + file.getSize());
}
if (!allowedTypes.contains(file.getContentType())) {
throw new InvalidFileException(
"허용되지 않는 타입: " + file.getContentType());
}
// 확장자 위변조 검사
String ext = getExtension(file.getOriginalFilename());
if (!matchesMimeType(ext, file.getContentType())) {
throw new InvalidFileException("확장자-MIME 불일치");
}
}
// ─── 고유 키 생성 ───
private String generateKey(String directory, String originalName) {
String ext = getExtension(originalName);
String date = LocalDate.now().format(
DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String uuid = UUID.randomUUID().toString().substring(0, 8);
return String.format("%s/%s/%s.%s",
directory, date, uuid, ext);
}
}
Presigned URL: 클라이언트 직접 업로드
대용량 파일은 서버를 경유하지 않고 클라이언트가 S3에 직접 업로드합니다. 서버는 Presigned URL만 발급합니다.
// Presigned URL 업로드
public PresignedUploadResponse generatePresignedUpload(
String directory, String filename, String contentType,
long fileSize, Long uploaderId) {
String key = generateKey(directory, filename);
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofHours(1))
.putObjectRequest(PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.contentLength(fileSize)
.metadata(Map.of(
"uploader-id", uploaderId.toString()))
.build())
.build();
PresignedPutObjectRequest presigned =
presigner.presignPutObject(presignRequest);
// 메타데이터 사전 등록 (상태: PENDING)
FileMetadata metadata = FileMetadata.builder()
.s3Key(key)
.originalName(filename)
.contentType(contentType)
.size(fileSize)
.uploaderId(uploaderId)
.status(UploadStatus.PENDING)
.build();
metadataRepo.save(metadata);
return PresignedUploadResponse.builder()
.uploadUrl(presigned.url().toString())
.key(key)
.expiresAt(presigned.expiration())
.headers(presigned.signedHeaders())
.build();
}
// Presigned URL 다운로드
public String generatePresignedDownload(String key) {
GetObjectPresignRequest request = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofHours(1))
.getObjectRequest(GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build())
.build();
return presigner.presignGetObject(request).url().toString();
}
멀티파트 분할 업로드
100MB 이상의 대용량 파일은 분할 업로드로 처리하며, S3TransferManager가 자동 관리합니다.
@Service
@RequiredArgsConstructor
public class LargeFileUploadService {
private final S3TransferManager transferManager;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
// TransferManager 활용 대용량 업로드
public CompletableFuture<String> uploadLargeFile(
Path filePath, String key) {
UploadFileRequest request = UploadFileRequest.builder()
.putObjectRequest(PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.build())
.source(filePath)
.build();
FileUpload upload = transferManager.uploadFile(request);
return upload.completionFuture()
.thenApply(response -> key);
}
// 수동 멀티파트 업로드 (진행률 추적)
public String multipartUpload(InputStream input, String key,
String contentType, long totalSize,
Consumer<Integer> progressCallback)
throws IOException {
// 1. 멀티파트 업로드 시작
CreateMultipartUploadResponse createResponse =
s3Client.createMultipartUpload(
CreateMultipartUploadRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.build());
String uploadId = createResponse.uploadId();
List<CompletedPart> parts = new ArrayList<>();
int partNumber = 1;
byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB 파트
long uploaded = 0;
try {
int bytesRead;
while ((bytesRead = input.read(buffer)) > 0) {
// 2. 각 파트 업로드
UploadPartResponse partResponse = s3Client.uploadPart(
UploadPartRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.partNumber(partNumber)
.contentLength((long) bytesRead)
.build(),
RequestBody.fromBytes(
Arrays.copyOf(buffer, bytesRead)));
parts.add(CompletedPart.builder()
.partNumber(partNumber)
.eTag(partResponse.eTag())
.build());
uploaded += bytesRead;
int percent = (int) (uploaded * 100 / totalSize);
progressCallback.accept(percent);
partNumber++;
}
// 3. 완료
s3Client.completeMultipartUpload(
CompleteMultipartUploadRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.multipartUpload(CompletedMultipartUpload.builder()
.parts(parts).build())
.build());
return key;
} catch (Exception e) {
// 실패 시 정리
s3Client.abortMultipartUpload(
AbortMultipartUploadRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.build());
throw new FileUploadException("멀티파트 업로드 실패", e);
}
}
}
Controller: REST API
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileUploadService uploadService;
// 직접 업로드
@PostMapping("/upload")
public ResponseEntity<FileMetadata> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "general") String directory,
@AuthenticationPrincipal UserDetails user) {
FileMetadata metadata = uploadService.upload(
file, directory, getUserId(user));
return ResponseEntity.status(HttpStatus.CREATED)
.body(metadata);
}
// 복수 파일 업로드
@PostMapping("/upload/batch")
public ResponseEntity<List<FileMetadata>> uploadBatch(
@RequestParam("files") List<MultipartFile> files,
@RequestParam(defaultValue = "general") String directory,
@AuthenticationPrincipal UserDetails user) {
List<FileMetadata> results = files.stream()
.map(f -> uploadService.upload(f, directory, getUserId(user)))
.toList();
return ResponseEntity.status(HttpStatus.CREATED)
.body(results);
}
// Presigned URL 발급
@PostMapping("/presigned-upload")
public ResponseEntity<PresignedUploadResponse> getPresignedUrl(
@Valid @RequestBody PresignedUploadRequest req,
@AuthenticationPrincipal UserDetails user) {
PresignedUploadResponse response =
uploadService.generatePresignedUpload(
req.getDirectory(), req.getFilename(),
req.getContentType(), req.getFileSize(),
getUserId(user));
return ResponseEntity.ok(response);
}
// 다운로드 URL 발급
@GetMapping("/{id}/download-url")
public ResponseEntity<Map<String, String>> getDownloadUrl(
@PathVariable Long id) {
FileMetadata metadata = uploadService.findById(id);
String url = uploadService
.generatePresignedDownload(metadata.getS3Key());
return ResponseEntity.ok(Map.of("url", url));
}
// 파일 삭제
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id,
@AuthenticationPrincipal UserDetails user) {
uploadService.delete(id, getUserId(user));
return ResponseEntity.noContent().build();
}
}
이미지 리사이징 연동
업로드 후 비동기로 썸네일을 생성합니다. 이벤트 기반 처리와 결합하면 확장성이 높아집니다.
@Async
@EventListener
public void onFileUploaded(FileUploadedEvent event) {
if (!event.getContentType().startsWith("image/")) return;
byte[] original = s3Client.getObjectAsBytes(
GetObjectRequest.builder()
.bucket(bucket)
.key(event.getS3Key())
.build())
.asByteArray();
// 썸네일 생성 (200x200)
byte[] thumbnail = imageResizer.resize(original, 200, 200);
String thumbKey = event.getS3Key()
.replace("uploads/", "thumbnails/");
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(thumbKey)
.contentType("image/webp")
.build(),
RequestBody.fromBytes(thumbnail));
metadataRepo.updateThumbnailKey(event.getId(), thumbKey);
}
테스트: LocalStack S3
Docker 기반 LocalStack으로 S3를 로컬 테스트합니다.
@SpringBootTest
@Testcontainers
class FileUploadServiceTest {
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(LocalStackContainer.Service.S3);
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("cloud.aws.s3.endpoint",
() -> localstack.getEndpointOverride(S3).toString());
registry.add("cloud.aws.s3.region", () -> "us-east-1");
registry.add("cloud.aws.s3.bucket", () -> "test-bucket");
}
@BeforeAll
static void createBucket() {
S3Client client = S3Client.builder()
.endpointOverride(localstack.getEndpointOverride(S3))
.region(Region.US_EAST_1)
.build();
client.createBucket(b -> b.bucket("test-bucket"));
}
@Test
void upload_validFile_success() {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg",
"fake-image-data".getBytes());
FileMetadata result = uploadService.upload(file, "test", 1L);
assertThat(result.getS3Key()).startsWith("test/");
assertThat(result.getContentType()).isEqualTo("image/jpeg");
}
@Test
void upload_invalidType_throwsException() {
MockMultipartFile file = new MockMultipartFile(
"file", "hack.exe", "application/x-msdownload",
"malicious".getBytes());
assertThatThrownBy(() -> uploadService.upload(file, "test", 1L))
.isInstanceOf(InvalidFileException.class)
.hasMessageContaining("허용되지 않는 타입");
}
}
운영 체크리스트
| 항목 | 확인 사항 |
|---|---|
| 파일 검증 | MIME 타입 + 확장자 위변조 검사 |
| 키 생성 | UUID 기반 고유 키 (덮어쓰기 방지) |
| Presigned URL | 대용량은 클라이언트 직접 업로드 |
| 멀티파트 정리 | 실패한 업로드 abort 처리 |
| S3 Lifecycle | 오래된 파일 자동 삭제/아카이빙 |
| CORS 설정 | Presigned URL 사용 시 S3 CORS 필수 |
마치며
Spring + S3 파일 업로드는 직접 업로드, Presigned URL, 멀티파트 분할의 세 가지 패턴을 상황에 맞게 선택하는 것이 핵심입니다. 파일 검증으로 보안을 확보하고, UUID 키로 충돌을 방지하며, LocalStack 테스트로 S3 의존성 없이 검증할 수 있습니다. 이미지 리사이징, 비동기 후처리까지 결합하면 운영 레벨의 파일 관리 시스템을 구축할 수 있습니다.