1. Presigned URL이란?
미리 서명된 URL의 생성자가 해당 객체에 대한 액세스 권한을 보유할 경우, 미리 서명된 URL은 URL에서 식별된 객체에 대한 액세스를 부여합니다.
즉, 객체를 업로드하기 위해 미리 서명된 URL을 수신하는 경우, 미리 서명된 URL의 생성자가 해당 객체를 업로드하는 데 필요한 권한을 보유하는 경우에만 객체를 업로드할 수 있습니다.
* 미리 서명된 URL은 Presigned URL입니다.
AWS 공식문서에서는 Presigned URL을 이렇게 정의하였습니다.
조금 더 쉽게 말하자면 해당 객체를 업로드하는데 필요한 권한을 보유하고 있는 사람이 객체를 업로드하고 싶은 사람에게 서명된 URL을 주는 것입니다. 이를 백엔드와 프론트엔드 개념에 적용시켜 보면 프론트엔드가 백엔드에게 자신이 업로드하고자 하는 파일명, 확장자... etc를 보내주면 백엔드는 Presigned URL을 발급한 뒤, 프론트엔드에게 전달하고 프론트엔드는 Presigned URL을 이용해 파일을 업로드합니다.
1. Client는 Server에게 파일에 대한 정보를 전송합니다. (파일명, 확장자, 크기... etc)
2. Server는 AWS에게 Presigned URL을 요청한 뒤, 발급받은 Presigned URL을 Client에게 전송합니다.
3. Client는 Server에게 전송받은 Presigned URL을 통해 S3 Bucket에 이미지를 전송합니다.
4. S3에 정상적으로 저장이 된 뒤, 204(no content)를 받습니다.
2. Amazon SDK
Amazon SDK는 AWS에서 제공하는 서비스들을 프로그래밍적으로 제어할 수 있도록 도와주는 개발키트입니다.
SDK의 S3Client를 사용하면 서버 측에서 S3 작업을 수행할 수 있습니다.
(본 글은 Presigned URL을 집중적으로 설명하기 때문에 간단히 설명하겠습니다)
위 링크를 누르시면 SDK을 이용해 S3 Bucket에 파일을 등록하는 코드를 .Net, Go, Python등 다양한 언어로 볼 수 있습니다.
3. Presigned URL을 선택한 이유
Fixadate는 사용자의 일정을 관리해 주는 캘린더 서비스입니다.
프로젝트에서 필자는 파일 업로드, 다운로드등 파일 관련된 작업을 맡게 되었다.
원래는 파일 업로드가 Oauth를 이용한 회원가입 시 받아오는 사용자의 프로필 이미지 저장이어서 Presigned URL을 고려하지 않고 서버에서 직접 SDK를 이용해 업로드하려고 했다. 하지만 기획팀의 한마디가 모든 것을 변경시켰다.
사용자가 일정을 등록할 때 PDF, PPT 등 다양한 파일들을 같이 올리는 건 어때?
이 말을 들었을 때 한 가지 생각이 들었다.
1. 서버가 살아남을 수 있을까?
일정 등록은 캘린더 서비스인 Fixadate의 메인 기능입니다. 메인 기능인 동시에 사용자들이 가장 많이 사용할 기능이기도 합니다. 이렇게 자주 사용되는 기능에서 1GB, 500MB 등 대용량 파일들이 업로드가 된다면 서버 성능은 떨어질 것이 분명하고 이는 사용자 경험의 하락으로 이어지기 때문에 절대 쉽게 넘어갈 사안이 아니었습니다.
심각성을 인지한 후, 기획팀과 함께 유저 시나리오를 작성했습니다.
(로그인등 파일 전송과 관련 없는 내용은 생략했습니다)
- 캘린더에 일정을 등록하면서 그날 필요한 발표자료를 등록한다.
- 학생들이 수업 끝나고 수업 자료와 녹화한 영상을 그날 일정과 함께 등록한다.
- Dates(팀 일정 관리 서비스)를 이용하는 팀원들이 서로 자료를 공유하고자 등록한다.
- etc...
여러 가지 시나리오를 작성해 본 결과 주로 등록하는 파일은 이미지보다는 용량이 큰 PDF, PPT,. AVI 등이었습니다.
카카오톡 프로필(640 * 640)을 1,200KB로 계산했을 때 프로필 이미지의 5배 ~ 20배 정도의 파일이 수시로 등록된다고 가정할 수 있습니다.
문제점
1. 네트워크 대역폭 및 지연
동영상과 같은 대용량 파일의 경우 업로드에 상당한 시간이 걸릴 수 있습니다. 또한 업로드 중간에 서버와 Amazon S3간의 대역폭을 사용하게 되므로, 이로 인해 다른 네트워크 트래픽에 영향을 줄 수 있습니다.
2. 서버 리소스 사용량
대용량 파일 업로드는 CPU 및 메모리를 사용하여 파일을 읽고 전송합니다. 이로 인해 서버가 다른 작업에 할당된 리소스를 사용하지 못하고 성능에 영향을 줄 수 있습니다.
-> 다른 서비스에 대한 응답시간이 느려질 수 있다.
문제점들은 주로 서버에서 대용량 파일을 보내므로 서버에서 발생하는 문제들이었습니다.
그러면 간단하게 생각해서 서버에서 파일을 보내지 않고 클라이언트에서 보내게 하면 되잖아.
이러한 생각에서 나온 해답이 바로 Presigned URL입니다.
4. 구현
Spring boot 3에서 실행 가능합니다.
1. S3Request
S3Request는 Amazon S3에 대한 요청을 나타내는 추상 클래스입니다.
S3Request를 상속한 클래스로는 GetObjectRequest, PutObjectRequest, DeleteObjectRequest... 등이 있습니다.
xxxObjectRequest 클래스에서는 주로 버켓(S3 Bucket 이름), 키(파일 식별자), mfa 등을 설정할 수 있습니다.
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.contentType(contentType)
.build();
파일을 넣기 위한 요청을 생성하는 PutRequest입니다.
bucket, key, contentType 등을 설정한 모습을 볼 수 있습니다.
2. PresignRequest
PresignRequest란 Presigned URL을 생성하기 위한 요청입니다. 이 요청을 통해 생성된 Presigned URL은 클라이언트가 서버를 거치지 않고도 저장, 다운로드 등을 가능하게 해 줍니다. 그리고 위에서 생성한 xxxObjectRequest를 필드로 가지고 있습니다. (Builder를 통해 받은 xxxObjectRequest값을 필드에 주입합니다.)
PresignRequest를 상속한 클래스로는 PutObjectPresignRequest, DeleteObjectPresignRequest, GetObjectPresignRequest 등이 있습니다.
xxxObjectPresignRequest에서는 주로 유효기간(TTL), 각 요청(Get, Put, Delete.. etc)에 대한 요청(xxxObjectRequest)을 필요로 합니다.
3. S3Presigner
S3Presigner 객체를 사용해 Amazon S3 SdkRequest에 서명함으로써 호출자에 대한 인증을 요구하지 않고 객체가 실행되도록 할 수 있습니다. - amazon 공식문서
밑의 설정을 통해 S3Presigner를 구성할 수 있습니다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Bean
public AwsCredentials basicAWSCredentials() {
return AwsBasicCredentials.create(accessKey, secretKey);
}
@Bean
public S3Presigner s3Presigner(AwsCredentials awsCredentials) {
return S3Presigner.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
}
이후, xxxObjectPresignedRequest를 통해 Presigned URL을 생성합니다.
5. 전체 코드
spring boot 3.x
spring-cloud-starter-aws 2.26
spring-cloud-aws-s3 3.1.0
1. S3Config
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Bean
public AwsCredentials basicAWSCredentials() {
return AwsBasicCredentials.create(accessKey, secretKey);
}
@Bean
public S3Presigner s3Presigner(AwsCredentials awsCredentials) {
return S3Presigner.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
}
2. S3Service
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Presigner s3Presigner;
@Value("${cloud.aws.bucket-name}")
private String bucketName;
//upload
public String generatePresignedUrlForUpload(String fileName, String contentType) {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.contentType(contentType)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(5))
.putObjectRequest(putObjectRequest)
.build();
return s3Presigner.presignPutObject(presignRequest).url().toString();
}
//download
public String generatePresignedUrlForDownload(String fileName) {
GetObjectRequest getObjectPresignRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.getObjectRequest(getObjectPresignRequest)
.signatureDuration(Duration.ofMinutes(5))
.build();
return s3Presigner.presignGetObject(presignRequest).url().toString();
}
//delete
public String generatePresignedUrlForDelete(String fileName) {
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.build();
DeleteObjectPresignRequest deleteRequest = DeleteObjectPresignRequest.builder()
.deleteObjectRequest(deleteObjectRequest)
.signatureDuration(Duration.ofMinutes(5))
.build();
return s3Presigner.presignDeleteObject(deleteRequest).url().toString();
}
}
Controller는 독자 여러분의 도메인에 맞게 설정하시면 됩니다.
위의 글에 있는 Service에서는 매개변수로 fileName, ContentType(Put에서만)을 받기 때문에 문자열로 두 개의 값만 보내주시면 정상 작동합니다.
6. 결과