[Spring Boot] 서버리스 기반 S3 Presigned URL 적용하기

유아 Yooa·2023년 9월 3일
6

Spring

목록 보기
18/18
post-thumbnail

Overview

파일과 같은 데이터 업로드는 웹/앱에서 가장 많이 다루는 기능 중 하나이다. 사진, 동영상, 문서와 같은 미디어 파일을 업로드하는 프로세스는 주로 아래와 같다.

1. 사용자가 파일을 어플리케이션 서버에 업로드한다.
2. 어플리케이션 서버는 처리를 위해 업로드를 임시 공간에 저장한다.
3. 파일을 데이터베이스, 파일 서버 또는 영구 저장을 위한 개체 저장소로 전송한다.

보통 파일 업로드는 권한 설정 때문에 서버를 경유해야 한다. 영상 파일 같이 컨텐츠의 용량이 높은 경우, 서버에 넘겨준 후 서버에서 스토리지에 저장하는 이중 작업은 비효율적일 때가 있다.
네트워크 I/O 및 서버 CPU 사용량이 커지며 속도 지연을 일으킬 수도 있다는 것.👀

파일 크기가 크지 않더라도 서버에서 Multipart file을 받아 S3 버킷에 업로드하면 서버쪽에서 파일을 갖고 있어야 하는 자체로 리소스 낭비가 발생할 수 있다.

그렇다면 클라이언트에서 바로 업로드하면 되지 왜 굳이 서버를 거쳐서 업로드하는 걸까?

보안 때문이다.
정해둔 규칙 안에서 데이터가 관리되어야 하기 때문에 권한을 가진 사용자만 S3에 접근해야 한다. 이를 서버가 수행해주며 일종의 보안 절차 작업을 거치게 되는 것.👀

위와 같은 단점을 개선하여 서버단의 리소스를 사용하지 않고 클라이언트가 S3에 직접 파일을 업로드할 수 있는 + 보안 절차까지 보장되는 Presigned URL 방식을 알아보자.

AWS S3 Presigned URL

직역하면 "미리 서명된 URL"을 의미한다. 서버에서 권한을 검증하여 나온 Presigned URL.
즉, 해당 URL은 이미 S3에 지급할 수 있는 권한을 가진 상태를 의미하기에, 서버에 거쳐 권한 검증을 할 필요가 없다.

  • S3의 Bucket Policy나 acl과 같은 권한 설정과 관계없이 특정 단말(채널)에 특정 유효 기간동안 S3에 PUT, GET이 가능하게 하는 URL이다.
  • 유효 기간 이후에 사라지기 때문에(Access Denied) 외부 유출에도 큰 위험이 없다.
  • S3에 파일을 직접 업로드하면 웹서버의 네트워크 트래픽과 서버 CPU 사용량을 크게 줄이고, 서버가 사용량이 많은 기간 동안 다른 요청을 처리할 수 있다.

1. Amazon API Gateway 를 호출하여, getSignedURL Lambda 함수 엔드포인트를 호출한다. 이를 통해 Signed URL을 얻을 수 있다.
2. 애플리케이션에서 S3 버킷으로 파일을 직접 업로드한다.

한 줄로 정리하자면,
필요에 따라 객체 소유자가 보안 자격 증명을 사용하여 일정 기간 동안 객체 접근을 허가하는 Pre-Signed URL을 만들어 다른 사용자와 객체를 공유할 수 있다는 것.

Presigned URL 파라미터

https://example-bucket.s3.amazonaws.com/images/2d098b12-5cd7-4f00-835b-a6c998a13617%EB%B8%94%EB%A1%9C%EA%B7%B8%20%EC%8D%B8%EB%84%A4%EC%9D%BC.png
?x-amz-acl=public-read
&X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Date=20230903T144326Z
&X-Amz-SignedHeaders=host
&X-Amz-Expires=120
&X-Amz-Credential=<ACCESS_KEY>/20230903/ap-northeast-2/s3/aws4_request
&X-Amz-Signature=<SIGNATURE_VALUE>

x-amz-acl

presigned url을 활용하여 업로드하고 리소스에 대한 GET 요청 시 access denied가 발생한다.
그러므로 presigned url 생성할 때 public read 권한을 설정해주어야 한다.

X-Amz-Algorithm

서명 버전과 알고리즘을 식별하고, 서명을 계산하는데 사용. 서명 버전 4를 위해서 “AWS4-HMAC-SHA256” 로 설정

X-Amz-Date

날짜는 ISO 8601 형식

X-Amz-SignedHeaders

서명을 계산하기 위해 사용되어지는 헤더 목록. HTTP host 헤더가 요구

X-Amz-Expires

미리 선언된 URL이 유효한 시간 주기. 초단위. 정수 값. 최소 1에서 최대 604800 (7일)

AWS Identity and Access Management (IAM) 인스턴스 프로파일 : 최대 6시간 유효
AWS Security Token Service (STS): 최대 36시간 유효
IAM User: 최대 7일 유효(AWS v4 증명을 사용할경우)

X-Amz-Credential

액세스 키 ID와 범위 정보(요청 날짜, 사용하는 리전, 서비스 명). 리전 명은 리전 및 엔드포인트에서 확인 가능

X-Amz-Signature

요청을 인증하기 위한 서명

AccessDenied

Presigned URL의 유효 기간이 지난 S3에 엑세스하면?

<?xml version="1.0" encoding="UTF-8"?>
<Error>
    <Code>AccessDenied</Code>
    <Message>Request has expired</Message>
    <X-Amz-Expires>120</X-Amz-Expires>
    <Expires>2023-09-03T14:45:26Z</Expires>
    <ServerTime>2023-09-03T16:16:52Z</ServerTime>
    <RequestId>Z3634PQ64CW2Y0EE</RequestId>
    <HostId>giWKwpSWmbv62iwAeD4pCbjOsIOyMv4NC3eMTZUrNmgaiiNcEPg7utHibWwQyVWF6C75wisnbf4=</HostId>
</Error>

Spring Boot 구현하기

dependency

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

application.yml

S3 IAM 인증 키를 작성해준다.

# s3
cloud:
  aws:
    s3:
      bucket: [bucket name]
    stack.auto: false
    region.static: ap-northeast-2
    credentials:
      accessKey: [access key]
      secretKey: [secret key]

S3Config

Presigned URL을 발급받기 위하여 S3 접근을 해야하므로 인증 정보를 설정한다.

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    @Primary
    public BasicAWSCredentials awsCredentialsProvider(){
        BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
        return basicAWSCredentials;
    }

    @Bean
    public AmazonS3 amazonS3() {
        AmazonS3 s3Builder = AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentialsProvider()))
                .build();
        return s3Builder;
    }
}

FileService

Presigned URL을 발급받는 로직이다. 각 메서드마다 주석으로 설명을 달아놓았다.

@Service
@RequiredArgsConstructor
public class FileService {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

	/**
     * presigned url 발급
     * @param prefix 버킷 디렉토리 이름
     * @param fileName 클라이언트가 전달한 파일명 파라미터
     * @return presigned url
     */
    public String getPreSignedUrl(String prefix, String fileName) {
        if(ValidatorUtil.isNotEmpty(prefix)) {
            fileName = createPath(prefix, fileName);
        }

        GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePreSignedUrlRequest(bucket, fileName);
        URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
        return url.toString();
    }

	/**
     * 파일 업로드용(PUT) presigned url 생성
     * @param bucket 버킷 이름
     * @param fileName S3 업로드용 파일 이름
     * @return presigned url
     */
    private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(String bucket, String fileName) {
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
                new GeneratePresignedUrlRequest(bucket, fileName)
                        .withMethod(HttpMethod.PUT)
                        .withExpiration(getPreSignedUrlExpiration());
        generatePresignedUrlRequest.addRequestParameter(
                Headers.S3_CANNED_ACL,
                CannedAccessControlList.PublicRead.toString());
        return generatePresignedUrlRequest;
    }

	/**
     * presigned url 유효 기간 설정
     * @return 유효기간
     */
    private Date getPreSignedUrlExpiration() {
        Date expiration = new Date();
        long expTimeMillis = expiration.getTime();
        expTimeMillis += 1000 * 60 * 2;
        expiration.setTime(expTimeMillis);
        return expiration;
    }

    /**
     * 파일 고유 ID를 생성
     * @return 36자리의 UUID
     */
    private String createFileId() {
        return UUID.randomUUID().toString();
    }

    /**
     * 파일의 전체 경로를 생성
     * @param prefix 디렉토리 경로
     * @return 파일의 전체 경로
     */
    private String createPath(String prefix, String fileName) {
        String fileId = createFileId();
        return String.format("%s/%s", prefix, fileId + fileName);
    }
}

파일 업로드 Post man 기능 테스트하기

  1. 클라이언트 ➡️ 서버 : Presigned URL 발급
{
    "code": "200",
    "message": "요청 성공",
    "data": "https://example-bucket.s3.amazonaws.com/images/2d098b12-5cd7-4f00-835b-a6c998a13617%EB%B8%94%EB%A1%9C%EA%B7%B8%20%EC%8D%B8%EB%84%A4%EC%9D%BC.png?x-amz-acl=public-read&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230903T144326Z&X-Amz-SignedHeaders=host&X-Amz-Expires=120&X-Amz-Credential=<ACCESS_KEY>/20230903/ap-northeast-2/s3/aws4_request&X-Amz-Signature=<SIGNATURE_VALUE>"
}
  1. 클라이언트 ➡️ Presigned URL을 사용해 PUT 데이터 전달
  • [Body]탭 - [Binary] 라디오 버튼 - 파일 선택
  • 200 OK 응답이 나오는지 확인
  1. 쿼리 파라미터를 제외한 Presigned URL에 접속해보면 S3에 업로드가 정상적으로 완료되었음을 확인할 수 있다!

참고

profile
기록이 주는 즐거움

4개의 댓글

comment-user-thumbnail
2023년 12월 4일

업로드 한파일을 Presigned URL를 통해 클라이언트가 볼수 있나요?

1개의 답글
comment-user-thumbnail
2024년 1월 18일

S3 설정은 어떻게 하셨나요?? S3의 모든 퍼블릭 엑세스가 차단된 상태인가요?

답글 달기
comment-user-thumbnail
2024년 6월 19일

좋은 게시물 잘 봤습니다. s3 작업중인데 유용한 도움이 되었어요.

답글 달기