파일 다운로드도 마찬가지로 다운로드 요청을 받으면 S3로 부터 파일을 가져오고 서버에서 파일을 스트림으로 응답한다.
이렇게 API 서버를 거치는 방식은 파일을 API 서버가 직접 처리할 수 있다는 것이다. 직접 처리할 수 있다는 의미는 특정 저장소에 직접 접근하여 저장할 수 있고, 바이너리 파일 검사(파일 포맷, 사이즈 등등), 인코딩 같은 전처리 작업을 수행할 수 있다는 것을 뜻한다.
장점이 있듯이 단점도 존재하는데, 파일 처리를 직접 하므로 API 서버 자원을 그만큼 사용한다.
문제 상황
CPU가 튀는 현상 (2곳 모두 파일 업로드)
Pinpoint APM 모니터링 중 CPU가 한번씩 튀는 현상을 발견했다.
CPU가 튀는 시점을 종합했을 때 거의 대부분 파일 업로드에서 발생했다.
지금까지 파일 업로드 요청이 몰리지 않아 장애는 없었지만 언젠가는 장애로 이어질 수 있는 잠재 문제이다.
내가 선택한 해결방법
S3의 Presigned URL을 이용하면 클라이언트가 API 서버에 파일을 보내지 않고도 S3에 업로드할 수 있다.
Sequence 다이어그램
이러면 API 서버에서 파일 관련 리소스 사용없이 파일 업로드/다운로드를 AWS에 완전히 맡길 수 있게된다.
지금까지 API 서버는 S3에 업로드할 수 있는 인증된 서버로 S3에 접근할 수 있는 IAM Role을 가지고 있다면 S3에 접근해 파일을 업로드/다운로드 할 수 있었다.
이름에도 알 수 있듯이 API 서버가 서명한 URL을 클라이언트에게 전달하면 클라이언트는 발급받은 URL만 이용하여 파일을 업로드할 수 있다는 것을 알 수 있다. 더 자세히 알아보려면 미리 서명된 URL을 통해 객체 공유 글을 참고하면 좋을 것 같다.
Presigned URL 방식을 우리 서비스에 적합한지 알아봤을 때
파일 업로드 시 인코딩을 요구 → X
파일 단순 저장 → O
Presigned URL만 있으면 업로드/다운로드 가능 → Presigned URL 획득 API는 적절한 권한이 있는 사용자에게만 인가되도록 구현
Presigned URL 유출 → HTTPS를 사용하므로 일반적으로 안전하나 만약 유출된다 하여도 만료기간을 설정할 수 있고 단일 파일에만 접근하므로 영향도가 크지 않다.
FE 구현이 복잡한가? → 이전과 비교해 상대적으로 복잡하다.
이런 결과가 나왔고 적용해도 문제없다는 판단을 내렸다.
구현
사용한 의존성
val springCloudVersion = "2023.0.0"
val springCloudAwsVersion = "3.1.1"
val awsSdkVersion = "2.24.12"
implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:$springCloudAwsVersion"))
implementation("io.awspring.cloud:spring-cloud-aws-starter-s3")
implementation("software.amazon.awssdk:apache-client")
implementation(platform("software.amazon.awssdk:bom:$awsSdkVersion"))
implementation("software.amazon.awssdk:s3")
implementation("software.amazon.awssdk:sts")
파일 저장소 인터페이스 정의
외부 의존성 영향도를 최소화하기 위해 DIP를 지켜 설계한다.
interface FileStorage {
fun requestUpload(request: FileUploadRequest): FileUploadResponse
fun requestDownload(path: String): FileDownloadResponse
fun lookup(path: String): Boolean
}
필요한 DTO 정의
data class FileUploadRequest( // Presigned URL 생성을 위한 요청
val uuid: String,
val path: String,
)
data class FileUploadResponse( // 업로드 Presigned URL 응답
val uuid: String,
val presignedUrl: String,
)
data class FileDownloadResponse( // 다운로드 Presigned URL 응답
val presignedUrl: String,
)
S3 파일 저장소 구현체
FileStorage 인터페이스를 구현한다.
@Storage
class S3FileStorage(
private val s3Properties: S3Properties,
private val s3Client: S3Client, // aws s3 sdk 의존성
private val s3Template: S3Template, // 스프링클라우드 의존성 사용
) : FileStorage {
override fun requestUpload(request: FileUploadRequest): FileUploadResponse {
val presignedUrl = s3Template.createSignedPutURL(s3Properties.bucketName, request.path, DEFAULT_DURATION)
return FileUploadResponse(request.uuid, presignedUrl.toString())
}
override fun requestDownload(path: String): FileDownloadResponse {
val presignedUrl = s3Template.createSignedGetURL(s3Properties.bucketName, path, DEFAULT_DURATION)
return FileDownloadResponse(presignedUrl.toString())
}
override fun lookup(path: String): Boolean {
try {
val headObjectRequest = HeadObjectRequest.builder()
.bucket(s3Properties.bucketName)
.key(path)
.build()
s3Client.headObject(headObjectRequest)
} catch (e: S3Exception) {
if (e.statusCode() == 404 || e.statusCode() == 403) {
return false
}
throw e
}
return true
}
companion object {
private val DEFAULT_DURATION = Duration.ofMinutes(1) // 기본 만료시간
}
}
업로드 파일의 사이즈, 타입 정의가 필요하면 요청을 아래와 같이 정의할 수 있다.
val metadata = ObjectMetadata.builder()
.contentLength(size)
.build()
val presignedUrl = s3Template.createSignedPutURL(s3Properties.bucketName, request.path, DEFAULT_DURATION, metadata, null)