팀 프로젝트를 진행하면서 얻은 소중한 경험을 이번 기회에 정리해보려 한다!
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 적용.
- 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'
- 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();
}
}
@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);
}
}
첫 번째 조회 로직. 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) (상수 시간)
- S3 객체 목록을 가져오면서 Pre-signed url을 생성하는 작업이 동시에 이뤄지게 리팩토링
- 비동기 처리를 도와주는 "CompletableFuture"객체 적용
java8 이후 복잡한 스레드 처리를 도와주는 객체. 기존의 비동기 작업의 결과값을 받는 Future의 한계를 극복했다. 외부에서 작업을 완료 시킬 수 없던 Future와 달리 CompletableFuture은 외부에서 작업 중단이 가능하다. 비동기 작업과 관련된 함수는 아래와 같다.
비동기 작업 실행
작업 콜백
작업 조합
//음원 조회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);
}
}
= 응답시간 감소 8sec -> 1sec
이렇게 구현한 기능의 장단점을 알아보자.
RDS의 자원을 절약할 수 있다. -> 일반적으로 RDS보다 S3의 사용량에 따른 청구 비용이 더 낮다. 저비용으로 동일한 기능을 구현할 수 있다.
데이터 엑세스 계층에서 발생할 수 있는 문제에서 다소 자유롭다.
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
잘 읽었습니다. 좋은 정보 감사드립니다.