[AWS] AWS SDK V2를 이용한 RESTful 객체 조회 API 구현

DY_DEV·2023년 7월 28일
0

PROJECT - COZYSTATES

목록 보기
1/3
post-thumbnail

💻시연영상

시연영상

팀 프로젝트를 진행하면서 얻은 소중한 경험을 이번 기회에 정리해보려 한다!

🛠️사용기술

JAVA11, Spring boot 2.x
EC2, S3, RDS(Mysql)
cloudFront, route53, ALB

목표

기획한 프로젝트의 핵심기능은 S3에 저장된 정적 리소스를 지연시간을 최소화해 사용자에게 전달하는 것이다.

  • 음원: 정적 리소스 URL을 리스트 형태로 클라이언트에게 반환 후, 클라이언트가 cloudFront를 통해 직접 S3에 접근
  • 이미지: 위와 동일함.
  • AWS SDK를 이용해 조회, 업로드의 기본 로직을 구현하는 것이 나의 역할이었다.

구현

객체 조회 (수정 전)

  • 정적 리소스 URL을 데이터베이스에 저장하고, 클라이언트 요청이 들어올 때 마다 JPA를 통해 리스트로 반환.
  • 하지만 구성원들 모두 배포 경험 부재로 프로젝트 일정 내 HTTPS 환경 구성이 불확실했음. 그래서 우선 HTTP로 배포한 뒤, 일정 끝나고 고도화에 해당 이슈 다루기로 결정.
  • 이런 이유로 클라이언트가 S3에 직접 접근하는 구조상의 보안 취약성이 존재했고, 대비책으로 Pre-signed-URL 적용.

객체조회(1차 수정)

  • AWS SDK V2 이용
  • 메타데이터가 설정 된 정적 리소스 URL을 호출하는 RESTful API 구현

의존성 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'software.amazon.awssdk:s3:2.19.1'

S3Config

  • S3를 이용하기 위한 설정 클래스를 작성
  • AWS 자격증명을 발급받아 accessKey와 secretKey 확보.
  • 그 후 자격증명 정보를 생성하는 awsCredentialsProvider()를 생성
package com.example.server.MusicResources;


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.AwsCredentials;
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.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;



@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String AwsaccessKey;
    @Value("${cloud.aws.credentials.secretKey}")
    private String AwssecretKey;
    @Value("${cloud.aws.region.static}")
    private String region;
    
	// access, secret key 이용해 aws 자격증명 제공 
    @Bean
    public AwsCredentialsProvider awsCredentialsProvider() {
        AwsCredentials awsCredentials = AwsBasicCredentials.create(AwsaccessKey, AwssecretKey);
        return StaticCredentialsProvider.create(awsCredentials);
    }
	// s3서비스를 이용하기 위한 S3Client 객체 생성 
    @Bean
    public S3Client s3Client() { 
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(awsCredentialsProvider())
                .build();
    }
	// Pre-signed Url을 적용하기 위한 S3Presigner 객체 생성 
    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
                .region(Region.of(region))
                .credentialsProvider(awsCredentialsProvider())
                .build();
    }
}

MusicController

  • 설정된 메타데이터를 기반으로 정적 리소스 URL 호출.
  • s3 > 저장된 객체 > 속성 > 메타데이터 (s3 콘솔상 'x-amz-meta'는 자동으로 생성됩니다.)
@RestController
@RequestMapping("/theme/{theme-id}/music") // 테마id를 기준으로 음원 조회 
@Slf4j
public class MusicController {
    private final AwsS3Service awsS3Service;
    private static final Logger logger = LoggerFactory.getLogger(MusicController.class);

    public MusicController(AwsS3Service awsS3Service) {
        this.awsS3Service = awsS3Service;
    }



    // s3에 저장된 mp3 파일의 url list 가져오기
    @GetMapping("/list")
    public ResponseEntity getMusicUrlList(@Positive @PathVariable("theme-id") Long themeId){

        List<String> mp3List = awsS3Service.getMp3FileListUrlV3(themeId);
        return ResponseEntity.ok(mp3List);
    }

}

Service-v1

첫 번째 조회 로직. s3에서 메타데이터를 기반으로 일치하는 객체들을 조회한 뒤, Pre-signed url 정책을 적용한 리스트를 반환한다.

@Service
public class AwsS3Service {
    private final S3Client s3Client;
    private final S3Presigner s3Presigner;
    private static final Logger logger = LoggerFactory.getLogger(MusicController.class);

    @Value("${cloud.aws.s3.bucket}")
    private String bucketName;

    @Value("${cloud.aws.region.static}")
    private String region;


    public AwsS3Service(S3Client s3Client,
                        S3Presigner s3Presigner) {
        this.s3Client = s3Client;
        this.s3Presigner = s3Presigner;
    }
    
    public List<String> getMp3FileListUrlV1(Long themeId){
        try{
            List<String> musicList = new ArrayList<>(); // url 결과 리스트 
            ListObjectsRequest listObjectsRequest = ListObjectsRequest.builder()
                    .bucket(bucketName)
                    .build();
            ListObjectsResponse listObjectsResponse = s3Client.listObjects(listObjectsRequest);
            for(S3Object s3Object : listObjectsResponse.contents()) {
                HeadObjectRequest headObjectRequest = HeadObjectRequest.builder() // 특정 메타데이터 검색 요청 객체 
                        .bucket(bucketName)
                        .key(s3Object.key())
                        .build();
                        
                HeadObjectResponse headObjectResponse = s3Client.headObject(headObjectRequest);
                Map<String, String> metadata = headObjectResponse.metadata(); // 메타데이터 추출
                String themeIdMetadata = metadata.get("themeid");
                // 메타데이터가 일치하는 객체들의 url 값을 리스트에 추가
                if (themeIdMetadata != null && themeIdMetadata.equals(String.valueOf(themeId))) { 
                    GetUrlRequest getUrlRequest = GetUrlRequest.builder()
                            .bucket(bucketName)
                            .key(s3Object.key())
                            .bucket(bucketName)
                            .key(s3Object.key())
                            .build();
                    
                    // pre-signed 객체 요청 정의
                    GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
                            .signatureDuration(Duration.ofMinutes(1)) // 만료시간
                            .getObjectRequest(getObjectRequest)
                            .build();
                    PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner.presignGetObject(getObjectPresignRequest);
                    String theUrl = presignedGetObjectRequest.url().toString();
                    musicList.add(theUrl);
                } 
            }  
            return musicList;
        } catch (SdkException e){
            throw new RuntimeException("list 반환 실패: " + e.getMessage(), e);
        }
    }

🚨문제점: 객체 수 증가에 비례하는 응답시간

for문을 통해 객체를 하나씩 가져오고, 그 객체의 메타데이터에 관련된 작업을 진행한 뒤, pre-signed 정책을 적용하고 있다. 결국 s3에 저장된 객체의 수가 증가할 수록 응답시간은 증가하게된다.

  • 객체 목록 가져오기 (listObjects): O(N) - N은 S3 버킷의 객체 수
  • 각 객체에 대한 메타데이터 조회 및 pre-signed URL 생성: O(N) - 객체 수만큼 반복
  • headObject: O(1) (상수 시간)
  • pre-signed URL 생성: O(1) (상수 시간)

👍해결방법: for 문에서 중첩되는 작업을 비동기 처리한다.

  • S3 객체 목록을 가져오면서 Pre-signed url을 생성하는 작업이 동시에 이뤄지게 리팩토링
  • 비동기 처리를 도와주는 "CompletableFuture"객체 적용

"CompletableFuture"객체란?

java8 이후 복잡한 스레드 처리를 도와주는 객체. 기존의 비동기 작업의 결과값을 받는 Future의 한계를 극복했다. 외부에서 작업을 완료 시킬 수 없던 Future와 달리 CompletableFuture은 외부에서 작업 중단이 가능하다. 비동기 작업과 관련된 함수는 아래와 같다.

비동기 작업 실행

  • runAsync
    - 반환값이 없는 경우
    - 비동기로 작업 실행 콜
  • supplyAsync
    - 반환값이 있는 경우
    - 비동기로 작업 실행 콜

작업 콜백

  • thenApply
    - 반환 값을 받아서 다른 값을 반환함
    - 함수형 인터페이스 Function을 파라미터로 받음

작업 조합

  • allOf
    - 여러 작업들을 동시에 실행하고, 모든 작업 결과에 콜백을 실행함
  • anyOf
    - 여러 작업들 중에서 가장 빨리 끝난 하나의 결과에 콜백을 실행함

그럼 이제 CompletableFuture 객체를 적용해서 코드를 수정해보자.

객체조회(2차 수정)

Service-v2

//음원 조회v2 - pre-signed 비동기 처리
    public List<String> getMp3FileListUrlV2(Long themeId) {
        try {
            List<String> musicList = new ArrayList<>(); // 반환 url 리스트
            // 개체 목록 요청
            ListObjectsRequest listObjectsRequest = ListObjectsRequest.builder()
                    .bucket(bucketName)
                    .build();

            // .listObjects()로 실제 s3client의 객체 목록 가져옴
            ListObjectsResponse listObjectsResponse = s3Client.listObjects(listObjectsRequest);
            List<String> objectKeys = listObjectsResponse.contents().stream()// 객체 목록 얻은 후 스트림 변환
                    .map(S3Object::key)
                    .collect(Collectors.toList()); // 결과를 리스트로 수집

            // 비동기적으로 객체 정보 처리
            List<CompletableFuture<String>> futures = objectKeys.stream()
                    .map(key -> CompletableFuture.supplyAsync(() -> { 
                        // 객체의 메타 데이터를 가져옴
                        HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
                                .bucket(bucketName)
                                .key(key)
                                .build();

                        HeadObjectResponse headObjectResponse = s3Client.headObject(headObjectRequest);
                        Map<String, String> metadata = headObjectResponse.metadata();
                        String themeIdMetadata = metadata.get("themeid");

                        // 메타데이터가 주어진 themeId와 일치하는 경우에만 Pre-signed URL 생성하여 반환
                        if (themeIdMetadata != null && themeIdMetadata.equals(String.valueOf(themeId))) {
                            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                                    .bucket(bucketName)
                                    .key(key)
                                    .build();

                            // pre-signed 객체 요청
                            GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
                                    .signatureDuration(Duration.ofMinutes(1)) // 만료시간
                                    .getObjectRequest(getObjectRequest)
                                    .build();

                            PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner.presignGetObject(getObjectPresignRequest);
                            return presignedGetObjectRequest.url().toString();
                        } else {
                            return null; // url이 존재하지 않는다면 null 값을 넣어준다.
                        }
                    }))
                    .collect(Collectors.toList());
           
            CompletableFuture<Void> allFutures = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
            CompletableFuture<List<String>> allUrlsFuture = allFutures.thenApply(v -> 
                    futures.stream() 
                            .map(CompletableFuture::join)
                            .filter(Objects::nonNull)
                            .collect(Collectors.toList()) 
            );

            musicList.addAll(allUrlsFuture.get());

            return musicList;
        } catch (Exception e) {
            throw new RuntimeException("list 반환 실패: " + e.getMessage(), e);
        }
    }

결과

Service-v1 응답시간


Service-v2 응답시간

= 응답시간 감소 8sec -> 1sec

결론

이렇게 구현한 기능의 장단점을 알아보자.

장점

  • RDS의 자원을 절약할 수 있다. -> 일반적으로 RDS보다 S3의 사용량에 따른 청구 비용이 더 낮다. 저비용으로 동일한 기능을 구현할 수 있다.

  • 데이터 엑세스 계층에서 발생할 수 있는 문제에서 다소 자유롭다.

단점

  • 동시성 문제가 발생할 수 있다. 많은 사용자가 접근할 경우 CompletableFuture pool의 한계가 발생할 수 있다.(CompletableFuture pool 크기 조절로 해결)


🙋‍♂️부족한 점이 많습니다. 오류 피드백 부탁드립니다!


[참고]

https://gksdudrb922.tistory.com/226
https://yuricoding.tistory.com/m/181
https://velog.io/@ililil9482/AWSSDK-java-v2-S3-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0
https://velog.io/@wonizizi99/Web-%ED%94%84%EB%A1%A0%ED%8A%B8%EB%B2%A1%EC%95%A4%EB%93%9C7spring-boot-S3-pre-signed-URL-%EC%A0%81%EC%9A%A9
https://velog.io/@wonizizi99/Web-%ED%94%84%EB%A1%A0%ED%8A%B8%EB%B2%A1%EC%95%A4%EB%93%9C7spring-boot-S3-pre-signed-URL-%EC%A0%81%EC%9A%A9
https://senni.tistory.com/43

[CompletableFuture]
https://www.devkuma.com/docs/java/completable-future/
https://mangkyu.tistory.com/263
https://recordsoflife.tistory.com/1469
https://jhproject.tistory.com/168


[aws 공식문서]
https://docs.aws.amazon.com/ko_kr/sdk-for-java/latest/developer-guide/asynchronous.html
https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/example_s3_GetObject_section.html
https://docs.aws.amazon.com/ko_kr/sdk-for-java/latest/developer-guide/examples-s3-objects.html#list-object
https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/javav2/example_code/s3#readme
https://docs.aws.amazon.com/ko_kr/sdk-for-java/latest/developer-guide/home.html
https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/javav2/usecases/create_spring_stream_app
https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/javav2/example_code/s3

2개의 댓글

comment-user-thumbnail
2023년 7월 28일

잘 읽었습니다. 좋은 정보 감사드립니다.

1개의 답글