반응형

 

 

이전 포스팅에서는 Spring 프로젝트에서 로컬 개발 환경과, EC2 프로덕션 환경에 따른 S3 연동 방법 및 기본적인 업로드와 다운로드를 구현해 보았었다.

 

이번 포스팅에서는 S3 업로드와 다운로드 방식을 고도화하여 Presigned URL을 사용하는 방법에 대해서 살펴보고, 업로드 시 여러개의 Chunk 단위로 분할 업로드하는 방식인 S3 Multipart Upload에 대해서도 함께 살펴보려고 한다.

 

1. Presigned URL
   - 업로드 및 다운로드 구현 (Spring + 바닐라 JS)
2. Multipart 업로드
   - 업로드 및 다운로드 구현 (Spring + 바닐라 JS)

 

 

 

<함께 보가> [AWS + Spring] S3 활용 : 파일 업로드와 다운로드 (1) (개발 환경 및 프로덕션 환경에서의 기본 연동 전략)

https://sjh9708.tistory.com/259

 

[AWS + Spring] S3 활용 : 파일 업로드와 다운로드 (1) (개발 환경 및 프로덕션 환경에서의 기본 연동

이번 포스팅에서는 기존 Local Stroage를 이용하여 파일 업로드 및 다운로드 API를 제공하던 Spring Application에 S3를 연동하는 작업을 해보도록 하겠다. 개발 환경에서의 S3 연동, EC2 프로덕션 환경에서

sjh9708.tistory.com

 

 

 

 

 


Presigned URL

 

서버를 매개체로 S3와 통신하는 경우

 

 

 

 

위의 그림은 이전 포스팅에서 다루었던 기본적인 서버를 경유하여 S3와 통신하는 파일 업로드 및 다운로드 과정을 나타낸다.
서버가 클라이언트와 S3 사이에서 중개 역할을 수행하며, 이는 보안 및 접근 제어의 이점을 제공하지만, 추가적인 비용과 성능 부담이 발생할 수 있다.

 

위의 그림처럼 업로드 및 다운로드 시 두 번의 네트워크 과정을 거쳐야 한다는 것을 확인할 수 있다.

 

업로드

클라이언트 → 서버 (Request Upload) : 사용자가 파일(이미지)을 Payload에 포함시켜 업로드 요청

서버 → S3 (S3 Upload, With credential) : 서버가 AWS 자격 증명을 사용하여 S3에 파일을 업로드

 

다운로드 

서버 → S3 (S3 Download, With credential) : 서버는 AWS 자격 증명을 사용하여 S3에서 파일을 다운로드

서버 → 클라이언트 (Response) : 다운로드한 파일을 클라이언트에게 반환

 

 

 

 

 

 


Presigned URL 사용

 

 

 

 

Presigned URL은 클라이언트가 IAM 인증 없이 직접 S3에 접근할 수 있도록 하는 임시 URL이다.
S3의 파일을 안전하게 공유하기 위해 사용되며, 생성자의 IAM 권한을 기반으로 특정 파일에 대한 접근 권한이 부여된다.

  1. 권한을 가진 Application → Presigned URL 생성 (서명 생성)
  2. 클라이언트 → 요청 → 서버 → Presigned URL 반환 → 클라이언트는 해당 Presigned URL을 사용하여 업로드 혹은 다운로드
  3. 즉 클라이언트에서 IAM 인증 없이 직접 S3의 업로드 및 다운로드가 가능.
  4.  특징
    1. URL의 만료 기간 및 Method(GET/PUT)등 설정 가능
    2. URL의 권한은 생성자가 가진 권한 중 일부 혹은 전체 사용

 

따라서 클라이언트가 임시적으로 직접 S3에 접근하는 것을 허용하여, 서버를 매개체로 할 때의 네트워크 오버헤드를 줄이면서 S3 버킷의 보안을 확보할 수 있는 방법이다.

 

 

 

 


 

다운로드 시 Presigned URL의 사용 예시

  1. 클라이언트 → 서버: Presigned URL 요청
  2. 서버 → S3: Presigned URL 요청 및 응답
    1. 서버는 자격 증명을 사용하여 S3에 Presigned URL을 요청
    2. Presigned URL은 특정 기간 동안만 유효하며, S3 객체에 대한 특정 행동(위 그림에서는 GET, 조회)을 허용하는 서명이 포함됨.
    3. 조회 뿐 아니라 저장 및 업데이트 메서드(POST/PUT) 등을 허용할 수도 있음. 
  3. 서버 → 클라이언트: Presigned URL 응답
    1. 서버는 클라이언트에게 S3 Presigned URL을 응답. 
  4. 클라이언트 → S3 : Presigned URL을 사용한 다운로드 요청
    1. 클라이언트는 Presigned URL을 이용하여 일정 기간동안 S3에 허용된 특정 행동 (조회, 업데이트 등..) 가능
    2. 서버를 거치지 않고, 클라이언트가 S3에 직접 요청하여 트래픽 비용을 절감
    3. Presigned URL은 만료 기간이 존재하기 때문에 만료 이후로는 요청 처리를 하지 않음.
  5. S3 → 클라이언트 : 파일 응답
    1. S3는 Presigned URL 요청이 유효하면 파일(image.jpeg)을 직접 반환

 

 

 

 

 


Presigned URL 기반 업로드 및 다운로드 구현

 

 

1. Configuration 업데이트  : S3 Presigner Bean 등록

 

기존에 스프링에서 S3와 통신하기 위한 S3 Client Bean을 생성하고 스프링 컨텍스트에 주입하기 위한 Configuration 파일을 구성하였었다.

추가적으로 Presined URL을 생성하는 클래스인 S3Presigner를 Bean으로 등록하기 위해서 Configuration에 추가해준다.

@Configuration
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
public class S3Config {

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

    @Value("${cloud.aws.auth}")
    private String authType;

    @Value("${cloud.aws.s3.profile:#{null}}")
    private String profile;

    @Bean
    public S3Client s3Client() {

        if(authType.equals("profile")){
            return S3Client.builder()
                    .region(Region.of(region))
                    .credentialsProvider(ProfileCredentialsProvider.create(profile)) // CLI 프로파일 사용
                    .build();
        }
        else if(authType.equals("role")){
            return S3Client.builder()
                    .region(Region.of(region))
                    .credentialsProvider(InstanceProfileCredentialsProvider.create()) // EC2 인스턴스 CredentialProvider 사용
                    .build();
        }
        return null;
    }

    @Bean
    public S3Presigner s3Presigner() {

        if(authType.equals("profile")){
            return S3Presigner.builder()
                    .region(Region.of(region))
                    .credentialsProvider(ProfileCredentialsProvider.create(profile)) // CLI 프로파일 사용
                    .build();
        }
        else if(authType.equals("role")){
            return S3Presigner.builder()
                    .region(Region.of(region))
                    .credentialsProvider(InstanceProfileCredentialsProvider.create()) // EC2 인스턴스 CredentialProvider 사용
                    .build();
        }
        return null;
    }
}

 

 

 


2. Service 비즈니스 로직 작성 : 업로드 및 다운로드의 Presigned URL 생성

 

이제 우리는 서버단의 비즈니스 로직에서 직접 파일을 핸들링해서 업로드하거나 다운로드하는 역할을 수행하지 않을 것이다. 단지 클라이언트가 S3와의 업로드와 다운로드 과정을 직접 수행할 수 있도록 미리 서명된 URL을 제공해주는 것이 서버의 역할이다.

 

@Service
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
@RequiredArgsConstructor
public class S3PresignedService {

    private final S3Presigner s3Presigner;

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

    public String getUploadPresignedURL(String key) {
        PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(
                req -> req.signatureDuration(Duration.ofMinutes(15)) // 15분 유효기간
                        .putObjectRequest(
                                PutObjectRequest.builder()
                                        .bucket(bucketName)
                                        .key(key)
                                        .build()
                        )
        );
        return presignedRequest.url().toString();
    }

    public String getDownloadPresignedURL(String key) {
        PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(
                req -> req.signatureDuration(Duration.ofMinutes(15)) // 15분 유효기간
                        .getObjectRequest(
                                GetObjectRequest.builder()
                                        .bucket(bucketName)
                                        .key(key)
                                        .build()
                        )
        );
        return presignedRequest.url().toString();
    }
}

 

  • getUploadPresignedURL()는 특정 키에 대한 Presigned PUT URL을 생성하는 서비스 메서드로 사용
  • getDownloadPresignedURL()는 특정 키에 대한 Presigned GET URL을 생성하는 서비스 메서드로 사용
  • S3Presigner 클래스presignPutObject()와 presignGetObject()는 각각 특정 Key에 대한 업로드 및 다운로드에 해당하는 Presigned URL을 생성하는 메서드이다.
    • 파라미터로 PresignedXXXObjectRequest 객체에 대한 Builder를 람다식으로 표현하여, 설정을 지정해주었다.
    • signatureDuration() : URL의 유효 기간을 설정한다.
    • ObjectReqeust에는 버킷과 Key를 지정한다.

 

 

 

 


3. Controller 엔드포인트 작성

 

클라이언트에게 제공할 API 엔드포인트를 작성해주었다. 해당 API들을 통해서 PUT/GET Presigned URL을 각각 반환받는다.

@RestController
@RequestMapping("/api/v1/file/s3")
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
@RequiredArgsConstructor
public class FilePresignedApiController {
    private final S3PresignedService s3PresignedService;


    /**
     * S3 파일 업로드용 Presigned URL 발급
     */
    @ApiOperation(
            value = "파일 업로드 Presigned URL 발급"
    )
    @GetMapping("/presigned-upload")
    public ResponseEntity<String> getPresignedUploadUrl(@RequestParam String key) {
        String presignedUrl = s3PresignedService.getUploadPresignedURL(key);

        return ResponseEntity.status(HttpStatus.OK).body(presignedUrl);
    }

    /**
     * S3 파일 다운로드용 Presigned URL 발급
     */
    @ApiOperation(
            value = "파일 다운로드 Presigned URL 발급"
    )
    @GetMapping("/presigned-download")
    public ResponseEntity<String> getPresignedDownloadUrl(@RequestParam String key) {
        String presignedUrl = s3PresignedService.getDownloadPresignedURL(key);

        return ResponseEntity.status(HttpStatus.OK).body(presignedUrl);
    }



}

 

 

 

 

 


4. 클라이언트 코드 작성

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>S3 Presigned URL Upload & Download</title>
</head>
<body>
    <h2>S3 파일 업로드</h2>
    <input type="file" id="uploadFile">
    <button onclick="uploadFile()">업로드</button>
    <p id="uploadStatus"></p>

    <h2>S3 파일 다운로드</h2>
    <input type="text" id="downloadKey" placeholder="파일 키 입력 (예: uploads/test.jpg)">
    <button onclick="downloadFile()">다운로드</button>

    <script>
        // 파일 업로드
        async function uploadFile() {
            const fileInput = document.getElementById("uploadFile");
            const uploadStatus = document.getElementById("uploadStatus");

            if (!fileInput.files.length) {
                uploadStatus.textContent = "업로드할 파일을 선택하세요.";
                return;
            }

            const file = fileInput.files[0];
            const key = `uploads/${file.name}`;  // S3 업로드 경로 지정

            // 1. 서버에서 Presigned URL 요청
            const response = await fetch(`http://localhost:8080/api/v1/file/s3/presigned-upload?key=${encodeURIComponent(key)}`);
            const presignedUrl = await response.text();
            console.log(response);
            console.log(presignedUrl);
            const method = 'PUT';

            // 2. Presigned URL을 사용하여 S3에 직접 업로드 (PUT 요청)
            const uploadResponse = await fetch(presignedUrl, {
                method: method,
                headers: { "Content-Type": file.type },
                body: file
            });

            if (uploadResponse.ok) {
                uploadStatus.textContent = "파일 업로드 성공!";
            } else {
                uploadStatus.textContent = "파일 업로드 실패: " + uploadResponse.statusText;
            }
        }

        // 파일 다운로드
        async function downloadFile() {
            const keyInput = document.getElementById("downloadKey").value;
            if (!keyInput) {
                alert("다운로드할 파일 키를 입력하세요.");
                return;
            }

            // 1. 서버에서 Presigned URL 요청
            const response = await fetch(`http://localhost:8080/api/v1/file/s3/presigned-download?key=${encodeURIComponent(keyInput)}`);
            const presignedUrl = await response.text();
            const method = 'GET';

            // 2. Presigned URL을 사용하여 S3에서 직접 다운로드
            window.location.href = presignedUrl;
        }
    </script>
</body>
</html>

 

  1. 클라이언트는 서버에 Presigned URL을 요청하여 업로드 또는 다운로드할 파일의 S3 접근 URL을 받는다.
  2. 업로드 : 클라이언트는 받은 Presigned URL을 이용해 파일을 S3에 직접 PUT 요청으로 업로드한다.
  3. 다운로드 : 해당 URL을 통해 S3에서 직접 파일을 가져온다.
  4. 결과적으로 서버가 파일을 직접 처리하지 않고 Presigned URL을 제공하여 클라이언트가 S3와 직접 통신할 수 있도록 하여 서버 부하를 줄이고 성능을 최적화할 수 있다.

 

 

 

 


5. S3 CORS 설정

 

S3 Service 또한 엔드포인트를 가지는 서버의 일종이기 때문에 CORS 정책에 영향을 받는다. 따라서 S3 버킷에서 CORS 정책을 허용해주어야 한다.

 

 

 

 

 


6. 결과 확인

 

업로드 확인

  • 파일 업로드 시 API 서버로 Presigned URL을 얻어오는 과정, 그리고 URL을 통해 S3로 직접 업로드를 하는 과정의 두 개의 네트워크를 확인할 수 있다. (중복된 하나는 CORS에 의한 요청)
  • S3 Bucket을 확인해보면 정상적으로 업로드 된 것을 확인할 수 있다. 

 

 

다운로드 확인

  • 파일 다운로드 역시 API 서버로 Presigned URL을 얻어오는 과정, 그리고 URL을 통해 S3로 직접 업로드를 하는 과정의 두 개의 네트워크를 확인할 수 있다. 

 

 

 

 

 

 


S3 Multipart Upload

 

지금까지는 하나의 파일을 Sigle Upload하는 형식을 기본으로 업로드 및 다운로드를 구현해보았다. 그런데 만약 파일 크기가 수백 MB~GB 단위로 커진다면, 업로드 속도가 느려지고 네트워크 오류 발생 시 처음부터 다시 업로드해야 하는 비효율적인 문제가 생길 수 있다.

 

S3에서는 하나의 파일을 여러 개의 Part로 분할하여 업로드한 후 최종 병합하는 Multipart Upload 방식을 지원한다.

 

1. 대용량 파일을 병렬 처리를 통해 빠르게 업로드할 수 있으며, 장애 발생 시 중단된 지점부터 업로드를 재개할 수 있어 안정성이 높다.
2. 그렇지만 단일 파트 업로드보다 업로드 로직이 복잡해지고, Part 관리 등의 추가적인 구현이 필요하다.
3. AWS에서는 100MB 이상의 파일을 업로드할 경우 멀티파트 업로드를 권장하며, Presigned URL을 함께 활용하면 서버 부하를 줄일 수 있다.

 

 

 

 


Multipart Upload의 과정

 

아래의 그림은 멀티파트 방식과 Presigned URL을 함께 활용하여 업로드를 구현하는 대표적인 플로우이다.

 

 

  1. Multipart Upload 생성 (초기화)
    • 클라이언트는 서버를 통해 S3에 멀티파트 업로드를 시작하는 요청을 보내고 uploadId를 발급받는다.
    • uploadId : 모든 Part를 관리하는 세션 ID 역할
  2. Part 및 Presigned URL 생성
    • 각 Part는 uploadId와 함께 Part 번호(1~10,000)로 관리되고 개별적으로 업로드하기 위한 Presigned URL이 생성된다.
    • 클라이언트는 해당 Presigned URL 리스트를 제공받는다.
  3. 파일의 Part를 분리하여 각 Presigned URL을 통해 업로드
    • 클라이언트 측에서 파일을 Chunk 단위로 분리하여 각 Part를 Presigned URL을 통해서 개별 단위로 업로드한다.
    • 각각의 파트가 업로드 완료될 시 ETag가 생성 및 반환되며, ETag는 S3에 업로드 된 파트들의 무결성을 확인하는 해시값이다.
  4. 모든 Part 업로드 이후 완료 처리 (최종 병합)
    • S3는 uploadId를 기준으로 Part를 정렬 후 하나의 파일로 병합해야 최종 저장이 완료된다.
    • 최종 병합 요청 시 모든 파트의 ETag들이 필요하다.

 

 

 

 

 


Multipart Upload 구현

 

이제 위에서 살펴본 업로드 방식을 직접 코드단에서 구현해보자. 우선적으로 Service Layer에 해당하는 비즈니스 로직을 작성해 줄 것이다. 위의 4가지 단계에 필요한 서버 로직을 구현한다고 생각하면 된다.

 

 

 

1. Service Layer 작성

 

@Service
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
@RequiredArgsConstructor
public class S3MultipartUploadService {

    private final S3Client s3Client;
    private final S3Presigner s3Presigner;

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



    /**
     * 1. Multipart Upload 초기화
     */
    public String initiateMultipartUpload(String key) {
        CreateMultipartUploadResponse response = s3Client.createMultipartUpload(
                CreateMultipartUploadRequest.builder()
                        .bucket(bucketName)
                        .key(key)
                        .build()
        );
        return response.uploadId();
    }

    /**
     * 2. Part별 Presigned URL 생성
     */
    public List<String> generatePresignedUrls(String key, String uploadId, int partCount) {
        List<String> presignedUrls = new ArrayList<>();

        for (int partNumber = 1; partNumber <= partCount; partNumber++) {

            final int finalPartNumber = partNumber;
            PresignedUploadPartRequest presignedRequest = s3Presigner.presignUploadPart(
                    req -> req.signatureDuration(Duration.ofMinutes(15)) // 15분 유효기간
                            .uploadPartRequest(
                                    UploadPartRequest.builder()
                                            .bucket(bucketName)
                                            .key(key)
                                            .uploadId(uploadId)  // 올바른 위치로 이동
                                            .partNumber(finalPartNumber)
                                            .build()
                            )
            );

            presignedUrls.add(presignedRequest.url().toString());
        }

        return presignedUrls;
    }

    /**
     * 3. 업로드 완료 요청
     */
    public void completeMultipartUpload(String key, String uploadId, List<Map<String, Object>> parts) {
        List<CompletedPart> completedParts = parts.stream()
                .map(part -> CompletedPart.builder()
                        .partNumber((Integer) part.get("partNumber"))
                        .eTag((String) part.get("eTag"))
                        .build())
                .collect(Collectors.toList());

        s3Client.completeMultipartUpload(
                CompleteMultipartUploadRequest.builder()
                        .bucket(bucketName)
                        .key(key)
                        .uploadId(uploadId)
                        .multipartUpload(CompletedMultipartUpload.builder()
                                .parts(completedParts)
                                .build())
                        .build()
        );
    }

    /**
     * 4. 업로드 실패 시 중단 요청
     */
    public void abortMultipartUpload(String key, String uploadId) {
        s3Client.abortMultipartUpload(
                AbortMultipartUploadRequest.builder()
                        .bucket(bucketName)
                        .key(key)
                        .uploadId(uploadId)
                        .build()
        );
    }
}
  1. Multipart Upload 생성 : initiateMultipartUpload() : 업로드 세션을 시작하고 uploadId를 반환
  2. Part 및 Presigned URL 생성 : generatePresignedUrls() : 각 Part에 대한 Presigned URL을 생성하여 반환
  3. 모든 Part 업로드 이후 완료 처리 (최종 병합) : completeMultipartUpload() : 업로드된 모든 Part를 병합하여 최종 파일로 저장
  4. 업로드 실패 시 취소 처리 : abortMultipartUpload() : 업로드가 실패했을 경우 해당 세션을 취소

 

 

 

 


2.  Controller 작성

 

이어서 Service Layer의 비즈니스 로직을 제공하기 위한 엔드포인트를 생성하기 위해서 Controller도 작성해주자.

 

@RestController
@RequestMapping("/api/v1/multipart/s3")
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
@RequiredArgsConstructor
public class FileMultipartUploadApiController {
    private final S3MultipartUploadService s3MultipartUploadService;

    @ApiOperation(
            value = "1. Multipart Upload 초기화 요청"
    )
    @GetMapping("/initiate")
    public ResponseEntity<Map<String, String>> initiateMultipartUpload(@RequestParam String key) {
        String uploadId = s3MultipartUploadService.initiateMultipartUpload(key);
        return ResponseEntity.status(HttpStatus.OK).body(Map.of("uploadId", uploadId));
    }

    @ApiOperation(
            value = "2. 각 Part의 Presigned URL 생성 요청"
    )
    @GetMapping("/presigned-urls")
    public ResponseEntity<List<String>> getPresignedUploadUrls(@RequestParam String key,
                                                               @RequestParam String uploadId,
                                                               @RequestParam int partCount) {
        List<String> presignedUrls = s3MultipartUploadService.generatePresignedUrls(key, uploadId, partCount);
        return ResponseEntity.status(HttpStatus.OK).body(presignedUrls);
    }

    @ApiOperation(
            value = "3. 업로드 완료 요청"
    )
    @PostMapping("/complete")
    public ResponseEntity<String> completeMultipartUpload(@RequestParam String key,
                                                          @RequestParam String uploadId,
                                                          @RequestBody List<Map<String, Object>> parts) {
        s3MultipartUploadService.completeMultipartUpload(key, uploadId, parts);
        return ResponseEntity.status(HttpStatus.OK).body("전체 업로드 성공");
    }

    @ApiOperation(
            value = "4. 업로드 취소 처리"
    )
    @PostMapping("/abort")
    public ResponseEntity<String> abortMultipartUpload(@RequestParam String key,
                                                       @RequestParam String uploadId) {
        s3MultipartUploadService.abortMultipartUpload(key, uploadId);
        return ResponseEntity.status(HttpStatus.OK).body("업로드 취소");
    }



}

 

 

 

 

 

 


3.  클라이언트 코드 작성

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>S3 Multipart Upload</title>
</head>
<body>
    <h2>S3 Multipart 파일 업로드</h2>
    <input type="file" id="uploadFile">
    <button onclick="startUpload()">업로드 시작</button>
    <p id="uploadStatus"></p>

    <script>
        const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

        async function startUpload() {
            const fileInput = document.getElementById("uploadFile");
            const uploadStatus = document.getElementById("uploadStatus");

            if (!fileInput.files.length) {
                uploadStatus.textContent = "업로드할 파일을 선택하세요.";
                return;
            }

            const file = fileInput.files[0];
            const key = `uploads/${file.name}`;
            const partCount = Math.ceil(file.size / CHUNK_SIZE);

            // 1. 서버에서 Multipart Upload 초기화 요청
            const initResponse = await fetch(`http://localhost:8080/api/v1/multipart/s3/initiate?key=${encodeURIComponent(key)}`);
            const { uploadId } = await initResponse.json();
            

            // 2. 서버에서 각 Part에 대한 Presigned URL 요청
            const urlResponse = await fetch(`http://localhost:8080/api/v1/multipart/s3/presigned-urls?key=${encodeURIComponent(key)}&uploadId=${uploadId}&partCount=${partCount}`);
            const presignedUrls = await urlResponse.json();

            const parts = [];

            // 3. 각 Part 업로드 수행
            for (let i = 0; i < presignedUrls.length; i++) {
                const start = i * CHUNK_SIZE;
                const end = Math.min(start + CHUNK_SIZE, file.size);
                const chunk = file.slice(start, end);

                const uploadResponse = await fetch(presignedUrls[i], {
                    method: "PUT",
                    headers: { "Content-Type": "application/octet-stream" },
                    body: chunk
                });

                if (!uploadResponse.ok) {
                    throw new Error(`Failed to upload part ${i + 1}`);
                }

                const eTag = uploadResponse.headers.get("ETag");
                parts.push({ partNumber: i + 1, eTag });
            }

            // 4. 서버에 업로드 완료 요청
            const completeResponse = await fetch(`http://localhost:8080/api/v1/multipart/s3/complete?key=${encodeURIComponent(key)}&uploadId=${uploadId}`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(parts)
            });

            if (completeResponse.ok) {
                alert("파일 업로드 완료!");
            } else {
                throw new Error("파일 업로드 완료 실패");
            }
        }
    </script>
</body>
</html>

 

  1. 파일 분할 : 클라이언트는 선택된 파일을 5MB 단위(CHUNK_SIZE)로 분할하여 멀티파트 업로드를 수행하도록 설정하였다.
  2. Multipart Upload 생성 : 서버에 Multipart Upload를 초기화 요청하여 uploadId를 발급받는다.
  3. Part 및 Presigned URL 생성 : 서버에서 uploadId와 Part 개수를 바탕으로 각 Part에 대한 Presigned URL을 요청한다.
  4. 개별 Presigned URL을 통해 업로드
    1. 반환된 Presigned URL을 사용하여 클라이언트는 각 Part를 S3에 개별적으로 PUT 요청으로 업로드한다.
    2. 각 Part 업로드가 완료되면, S3에서 반환된 ETag 값을 저장하여 최종 병합 요청에 활용한다.
  5. 모든 Part 업로드 이후 완료 처리 (최종 병합)
    1. 모든 Part가 업로드되면, 클라이언트는 uploadId 및 ETag 정보를 포함하여 서버에 업로드 완료 처리 요청을 한다
    2. 서버는 S3에 해당 요청을 전달하여 최종 파일을 병합 및 저장한다.

 

 

 

 

 


4.  결과 확인

 

결과를 확인해보면 하나의 mp4 동영상 파일이 여러개의 Chunk 단위로 분리되어 Multipart 업로드 요청을 보내는 것을 네트워크에서 확인할 수 있다. S3 Bucket을 확인해보면 동영상이 정상적으로 업로드 된 것을 확인할 수 있다.

 

 

 

 

 


5.  유의점 : 버킷 수명 주기 설정

 

멀티파트 업로드는 대용량 파일을 여러 개의 Part로 업로드하는 효율적인 방법이지만 업로드가 성공하지 못했을 경우, 완료되지 않은 업로드된 Part가 S3에 계속 남아있게 되어 스토리지 비용이 증가할 수 있다.


이를 방지하기 위해 버킷 수명 주기를 설정하여 미완료 멀티파트 업로드를 자동으로 정리하는 것이 필요하다.

 

 

 

 

실제로 아래의 CLI 명령어를 실행해보면 정리되지 않은 중단된 멀티파트파일 데이터들을 확인할 수 있다.

aws s3api list-multipart-uploads --bucket YOUR_BUCKET

 

 

 

버킷의 수명 주기 규칙을 생성할 때 "완료되지 않은 멀티파트 업로드 삭제" 항목이 존재한다. 해당 항목 설정을 통해 잔존 데이터들의 수명주기 관리가 동반되도록 하자.

 

 

 

<함께 보기> [AWS] S3 : 기본 개념과 버킷 설정 (스토리지 클래스, 버킷정책, 버전관리, 수명주기, 암호화)

https://sjh9708.tistory.com/258

 

[AWS] S3 : 기본 개념과 버킷 설정 (스토리지 클래스, 버킷정책, 버전관리, 수명주기, 암호화)

S3는 AWS에서 제공하는 스토리지 서비스이다. 높은 가용성 및 내구성을 제공하여 데이터를 안전하게 저장하고 어디에서나 접근할 수 있도록 서비스를 제공한다.이번 포스팅에서는 S3의 기본적인

sjh9708.tistory.com

 

 

 

 

 

 

 

 


References

 

https://docs.aws.amazon.com/

 

https://docs.aws.amazon.com/

 

docs.aws.amazon.com

https://www.inflearn.com/course/%EC%89%BD%EA%B2%8C-%EC%84%A4%EB%AA%85%ED%95%98%EB%8A%94-aws-%EA%B8%B0%EC%B4%88

 

쉽게 설명하는 AWS 기초 강의 강의 | AWS 강의실 - 인프런

AWS 강의실 | , 안녕하세요. AWS 강의실입니다.AWS 공식 커뮤니티 빌더이자 2만명의 구독자를 보유한 AWS Only 강의 유튜브의 경험으로 AWS를 쉽게 알려드립니다.이 강의는AWS의 서비스 및 활용 지식을

www.inflearn.com

 

https://techblog.woowahan.com/11392/

 

Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 우아한형제들 기술블로그

Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 안녕하세요. 세일즈서비스팀에서 전자계약서 시스템을 개발하고 있는 박민규입니다. 최근 저는 Spring Boot + Kotlin을 활용한 프로젝트에서

techblog.woowahan.com

 

반응형

BELATED ARTICLES

more