DevOps/AWS

[AWS] Spring Boot 와 멀티파트 업로드를 사용해 S3에 대용량 파일 업로드하기

킵고잉 개발자 2022. 6. 7. 22:44

이전 글에서 SpringBoot에서 S3로 파일(이미지, 동영상) 업로드하는 기능을 개발했습니다.

 

[AWS] Springboot에 AWS S3 연동 (이미지, 동영상 업로드)

안녕하세요? 이번 시간엔 SpringBoot & AWS S3 연동하기를 진행해보려고 합니다. 따라 하시기만 해도 로컬에서 이미지, 동영상 파일 업로드가 가능하고, EC2에 배포한 환경에서도 파일 업로드가 가능

develop-writing.tistory.com

하지만 위 방식은 크게 두 가지 문제가 있습니다.

1. 대용량 파일을 업로드할 때 시간이 많이 걸립니다. 

필자가 EC2 t2.small에서 S3로 업로드 테스트할 시 1.3GB 동영상은 무려 90초가 걸렸습니다.

동일 환경에서 멀티파트 업로드 방식을 테스트 시 해당 시간을 50초로 단축되었습니다.(약 45%) 

2. 최대 5GB 크기를 가진 파일만 업로드 가능합니다.

Amazon S3에 단일 작업으로 올릴 수 있는 객체 크기는 최대 5GB입니다.

따라서 5GB보다 큰 오브젝트는 멀티파트 업로드를 반드시 이용해서 업로드해야 하며, 최대 5TB 크기 오브젝트를 업로드할 수 있습니다. 

 

목차

1. 멀티파트 업로드란?

2. 대용량 파일 업로드시 멀티파트 업로드를 사용해야 하는 이유

3. 멀티파트 업로드 성능 테스트

    3-1. 테스트 결과

    3-2. 테스트 환경

4. 멀티파트 업로드 프로세스

5. 멀티파트 업로드 개발하기

    5-1. gradle 설정

    5-2. S3Config.java

    5-3. S3MultipartService.java

    5-4. S3MultipartController.java

    5-5. 테스트 웹페이지

6. 멀티파트 업로드 테스트하기

 

깃허브에 코드가 있습니다. 이해하는데 도움이 될 것입니다.

1. 멀티파트 업로드란?

Amazon S3에 파일을 업로드하면 S3 객체로 저장됩니다.

멀티파트 업로드는 객체를 더 작은 부분으로 나누고 각 부분을 개별적으로 업로드합니다. 모든 부분들이 업로드된 경우 다시 합쳐집니다.

 

2. 대용량 파일 업로드 시 멀티파트 업로드를 사용해야 하는 이유

1. 더 높은 처리량

    부분을 병렬적으로 업로드하여 처리량을 개선할 수 있습니다.

    필자가 EC2 서버 환경에서 S3에 1.3GB인 동영상을 업로드했을 시 약 90초가 걸렸고, 멀티파트 업로드 시에 약 50초로 감축됐습니다.

    45퍼센트의 성능 향상이 있었습니다. 

2. 단일 파일 최대 용량

    Amazon S3에 단일 작업으로 올릴 수 있는 객체 크기는 최대 5GB입니다. [공식문서]

    반면 멀티파트 업로드 API를 사용하면 최대 5TB 크기의 단일 대형 객체를 업로드할 수 있습니다.

3. 업로드 일시 중지 및 재개

    언제든지 부분들을 업로드할 수 있습니다. 전체 프로세스를 일시 중지하고 나머지 부분을 나중에 업로드할 수 있습니다.

3. 멀티파트 업로드 성능 테스트

    3-1. 테스트 결과

단일 파일 업로드할 때 걸린 시간

  • 7MB인 mp4 동영상 → 1678ms, 약 1초
  • 180MB인 mp4 동영상 → 9841ms, 약 9초
  • 1.3GB인 mp4 동영상 → 90180ms, 약 90초

멀티파트 방식으로 s3에 업로드할 때 걸린 시간

  • 7MB인 mp4 동영상 → 1754ms, 약 1초
  • 180MB인 mp4 동영상 → 4859ms, 약 4초
  • 1.3GB인 mp4 동영상 → 50540ms, 약 50초

서버 사양에 따라 다르겠지만 청크 크기보다 작은 영상은 업로드 시간에서 큰 차이가 없습니다.

** 여기서 청크는 위에서 말한 부분이랑 같은 의미이며 한 개의 파일을 특정 크기만큼 자른 일부분입니다.**
영상 크기가 클수록 멀티파트 방식으로 업로드하는 게 훨씬 더 높은 성능을 보여줍니다.
업로드 영상 크기에 청크 사이즈를 조절해서 업로드하면 시간이 더 빨라질 것으로 보입니다.

    3-2. 테스트 환경

- 업로드 서버 사양 : EC2 t2.small
- 멀티파트 업로드 시 청크 크기 : 10MB

- 업로드 서버 : SpringBoot

4. 멀티파트 업로드 프로세스

멀티파트 업로드는 3단계 프로세스입니다. 업로드를 시작하고 객체 부분을 업로드하고 모든 부분을 업로드한 후 멀티파트 업로드를 완료합니다.

1.  멀티파트 업로드 시작

멀티파트 업로드 시작 요청을 보내면 Amazon S3는 멀티파트 업로드에 대한 고유 식별자인 업로드 ID가 포함된 응답을 반환합니다. 부품을 업로드하거나, 부품을 나열하거나, 업로드를 완료하거나, 업로드를 중지할 때마다 이 업로드 ID를 포함해야 합니다. 업로드되는 객체를 설명하는 메타데이터를 제공하려면 멀티파트 업로드 시작 요청에 메타데이터를 제공해야 합니다.

 

2. 부분 업로드

부분을 업로드할 때 업로드 ID 외에 부분 번호를 지정해야 합니다. 1에서 10,000 사이의 부품 번호를 선택할 수 있습니다. 부분 번호는 업로드 중인 개체에서 부분과 해당 위치를 고유하게 식별합니다.

 

3. 멀티파트 업로드 완료

멀티파트 업로드를 완료하면 Amazon S3는 부분 번호를 기준으로 오름차순으로 부분을 연결하여 객체를 생성합니다.

 

5. 멀티파트 업로드 개발하기

S3 버킷 생성과 Spring Boot에 s3 연동은 이전 글을 참고하시면 됩니다.

S3 버킷 생성과 Spring Boot 연동이 끝난 것을 가정하고 설명하겠습니다.

5-1. gradle 설정

S3에 객체를 업로드하는 방법은 크게 Web Console에서 하는 방법과

AWS SDKs, REST API, or AWS CLI를 이용하는 방법이 있습니다.

저희는 AWS SDKs를 이용해 연동할 것입니다. 특히 aws-sdk-java-v2를 이용할 것입니다.

공식문서공식 깃허브를 확인하시면 됩니다.

특히 v1과 사용법과 기본 패키지명이 달라졌으므로 주의하셔야 합니다. 

다음 의존성을 추가합니다.

implementation platform('software.amazon.awssdk:bom:2.17.53')
implementation 'software.amazon.awssdk:s3'

5-2. S3Config.java

AWS 서버와 통신할 S3Client, 서명된 Url을 발급받기 위한 S3Presigner, 시크릿키와 엑세스 키로 인증할 AwsCredentials를 스프링 빈으로 등록합니다.

@Configuration
public class S3Config {
    @Value("${cloud.aws.region.static}")
    private String region;
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    public static final String videoFolder = "video";

    @Bean
    public AwsCredentials basicAWSCredentials() {
        return AwsBasicCredentials.create(accessKey, secretKey);
    }

    @Bean
    public S3Client s3Client(AwsCredentials awsCredentials) {
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }

    @Bean
    public S3Presigner s3Presigner(AwsCredentials awsCredentials) {
        return S3Presigner.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }
}

5-3. S3MultipartService.java

aws-java-sdk-v2를 이용해 AWS 서버에 멀티파트 업로드 요청하는 곳입니다.

주요 메서드는 3개입니다.
1. 멀티파트 업로드 시작
2. 부분 업로드를 위한 미리 서명된 URL 발급
3. 멀티파트 업로드 완료

추가적으로 업로드 중지 요청 시 사용할 abortUpload 메서드가 있습니다.

@Service
@RequiredArgsConstructor
public class S3MultipartService {

    private final S3Client s3Client;
    private final S3Presigner s3Presigner;
    private final AmazonS3Client amazonS3Client;

    public S3UploadDto initiateUpload(String originFileName, String targetBucket, String targetObjectDir) {

        // 사용자가 보낸 파일 확장자와 현재 시간을 이용해 새로운 파일 이름을 만든다.
        String fileType = originFileName.substring(originFileName.lastIndexOf(".")).toLowerCase();
        String newFileName = System.currentTimeMillis() + fileType;
        Instant now = Instant.now();

        CreateMultipartUploadRequest createMultipartUploadRequest = CreateMultipartUploadRequest.builder()
                .bucket(targetBucket) // 버킷 설정
                .key(targetObjectDir + "/" + newFileName) // 업로드될 경로 설정
                .acl(ObjectCannedACL.PUBLIC_READ) // public_read로 acl 설정
                .expires(now.plusSeconds(60 * 20)) // 객체를 더 이상 캐시할 수 없는 날짜 및 시간
                .build();

        // Amazon S3는 멀티파트 업로드에 대한 고유 식별자인 업로드 ID가 포함된 응답을 반환합니다.
        CreateMultipartUploadResponse response = s3Client.createMultipartUpload(createMultipartUploadRequest);

        return new S3UploadDto(response.uploadId(), newFileName);
    }

    public S3PresignedUrlDto getUploadSignedUrl(S3UploadSignedUrlDto s3UploadSignedUrlDto, String targetBucket, String targetObjectDir) {

        UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
                .bucket(targetBucket)
                .key(targetObjectDir + "/" + s3UploadSignedUrlDto.getFileName())
                .uploadId(s3UploadSignedUrlDto.getUploadId())
                .partNumber(s3UploadSignedUrlDto.getPartNumber())
                .build();

        // 미리 서명된 URL 요청
        UploadPartPresignRequest uploadPartPresignRequest = UploadPartPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(10))
                .uploadPartRequest(uploadPartRequest)
                .build();

        // 클라이언트에서 S3로 직접 업로드하기 위해 사용할 인증된 URL을 받는다.
        PresignedUploadPartRequest presignedUploadPartRequest = s3Presigner.presignUploadPart(uploadPartPresignRequest);

        return new S3PresignedUrlDto(presignedUploadPartRequest.url().toString());
    }

    public S3UploadResultDto completeUpload(S3UploadCompleteDto s3UploadCompleteDto, String targetBucket, String targetObjectDir) {
        List<CompletedPart> completedParts = new ArrayList<>();
        // 모든 한 영상에 대한 모든 부분들에 부분 번호와 Etag를 설정함
        for (S3UploadPartsDetailDto partForm : s3UploadCompleteDto.getParts()) {
            CompletedPart part = CompletedPart.builder()
                    .partNumber(partForm.getPartNumber())
                    .eTag(partForm.getAwsETag())
                    .build();
            completedParts.add(part);
        }

        // 멀티파트 업로드 완료 요청을 AWS 서버에 보냄
        CompletedMultipartUpload completedMultipartUpload = CompletedMultipartUpload.builder()
                .parts(completedParts)
                .build();

        String fileName = s3UploadCompleteDto.getFileName();
        CompleteMultipartUploadRequest completeMultipartUploadRequest = CompleteMultipartUploadRequest.builder()
                .bucket(targetBucket) // 버킷 설정
                .key(targetObjectDir + "/" + fileName) // 파일이름 설정
                .uploadId(s3UploadCompleteDto.getUploadId()) // 업로드 아이디
                .multipartUpload(completedMultipartUpload) // 영상의 모든 부분 번호, Etag
                .build();

        CompleteMultipartUploadResponse completeMultipartUploadResponse =
                s3Client.completeMultipartUpload(completeMultipartUploadRequest);
        // s3에 업로드된 파일 이름
        String objectKey = completeMultipartUploadResponse.key();
        // s3에 업로드된 Url
        String url = amazonS3Client.getUrl(targetBucket, objectKey).toString();
        String bucket = completeMultipartUploadResponse.bucket();

        // 영상 사이즈를 구함
        long fileSize = getFileSizeFromS3Url(bucket, objectKey);

        return S3UploadResultDto.builder()
                .name(fileName)
                .url(url)
                .size(fileSize)
                .build();
    }

    public void abortUpload(S3UploadAbortDto s3UploadAbortDto, String targetBucket, String targetObjectDir) {

        AbortMultipartUploadRequest abortMultipartUploadRequest = AbortMultipartUploadRequest.builder()
                .bucket(targetBucket)
                .key(targetObjectDir + "/" + s3UploadAbortDto.getFileName())
                .uploadId(s3UploadAbortDto.getUploadId())
                .build();

        s3Client.abortMultipartUpload(abortMultipartUploadRequest);

    }

    private long getFileSizeFromS3Url(String bucketName, String fileName) {
        GetObjectMetadataRequest metadataRequest = new GetObjectMetadataRequest(bucketName, fileName);
        ObjectMetadata objectMetadata = amazonS3Client.getObjectMetadata(metadataRequest);
        return objectMetadata.getContentLength();
    }
}

5-4. S3MultipartController.java

웹에서 멀티파트 업로드 요청을 위해 사용할 API입니다.

@RequiredArgsConstructor
@RestController
public class S3MultipartController {
    private final S3MultipartService s3MultipartService;

    @Value("${cloud.aws.s3.bucket}")
    private String videoBucket;

    /**
     * 멀티파트 업로드 시작한다.
     * 업로드 아이디를 반환하는데, 업로드 아이디는 부분 업로드, 업로드 완료 및 중지할 때 사용된다.
     * @param s3UploadInitiateDto
     * @return
     */
    @PostMapping("/initiate-upload")
    public S3UploadDto initiateUpload(@RequestBody S3UploadInitiateDto s3UploadInitiateDto) {
        return s3MultipartService.initiateUpload(s3UploadInitiateDto.getFileName(), videoBucket, S3Config.videoFolder);
    }

    /**
     * 부분 업로드를 위한 서명된 URL 발급 요청
     * @param s3UploadSignedUrlDto
     * @return
     */
    @PostMapping("/upload-signed-url")
    public S3PresignedUrlDto getUploadSignedUrl(@RequestBody S3UploadSignedUrlDto s3UploadSignedUrlDto) {
        return s3MultipartService.getUploadSignedUrl(s3UploadSignedUrlDto, videoBucket,S3Config.videoFolder);
    }

    /**
     * 멀티파트 업로드 완료 요청
     * @param s3UploadCompleteDto
     * @return
     */
    @PostMapping("/complete-upload")
    public S3UploadResultDto completeUpload(@RequestBody S3UploadCompleteDto s3UploadCompleteDto) {
        return s3MultipartService.completeUpload(s3UploadCompleteDto, videoBucket, S3Config.videoFolder);
    }

    /**
     * 멀티파트 업로드 중지
     * @param s3UploadAbortDto
     * @return
     */
    @PostMapping("/abort-upload")
    public Void abortUpload(@RequestBody S3UploadAbortDto s3UploadAbortDto) {
        s3MultipartService.abortUpload(s3UploadAbortDto, videoBucket, S3Config.videoFolder);
        return null;
    }
}

5-5. 테스트 웹페이지

웹 템플릿 뷰를 보여줄 컨트롤러와 뷰입니다.

HomeController.java

@Controller
public class HomeController {
    @GetMapping("/v2/video")
    public String multipartS3() {
        return "multipart-upload-s3";
    }
}

 

multipart-upload-s3.html 주요 javascript

웹을 브라우저에서 실행하면 다음과 같이 나옵니다.

멀티파트 업로드하는 javascript 주요 로직은 다음과 같습니다.

1. Spring Boot 서버로 멀티파트 업로드 시작 요청합니다.
2. 청크 사이즈와 파일 크기를 통해 청크 개수를 설정합니다.
3. Spring Boot 서버로 Part **업로드를 위한 미리 서명된 URL 발급 받습니다.**
4. 3번에서 받은 미리 서명된 URL과 PUT을 사용해 AWS 서버에 청크를 업로드합니다,
5. 3,4번을 반복합니다.
6. 모든 청크 업로드가 완료되면 Spring Boot 서버로 업로드 완료 요청을 보냅니다.

 

const url = `http://localhost:8080`;

try {
    let start = new Date();
    // 1. Spring Boot 서버로 멀티파트 업로드 시작 요청합니다.
    let res = await axios.post(`${url}/initiate-upload`, { fileName: fileName });
    const uploadId = res.data.uploadId;
    const newFilename = res.data.fileName; // 서버에서 생성한 새로운 파일명
    console.log(res);

    // 세션 스토리지에 업로드 아이디와 파일 이름을 저장합니다.
    sessionStorage.setItem('uploadId', uploadId);
    sessionStorage.setItem('fileName', newFilename);

    // 청크 사이즈와 파일 크기를 통해 청크 개수를 설정합니다.
    const chunkSize = 10 * 1024 * 1024; // 10MB
    const chunkCount = Math.floor(fileSize / chunkSize) + 1;
    console.log(`chunkCount: ${chunkCount}`);

    let multiUploadArray = [];

    for (let uploadCount = 1; uploadCount < chunkCount + 1; uploadCount++) {
        // 청크 크기에 맞게 파일을 자릅니다.
        let start = (uploadCount - 1) * chunkSize;
        let end = uploadCount * chunkSize;
        let fileBlob = uploadCount < chunkCount ? file.slice(start, end) : file.slice(start);

        // 3. Spring Boot 서버로 Part 업로드를 위한 미리 서명된 URL 발급 바듭니다.
        let getSignedUrlRes = await axios.post(`${url}/upload-signed-url`, {
            fileName: newFilename,
            partNumber: uploadCount,
            uploadId: uploadId
        });

        let preSignedUrl = getSignedUrlRes.data.preSignedUrl;
        console.log(`preSignedUrl ${uploadCount} : ${preSignedUrl}`);
        console.log(fileBlob);

        // 3번에서 받은 미리 서명된 URL과 PUT을 사용해 AWS 서버에 청크를 업로드합니다,
        let uploadChunck = await fetch(preSignedUrl, {
            method: 'PUT',
            body: fileBlob
        });
        console.log(uploadChunck);
        // 응답 헤더에 있는 Etag와 파트 번호를 가지고 있습니다.
        let EtagHeader = uploadChunck.headers.get('ETag').replaceAll('\"', '');
        console.log(EtagHeader);
        let uploadPartDetails = {
            awsETag: EtagHeader,
            partNumber: uploadCount
        };

        multiUploadArray.push(uploadPartDetails);
    }

    console.log(multiUploadArray);
    // 6. 모든 청크 업로드가 완료되면 Spring Boot 서버로 업로드 완료 요청을 보냅니다.
    // 업로드 아이디 뿐만 아니라 이 때 Part 번호와 이에 해당하는 Etag를 가진 'parts'를 같이 보냅니다.
    const completeUpload = await axios.post(`${url}/complete-upload`, {
        fileName: newFilename,
        parts: multiUploadArray,
        uploadId: uploadId
    });
    let end = new Date();
    console.log("파일 업로드 하는데 걸린 시간 : "+(end-start)+"ms")
    console.log(completeUpload.data, ' 업로드 완료 응답값');
} catch (err) {
    console.log(err, err.stack);
}

 

6. 멀티파트 업로드 테스트하기

180MB 영상을 업로드했을 때

1. 업로드 시작(”POST initiate-upload”)해서 업로드 아이디를 받습니다.
2. 업로드 아이디와 Part 번호에 해당하는 서명된 URL을 받아오고(”POST upload-signed-url”)  
3. Part를 AWS API 서버에 업로드하는(”PUT 165**.mp4?”)  과정을 거칩니다.
4. 모든 Part에 대한 업로드가 끝날 때까지 2번과 3번을 반복합니다.
5. 마지막으로 (”POST complet-upload”)업로드 종료를 알립니다.

S3에 mp4 파일이 잘 저장된 것을 확인할 수 있습니다.

 

- 참고 

https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/mpuoverview.html

https://github.com/aws/aws-sdk-java-v2