승이의 기술블로그
article thumbnail

presigned url 이란?

aws의 공식 문서

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 이 생성되고 있었다. 이에 대한 원인을 찾아보니 다음과 같았다.

 

https://stackoverflow.com/questions/64951669/aws-sdk-s3-generatepresignedurl-method-gives-x-amz-expires-must-be-non-negati

 

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

 

검색 태그