Kotlin, Webflux와 S3 다운로드 With API

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

서론

Kotlin, Spring, Webflux로 클라우드 스토리지 서비스에서 파일을 받아 API 응답으로 파일 자체를 보내는 방법에 대해서 정리한다.
AWS SDK for Java v2 를 사용하였다. 의존성 추가 및 Configuration등 모든 작업이 S3 업로드 With API(1편)과 같으니 1편을 먼저 보는게 좋다.

결론

AWS SDK for Java v2에서 S3에서 파일을 받는 방식이 파일로 받거나, 바이트로 받거나 하는 방법 두가지 뿐이었는데 불과 2~3주 전에 publisher로 받는 방법이 생겼다.

구현 과정

작업환경

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

API 작성

#1 삽질

// build.gradle.kts
[..생략..]

dependencies {
    /* AWS SDK For JAVA 2.X */
    implementation(platform("software.amazon.awssdk:bom:2.15.0"))
    implementation("software.amazon.awssdk:s3")
    implementation("software.amazon.awssdk:netty-nio-client")
}

[..생략..]

처음에는 이렇게 platform("software.amazon.awssdk:bom:2.15.0" 으로 추가했었다.
이렇게 추가하고나면 파일 다운로드를 할때 AsyncResponseTransformer.toBytes(), .toFile() 매서드뿐인데
바이트로 받든 파일로 직접쓰든지 할 필요없이 Publisher 자체를 API응답으로 주는 방법이 없는지 한참 찾아헤맸다.

package api.s3.demo.service

import org.springframework.core.io.buffer.DataBuffer
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import api.s3.demo.config.S3Properties
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.GetObjectRequest
import software.amazon.awssdk.services.s3.model.GetObjectResponse
import java.util.*

@Service
class AttachmentService(
    val s3Client: S3AsyncClient,
    val s3Props: S3Properties,
) {
    fun bucket(): String = s3Props.bucket

    private fun downloadFromS3(fileLocation: String): Mono<ResponsePublisher<GetObjectResponse>> {
        val key = parseFileLocation(fileLocation)
        val future = ssClient.getObject(
            getObjectRequest(key),
            AsyncResponseTransformer.toBytes() // @note here
            // or AsyncResponsetransformer.toFile(path)
        )
        return Mono.fromFuture(future)
    }

    private fun getObjectRequest(key: String): GetObjectRequest {
        return GetObjectRequest.builder()
            .bucket(bucket())
            .key(key)
            .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("/")
    }
}

한참 찾아보던중 두둔 AWS SDK for Java V2 공식 레포지토리에 이슈가 있는걸 발견했다. 무려 2018년에 올라온 이슈인데, 불과 오늘로부터 2주전에 코멘트가 하나 달렸고 3주 전에 PR이 하나 머지가 됐다. 내용인 즉슨 이슈에 올라온 내용대로 AsyncResponse.toPublisher() 할 수 있게 되었다는 것, 운이 아주 좋다. 바로 적용해보았다.

Solution

일단 의존성을 바로 해당 버전으로 올려준다.

// 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")
}

[..생략..]

그리고 서비스에서 AsyncResponseTransformer.toPublisher()를 바로 적용해준다. 실제 Service의 구현을 아주아주 간단하게 요약하면 아래와 같이 되었다.

package api.s3.demo.service

import org.springframework.core.io.buffer.DataBuffer
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import api.s3.demo.config.S3Properties
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.GetObjectRequest
import software.amazon.awssdk.services.s3.model.GetObjectResponse
import java.util.*

@Service
class AttachmentService(
    val s3Client: S3AsyncClient,
    val s3Props: S3Properties,
    val attachmentRepository: AttachmentRepository
) {
    fun bucket(): String = s3Props.bucket
    
    fun download(attachmentId: Int): Mono<AttachmentFileResponseDTO> {
		/**
         * 1. attachmentRepository에서 레코드 찾는다.
         * 2. downloadFromS3(fileLocation) 한다.
         * 3. AttachmentFileDownloadDTO 로 변환한다.
         */
    }

    private fun downloadFromS3(fileLocation: String): Mono<ResponsePublisher<GetObjectResponse>> {
        val key = parseFileLocation(fileLocation)
        val future = ssClient.getObject(
            getObjectRequest(key),
            AsyncResponseTransformer.toPublisher() // @note here
        )
        return Mono.fromFuture(future)
    }

    private fun getObjectRequest(key: String): GetObjectRequest {
        return GetObjectRequest.builder()
            .bucket(bucket())
            .key(key)
            .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("/")
    }
}

AttachmentFileResponseDTO생략한다. 생략안한다. 다음과 같다

package api.s3.demo.dto

import api.s3.demo.entity.Attachment
import software.amazon.awssdk.core.async.ResponsePublisher
import software.amazon.awssdk.services.s3.model.GetObjectResponse

data class AttachmentFileResponseDTO(
    val response: ResponsePublisher<GetObjectResponse>,
    val contentType: String,
    val contentLength: String,
    val fileName: String,
) {
    constructor(
        getObjectResponse: ResponsePublisher<GetObjectResponse>,
        attachment: Attachment
    ) : this(
        response = getObjectResponse,
        contentType = attachment.contentType!!,
        contentLength = attachment.fileSize!!.toString(),
        fileName = attachment.fileName,
    )
}

컨트롤러에서는 그냥 다음과 같이 심플하게 넘겨주면 해당 API Get요청이 들어왔을때 브라우저로 바로 파일이 다운로드가 된다.

package api.s3.demo.controller

import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import api.s3.demo.service.AttachmentService
import software.amazon.awssdk.core.async.SdkPublisher
import java.nio.ByteBuffer

@RestController
class AttachmentController(
    val attachmentService: AttachmentService,
) {
    @GetMapping("/api/attachment/{attachmentId}")
    fun download(
        @PathVariable("attachmentId")
        attachmentId: Int
    ): Mono<ResponseEntity<SdkPublisher<ByteBuffer>>> {
        return attachmentService.download(attachmentId)
            .flatMap {
                Mono.just(
                    ResponseEntity
                        .status(200)
                        .header(HttpHeaders.CONTENT_TYPE, it.contentType)
                        .header(HttpHeaders.CONTENT_LENGTH, it.contentLength)
                        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=${it.fileName}")
                        .body(it.response.map { it }) 
                        /**
                         * @note: it.response가 ResponsePublisher<GetObjectResponse> 타입이다.
                         * 이걸 SdkPublisher<ByteBuffer> 로 바꿔준다음 그냥 내보내면 된다.
                         */
                )
            }
    }
}

0개의 댓글