presigned url 이란?
pre(미리) signed(서명된) url 이라는 뜻으로 권한이 없는 사용자도 S3 버킷에 접근할 수 있는 권한을 부여하는 url 이다.
왜 Presigned Url 을 사용해야 할까?

기존의 이미지 등록 방식은 위와 같았다. 클라이언트는 서버에 직접적으로 이미지 파일을 전달하고, 서버는 이를 s3 에 전달해 이미지를 저장했다. 그러나 이는 이미지 파일을 직접 서버에 전달하기 때문에 서버에 부하가 발생할 수 있다.

그러나 presigned url 을 사용하면 S3 에 클라이언트가 직접 접근하여 이미지를 업로드 할 수 있다. 즉, 서버가 이미지 파일을 전달할 필요가 없어지므로 부하가 줄어든다.
S3 에 클라이언트는 이미지 파일을 저장하고, 이미지 url 만을 서버에 전달해 저장한다.
Presigned Url 생성하기
우선 기본적인 세팅부터 시작해보자.
// 생략..
amazon:
aws:
accessKey: access-key
secretKey: secret-key
region: ap-northeast-2
bucket: bucket-name
AWS 에 접속해 IAM 을 이용하여 S3 에 모든 권한을 갖고 있는 사용자를 하나 생성해준 뒤, 액세스 키와 시크릿키를 받아 저장해둔다. 이후 yaml 파일에 위와 같이 추가한다.
@Configuration
public class AwsConfig {
@Value("${amazon.aws.accessKey}")
private String accessKeyId;
@Value("${amazon.aws.secretKey}")
private String accessKeySecret;
@Value("${amazon.aws.region}")
private String regionName;
@Bean
public AmazonS3 getAmazonS3Client() {
final BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKeyId, accessKeySecret);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
.withRegion(regionName)
.build();
}
}
AmazonS3 클래스가 사용되기 때문에 빈으로 등록해줘야 한다. 이 때, 이전에 yaml 에 설정해 둔 권한 관련 정보를 이용해 AmazonS3 클래스를 생성해준다.
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3 amazonS3;
@Value("${amazon.aws.bucket}")
private String bucketName;
public String generatePresignedUrl(String extension) {
String filePath = getFilePath(extension);
return amazonS3.generatePresignedUrl(bucketName, filePath, getExpiredDate(), HttpMethod.PUT).toString();
}
private String getFilePath(String extension) {
return UUID.randomUUID() + "." + extension;
}
private Date getExpiredDate() {
LocalDateTime now = new LocalDateTime().plusMinutes(50);
return now.toDate();
}
}
presigned url 을 만드는 generatePresignedUrl 메서드를 구현한다.
이 때, 나의 경우에는 extension(ex. JPG, PNG) 를 쿼리 파라미터로 받아 파일의 이름을 생성하도록 했다. 파일의 이름은 중복되면 안되기 때문에 UUID 를 이용해 랜덤으로 생성되도록 했다.
https://버킷이름.region 이름.amazonaws.com/
UUID 로 생성된 파일명.JPG?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240817T122501Z
&X-Amz-SignedHeaders=host&X-Amz-Expires=2999
url 은 위와 같은 형태로 생성된다.
쿼리 스트링으로 각종 권한 정보들을 담아 요청을 보내면 S3 에 이미지를 등록할 수 있다.
CloudFront 와 연동
현재 presigned url 은 S3 에 이미지를 등록만 할 수 있고, 조회할 수 있는 방법이 없다.
조회를 위해 버킷을 public 으로 열게 되면 보안상 문제가 생길 수 있다.
이러한 상황에서는 CloudFront 를 이용해 연동해주면 된다.
이에 대해서는 다음 글을 참고하면 된다.
https://velog.io/@rungoat/AWS-S3%EC%99%80-CloudFront-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0
[AWS] S3와 CloudFront 연동하기
S3와 CloudFront 연동하기
velog.io
위와 같이 CloudFront 와 연동하게 되면, 버킷에 저장된 이미지를 url 로는 접근할 수 없고 CloudFront 를 이용해서 배포된 도메인을 통해서만 접근할 수 있다. 즉, 버킷의 이름을 가릴 수 있게 되어 보안상의 이슈가 발생할 위험이 덜어진다.
이미지 저장 구현
내가 선택한 플로우는 다음과 같다.
1. 클라이언트가 서버에 presigned url 을 요청한다.
2. 서버는 권한 정보를 갖고 S3 에 put 요청만을 할 수 있는 presigned url 을 요청한다.
3. 클라이언트는 부여받은 presigned url 을 통해 버킷에 접근해 이미지를 업로드한다.
4. 클라이언트는 이미지의 url 만을 서버에 전달해 저장할 수 있도록 한다.
1번부터 차근차근 해보자.
@RestController
@RequestMapping("/manager/aws")
@RequiredArgsConstructor
public class S3Controller {
private final S3Service s3Service;
@GetMapping("/generate-presigned-url")
public ResponseEntity<String> generatePresignedUrl(@RequestParam String extension) {
return ResponseEntity.ok(s3Service.generatePresignedUrl(extension));
}
}
클라이언트는 /manager/aws/generate-presigned-url 로 presigned url 을 요청한다.
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3 amazonS3;
@Value("${amazon.aws.bucket}")
private String bucketName;
public String generatePresignedUrl(String extension) {
String filePath = getFilePath(extension);
return amazonS3.generatePresignedUrl(bucketName, filePath, getExpiredDate(), HttpMethod.PUT).toString();
}
private String getFilePath(String extension) {
return UUID.randomUUID() + "." + extension;
}
private Date getExpiredDate() {
LocalDateTime now = new LocalDateTime().plusMinutes(50);
return now.toDate();
}
}
presigned url 을 만들어 반환한다.
https://버킷이름.region 이름.amazonaws.com/UUID 로 생성된 파일명.확장명
?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240817T122501Z
&X-Amz-SignedHeaders=host&X-Amz-Expires=2999
클라이언트는 위와 같은 url 로 S3 에 접근해 이미지를 저장한다.
https://버킷이름.region 이름.amazonaws.com/UUID 로 생성된 파일명.확장명
이후 해당 이미지 url 이 필요한 api 의 request dto 에 위와 같이 권한 정보가 담긴 쿼리 스트링을 뺀 파일명만을 요청에 담아 보낸다.
image:
origin-prefix: origin/
replaced-prefix: replace/
public class LeagueTeamService {
@Value("${image.origin-prefix}")
private String originPrefix;
@Value("${image.replaced-prefix}")
private String replacePrefix;
public void register(final Long leagueId, final Member manager, final LeagueTeamRequest.Register request) {
// 생략..
String imgUrl = changeLogoImageUrlToBeSaved(request.logoImageUrl());
LeagueTeam leagueTeam = request.toEntity(manager, league, imgUrl);
// 생략..
}
private String changeLogoImageUrlToBeSaved(String logoImageUrl) {
if (!logoImageUrl.contains(originPrefix)) {
throw new IllegalStateException("잘못된 이미지 url 입니다.");
}
return logoImageUrl.replace(originPrefix, replacePrefix);
}
}
originPrefix 는 감춰져야 할 버킷의 이름을 포함한 url 의 prefix, replacePrefix 는 CloudFront 를 통해 배포된 url 이다.
다시 설명하자면 버킷이름이 bucket-name 이라고 해보자. 그렇다면 originPrefix 는 https://bucket-name.s3.ap-northeast-2.amazonaws.com/ 이다. 그러나 우리는 bucket-name 을 감춰야 한다. 이를 위해 CloudFront 를 이용해 배포한 url 인 https://images/ 는 replacePrefix 가 되는 것이다.
서버에서 DB 에 저장하기 전에, 클라이언트가 추후 이 이미지를 조회할 때 권한 관련 문제가 발생하지 않도록 기존의 prefix 를 변경하는 작업을 수행하는 changeLogoImageUrlToBeSaved 라는 메서드를 작성해줬다.
나의 경우에는, 저장할 때 url 을 바꿔줬지만 바꾸지 않은 채로 저장했다가 조회할 때 바꿔서 클라이언트에게 제공하는 방법도 가능할 것 같다!
삭제
private final String backupPrefix = "backup/";
public void deleteFile(String key) {
try {
String backupKey = backupPrefix + key;
amazonS3.copyObject(new CopyObjectRequest(bucketName, key, bucketName, backupKey));
amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
} catch (Exception e) {
throw new CustomException(HttpStatus.BAD_REQUEST, "이미지 파일 삭제에 실패했습니다.");
}
}
이미지를 삭제할 때는 이미지의 파일명(key) 을 받아 복제를 한 뒤 삭제를 한다.
트러블 슈팅
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AuthorizationQueryParametersError</Code>
<Message>X-Amz-Expires must be non-negative</Message>
<RequestId>5VPGRPM11MCAHFK3</RequestId>
<HostId>ECLkPBvYij/SXfBOe3fTl8AouI2DguLBwC2+lg6JCm+DGNdu0VEA+yL5tGSfEztHLo1WmxvG66k=</HostId>
</Error>
위와 같은 에러가 지속해서 발생했다. url 을 살펴보니 다음과 같은 문제를 발견할 수 있었다.
https://버킷이름.region 이름.amazonaws.com/UUID 로 생성된 파일명.확장명
?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240817T122501Z
&X-Amz-SignedHeaders=host&X-Amz-Expires=-2999
X-Amz-Expires 가 양수가 아닌 음수로 url 이 생성되고 있었다. 이에 대한 원인을 찾아보니 다음과 같았다.
AWS SDK - S3 GeneratePreSignedURL method gives "X-Amz-Expires must be non-negative"
I'm using AWS SDK to access S3 bucket for READ and WRITE objects in C#. I was able to get it working in a .NET project according to the instructions followed in the documentation. I want to limit the
stackoverflow.com
X-Amz-Expires 는 만료 시간을 나타내고, 초로 환산된다. 이가 음수로 나타나니 당연히 오류가 발생하는 것이었다. 대개 만료 시간을 계산하는 부분에서 발생하는 오류라고 한다.
private Date getExpiredDate() { // 수정 전
LocalDateTime now = new LocalDateTime().plusMinutes(50);
return now.toDate();
}
private Date getExpiredDate() { // 수정 후
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).plusMinutes(50);
return Date.from(now.toInstant());
}
따라서 타임존을 명시하고, 만료 시간을 계산할 수 있도록 하니 오류가 해결됐다!
참고 자료
https://www.geeksforgeeks.org/introduction-to-aws-simple-storage-service-aws-s3/#what-is-amazon-s3
Introduction to AWS Simple Storage Service (AWS S3) - GeeksforGeeks
A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.
www.geeksforgeeks.org
https://medium.com/@aidan.hallett/securing-aws-s3-uploads-using-presigned-urls-aa821c13ae8d
Securing AWS S3 uploads using presigned URLs
How can I allow users to access objects in S3?
medium.com
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 도메인 이벤트를 알아보고 AbstractAggregateRoot 를 이용해 채팅 서비스를 구현해보자 (0) | 2024.04.18 |
---|---|
[SpringBoot] 이벤트 기반 아키텍처를 알아보고 스프링부트의 이벤트를 구현해보자 (0) | 2024.03.01 |
[SpringBoot] Spring REST Docs 로 API 명세를 문서화 하자 (1) | 2024.01.23 |
[SpringBoot] 동시성 문제를 해결하자 (Synchronized, MySQL, Redis) (2) | 2024.01.10 |
[SpringBoot] N+1 을 고려하여 페이징 쿼리 작성하기 (1) | 2024.01.01 |