AWS S3의 presigned URL을 이용한 이미지 업로드 기능

파이 ఇ·2025년 2월 12일
1
post-thumbnail

Presigned URL이란?

미리 서명된 URL을 사용하여 다른 사람이 Amazon S3 버킷에 객체를 업로드할 수 있는 기능입니다.
즉, 서버를 통해 컨텐츠를 S3 버킷에 업로드하는게 아닌, AWS 보안 자격 증명이나 권한이 없어도 사용자가 직접 미리 서명된 URL을 이용하여 S3 버킷에 컨텐츠를 업로드할 수 있게 됩니다.

왜 presigned URL일까?

이전에 사용하던 MultipartFile과의 차이를 얘기하자면


MultipartFile은 client가 파일 업로드를 요청하면 서버에서 파일의 바이너리를 MultipartFile 객체로 변환 시킨 후 S3에 스트림 데이터로 파일을 업로드 시키고, 또 S3는 서버의 권한을 검증하는 등 서버의 부하가 매우 큰 작업입니다.

따라서 이미지 업로드가 백엔드 서버를 거치게 되면 최악의 상황엔 서버는 금방 죽을 수 도 있고, 아니면 다수의 사용자로부터 동시에 요청을 제한하거나 파일 업로드를 전담하는 서버를 별도로 분리하는 등 추가적인 작업을 야기합니다.

그렇다면 Presigned URL은 어떨까요?

Presigned URL은 업로드할 파일을 작은 part로 나누어 각 부분을 개별적으로 업로드 합니다.
그리고 파일의 바이너리가 spring boot를 거치지않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도되는 큰 장점을 가지고 있습니다.
백엔드 서버는 Presigned URL 생성으로 보안절차 작업만 해주면 됩니다.

모든 part가 업로드 되었을 경우 AWS에서 하나의 객체로 조립하여 저장합니다.

또한 몇개의 파트가 업로드 되었는지 확인하여 위와 같이 사용자에게 업로드 진행사항을 제공할 수 도 있습니다.

Multipart Upload 방식

Multipart Upload는 총 4단계 프로세스로 구성되어 있습니다.

1. Multipart 업로드 시작

멀티파트 업로드 시작(initiate-upload)을 요청하면 서버는 멀티파트 업로드에 대한 고유 식별자인 Upload ID를 응답합니다.
부분 업로드, 업로드 완료 또는 업로드 중단 요청시 항상 Upload ID를 포함해야 하기 때문에 클라이언트는 이 값을 잘 저장해야 합니다.

2. PresignedURL 발급

업로드를 위한 AWS의 서명된 URL을 발급받는 요청입니다.
멀티파트 시작 요청에서 받은 UploadID 그리고 PartNumber 값을 함께 요청해야 합니다.
PartNumber는 1부터 10,000까지 파트 번호 지정이 필요합니다.
AWS에서 파트 번호를 이용하여 업로드하는 객체의 각 부분과 그 위치를 고유하게 식별하기 때문입니다. 파트 번호는 연속적인 시퀀스로 선택할 필요는 없습니다.
만약 이전에 업로드한 부분과 동일한 부분 번호로 새 부분을 업로드할 경우 이전에 업로드한 부분을 덮어쓰게 됩니다.

3. PresignedURL part 업로드

발급받은 PresignedURLPUT 메서드로 파트의 바이너리를 실어서 요청합니다. 이때 파트의 용량은 클라이언트에서 결정하여 업로드합니다.

분할된 파트는 5MB~5GB의 크기만 가능합니다. 다만 마지막 파트는 5MB 이하도 괜찮습니다.
만약 업로드할 파일의 크기가 5MB 이하라면 파트 업로드는 한번 수행되어야 합니다.
즉, 첫번째 파트가 마지막 파트이기도 하다면 위 규칙은 위반되지 않으며 AWS S3는 멀티파트 업로드로 수락하여 객체를 생성하게 됩니다. 파트는 최대 5GB까지 10,000개까지 업로드할 수 있으니 이론상으로 5TB 크기의 파일까지 업로드할 수 있습니다.

파트를 업로드 후 받는 응답 헤더에 MD5 Checksum값인 ETag(Entity Tag)가 포함되어 있습니다. 클라이언트는 각 파트 업로드 시 PartNumberETag값을 매칭하여 보관합니다. 이후 멀티파트 업로드 완료 요청에 이러한 값을 포함해야 하기 때문입니다.

4. Multipart 업로드 완료

멀티파트 업로드 완료는 UploadID, 각 PartNumber와 매칭되는 ETag값이 배열로 포함되어야 합니다.
업로드 완료가 수행되어야 AWS S3에서 PartNumberETag를 기준으로 객체를 재조립합니다.
객체가 매우 클 경우 이 프로세스는 몇 분 정도 걸릴 수 있습니다.
만약 업로드 완료를 하지 않을 경우 S3 버킷에 파일이 생성되지 않습니다.

예외. Multipart 업로드 취소

하나 이상의 파트가 업로드된 상태에서 예기치 못한 이슈가 발생한다면 멀티파트 업로드를 완료하거나 취소해야 업로드된 파트의 스토리지 비용이 청구되지 않습니다.

따라서 클라이언트에서 예외 발생 시 멀티파트 업로드를 취소할 것을 권장 드립니다. 단, 한번 취소된 UploadID로 다시 파트를 업로드할 수 없습니다.

Presigned URL 적용기

gradle 설정

아래와 같은 의존성을 추가 합니다.

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'

S3Config 설정

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());
	}

S3Properties

@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;
	}
}

S3MultipartService

AWS 서버에 멀티파트 업로드 요청하는 곳 입니다. 주요 메서드는 3개 입니다.

  1. 멀티파트 업로드 시작
  2. 부분 업로드를 위한 미리 서명된 URL 발급
  3. 멀티파트 업로드 완료
  4. 추가적으로 업로드 중지 요청 시 사용할 abortUpload 메서드가 있습니다.
@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();
	}

S3MultupartController

웹에서 멀티파트 업로드 요청을 위해 사용할 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";

multipart-upload-s3.html

웹 브라우저에서 실행하면 아래와 같이 나오게 됩니다.

멀티파트 업로드하는 javascript 주요 로직은 다음과 같습니다.

  1. SpringBoot 서버로 멀티파트 업로드 시작 요청을 합니다.
  2. 청크 사이즈와 파일 크기를 통해 청크 개수를 설정합니다.
  3. SpringBoot 서버로 멀티파트 업로드를 위한 미리 서명된 URL을 발급 받습니다.
  4. 3번에서 받은 미리 서명된 URL과 PUT을 사용해 AWS 서버에 청크를 업로드합니다.
  5. 3,4번을 반복합니다.
  6. 모든 청크 업로드가 완료되면 SpringBoot 서버로 업로드 완료 요청을 보냅니다.

멀티파트 업로드 테스트

파일 선택 후 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

profile
⋆。★⋆⁺₊⋆☾ ⁺⋆。⋆ ☁︎。₊⋆

0개의 댓글

관련 채용 정보