-
[SpringBoot] Presigned Url 을 이용해 S3에 이미지 업로드 하기SpringBoot 2024. 8. 17. 22:48
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