미리 서명된 URL을 사용하여 다른 사람이 Amazon S3 버킷에 객체를 업로드할 수 있는 기능입니다.
즉, 서버를 통해 컨텐츠를 S3 버킷에 업로드하는게 아닌, AWS 보안 자격 증명이나 권한이 없어도 사용자가 직접 미리 서명된 URL을 이용하여 S3 버킷에 컨텐츠를 업로드할 수 있게 됩니다.
이전에 사용하던 MultipartFile
과의 차이를 얘기하자면
MultipartFile
은 client가 파일 업로드를 요청하면 서버에서 파일의 바이너리를 MultipartFile
객체로 변환 시킨 후 S3에 스트림 데이터로 파일을 업로드 시키고, 또 S3는 서버의 권한을 검증하는 등 서버의 부하가 매우 큰 작업입니다.
따라서 이미지 업로드가 백엔드 서버를 거치게 되면 최악의 상황엔 서버는 금방 죽을 수 도 있고, 아니면 다수의 사용자로부터 동시에 요청을 제한하거나 파일 업로드를 전담하는 서버를 별도로 분리하는 등 추가적인 작업을 야기합니다.
그렇다면 Presigned URL
은 어떨까요?
Presigned URL
은 업로드할 파일을 작은 part로 나누어 각 부분을 개별적으로 업로드 합니다.
그리고 파일의 바이너리가 spring boot를 거치지않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도되는 큰 장점을 가지고 있습니다.
백엔드 서버는 Presigned URL
생성으로 보안절차 작업만 해주면 됩니다.
모든 part가 업로드 되었을 경우 AWS에서 하나의 객체로 조립하여 저장합니다.
또한 몇개의 파트가 업로드 되었는지 확인하여 위와 같이 사용자에게 업로드 진행사항을 제공할 수 도 있습니다.
Multipart Upload는 총 4단계 프로세스로 구성되어 있습니다.
멀티파트 업로드 시작(initiate-upload
)을 요청하면 서버는 멀티파트 업로드에 대한 고유 식별자인 Upload ID
를 응답합니다.
부분 업로드, 업로드 완료 또는 업로드 중단 요청시 항상 Upload ID
를 포함해야 하기 때문에 클라이언트는 이 값을 잘 저장해야 합니다.
업로드를 위한 AWS의 서명된 URL을 발급받는 요청입니다.
멀티파트 시작 요청에서 받은 UploadID
그리고 PartNumber
값을 함께 요청해야 합니다.
PartNumber
는 1부터 10,000까지 파트 번호 지정이 필요합니다.
AWS에서 파트 번호를 이용하여 업로드하는 객체의 각 부분과 그 위치를 고유하게 식별하기 때문입니다. 파트 번호는 연속적인 시퀀스로 선택할 필요는 없습니다.
만약 이전에 업로드한 부분과 동일한 부분 번호로 새 부분을 업로드할 경우 이전에 업로드한 부분을 덮어쓰게 됩니다.
발급받은 PresignedURL
에 PUT
메서드로 파트의 바이너리를 실어서 요청합니다. 이때 파트의 용량은 클라이언트에서 결정하여 업로드합니다.
분할된 파트는 5MB~5GB의 크기만 가능합니다. 다만 마지막 파트는 5MB 이하도 괜찮습니다.
만약 업로드할 파일의 크기가 5MB 이하라면 파트 업로드는 한번 수행되어야 합니다.
즉, 첫번째 파트가 마지막 파트이기도 하다면 위 규칙은 위반되지 않으며 AWS S3는 멀티파트 업로드로 수락하여 객체를 생성하게 됩니다. 파트는 최대 5GB까지 10,000개까지 업로드할 수 있으니 이론상으로 5TB 크기의 파일까지 업로드할 수 있습니다.
파트를 업로드 후 받는 응답 헤더에 MD5 Checksum값인 ETag(Entity Tag)
가 포함되어 있습니다. 클라이언트는 각 파트 업로드 시 PartNumber
와 ETag
값을 매칭하여 보관합니다. 이후 멀티파트 업로드 완료 요청에 이러한 값을 포함해야 하기 때문입니다.
멀티파트 업로드 완료는 UploadID
, 각 PartNumber
와 매칭되는 ETag
값이 배열로 포함되어야 합니다.
업로드 완료가 수행되어야 AWS S3에서 PartNumber
와 ETag
를 기준으로 객체를 재조립합니다.
객체가 매우 클 경우 이 프로세스는 몇 분 정도 걸릴 수 있습니다.
만약 업로드 완료를 하지 않을 경우 S3 버킷에 파일이 생성되지 않습니다.
하나 이상의 파트가 업로드된 상태에서 예기치 못한 이슈가 발생한다면 멀티파트 업로드를 완료하거나 취소해야 업로드된 파트의 스토리지 비용이 청구되지 않습니다.
따라서 클라이언트에서 예외 발생 시 멀티파트 업로드를 취소할 것을 권장 드립니다. 단, 한번 취소된 UploadID
로 다시 파트를 업로드할 수 없습니다.
아래와 같은 의존성을 추가 합니다.
implementation platform('software.amazon.awssdk:bom:2.17.53')
implementation 'software.amazon.awssdk:s3'
// presigned-url 테스트를 위해 html 파일 추가할 예정
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
AWS 서버와 통신할 S3Client
, 서명된 Url을 발급받기 위한 S3Presigner
, 시크릿 키와 엑세스 키로 인증할 AwsCredentials
를 스프링 빈으로 등록합니다.
@Configuration
public class S3Config {
@Bean
public S3Client s3Client(S3Properties s3Properties) {
AwsBasicCredentials credentials = getCredentials(s3Properties);
return S3Client.builder()
.region(Region.of(s3Properties.getRegion())
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
@Bean
public S3Presigner s3Presigner(S3Properties s3Properties) {
AwsBasicCredentials credentials = getCredentials(s3Properties);
return S3Presigner.builder()
.region(Region.of(s3Properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}
private AwsBasicCredentials getCredentials(S3Properties s3Properties) {
return AwsBasicCredentials.create(
s3Properties.getCredentials().getAccessKey(),
s3Properties.getCredentials().getSecretKey());
}
@ConfigurationProperties
애노테이션이 application-secret.yml
파일에 있는 aws 정보를 읽어 값을 바인딩 해줍니다.
@Getter
@ConfigurationProperties("aws")
public class S3Properties {
private final Credentials credentials;
private final S3 s3;
private final String region;
@ConstructorBinding
public S3Properties(Credentials credentials, S3 s3, Map<String, String> region) {
this.credentials = credentials;
this.s3 = s3;
this.region = region.get("static");
}
@Getter
@RequiredArgsConstructor
public static class Credentials {
private final String accessKey;
private final String secretKey;
}
@Getter
@RequiredArgsConstructor
public static class S3 {
private final String bucket;
}
}
AWS 서버에 멀티파트 업로드 요청하는 곳 입니다. 주요 메서드는 3개 입니다.
@Service
@RequiredArgsConstructor
public class S3MultupartService {
private static final String UPLOADED_IMAGES_DIR = "public/";
private final S3Client s3Client;
private final S3Presigner s3Presigner;
private final AmazonS3Client amazonS3Client;
**public S3UploadResponse initiateUpload(S3UploadInitiateRequest request,
String bucket)** {
// 사용자가 보낸 파일 확장자와 현재 시간을 이용해 새로운 파일 이름을 만든다
String originalFileName = request.getFileName();
String fileType =
originalFileName.substring(originalFileName.lastIndexOf("."))
.toLowerCase();
String newFileName = System.currentTimeMillis() + fileType;
Instant now = Instant.now();
CreateMultipartUploadRequest createMultipartUploadRequest =
CreateMultipartUploadRequest.builder()
.bucket(bucket) // 버킷 설정
.key(UPLOADED_IMAGES_DIR + newFileName) // 업로드될 경로 설정
.acl(ObjectCannedACL.PUBLIC_READ) // public_read로 acl 설정
.expires(now.plusSeconds(60 * 20)) // 객체를 더 이상 캐시할 수 없는 날짜 및 시간
.build();
//Amazon S3는 멀티파트 업로드에 대한 고유 식별자인 업로드 ID가 포함된 응답을 반환합니다.
CreateMultipartUploadResponse response =
s3Client.createMultipartUpload(createMultipartUploadRequest);
return S3UploadResponse.toEntity(response.uploadId(), newFileName);
**public S3PresignedUrlResponse getUploadSignedUrl(
S3UploadSignedUrlRequest request, String targetBucket) {**
// 업로드 할 파일의 chunk로 쪼개서 각각 요청을 보내는 단계
UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
.bucket(targetBucket)
.key(UPLOADED_IMAGES_DIR + request.getFileName())
.uploadId(request.getUploadId())
.partNumber(request.getPartNumber())
.build();
// 미리 서명된 URL 요청
UploadPartPresignRequest uploadPartPresignRequest = UploadPartPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10))
.uploadPartRequest(uploadPartRequest)
.build();
// 클라이언트에서 S3로 직접 업로드하기 위해 사용할 인증된 URL을 받는다.
PresignedUploadPartRequest presignedUploadPartRequest
= s3Presigner.presignUploadPart(uploadPartPresignRequest);
return new S3PreSignedUrlResponse(
presignedUploadPartRequest.url().toString());
**public S3UploadResultResponse completeUpload(
S3UploadCompleteRequest request, String targetBucket)** {
List<CompletedPart> completedParts = new ArrayList<>();
// 한 영상에 대한 모든 부분들에 부분 번호와 Etag를 설정한다.
for (S3UploadPartsDetail s3UploadPartsDetail : request.getParts()) {
CompletedPart completedPart = CompletedPart.builder()
.partNumber(s3UploadPartsDetail.getPartNumber())
.eTag(s3UploadPartsDetail.getAwsETag())
.build();
completedParts.add(completedPart);
}
// 멀티파트 업로드 완료 요청을 AWS 서버에 보냄
CompletedMultipartUpload completedMultipartUpload =
CompletedMultipartUpload.builder()
.parts(completedParts)
.build();
String fileName = request.getFileName();
// 업로드 요청을 완료하여 업로드된 모든 chunk들을 하나로 합치고,
// (S3에 저장한 파일의) 복사된 객체를 사용할 수 있게합니다.
CompleteMultipartUploadRequest completeMultipartUploadRequest =
CompleteMultipartUploadRequest.builder()
.bucket(targetBucket)
.key(UPLOADED_IMAGES_DIR + fileName)
.uploadId(request.getUploadId())
.multipartUpload(completedMultipartUpload)
.build();
CompleteMultipartUploadResponse completeMultipartUploadResponse =
s3Client.completeMultipartUpload(completeMultipartUploadRequest);
// s3에 업로드 된 파일 이름
String objectKey = completeMultipartUploadResponse.key();
// s3에 업로드된 url
String url = amazonS3Client.getUrl(targetBucket, objectKey).toString();
String bucket = completeMultipartUploadResponse.bucket();
// 파일 size를 구함
long fileSize = getFileSizeFromS3Url(bucket, objectKey);
return S3UploadResultResponse.builder()
.name(fileName)
.url(url)
.size(fileSize)
.build();
**public void abortUpload(S3UploadAbortRequest abortRequest,
String targetBucket) {**
AbortMultipartUploadRequest abortMultipartUploadRequest = AbortMultipartUploadRequest.builder()
.bucket(targetBucket)
.key(UPLOADED_IMAGES_DIR + abortRequest.getFileName())
.uploadId(abortRequest.getUploadId())
.build();
s3Client.abortMultipartUpload(abortMultipartUploadRequest);
**private long getFileSizeFromS3Url(String bucketName, String fileName)** {
GetObjectMetadataRequest metadataRequest =
new GetObjectMetadataRequest(bucketName, fileName);
ObjectMetadata objectMetadata =
amazonS3Client.getObjectMetadata(metadataRequest);
return objectMetadata.getContentLength();
}
웹에서 멀티파트 업로드 요청을 위해 사용할 API입니다.
@RestController
@RequiredArgsContructor
public class S3MultipartController {
private final S3Properties s3Properties;
private final S3MultipartService s3MultipartService;
/**
* 멀티파트 업로드 시작
* 업로드 아이디를 반환하는데, 업로드 아이디는 부분 업로드, 업로드 완료 및 중지할 때 사용된다.
*/
@PostMapping("/initiate-upload")
public S3UploadResponse initiateUpload(
@RequestBody S3UploadInitiateRequest initiateRequest) {
return s3MultipartService.initiateUpload(
initiateRequest, s3Properties.getS3().getBucket());
/**
* 부분 업로드를 위한 서명된 URL 발급 요청
*/
@PostMapping("/upload-signed-url")
public S3PreSignedUrlResponse getUploadSignedUrl(
@RequestBody S3UploadSignedUrlRequest signedUrlRequest) {
return s3MultipartService.getUploadSignedUrl(
signedUrlRequest, s3Properties.getS3().getBucket());
/**
* 멀티파트 업로드 완료 요청
*/
@PostMapping("/complete-upload")
public S3UploadResultResponse completeUpload(
@RequestBody S3UploadCompleteRequest completeRequest) {
return s3MultipartService.completeUpload(
completeRequest, s3Properties.getS3().getBucket());
}
/**
* 멀티파트 업로드 중지
*/
@PostMapping("/abort-upload")
public Void abortUpload(@RequestBody S3UploadAbortRequest abortRequest) {
s3MultipartService.abortUpload(abortRequest, s3Properties.getS3().getBucket());
return null;
}
}
웹 템플릿 뷰를 보여줄 컨트롤러와 뷰입니다.
@Controller
public class HomeController {
@GetMapping("/home")
public String multipartS3() {
return "multipart-upload-s3";
웹 브라우저에서 실행하면 아래와 같이 나오게 됩니다.
멀티파트 업로드하는 javascript 주요 로직은 다음과 같습니다.
파일 선택 후 send file 버튼을 누르면 아래와 같이 S3 버킷에 저장되는걸 확인하실 수 있습니다.
아니라니까요.???..
Ref.
https://techblog.woowahan.com/11392/
https://aws.amazon.com/ko/blogs/korea/aws-api-call-2-s3-pre-signed-url/
https://develop-writing.tistory.com/129