Spring S3 파일 업로드 심화

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 의존성 없이 검증할 수 있습니다. 이미지 리사이징, 비동기 후처리까지 결합하면 운영 레벨의 파일 관리 시스템을 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux