훈훈훈

Spring boot :: Multipart upload API using Amazon S3 API 구현 과정 정리 본문

Spring Framework/Kotlin

Spring boot :: Multipart upload API using Amazon S3 API 구현 과정 정리

훈훈훈 2020. 11. 9. 01:31

이번에 사내에서 S3 업로드 방식을 멀티파트(Multipart) 업로드 방식으로 변경하는 일을 맡게 되었다.

 

해당 기능 구현 중 SDK를 사용한 예제는 많았지만 S3에서 지원하는 REST API를 사용하는 예제는 찾기 힘들었기 때문에 이 기회에 정리하게 되었다.  ( 해당 예제 코드는 Spring boot 와 Kotlin으로 작성하였다. )

Why Multipart Upload ??

S3에서 단일 객체를 업로드할때 최대 5GB 이상은 업로드할 수 없다.

일반적으로 5GB를 초과하는 파일을 업로드할때 용량을 압축해서 올리는 방안이 있겠지만, 압축에도 한계가 발생할 수 있다.

 

그런 상황에서 멀티파트 업로드를 사용하면 하나의 파일을 최대 5GB까지 10,000개로 분할 후 업로드 할 수 있다.

즉 5TB 파일까지 업로드가 가능하다. 

Why REST API ? 

위 그림은 공식문서에서 확인한 멀티파트 업로드 방법들이다.

 

S3에서 멀티파트 업로드를 구현하는 방법은 AWS에서 제공하는 SDK, REST API, CLI 총 3가지가 있다.

필자는 그 중에서 REST API를 사용해서 구현하려고 한다.

 

그 이유는 SPA 구조로 개발 시 프론트와 백엔드가 분리된 환경이기 때문에 AWS에서 제공하는 SDK는 사용하지 않고 REST API를 사용하여 업로드 권한을 프론트에게 전달하는 구조로 개발하려고 했기때문이다.

 

SDK 사용 시, 단점으로는 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.

 

혹은 프론트에서 JavaScript SDK를 사용하여 진행할 수 있지만, 그럴 경우 accessKey와 secretKey를 프론트에서도 가지고 있어야되는 단점이 존재한다. 혹은 백엔드에서 요청을 받을때마다 키 값을 전송해주는 위험은 구조로 개발될 수 있다.

 

통신 과정

위 그림은 통신 과정을 간략하게 보여준다. 파일을 분할하고 각각의 키 값이 필요하고 요청을 보내야하기 때문에 실제 통신 과정은 위 그림보다는 다소 복잡하다.

 

간단히 요약하자면 백엔드는 업로드에 필요한 값들을 생성해서 프론트에게 전달하고 프론트가 요청을 받고 실제 업로드를 담당하는 구조다.

기능 정의

공식문서를 살펴보면 실제 멀티파트 업로드하는데 필요한 API는 아래와 같다.

-  Initiate Multipart Upload  :  멀티 파트 업로드 요청 생성
-  Upload Part  :  분할된 Parts 업로드 (프론트에서 수행) 
-  Complete Multipart Upload  :  모든 Parts 업로드 후, 최종적으로 업로드 완료 요청 (백엔드에서 수행) 

위 API를 바탕으로 이제 프론트와 백엔드의 역할을 정의해보자. 

 

-  프론트 

1.  클라이언트로 부터 파일 업로드 요청 받음
2.  파일 분할
3.  S3로 파일 업로드 
4.  업로드 완료 후, 백엔드로 완료 요청 보냄 

-  백엔드

1.  멀티파트 업로드 요청 생성 (Create Upload ID)
2.  멀티파트 업로드에 필요한 Values 생성 후 프론트로 전달
3.  프론트로부터 업로드 완료 요청 받은 후, S3로 최종 요청 전달   

따라서 백엔드는 아래 3가지에 대하여 API 구현이 필요하다.

1. Initiate Multipart Upload API를 호출하여 Upload ID를 받아서 프론트에게 전달이 필요하다.
2. 프론트가 Upload Part로 요청을 보내는데 필요한 값들을 생성해서 전달해줘야 한다.
3. Complete Multipart Upload API를 호출하여 최종적으로 업로드 완료 요청을 해야한다.

구현 코드

1.  Initiate Multipart Upload


해당 기능에 대한 공식문서를 먼저 간략하게 요약해보자.

 

먼저 관련 요청 (위 3가지 API)을 보내기 위해서는 AWS Signature Version 4가 헤더 값으로 필요하다고 명시하고 있다.

해당 서명에 대한 자세한 내용은 공식문서 링크 참고하길 바란다.

 

필자는 직접 구현하지는 않고 오픈소스 라이브러리를 이용해서 서명을 하였다.

라이브러리 자체가 무겁지 않고 서명 기능만 심플하게 제공되서 별다른 사이드이펙트는 발생하지 않을 것 같아서 적용했다.

아래 예시처럼 심플하게 서명을 생성할 수 있다.

 

 

이제 API 호출하는 예시를 살펴보자

 

위 그림을 보면 정말 간단하다. Key(example-object)라는 파일이 s3에 위치하게될 경로와 Authorization을 보내주면 된다.

Authorization은 현재 AWS Signature Version 4를 사용하고 있기 때문에 아래 총 3가지 Value가 헤더에 담겨야한다. 

 

1.  X-Amz-Content-Sha256  :  Body 값에 담기는 내용에 대한 Sha256 Checksum 값

2.  X-Amz-Date  :  요청 시간, Authorization 값을 만드는데 사용되며 Authorization을 만들 때 사용한 값이랑 동일해야한다.

3.  Authorization  : 1번과 2번을 사용하여 생성, 오픈소스 라이브러리를 사용하여 간단히 생성 가능

 

 

이제 Resonse example을 살펴보자

 

요청이 성공적했을때 XML 형식으로 UploadID를 반환하는걸 볼 수 있다. 이제 코드로 살펴보자.

 

 

-  Controller

 
 @Autowired
 lateinit var multipartUploadService: MultipartUploadService


 @PostMapping("createMultipartUpload")
 fun createMultipartUpload(
     @RequestParam(name = "fileName", required = true) fileName: String,
     @RequestParam(name = "accountId", required = true) accountId: Long
 ) : ResponseEntity<S3CreateMultipartUploadDTO> {
     return ResponseEntity
         .ok()
         .body(multipartUploadService.createMultipartUploadId(accountId, fileName))
 }

 

컨트롤러는 간단하게 File Path를 만들기 위해서 accountId와 fileName 그리고 바디 값에 대한 Checksum으로 sha256Checksum을 파라미터로 받았다.

 

사실 Initiate Multipart Upload 요청을 할 때 바디 값은 필요없기 때문에 무조건 Empty String에 대한 Sha256Checksum ("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")으로만 요청을 보내기 때문에 해당 값으로 고정해서 넣었다.

 

Sha256Checksum 생성은 간단하게 온라인으로도 확인할 수 있다.

 

-  Service

 
 @Value("\${location.endpoint}")
 lateinit var endpoint: String

 @Value("\${location.bucket}")
 lateinit var bucket: String

 @Autowired
 lateinit var s3UploadPolicyGenerator: S3UploadPolicyGenerator

 fun createMultipartUploadId(
     accountId: Long,
     fileName: String
 ): S3CreateMultipartUploadDTO {
     val datetime: String = s3UploadPolicyGenerator.generateDateTimeHeaderValue()
     val host: String = "$bucket.$endpoint"
     val key: String = "$accountId$fileName"
     val uri: String = "https://$host/$key?uploads"
     val sha256Checksum: String = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
     
     val authorization: String = s3UploadPolicyGenerator
         .generateS3UploadAuth("POST", uri, host, datetime, sha256Checksum)

     val response = khttp.post(
         url = uri,
         headers = mapOf(
             "Authorization" to authorization,
             "X-Amz-Content-Sha256" to sha256Checksum,
             "X-Amz-Date" to datetime
         )
     )
    
     val uploadId: String = createDocument(response.text)
         .getElementsByTagName("UploadId")
         .item(0)
         .textContent
 
     return S3CreateMultipartUploadDTO(key, uploadId)
 }
 
 private fun generateDateTimeHeaderValue(): String {
     val now: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC)
     return now.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmSS'Z'"))
 }
 
 private fun createDocument(responseXMLDate: String): Document {
     return DocumentBuilderFactory
         .newInstance()
         .newDocumentBuilder()
         .parse(InputSource(StringReader(responseXMLDate)))
 }
 
 data class S3CreateMultipartUploadDTO(
     val key: String,
     val uploadId: String
 )

 

Service layer에서는 Authorization을 생성 후, Initiate Multipart Upload API를 호출하여 반환 받은 Upload ID를 리턴하는 로직이다.

S3CreateMultipartUploadDTO 같은 경우 코드를 분리 해야되는게 맞지만, 편하게 글을 보기 위해 여기서는 하나의 파일로 합쳤다.

 

 

-  gernateS3UploadAuth

fun generateS3UploadAuth(
    method: String,
    uri: String,
    host: String,
    datetime: String,
    sha256Checksum: String
): String {
    val request: HttpRequest = HttpRequest(method, URI(uri))

    return Signer.builder()
        .awsCredentials(AwsCredentials(accessKey, secretKey))
        .region(region)
        .header("Host", host)
        .header("x-amz-date", datetime)
        .header("x-amz-content-sha256", sha256Checksum)
        .buildS3(request, sha256Checksum)
        .signature
}

위 코드는 Authorization을 생성하는 코드이며 위에서 언급한 라이브러리 예시를 참고하여 작성하였다. 

 

배포 후 해당 API를 호출하면 아래와 같이 응답해주는 것을 확인할 수 있다.

 

 

 

2.  Createa Upload Part Value


마찬가지로 해당 기능에 대한 공식문서를 먼저 간략하게 요약해보자.

해당 기능을 사용하기 위해서는 사전에 파일이 분리되어 있어야한다.

 

백엔드에서 간단히 테스트할때는 Linux CLI에서 제공하는 Split 명령어로 파일을 분리 후 분리된 개수 만큼 요청을 보내서 테스트할 수 있다.

> split -b 10m test.mp4 part-file

 

이제 API 호출 예제를 살펴보자

 

Initiate Multipart Upload API 와 큰 차이는 없다.

다른점은 분할된 파일이 3개라면 partNumber = 1, 2, 3 각각 총 3번의 요청을 보내야 한다.

 

그리고 파일을 업로드 하는 API이기 때문에 파일을 바디에 넣어서 보내야한다. 또한 해당 파일(Part)에 대한 Sha256Checksum 값도 같이 헤더에 보내야 한다.

 

추가적으로 Content-MD5라는 헤더 값은 Optional한 값이라 해당 예제에서는 사용하지 않았다. 이제 코드로 살펴보자.

 

-  Controller

@PostMapping("createUploadPartHeaderValue")
fun createUploadPartHeaderValue(
    @RequestParam(name = "sha256Checksum", required = true) sha256Checksum: String,
    @RequestParam(name = "partNumber", required = true) partNumber: Int,
    @RequestParam(name = "uploadId", required = true) uploadId: String,
    @RequestParam(name = "key", required = true) key: String,
    @RequestParam(name = "accountId", required = true) accountId: Long
): ResponseEntity<S3UploadPartHeaderValueDTO> {
    return ResponseEntity
        .ok()
        .body(multipartUploadService.createUploadPartHeaderValue(
            accountId,
            projectId,
            partNumber,
            uploadId,
            key,
            sha256Checksum)
        )
}

위 공식문서 예시에서 언급된 파라미터들을 생성하기 위한 값들을 받도록 작성하였다.

 

 

-  Service

fun createUploadPartHeaderValue(
    accountId: Long,
    partNumber: Int,
    uploadId: String,
    key: String,
    sha256Checksum: String
): S3UploadPartHeaderValueDTO {
    val datetime: String = s3UploadPolicyGenerator.generateDateTimeHeaderValue()
    val host: String = "$bucket.$endpoint"
    val uri: String = "https://$host/$key?partNumber=$partNumber&uploadId=$uploadId"
    val authorization: String = s3UploadPolicyGenerator
        .generateS3UploadAuth("PUT", uri, host, datetime, sha256Checksum)

    return S3UploadPartHeaderValueDTO(authorization, sha256Checksum, datetime, bucket)
}

마찬가지로 전달 받은 값들을 사용해서 Authorization 값을 생성하여 전달하였다.

 

배포 후, 생성한 API를 호출하면 아래와 같이 응답이 오는 걸 확인할 수 있다.

 

 

이제 이걸 PostMan을 사용하여 요청해보자.

 

각각의 PartNumber 에 대해서 요청을 보낸다면, 위 그림 처럼 헤더에서 ETag를 값을 확인할 수 있다.

 

 

3.  Complete Multipart Upload


마찬가지로 해당 기능에 대한 공식문서를 먼저 간략하게 요약해보자.

 

API 호출 예제를 살펴보자

 

앞에서 보았던 두개 API와 차이점이라면 바디에 PartNumber와 Etag를 쌍으로 XML 형식으로 보내는 것이다.

PartNumber와 Etag는 Upload Part API를 호출하여 반환 받은 그대로 쌍이 맞아야한다.

 

마지막으로 Response 값을 살펴보면 요청이 성공하면 XML형식으로 경로, 버켓명 등을 반환하는 것을 알 수 있다.

 

 

-  Controller

@PostMapping("completeMultipartUpload")
fun completeMultipartUpload(
    @RequestParam(name = "key", required = true) key: String,
    @RequestParam(name = "uploadId", required = true) uploadId: String,
    @RequestParam(name = "partNumbers", required = true) partNumbers: List<Int>,
    @RequestParam(name = "eTags", required = true) eTags: List<String>
): ResponseEntity<S3CompleteMultipartUploadDTO> {
    return ResponseEntity.ok(multipartUploadService.completeMultipartUpload(key, uploadId, partNumbers, eTags))
}

Upload Part API를 호출해서 받은 Etags와 partNumbers를 리스트 타입으로 받도록 작성하였다.

그리고 Initial multipart upload를 호출하여 반환 받은 Upload ID 그리고 S3에 저장될 path인 key값도 파라미터로 받도록 했다.

 

-  Service

fun completeMultipartUpload(
    key: String,
    uploadId: String,
    partNumbers: List<Int>,
    eTags: List<String>
): String {
    val datetime: String = s3UploadPolicyGenerator.generateDateTimeHeaderValue()
    val host: String = "$bucket.$endpoint"
    val uri: String = "https://$host/$key?uploadId=$uploadId"

    val bodyData = generateCompleteMultipartUploadXMLBody(partNumbers, eTags)
    val sha256Checksum = sha256(bodyData).joinToString("") { String.format("%02x", it) }

    val authorization: String = s3UploadPolicyGenerator
        .generateS3UploadAuth("POST", uri, host, datetime, sha256Checksum)

    val response = khttp.post(
        url = uri,
        headers = mapOf(
            "Authorization" to authorization,
            "X-Amz-Content-Sha256" to sha256Checksum,
            "X-Amz-Date" to datetime
        ),
        data = bodyData
    )

    val responseLocation: String = createDocument(response.text)
        .getElementsByTagName("Location")
        .item(0)
        .textContent

    return responseLocation
}

private fun generateCompleteMultipartUploadXMLBody(partNumbers: List<Int>, eTags: List<String>): String {
    var partEtags: String = ""

    for (index: Int in eTags.indices) {
        partEtags += "<Part><ETag>${eTags[index]}</ETag><PartNumber>${partNumbers[index]}</PartNumber></Part>\n"
    }
    val bodyData: String = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
            "<CompleteMultipartUpload xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\n" +
            "$partEtags" +
            "</CompleteMultipartUpload>"

    return bodyData
 }

컨트롤러에서 받은 partNumbers와 Etags를 쌍으로 묶어서 XML 형식으로 생성 후, 전달받은 Upload ID와 Key값을 받아서 Complete MultipartUpload API를 호출하도록 작성하였다.

 

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

 

Comments