저는 Yapp 24기에서 Wespot이라는 사이드 프로젝트를 시작했습니다. (백엔드 서버)
해당 서비스는 청소년 아이들이 서로 투표하고, 쪽지를 주고 받으면서 설렘을 공유하는 서비스인데요. 그렇다보니 학생들이 본인들을 사진을 통해 더욱이 자세하게 나타낼 수 있는 기능은 필수라고 볼 수 있었습니다.
저는 이전까지, 클라이언트 쪽에서 이미지 파일을 넘겨주면 서버에서 해당 이미지를 Multipart File로 받고 Amazon sdk를 통해 직접 업로드 하는 방식으로 이미지 업로드를 구현해왔습니다.
하지만, 그러다보니 단점이 존재했습니다.
Tomcat 및 Spring Servlet에는 파일 용량 제한이 존재했고, 이 기준을 넘기면 요청을 리젝하는 것이었습니다.
물론 어느정도 조절할 수 있는걸로 알고 있긴하지만, 그것보다는 클라이언트 딴에서 이를 올려버리게 해서 이러한 단점을 제거하는 편이 더 낫지 않을까라는 생각을 하게 되어 Presigned URL을 선택하게 되었습니다.
물론 어느정도 조절할 수 있는걸로 알고 있긴하지만, 그것보다는 클라이언트 딴에서 이를 올려버리게 해서 이를 거치게 하지 않으면 되지 않을까라는 생각을 해 Presigned URL을 선택하게 되었습니다.
위에서 언급하였듯이, Presigned URL은 서버에서 S3에 일부 권한이 허용된 URL을 클라이언트딴에게 넘기면, 클라이언트는 PUT 요청을 통해 이미지를 업로드 하는 방식입니다.
여기서 일부 권한이 허용된 URL을 Presigned URL이라고 칭하게 됩니다.
자 이제 간단하게 알게 되었으니 구현에 들어가보겠습니다.
일단, Presigned URL을 활용하기 위해서 가장 먼저 S3에 접근 권한이 일부 허용된 URL을 발급받을 수 있어야겠죠? 이를 위해서 앞단에 Bucket 대한 정책 설정이 필요합니다.
저는 일반적으로 사용되는 S3에 Access 권한을 가지고 있는 IAM(Identity and Access Management, 특정 리소스에 액세스 할 수 있는 유저를 의미합니다. 일반적으로 루트 계정이 IAM을 만들어주고, 해당 IAM이 접근 가능한 리소스들을 지정해줍니다)을 만들어주고, 이에 대해 권한을 부여해주었습니다.
다음 사진처럼 말이죠.
이렇게 설정하게 되면, Presigned URL을 발급받고 나면 해당 URL에 DELETE, GET, PUT에 대한 권한이 열려있는 상태가 됩니다.
S3 Bucket, IAM을 만든 뒤 위에서 언급한 정책 설정을 마쳤다면, 이제 구현을 진행하면 됩니다. 구현은 SDK 의존성을 가져오고, Amazon Bean 설정을 완료해주고 URL을 발급받아 클라이언트에게 반환하는 형식으로 굉장히 간단하게 구현이 가능합니다.
지금부터 해당 부분들에 대해 설명드리도록 하겠습니다.
implementation("com.amazonaws:aws-java-sdk-s3:1.12.767")
implementation("software.amazon.awssdk:s3:2.27.3")
implementation("software.amazon.awssdk:s3control:2.27.3")
implementation("software.amazon.awssdk:s3outposts:2.27.3")
의존성은 다음과 같이 추가해주었습니다.
@Bean
fun presigner(): S3Presigner {
val credentialProvider: StaticCredentialsProvider = StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)
)
return S3Presigner.builder()
.credentialsProvider(credentialProvider)
.region(Region.AP_NORTHEAST_2)
.build()
}
IAM 사용자를 만들 때 발급받은 Access Key, Secret Key와 bucket에 대한 Region을 활용해 다음과 같이 Bean을 선언해주면 됩니다.
그러면 이제 해당 Bean을 가지고 기능을 구현해보죠.
@GetMapping("/presigned-url")
fun getBucketDomain(imageExtension: String): ResponseEntity<PresignedResponse> {
val tenMinute = 10L
val createPresignedUrl = imageService.createPresignedUrl(imageExtension, tenMinute)
return ResponseEntity.ok(createPresignedUrl)
}
@Service
class ImageService(
private val s3Port: S3Port,
) : ImageUseCase {
fun createPresignedUrl(imageExtension: String, expirationTime: Long): PresignedResponse {
val prefix = UUID.randomUUID()
.toString()
.replace("-", "") // prefix is what ?
val imageName = "$prefix.$imageExtension" // image name is prefix and image extension
val url = s3Port.getPresignedUrl(imageName, expirationTime)
return PresignedResponse(url, imageName)
}
}
@Component
class S3Port(
private val s3Presigner: S3Presigner,
@Value("\${aws.s3.bucket}")
private val bucket: String,
) : S3Port {
fun getPresignedUrl(imageName: String, expirationTime: Long): String {
val presignRequest: PutObjectPresignRequest = getPresignedRequest(imageName, expirationTime)
return s3Presigner.presignPutObject(presignRequest)
.url()
.toString()
}
private fun getPresignedRequest(
prefix: String,
expirationTime: Long
): PutObjectPresignRequest {
val objectRequest: PutObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(prefix)
.build()
return PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(expirationTime))
.putObjectRequest(objectRequest)
.build()
}
}
실제로는 헥사고날 패턴을 사용해서 S3Port는 인터페이스입니다. 코드의 이해를 쉽게 하기 위해 다음과 같이 작성했습니다. (Controller -> ImageService -> S3Port)
bucket 변수는 S3의 Bucket 경로명이 들어있습니다.
코드를 보면 아실 수 있겠지만, 굉장히 간단한 것을 보실 수 있습니다. 하지만, 조금 더 디테일하게 설명드려보도록 하죠.
UUID에서 -
를 제거해준 상태로 prefix를 만들어주고 클라이언트가 넘겨준 ImageExtension을 뒤에다가 붙여줘 S3에 올라갈 이미지 이름을 만들어줍니다. (key로 넣어줘, 클라이언트가 이미지를 성공적으로 업로드 했을 때, S3에도, 반환받는 이미지 이름에도 이가 입력됩니다. 아래 사진처럼 말입니다.)
그렇게 S3Port에 imageName, expirationTime을 넘기고, S3Port에서는 이를 통해 유효시간을 설정해주고 URL을 발급해줍니다.
이러한 과정들을 거치고나면, 10분의 유효시간을 가진 Presigned URL을 발급받을 수 있고, 위에서 정책으로 설정해둔 작업을 10분 여간 수행할 수 있게 되는 것입니다.
마지막으로 Postman으로 진짜 이미지를 업로드 해볼까요?
먼저 Presigned URL을 얻어냅니다.
해당 Presigned URL을 기반으로 PutMapping으로 Body에 파일을 담아서 보내면 200 OK가 떨어집니다.
S3에 잘 올라간 것을 확인할 수 있었습니다!
GET, DELETE, PUT Action에 10분의 유효시간이 걸려있습니다. 그렇다고 S3에 대한 Access를 전부 다 열어놓고 있다가는, 언젠가 DDOS 공격을 받아 요금에 허덕일지도 모릅니다. 그래서 이를 해결하고, 또한 CDN을 통해서 네트워크(물리적) 지연시간을 줄이기 위해 CloudFront를 사용합니다.
CloudFront를 사용하기 위해 정책은 다음과 같이 설정합니다.
이와 같이 설정하고, {CloudFrontURL}/{이미지 이름} 형식으로 요청하게 되면 이미지를 조회해 올 수 있습니다.
전체적인 플로우를 그림으로 정리하면 다음과 같습니다.
긴 글 읽어주셔서 정말 감사합니다!