Presigned Url을 사용해야 하는 이유
백엔드 서버를 구축할 때, 기초적인 이미지 업로드 구현을 서버가 S3 버킷에 직접 업로드하는 방식으로 하는 경우가 많다. 소규모 프로젝트에서 이 방식이 가장 간편하기 때문이다. 이 구조를 그림으로 표현하면 다음과 같다.
즉, 서버에서 이미지 파일을 직접 전송받고 s3에 전송해 줘야 하기에, 이 과정에서 서버 자원이 많이 소모된다. 하지만 aws s3에서 제공하는 presigned url을 사용한다면 서버에서 파일을 직접 처리하지 않게 되기에 서버의 부담이 많이 줄어드는 큰 장점이 있다. 또한, aws에서 presigned url을 통해 파일을 업로드하는 경우 처리 속도를 아주 빠르게 제공해 주고 있어서 기존 프로젝트보다 성능이 향상된다.
Presigned Url을 통해 업로드하는 구조
- client -> server : presigned url을 달라고 요청
- server -> s3 bucket : presigned url을 새로 생성하도록 요청
- s3 bucket -> server : url을 생성해서 전달
- server -> client : 받은 url을 전달
- client -> s3 bucket : url에 직접 파일을 업로드하면 bucket에 업로드
이 flow를 보면, presigned url이란 결국 s3 bucket으로 접근을 할 수 있게 해 주는 endpoint라는 것을 알 수 있다. 보통 s3 버킷에는 함부로 접근을 하지 못하게 막아야 보안적으로 바람직하지만, presigned url은 제한된 조건 하에서 aws credentials이 없어도 접근이 가능하도록 통로를 열어주는 것이다. 여기서 가장 큰 핵심은 클라이언트에서 직접 s3에 파일을 보내게 된다는 것이다.
구현 : server -> s3 bucket 으로 url 생성 요청 코드
스프링 부트 프로젝트 기반에서 aws sdk for java를 사용하면 된다. 우선, 다음 depedency들을 추가해준다.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.767'
implementation 'software.amazon.awssdk:s3:2.27.3'
implementation 'software.amazon.awssdk:s3control:2.27.3'
implementation 'software.amazon.awssdk:s3outposts:2.27.3'
그리고 config파일을 만들어서 Bean으로 S3Presigner를 등록한다
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.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 S3Presigner presigner() {
AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)
);
return S3Presigner.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(credentialsProvider)
.build();
}
}
이러면 presigned url을 생성하는 메서드를 만들 수 있게 된다
package com.mobileapp.commonplus.global.utils;
import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.mobileapp.commonplus.global.error.ErrorCode;
import com.mobileapp.commonplus.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
@RequiredArgsConstructor
public class S3Uploader {
@Autowired
private final S3Presigner s3Presigner;
private static final String BUCKET_DOMAIN = "버킷의 객체들이 갖는 도메인 url";
@Value("${cloud.aws.s3.bucket2}")
private String bucket2;
/**
* presigned url을 생성해 주는 메소드. bucket v3에 생성해 줌
* @param imageExtension 이미지의 확장자
* @return upload url, public url
*/
public Map<String, String> createPresignedUrl(String imageExtension) {
String keyName = UUID.randomUUID() + "." + imageExtension;
keyName = keyName.replace("-", "");
String contentType = "image/" + imageExtension;
Map<String, String> metadata = Map.of(
"fileType", contentType,
"Content-Type", contentType
);
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucket2)
.key(keyName)
.metadata(metadata)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL expires in 10 minutes.
.putObjectRequest(objectRequest)
.build();
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
String myURL = presignedRequest.url().toString();
myURL = myURL.replace(bucketDomain, "");
String publicUrl = BUCKET_DOMAIN + keyName;
log.info("Presigned URL to upload a file to: {}", myURL);
log.info("HTTP method: {}", presignedRequest.httpRequest().method());
Map<String, String> map = new ConcurrentHashMap<>();
map.put("uploadUrl", myURL);
map.put("publicUrl", publicUrl);
return map;
}
}
여기서 upload url이 presigned url이고, public url은 업로드된 이미지를 공개적으로 볼 수 있는 url이다.
구현 : S3 bucket에서 presigned url을 생성할 수 있도록 정책 세팅
버킷에서 업로드를 허용할 수 있도록 다음과 같이 정책을 만들어준다
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowPresignedUrl",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::commonplus-back-v3/*",
"Condition": {
"StringEquals": {
"s3:authType": "REST-QUERY-STRING"
}
}
},
{
"Sid": "Deny a presigned URL request if the signature is more than 10 min old",
"Effect": "Deny",
"Principal": {
"AWS": "*"
},
"Action": "s3:*",
"Resource": "arn:aws:s3:::commonplus-back-v3/*",
"Condition": {
"NumericGreaterThan": {
"s3:signatureAge": "600000"
}
}
}
]
}
구현 : client -> s3 bucket 이미지 업로드 코드
이제 마지막으로 url에 업로드하면 flow가 완성된다. 여기서는 서버 측에서 url을 생성하는 것에 초점을 맞췄기 때문에 프론트 로직은 javascript로 간단하게 구현한 테스트 코드를 사용했다.
테스트할 때, cors error를 방지하기 위해 해당 코드를 그냥 실행하는 것이 아니라, local server에 포트를 열어서 실행해야 한다. 또한, 해당 local server의 포트를 s3 버킷의 cors 정책에도 추가해야 한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Upload</title>
</head>
<body>
<h1>Image Upload</h1>
<input type="file" id="imageInput" accept="image/*">
<button id="uploadButton">Upload</button>
<br><br>
<div>응답값 - 성공하면 응답값 없음</div>
<textarea name="" id="demotext" readonly="true" style="width: 300px; height: 180px;"></textarea>
<br><br><br>
<div>이미지</div><br>
<img src="public image url을 넣어서 검증 완료" alt="" width="200px">
<script>
const demotext = document.getElementById('demotext');
async function put(url, data) {
try {
const response = await fetch(url, {
method: 'PUT',
headers: {
'x-amz-meta-Content-Type': 'image/jpg',
'x-amz-meta-fileType': 'image/jpg',
},
body: data
});
const responseBody = await response.text();
return responseBody;
} catch (error) {
throw error;
}
}
document.getElementById('uploadButton').addEventListener('click', () => {
const fileInput = document.getElementById('imageInput');
const file = fileInput.files[0];
if (!file) {
alert('Please select an image file.');
return;
}
const reader = new FileReader();
reader.onload = function(event) {
const data = event.target.result;
// S3에서 생성한 pre-signed URL
const url = "presigned url을 여기에 넣기";
put(url, data)
.then((response) => {
console.log(response);
if (response != null) {
demotext.innerText = response;
}
})
.catch((error) => {
console.error(error);
demotext.innerText = error;
});
};
reader.readAsArrayBuffer(file);
});
</script>
</body>
</html>
업로드에 성공하면 200 OK 상태만 뜨고 따로 응답값은 반환하지 않는다. 반면 업로드에 실패하면 에러문구를 보내준다. 예시코드에서는 다음과 같이 에러문구가 뜨도록 만들었다.
마무리, 추가 이슈
presigned url로 파일을 업로드하는 구조만 이해하면, 이를 구현하는 코드는 별로 어렵지 않다. 오히려 서버측에서는 파일을 직접 처리하는 로직보다 더 간편한 면도 많다. 다만 이 로직을 프로젝트에 직접 적용할 때, 다른 이슈들이 생겨서 이를 해결하는 것이 꽤 까다롭다.
특히 다음 이슈가 보편적으로 생길 것으로 생각된다 :
- 이미지를 비롯하여 여러 항목을 가진 객체를 저장하는 api에서 presigned url을 사용하는 경우 -> 클라이언트에서 이미지를 bucket에 업로드하고 객체를 서버에 저장하지 않은 상황에서, 버킷에 아무도 사용하지 않는 쓰레기 데이터가 남아버림
이를 방지하기 위해, presigned url을 생성할 때, redis에서 이를 저장해 놓고 나중에 객체를 저장하는 api가 호출되지 않아서 해당 entry가 오래 방치되었을 때, scheduler를 통해서 해당 이미지를 서버에서 자동으로 삭제해야 한다.
'AWS' 카테고리의 다른 글
[AWS, Java] Cloudwatch 경보를 AWS sdk for Java로 만들기 (0) | 2024.07.09 |
---|