유미의 기록들

[최종 프로젝트] AWS Multipart Upload로 대용량 파일 업로드 본문

대외활동 기록/내일배움캠프

[최종 프로젝트] AWS Multipart Upload로 대용량 파일 업로드

지유미 2024. 11. 9. 23:55
728x90
반응형

💡 배경

인터넷 강의 서비스

인프런과 같이 튜터가 강의를 등록하고 유저가 결제를 통해 강의를 조회할 수 있는 서비스를 담당했다. 대용량 영상 파일을 AWS S3에 업로드 하기 위해, 초기에는 MultipartFile업로드 방식을 사용했으나, 150MB이상 파일을 업로드할 때, 속도가 느려지는 문제가 발생했다. 성능 개선을 하고자 구글링 하던 중, 파일을 여러 부분으로 나눠 병렬로 업로드하는 AWS S3 MultipartUpload 방식이 있다는 것을 알게 되어 이를 도입하고자 하였다

 

 

📝 S3에 파일 업로드 하는 방식

S3에 파일을 업로드 하는 방법에는 3가지가 있다

1. Stream 업로드

HttpServletRequest의 `InputStream`을 이용해서 AWS S3에 바로 파일을 전송하는 방식

파일의 바이너리 전체를 SpringBoot 서버의 디스크나 힙 메모리에 저장하지 않는다. 따라서 서버의 부하가 적고 메모리 사용량을 줄일 수 있다. 하지만 업로드할 파일 용량이 커지면 업로드 속도가 느려지고, 파일을 업로드하는 동안 네트워크 문제가 발생하면, 처음부터 다시 업로드해야 하는 문제가 있다.

 

 

2. MultipartFile 업로드

SpringMVC에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식

클라이언트가 파일을 업로드 했을 때 서버는 스트림 형태의 파일을 `MultipartFile` 객체로 변환하고 이를 임시 디스크에 저장한다. Spring에서는 업로드한 파일을 메모리에 로드하거나, 설정된 임시 디렉토리에 파일을 저장한다. 파일 처리 속도는 더 빠르지만 스레드가 작업을 수행하는 동안 부담이 될 수 있기 때문에 충분한 검토가 필요하다.

 

 

3. AWS Multipart Upload

AWS S3에서 제공하며, 업로드할 파일을 작은 여러개의 파트로 나누어 각 부분을 개별적으로 업로드하는 방식

동시에 여러 파트를 병렬적으로 업로드하여 속도를 최적화할 수 있고, 대용량 파일을 전송할 때, 네트워크 중단이 발생해서 중간에 실패해도, 실패한 파트만 다시 업로드 할 수 있어 안정성이 높다 

 

따라서 Stream방식과  MultipartFile에 비해 AWS Multipart Upload 방식이 강의 영상과 같은 대용량 파일에 적합한 방식이라고 판단하여 구현하기로 결정했다

 

 

💻 Multipart Upload 적용

 

Uploading and copying objects using multipart upload - Amazon Simple Storage Service

If you are using a multipart upload with additional checksums, the multipart part numbers must use consecutive part numbers. When using additional checksums, if you try to complete a multipart upload request with nonconsecutive part numbers, Amazon S3 gene

docs.aws.amazon.com

 

AWS 공식문서를 보면 S3는 다양한 방식으로 멀티파트 업로드를 지원한다. 그 중에서 AWS SDK를 사용하였다. 따라서 build.gradle에 의존성을 추가하였다

implementation 'software.amazon.awssdk:s3:2.20.30'

 

멀티파트 업로드에는 크게 3가지 단계가 있다

1. Multipart upload initiation : 멀티 파트 업로드 요청
2. Parts upload : 분할 된 parts 업로드
3. Multipart upload completion : 멀티 파트 업로드 완료 요청

 

1. Multipart upload initiation 

멀티 파트 업로드 시작 요청을 하면 S3는 멀티파트 업로드 고유 식별자인 `uploadId`를 응답한다. 부분 업로드, 업로드 완료, 업로드 중단 요청 시에 `uploadId`를 포함해야 한다

 //멀티파트 업로드 시작 요청 및 UploadId 반환
 uploadId = getUploadId(fileName);
 
//Upload Id 반환
public String getUploadId(String fileName) {
    //어떤 bucket에 어떤 object를 업로드할 것인지에 대한 Request 정보 생성하기 위해 MultipartUploadRequest 빌드하여 객체 생성
    CreateMultipartUploadRequest request = CreateMultipartUploadRequest.builder()
            .bucket(bucketName)
            .key(fileName)
            .build();

    //생성된 객체를 createMultipartUpload 메서드에 전달
    CreateMultipartUploadResponse response = s3Client.createMultipartUpload(request);

    return response.uploadId();
}

 

이렇게 uploadId를 받아오는 것을 확인할 수 있다

 

2. Parts upload 

파일을 5MB 단위로 분할하여 `uploadId`와 함께 `partNumber`를 지정하여 업로드한다. 부분을 업로드 하면 반환 받은 부분에 대한 `ETag`를 수집하여 `completedParts` 리스트에 추가한다

//파일을 partSize만큼 파트로 나누어 업로드
long partSize = 5 * 1024 * 1024; //5MB 단위로 파트 분할
long filePosition = 0;
String uploadId = null;
List<CompletedPart> completedParts = new ArrayList<>();

try (InputStream inputStream = multipartFile.getInputStream()) {
    for (int i = 1; filePosition < fileSize; i++) {
        //마지막 파트 크기가 partSize 미만일 경우 조정
        long currentPartSize = Math.min(partSize, (fileSize - filePosition));
  
        //각 파트에 대한 객체 생성
        UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
                .bucket(bucketName)
                .key(fileName)
                .uploadId(uploadId)
                .partNumber(i)
                .build();

        //각 파트를 업로드하고 ETag를 partETags에 추가
        UploadPartResponse uploadPartResponse = s3Client.uploadPart(
                uploadPartRequest,
                RequestBody.fromInputStream(inputStream, currentPartSize)
        );

        CompletedPart part = CompletedPart.builder()
                .partNumber(i)
                .eTag(uploadPartResponse.eTag())
                .build();

        completedParts.add(part);
        
        filePosition += currentPartSize;
    }
    ...
 }

 

15MB 영상 파일을 업로드 했을 때, 5MB 단위씩 업로드하여 각 파트 별로 ETag 값을 받아오는 것을 확인할 수 있다

 

3. Multipart upload completion 

모든 파트가 성공적으로 업로드된 후 `completeMultipartUploadRequest` 객체를 생성하여 S3에 멀티파트 업로드 완료를 요청한다 

//멀티 파트 업로드 완료 요청
CompleteMultipartUploadRequest completeMultipartUploadRequest = CompleteMultipartUploadRequest.builder()
        .bucket(bucketName)
        .key(fileName)
        .uploadId(uploadId)
        .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())
        .build();

s3Client.completeMultipartUpload(completeMultipartUploadRequest);

 

최종적으로 Multipart Upload가 성공하면 이와 같이 S3에 업로드 된 것을 확인할 수 있다

 

 

더보기
    /**
     * 멀티파트 파일 업로드
     *
     * @param authUser
     * @param lectureId
     * @param multipartFile
     * @param title
     * @return
     * @throws IOException
     */
    public String uploadLectureVideo(AuthUser authUser, Long lectureId, MultipartFile multipartFile, String title) {
        //유저가 존재하는 지 확인
        User user = userService.findByUserId(authUser.getId());

        //강의가 존재하는 지 확인
        Lecture lecture = lectureService.findById(lectureId);

        //유저가 강의 등록한 유저인지 확인
        if (!user.getId().equals(lecture.getUser().getId())) {
            throw new ApiException(ErrorStatus._HAS_NOT_ACCESS_PERMISSION);
        }

        //파일 타입 확인(사진으로 테스트하기 위해 임시로 주석 처리)
        fileValidator.fileTypeValidator(multipartFile,lecture);
        FileFormat fileType = fileValidator.mapStringToFileFormat(Objects.requireNonNull(multipartFile.getContentType()));

        //파일 사이즈 확인 (5GB까지 가능)
        fileValidator.fileSizeValidator(multipartFile, 5L * 1024 * 1024 * 1024);
        long fileSize = multipartFile.getSize();

        String folderPath = "lectures/" + lectureId + "/";
        String fileName = makeFileName(folderPath, multipartFile);
        long partSize = 5 * 1024 * 1024; //5MB 단위로 파트 분할
        String uploadId = null;
        List<CompletedPart> completedParts = new ArrayList<>();

        //멀티파트 업로드 시작 요청 및 UploadId 반환
        uploadId = getUploadId(fileName);

        //파일을 partSize만큼 파트로 나누어 업로드
        long filePosition = 0;

        try (InputStream inputStream = multipartFile.getInputStream()) {
            for (int i = 1; filePosition < fileSize; i++) {
                //마지막 파트 크기가 partSize 미만일 경우 조정
                long currentPartSize = Math.min(partSize, (fileSize - filePosition));

                //각 파트에 대한 객체 생성
                UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
                        .bucket(bucketName)
                        .key(fileName)
                        .uploadId(uploadId)
                        .partNumber(i)
                        .build();

                //각 파트를 업로드하고 ETag를 partETags에 추가
                UploadPartResponse uploadPartResponse = s3Client.uploadPart(
                        uploadPartRequest,
                        RequestBody.fromInputStream(inputStream, currentPartSize)
                );

                CompletedPart part = CompletedPart.builder()
                        .partNumber(i)
                        .eTag(uploadPartResponse.eTag())
                        .build();

                completedParts.add(part);

                filePosition += currentPartSize;
            }
            
            //멀티 파트 업로드 완료 요청
            CompleteMultipartUploadRequest completeMultipartUploadRequest = CompleteMultipartUploadRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .uploadId(uploadId)
                    .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())
                    .build();
            
            s3Client.completeMultipartUpload(completeMultipartUploadRequest);

            //새로운 강의 영상 객체 생성
            LectureVideo lectureVideo = LectureVideo.of(
                    fileName,
                    title,
                    VideoStatus.COMPLETED,
                    fileType,
                    lecture
            );
            lectureVideoRepository.save(lectureVideo);

            return fileName;

        } catch (S3Exception e) {
            //예외 발생 시, 업로드 취소
            s3Client.abortMultipartUpload(AbortMultipartUploadRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .uploadId(uploadId)
                    .build());

            throw new ApiException(ErrorStatus._S3_UPLOAD_ERROR);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    //파일 이름 생성
    public String makeFileName(String folderPath, MultipartFile multipartFile) {
        return folderPath + UUID.randomUUID() + "_" + multipartFile.getOriginalFilename();
    }

    //Upload Id 반환
    public String getUploadId(String fileName) {
        //어떤 bucket에 어떤 object를 업로드할 것인지에 대한 Request 정보 생성하기 위해 MultipartUploadRequest 빌드하여 객체 생성
        CreateMultipartUploadRequest request = CreateMultipartUploadRequest.builder()
                .bucket(bucketName)
                .key(fileName)
                .build();

        //생성된 객체를 createMultipartUpload 메서드에 전달
        CreateMultipartUploadResponse response = s3Client.createMultipartUpload(request);

        return response.uploadId();
    }
728x90
반응형
Comments