Kotlin, Webflux와 S3 업로드 With API

fana·2022년 2월 16일
0
post-thumbnail

서론

Kotlin과 Spring, Webflux로 API를 구현하면서 흔하게 사용하는 클라우드 스토리지 서비스에 파일을 업로드하는 API를 구현하게 되었다.
작업중에 생각보다 레퍼런스도 마땅치 않고 여러 시행착오를 겪으면서 이참에 한번 정리하고자 한다.

결론

결론부터 말하면 일반적으로 multipart/form-data로 파일 업로드를 구현하는 것 보다는 파일과 관련된 DB 레코드 생성과, 실제로 스토리지에 파일을 업로드하는 API를 분리해서 구현하는게 더 편하다. 프론트엔드단에서 한개의 form으로 입력값과 파일을 받고, API서버에 직접 두번의 API요청을 보내도록 구현한다는 뜻이다.

구현 과정

작업환경

  • 빌드도구 Gradle
  • Spring boot v2.5.8
  • Spring v5.3.14

의존성 추가

AWS SDK for Java 2.x을 사용하기 위해서 dependency를 추가해준다.

// build.gradle.kts

dependencies {
    [..생략..]
    
    /* AWS SDK For JAVA 2.X */
    implementation(platform("software.amazon.awssdk:bom:2.17.122"))
    implementation("software.amazon.awssdk:s3")
    implementation("software.amazon.awssdk:netty-nio-client") // AWS SDK HttpClient로 netty-nio-client가 사용된다.
    
    [..생략..]
}

🔖 software.amazon.awssdk 버전이 2.17.122인데 그 이유가 있다.. 이건 S3 다운로드 With API편에서 설명한다.

Configuration

S3ClientConfiguration

자세한 설정값( writeTimeout, maxConcurrency 등에 대한 설명은 생략한다.

package api.s3.demo.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.AwsCredentials
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
import software.amazon.awssdk.http.async.SdkAsyncHttpClient
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.S3AsyncClientBuilder
import software.amazon.awssdk.services.s3.S3Configuration
import java.net.URI
import java.time.Duration


@Configuration
class S3ClientConfiguration(
    val s3Props: S3Properties,
) {
    @Bean
    fun client(provider: AwsCredentialsProvider): S3AsyncClient {
        val httpClient: SdkAsyncHttpClient = NettyNioAsyncHttpClient.builder()
            .writeTimeout(Duration.ZERO)
            .maxConcurrency(64)
            .build()

        val serviceConfig: S3Configuration = S3Configuration.builder()
            .checksumValidationEnabled(false)
            .chunkedEncodingEnabled(true)
            .build()

        val builder: S3AsyncClientBuilder = S3AsyncClient.builder()
            .httpClient(httpClient)
            .region(s3Props.region)
            .credentialsProvider(provider)
            .serviceConfiguration(serviceConfig)

        return builder.build()
    }

    @Bean
    fun awsCredentialsProvider(): AwsCredentialsProvider {
        return AwsCredentialsProvider {
            val credentials: AwsCredentials = AwsBasicCredentials.create(s3Props.publicKey, s3Props.privateKey)
            credentials
        }
    }
}

S3Properties는 다음과 같다.
@ConstructorBinding, @ConfigurationProperties 어노테이션에 대한 설명도 생략한다.

package api.s3.demo.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import software.amazon.awssdk.regions.Region

@ConstructorBinding
@ConfigurationProperties(prefix = s3)
data class S3Properties(
    val publicKey: String,
    val privateKey: String,
    val region: Region = Region.AP_NORTHEAST_2,
    val host: String,
    val bucket: String,
)

여기까지면 AWS SDK for Java 2.x를 사용하기 위한 기본적인 설정은 끝이 났다.

API 작성

#1 삽질

처음에는 multipart/form-data로 파일도 받고 여러 파라메터도 같이 받으려고 하였다.
이렇게 API를 요청하는건 어떻게보면 당연한데, AWS SDK for Java 2.x에서 업로드하려는 파일의 사이즈를 같이 보내야 한다.
그런데 multipart/form-datacontent-length 헤더가 아래와 같이 통짜로 계산해서 오기때문에, 실제 파일 사이즈를 알려면 메모리에 파일을 다 읽은 후에 사이즈를 보내야해서 한번에 여러개의 업로드 요청이 오는 경우 맛탱이가 갈 수가 있다.

POST /api/attachment HTTP/1.1
Host: localhost:8080
Content-Length: 282
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="blahblah"

123123
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="redrocket_cube_logo.png"
Content-Type: image/png

(data)
----WebKitFormBoundary7MA4YWxkTrZu0gW

Solution

그래서 굳이 DB 레코드를 만드는 POST 요청을 받고, 그 레코드에 파일정보를 추가하면서 스토리지에 실제 파일을 업로드하는 PUT 요청을 받도록 API를 분리하였다. 실제 구현은 다음과 같다.

먼저 예시 Attachment 엔티티다.

package api.s3.demo.entity

import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import java.time.Instant
import java.time.LocalDate

@Table("attachments")
data class Attachment(
    @Id
    val id: Int? = null,
    val fileName: String, // 파일 이름
    var fileLocation: String? = null, // 클라우드 스토리지 경로, 예를들어서 "s3://<bucketNamn>/<key>" 가 된다.
    var contentType: String? = null, // 파일 content-type
    var fileSize: Long? = null, // 파일 content-length
    val createdAt: Instant = Instant.now(),
    val updatedAt: Instant = Instant.now(),
)

그 다음 POST 요청으로 실제 파일 없이 DB 레코드를 생성한다.

package api.s3.demo.controller

@RestController
class AttachmentController(
    val attachmentService: AttachmentService,
) {
    @PostMapping("/api/attachment")
    fun create(
        @RequestBody(required = true)
        data: CreateAttachmentDTO
    ): Mono<ResponseEntity<CreateAttachmentDTO>> {
        return attachmentService
            .createAttachment(data)
            .map {
                ResponseEntity
                    .status(201)
                    .body(data.copy(id = it.id))
            }
    }

DB레코드만 생성하는거라 구현 자체는 그냥 서비스를 통해서 레포지토리를 통해 레코드를 하나 만들어주면 된다.

그 다음은 PUT 요청을 받아 해당 레코드를 찾고, 스토리지에 실제 파일을 업로드하면 된다. 이때 프론트엔드단에서 하나의 Form으로 서버에 API요청을 두번할 수 있도록 하는게 좋다. 실제 구현에는 Attachment 엔티티에 Status Enum 타입이 있어서 두번에 걸쳐 들어오는 요청에 따라 레코드가 정상적으로 생성되었는지, 파일 업로드가 되기를 기다리는 중인지, 파일 업로드가 완료되었는지 상태관리가 들어가있다. POST 요청이 들어왔는데 바로 PUT 요청이 들어오지 않는 경우 비정상적인 시나리오이기 때문이다. 여기서는 상태관리에 대한 자세한 구현은 생략하였다.
또 POST 요청을 한 유저와 그 레코드에 파일을 추가하려는 PUT 요청을 한 유저가 같아야 한다든지, X분 내 PUT 요청이 들어오지 않으면 Attachment 레코드를 만료로 만든다든지 등의 validation도 생략한다.

package api.s3.demo.controller

@RestController
class AttachmentController(
    val attachmentService: AttachmentService,
) {
    // POST 요청은 이전과 같다.
    @PostMapping("/api/attachment")
    fun create(
        @RequestBody(required = true)
        data: CreateAttachmentDTO
    ): Mono<ResponseEntity<CreateAttachmentDTO>> {
        return attachmentService
            .createAttachment(data)
            .map {
                ResponseEntity
                    .status(201)
                    .body(data.copy(id = it.id))
            }
    }
    
    @PutMapping("/api/attachment/{attachmentId}")
    fun upload(
        @PathVariable("attachmentId")
        attachmentId: Int,
        @RequestHeader("Content-Length", required = true)
        contentLength: Int,
        @RequestHeader("Content-Type", required = true)
        contentType: String,
        @RequestBody
        file: Flux<DataBuffer>
      ): Mono<ResponseEntity<String>> {
        return attachmentService.upload(
            attachmentId,
            file,
            contentLength,
            contentType
          ).thenReturn(ResponseEntity.status(204).build())
      }

AttachmentService는 다음과 같이 구현되어있다.

package api.s3.demo.service

import api.s3.demo.config.S3Properties
import api.s3.demo.repository.AttachmentRepository
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import software.amazon.awssdk.core.async.AsyncRequestBody
import software.amazon.awssdk.core.async.AsyncResponseTransformer
import software.amazon.awssdk.core.async.ResponsePublisher
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectResponse
import java.util.*

@Service
class AttachmentService(
    val s3Client: S3AsyncClient,
    val s3Props: S3Properties,
    val attachmentRepository: AttachmentRepository,
) {
    fun bucket(): String = s3Props.bucket
    
    fun upload(
        attachmentId: Int,
        file: Flux<DataBuffer>, 
        contentType: String, 
        contentLength: Int
      ): Mono<Attachment> {
        return attachmentRepository.findById(attachmentId)
            .flatMap { 
                actualUpload(
                    file, 
                    parseFileLocation(it.fileLocation),
                    contentType,
                    contentLength.toLong()
                  ) 
              }
//          [...생략...]
    }

    private fun actualUpload(
        file: Flux<DataBuffer>,
        fileLocation: String,
        contentType: String,
        fileSize: Long,
    ): Mono<PutObjectResponse> {
        val key = parseFileLocation(fileLocation)
        val future = s3Client.putObject(
            putObjectRequest(key, contentType, fileSize),
            AsyncRequestBody.fromPublisher(file.map { it.asByteBuffer() }) // DataBuffer to ByteBuffer
        )
        return Mono.fromFuture(future)
    }

    private fun putObjectRequest(
        key: String,
        contentType: String,
        fileSize: Long,
    ): PutObjectRequest {
        return PutObjectRequest.builder()
            .contentType(contentType)
            .contentLength(fileSize)
            .key(key)
            .bucket(s3Props.bucket)
            .build()
    }

    /**
     * @param [String] fileLocation, "s3://{bucket}/{key}"
     * example: "s3://demoBucket/demo/file/path/in/s3/fileName.jpg" => "demo/file/path/in/s3/fileName.jpg"
     */
    private fun parseFileLocation(fileLocation: String): String {
        val temp = fileLocation.split("/")
        return temp.subList(3, temp.lastIndex + 1).joinToString("/")
    }

}

최대한 간단하게 합친다고 합친건데 아주 개판이 되었다. 아무튼 POST요청하고 PUT요청을 하면 S3에 정상적으로 파일이 업로드되는것을 확인할 수 있다.
NaverCloudPlatform의 ObjectStorage 서비스도 클라우드 스토리지 서비스로, config만 바꾸면 똑같은 방식(AWS SDK로)으로 스토리지에 업로드가 가능하다.

다음편에는 스토리지에서 객체를 가져와 API 응답으로 쏴주는 내용을 적어야겠다.
기회가 되면(제발ㅠ) Server Side Encryption Customer Key로 스토리지에 업로드할때 암호화시키고 암호화된 객체를 다운로드하는 SSE-C 및 KMS(Key Management Service)를 사용한 작업내용도 작성하겠다~~~!

0개의 댓글