Junlog

AWS S3 Presigned URL 안전하게 쓰기

ByYoon Woo Jun
55

S3와 미리 서명된 URL

예전부터 S3를 사용하면서, 아무 고민도 없이 사용했던 미리 서명된 URL 즉, presigned URL에 대해 고민한 내용을 적어보려고 한다.

워낙 래퍼런스와 문서들이 많아서, 해당 개념에 대한 내용은 생략하고, 클라이언트와 백엔드, S3 간의
흐름을 적어보자면 다음과 같다.

  1. client에서 server로 presigned url 발급 요청
  2. server는 presigned url을 aws sdk 등의 방법을 통해 생성, client로 응답
  3. client는 presigned url로 조회 또는 업로드

언뜻보면 빈틈이 없어보이는 흐름이지만, 사용하다보면 구린내가 나는 부분이 조금있다.

그중에 업로드를 할 때, 주의할 부분 2가지에 대해 적어보자면...


미리 서명된 URL로 업로드할 때 주의할 점

1.업로드 검증

client가 presigned url을 가지고 있는 순간, client는 어떤 객체든 버킷으로 업로드가 가능해진다.
client가 이미지만 업로드하길 바랬는데, 4k 1시간짜리 영상을 업로드할 수도 있다는 거다 (aws에서 걸어둔 최대 업로드 용량이 있긴하다)

즉, client가 presigned url로 PUT 요청을 쏠 때, 같이 보내주는 객체에 검증 조건을 걸어줘야한다.

2.쓰레기 객체

보통 객체 업로드 로직은 프로필 사진 추가, 메뉴 이미지 추가 등의 비즈니스 로직과 결합되어 쓰인다.

객체가 버킷에 정상적으로 업로드 되었지만, 비즈니스 로직을 수행하다 실패하거나, 수행 요청이 누락되거나 하면, 버킷에는 쓰레기 객체가 남게되고, 이는 곧 비용으로 직결된다.


업로드를 검증하는 방법

처음 aws 공식문서를 참고하고 알게된 방식은 presigned url POST 방식이다.
POST 방식 업로드를 사용하면, condition을 걸어서 유연한 검증 로직을 적용할 수 있어보였다.

Presigned POST 업로드 방식 공식문서

하지만, 현재 개발중인 프로젝트는 java 기반이였고, 애석하게도 java aws sdk에서는 POST 방식의
미리 서명된 URL을 발급하는 로직이 아직 개발되지 않았다... 해당 이슈 링크

그래서, presigned URL PUT 방식에서 Object Metadata와 content Type을 지정해서, URL을 발급해주는 쪽으로 해결했다.

1public StorageUploadDto getPendingPresignedUrl(String path, String extension, Long fileSize, String contentType) {
2	String contentKey = path + "/" + ObjectKeyTransformer.createRandomKey(extension);
3	ObjectMetadata objectMetadata = ObjectMetadata.builder()
4			.contentLength(fileSize)
5			.contentType(contentType)
6			.build();
7	String uploadUrl = s3Template
8			.createSignedPutURL(s3Bucket, contentKey, uploadUrlExpirationTime, objectMetadata, contentType)
9			.toString();
10	return StorageUploadDto.of(uploadUrl, contentKey);
11}

awspring 라이브러리를 사용해서, 좀더 코드가 간결해보이지만, 핵심은 Object Metadata로 file size와 content type을 지정해준다는 것이다.

즉, client에서 server로 presigned url 발급 요청시, 업로드 하려는 객체의 크기와 content type을 같이 보내도록하고, 그 크기와 타입으로 고유한 presigned url을 생성하고 응답한다.

client에서 고유한 presigned url로 PUT 요청을 보낼때, url을 위조하거나, 같이 업로드하는 객체의 크기가 이전에 알려준 크기와 다르거나, MIME 타입이 다르면, SignatureDoesNotMatch 를 응답한다.

이제, 파일 크기, 파일 타입에 대한 컨트롤을 서버가 할 수 있게 된것이다.
위 메서드가 쓰이기 이전에, 클라이언트가 보낸 파일 크기와 content type이 서버가 원하는 바에 일치하는지 검증하는 로직을 추가해주면 된다.

1private static final long MAX_IMAGE_BYTES = 10L * 1024 * 1024;
2
3private static final Map<String, String> IMAGE_MIME = Map.ofEntries(
4		Map.entry("jpg", "image/jpeg"),
5		Map.entry("jpeg", "image/jpeg"),
6		Map.entry("png", "image/png"),
7		Map.entry("webp", "image/webp"));
8
9public void validateImage(String extension, Long fileSize) {
10	if (extension == null || !IMAGE_MIME.containsKey(extension.toLowerCase())) {
11		throw new IllegalArgumentException("지원하지 않는 이미지 형식입니다.");
12	}
13	if (fileSize == null || fileSize <= 0 || fileSize > MAX_IMAGE_BYTES) {
14		throw new IllegalArgumentException("이미지 크기는 0MB 초과 10MB 이하여야 합니다.");
15	}
16}

검증 로직에서는 주로 업로드 되는 객체가 이미지 타입이기 때문에 위 처럼 구현했다.
MIME Type은 해당 링크에서 좀더 알 수 있다. MIME Type | MDN


쓰레기 객체가 생기는 경우를 방지하는 방법

이 부분은 이전 프로젝트에서는 redis를 사용해서 해결했었는데, presigned url을 발급하면, content key를 redis에 넣어놓고, 비즈니스 로직이 성공적으로 수행되면, redis에서 지우도록 해놓고, 스케줄러로 redis에 일정시간동안 남아있는 content key를 통해 s3에서 쓰레기로 간주해서 삭제하도록 했었다.

하지만, 이번 프로젝트에서는 s3 라이프 사이클로 해결해보았는데 꽤 만족스러웠다.

특별한건 없고, presigned url로 처음 객체의 경로를 pending 디렉터리로 업로드하도록 한다.

그리고, 비즈니스 로직이 성공적으로 수행되면 pending 디렉터리에서 평범한 디렉터리로 객체를 이동시켜준다.

만약 비즈니스 로직이 실패하면, pending 에 남아있게 되고, 이때, s3 라이프 사이클이 일정 기간동안 해당 경로에 있는 객체를 지워주도록 설정해주면 된다.

주의할 점은 prefix를 pending/ 이렇게 해줘야 한다는 점이다. /pending 이렇게 하면 하위 객체가 적용되지 않는다.

1public String moveUploadCompleteObject(String pendingContentKey) {
2	String originalKey = ObjectKeyTransformer.removePendingPrefix(pendingContentKey);
3	try {
4		CopyObjectRequest copyObjectRequest = CopyObjectRequest.builder()
5				.sourceBucket(s3Bucket)
6				.sourceKey(pendingContentKey)
7				.destinationBucket(s3Bucket)
8				.destinationKey(originalKey)
9				.build();
10		s3Client.copyObject(copyObjectRequest);
11		s3Template.deleteObject(s3Bucket, pendingContentKey);
12		return originalKey;
13	} catch (Exception e) {
14	}
15}

비즈니스 로직이 성공하면, 위 로직을 수행하도록 하면된다.
즉, pending 디렉터리에서 원래 있어야할 곳으로 옮겨주면 된다.

redis를 사용한 방식과 s3 라이프사이클을 사용한 방식을 두고보면, 성능상으로나 비용상으로나 redis의 승이다. 사실 쓰레기 값이 생기는경우가 많을까 생각해보면 아니기 때문에, 매 요청마다 PUT, COPY, DELETE 요청을 보내게 되는 라이프 사이클 방식은 redis보다는 비용이나 성능적으로 떨어진다.

특히, PUT, COPY는 요청만으로 비용이 발생하기 때문에, 2배의 요금이 든다 (DELETE는 안든다)

하지만, redis와 스케줄러를 활용했을때, presigned url 발급 요청시 redis에 일단 content key를 저장하는 방식 또한 장단점이 존재하므로, 고민해보면 좋을것 같다.