이번에 서비스를 개발하면서 게시글에 이미지를 업로드하는 기능을 구현하게 되었습니다.
일반적으로 이미지 등의 파일은 일반적인 문자열 등의 데이터와 달리 용량이 커서 데이터베이스가 아닌 외부 저장소에 저장되는데요.
저는 외부 저장소로 비용이 저렴한 클라우드 스토리지인 AWS S3(Simple Storage Service)를 사용하기로 했습니다.
AWS S3
AWS S3란 비용도 저렴하고 저장할 수 있는 데이터의 양도 제한이 없는 수준의 클라우드 스토리지 서비스입니다.
S3에서 객체 하나는 최대 5TB의 제한을 가지고 있는데, 사실상 이미지는 500KB 정도의 용량을 가지기 때문에 용량 측면에서 문제는 없습니다.
일반적으로 버킷 내 객체에 대한 권한은 ACL(Access Control List) 또는 버킷 정책으로 설정할 수 있습니다.
저는 개별적인 이미지에 대한 권한보다는 모든 이미지들에 대한 권한 설정이 필요한데요.
그러므로 전체적인 객체 권한 설정에 용이한 버킷 정책을 사용할 예정입니다.
앞서, 버킷 정책을 통해 권한을 설정하기 위해서는 버킷 정책이 적용하는 퍼블릭 액세스 차단을 해제해야 합니다.
저는 현재 Spring WebFlux를 사용하고 있으므로 비동기 방식의 업로드를 지원하는 SDK를 사용하였습니다.AwsConfiguration.kt
@Configurationclass AwsConfiguration( @Value("\${aws.credentials.accessKey}") private val accessKey: String, @Value("\${aws.credentials.secretKey}") private val secretKey: String) { @Bean fun awsCredentialsProvider(): AwsCredentialsProvider = AwsCredentialsProvider { AwsBasicCredentials.create(accessKey, secretKey) }}
IAM으로 생성한 사용자의 액세스 키와 시크릿 액세스 키를 통해 AwsCredentialsProvider를 Bean으로 등록합니다.S3Configuration.kt
@Configurationclass S3Configuration( private val credentialsProvider: AwsCredentialsProvider, @Value("\${aws.s3.region}") private val region: String) { @Bean fun amazonS3Client(): S3AsyncClient = S3AsyncClient.builder() .region(Region.of(region)) .credentialsProvider(credentialsProvider) .serviceConfiguration( S3Configuration.builder() .checksumValidationEnabled(false) .chunkedEncodingEnabled(true) .build() ) .build()}
그 다음, 업로드에 사용할 S3AsyncClient를 Bean으로 등록했는데요.
성능 오버헤드 및 비용 문제를 생각해 객체의 무결성을 검사하는 체크섬(Checksum) 유효성 검사는 해제했습니다.S3AsyncClient.java
public interface S3AsyncClient extends SdkClient { default CompletableFuture<PutObjectResponse> putObject(PutObjectRequest putObjectRequest, AsyncRequestBody requestBody) { throw new UnsupportedOperationException(); } ...}
S3AsyncClient는 기존의 S3Client와 달리 putObject()가 비동기적으로 작동하므로 CompletableFuture를 반환한다는 차이점이 있습니다.
Spring WebFlux에서는 CompletableFuture를 fromFuture()를 통해 Mono로 변환해서 사용하면 됩니다.
이미지 업로드 기능 구현
이제 이미지 업로드 기능을 구현해 보겠습니다.
구현하기에 앞서, 저는 이미지를 multipart/form-data를 통해 받을 예정인데요.
Spring WebFlux에서는 multipart/form-data로 받은 Request Body는 MultiValueMap<String, Part>의 타입을 가지고 있습니다.Part.java
public interface Part { String name(); HttpHeaders headers(); Flux<DataBuffer> content(); ...}
Part는 기본적으로 위와 같은 명세를 가지고 있는데요.FormFieldPart.java
public interface FormFieldPart extends Part { String value();}
FilePart.java
public interface FilePart extends Part { String filename(); ...}
여기서 Part는 받은 데이터의 종류에 따라 FilePart 또는 FormFieldPart로 구현됩니다.
문제는 AsyncS3Client가 content()의 반환 값인 Flux<DataBuffer>에 대한 지원을 하지 않는다는 점입니다.
그러므로 Flux<DataBuffer>를 AsyncS3Client가 받을 수 있는 ByteArray 또는 String 등의 형태로 변환해야 합니다.WebUtil.kt
저는 Flux<DataBuffer>를 ByteArray로 변환하는 방법을 사용해 보겠습니다. DataBufferUtils를 사용하면 Flux<DataBuffer>를 Mono<DataBuffer>로 변환하고 안전하게 메모리 해제까지 수행할 수 있습니다.S3Provider.kt
앞서 구현한 toByteArray()와 함께 FilePart 형태의 이미지를 업로드하는 S3Provider를 구현했습니다. FilePart는 따로 파일의 크기를 제공하지 않아서 DataBuffer의 readableByteCount()를 통해 크기를 구하였습니다.
또한 해당 이미지가 저장된 후의 URI(Uniform Resource Identifier)를 데이터베이스에 저장하기 위해 upload()가 객체 URI를 반환하도록 했습니다.
마지막으로 구현한 이미지 업로드를 포함한 비즈니스 로직은 다음과 같습니다.PostHandler.kt
제가 구현한 이미지 업로드는 URI에 버킷의 정보가 노출된다는 단점이 존재하는데요.
해당 단점은 CDN(Content Delivery Network)을 적용해서 해결할 수 있습니다.
CDN이란 사용자의 위치와 인접한 곳에서 정적 파일를 캐싱(Caching)해 제공하는 방식인데요.
일반적으로 정적 파일이 저장된 리전과 다른 리전에서도 정적 파일을 빠르게 로드하기 위해 사용그러나, AWS S3에 CDN을 적용하면 URI로부터 버킷 정보를 숨기고 비용을 절감할 수 있다는 장점도 챙길 수 있습니다.
AWS에서는 엣지 로케이션(Edge Location)에 파일을 캐싱해 제공하는 CloudFront라는 CDN 서비스를 사용하면 됩니다.
CloudFront 배포
우선 원본을 가진 오리진(Origin) 서버가 S3 도메인을 가지도록 설정합니다.
이렇게 되면 CloudFront에서 특정 파일이 없는 경우, 오리진 서버인 S3로부터 해당 파일을 가져와 캐싱하게 됩니다.
또한 보안을 위해 OAC(Origin Access Control)를 설정했습니다.
정상적으로 S3 버킷이 CloudFront의 오리진 서버로 추가된 것을 확인할 수 있습니다.
버킷 정책 수정
이제부터는 외부에서 기존의 S3가 아닌 CloudFront로 접근할 것이므로 버킷 정책을 수정해야 합니다.