이전에 Spring을 통해 S3 파일을 압축해서 다운로드하는 글을 작성했는데, 많은 도움이 되었다.
2022.08.05 - [java/spring] - [Spring] AWS S3 파일 압축해서 다운로드 - 여러가지 방법 비교분석
이번에는 반대로 압축파일을 업로드하면 S3에 풀어서 저장되는 API를 구현하고자 한다.
업로드에는 여러가지 방법이 있지만, 파일 다운로드 테스트를 할 때, TransferManager의 효과를 톡톡히 봤기 때문에, 이번에도 사용하려 한다.
기본 세팅
먼저 S3 버킷에 대한 퍼블릭 엑세스 설정이 필요하다. 해당 글을 참고하자.
2022.07.25 - [aws] - [AWS] S3 버킷 퍼블릭 엑세스 설정
또한, Spring Boot에서 S3 설정 및 접근에 대한 인증 키를 입력해야 한다. 해당 글을 참고하자.
2022.07.25 - [java/spring] - [Spring] AWS S3 접근
다음 라이브러리를 추가한다.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' // S3 접근
implementation 'net.lingala.zip4j:zip4j:2.6.1' // 압축 업로드
implementation 'org.apache.commons:commons-text:1.9' // 랜덤 문자열 생성
S3Config에 TransferManager를 추가한다.
TransferManager는 AWS가 제공하는 SDK로, S3 파일 다운로드, 업로드를 용이하게 해주는 클래스이다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
public String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
public String secretKey;
@Value("${cloud.aws.region.static}")
public 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;
}
// 추가
@Bean
public TransferManager transferManager() {
TransferManager transferManager = TransferManagerBuilder.standard()
.withS3Client(amazonS3())
.build();
return transferManager;
}
}
application.yml(.properties) 파일에 다운로드할 파일이 속한 버킷 이름을 설정하자.
aws:
s3:
bucket: danuri
당연히 S3에 해당 이름의 버킷이 존재해야 한다.
나는 'danuri'라는 빈 버킷을 생성했다.
TransferManager 업로드
컨트롤러
@Controller
@RequiredArgsConstructor
@RequestMapping("/s3")
public class S3Controller {
private final S3Service s3Service;
@PostMapping(value = "/upload/zip/**")
public ResponseEntity uploadZip(@RequestPart("file") MultipartFile multipartFile, HttpServletRequest request) throws IOException, InterruptedException {
String prefix = getPrefix(request.getRequestURI(), "/s3/upload/zip/");
s3Service.uploadZip(prefix, multipartFile);
return new ResponseEntity<>(HttpStatus.CREATED);
}
private String getPrefix(String uri, String regex) {
String[] split = uri.split(regex);
return split.length < 2 ? "" : split[1];
}
}
우선 파일 업로드를 위해서 @RequestPart를 사용한다.
form-data로 파일을 넣어주는 형식인데, Postman으로 테스트 할 때는 다음과 같이 파일을 넣어주면 된다.
- URL 구조
/upload/zip/{버킷 내에서 업로드 할 디렉토리}
{버킷 내에서 업로드 할 디렉토리} 는 @PathVariable로 가져오지 않는데, 이유는 /v1/v2/... 처럼 path 형태로 변수를 저장하기 위해서다.
이를 getPrefix라는 함수를 통해 URI를 파싱해서 디렉토리 경로(prefix)를 가져오도록 설정했으며, 이를 통해
/upload/zip1/v1
/upload/zip1/v1/v2
등 다양한 디렉토리 구조 형태를 가져올 수 있다.
나는 http://localhost:8080/s3/upload/zip/upload로 테스트를 진행할 것이다.
해당 URL은 S3 버킷 내 upload라는 디렉토리에 파일을 업로드하겠다는 뜻이다.
서비스
public void uploadZip(String prefix, MultipartFile multipartFile) throws InterruptedException, IOException {
// (1)
// 압축 파일 File 형태로 전환
File zipSource = FileUtil.convert(multipartFile);
ZipFile zipFile = new ZipFile(zipSource);
// (2)
// 로컬 디렉토리에 압축 풀기
log.info("Extract zip file...");
String uploadLocalPath = RandomStringUtils.randomAlphanumeric(6) + "-s3-upload";
zipFile.extractAll(uploadLocalPath);
FileUtil.remove(zipSource);
File localDirectory = new File(uploadLocalPath);
try {
// (3)
// 파일 업로드
MultipleFileUpload uploadDirectory = transferManager.uploadDirectory(bucket, prefix, localDirectory, true);
// (4)
// 업로드 상태 확인
log.info("[" + prefix + "] upload progressing... start");
DecimalFormat decimalFormat = new DecimalFormat("##0.00");
while (!uploadDirectory.isDone()) {
Thread.sleep(1000);
TransferProgress progress = uploadDirectory.getProgress();
double percentTransferred = progress.getPercentTransferred();
log.info("[" + prefix + "] " + decimalFormat.format(percentTransferred) + "% upload progressing...");
}
log.info("[" + prefix + "] upload success!");
} finally {
// (6)
// 로컬 디렉토리 삭제
FileUtil.remove(localDirectory);
}
}
코드가 길어보이지만 주석으로 단 번호와 설명을 함께 보면 구조 자체는 크게 어렵지 않다.
코드를 부분적으로 분석해보자.
(1)
// (1)
// 압축 파일 File 형태로 전환
File zipSource = FileUtil.convert(multipartFile);
ZipFile zipFile = new ZipFile(zipSource);
@RequestPart는 MultipartFile로 input이 되기 때문에, 먼저 업로드가 가능한 File 클래스 형태로 바꿔줘야 한다.
이후 생성된 zip 파일(zipSource)를 ZipFile 클래스에 주입한다.
convert 함수의 코드는 다음과 같다.
convert 함수가 정상적으로 수행되면 로컬에 업로드를 위한 zip 파일이 생성된다.
@Slf4j
public class FileUtil {
public static File convert(MultipartFile multipartFile) throws IOException {
final int INPUT_STREAM_BUFFER_SIZE = 2048;
File convertFile = new File(multipartFile.getOriginalFilename());
if (convertFile.exists()) {
remove(convertFile);
}
if (convertFile.createNewFile()) { // 로컬에 File이 생성됨 (이미 해당 File이 존재한다면 생성 불가)
try (BufferedInputStream bis= new BufferedInputStream(multipartFile.getInputStream());
FileOutputStream fos = new FileOutputStream(convertFile)) {
int length;
byte[] bytes = new byte[INPUT_STREAM_BUFFER_SIZE];
log.info("Create file in local storage...");
while((length = bis.read(bytes)) >= 0) {
fos.write(bytes,0, length);
}
}
return convertFile;
} else {
throw new IllegalArgumentException("error: MultipartFile -> File convert fail");
}
}
public static void remove(File file) throws IOException {
if (file.isDirectory()) {
removeDirectory(file);
} else {
removeFile(file);
}
}
public static void removeDirectory(File directory) throws IOException {
File[] files = directory.listFiles();
for (File file : files) {
remove(file);
}
removeFile(directory);
}
public static void removeFile(File file) throws IOException {
if (file.delete()) {
log.info("File [" + file.getName() + "] delete success");
return;
}
throw new FileNotFoundException("File [" + file.getName() + "] delete fail");
}
}
remove() 함수는 로컬 디렉토리 삭제를 편하게 하기 위해 직접 추가한 함수로 이후에 사용해야 하니 미리 넣어두자.
(2)
// (2)
// 로컬 디렉토리에 압축 풀기
log.info("Extract zip file...");
String uploadLocalPath = RandomStringUtils.randomAlphanumeric(6) + "-s3-upload";
zipFile.extractAll(uploadLocalPath);
FileUtil.remove(zipSource);
File localDirectory = new File(uploadLocalPath);
TransferManager는 로컬에 있는 파일을 S3에 업로드할 수 있는 SDK이다.
따라서, 앞서 로컬에 생성한 zip파일을 로컬 디렉토리에 압축 해제한다.
디렉토리 이름은 {RandomString(6)}-s3-download 으로 설정했다. RandomString을 통해 동일한 이름을 사용하지 않은 이유는 여러 사용자가 동시에 업로드 하는 경우 디렉토리가 겹칠 수 있기 때문이다.
압축 해제가 끝난 zip파일은 remove()를 통해 제거한다.
(3)
// (3)
// 파일 업로드
MultipleFileUpload uploadDirectory = transferManager.uploadDirectory(bucket, prefix, localDirectory, true);
TransferManager를 통해 로컬디렉토리를 S3에 업로드한다.
TransferManager에 대한 더 자세한 설명은 아래 참고자료 첫 번째 링크를 참고하자.
(4)
// (4)
// 업로드 상태 확인
log.info("[" + prefix + "] upload progressing... start");
DecimalFormat decimalFormat = new DecimalFormat("##0.00");
while (!uploadDirectory.isDone()) {
Thread.sleep(1000);
TransferProgress progress = uploadDirectory.getProgress();
double percentTransferred = progress.getPercentTransferred();
log.info("[" + prefix + "] " + decimalFormat.format(percentTransferred) + "% upload progressing...");
}
log.info("[" + prefix + "] upload success!");
TransferManager은 멀티스레드를 사용하여 멀티파트 전송을 한다.
처리량이 빠르다 등의 좋은 장점이 많은데, 여기서 주의해야 할 점은 비동기 처리이기 때문에, transferManager.uploadDirectory() 메서드 호출 후 업로드가 완료되지 않아도 다음 코드를 수행한다.
순서가 중요한 코드의 경우 이를 block하는 설정이 필요하다.
만약을 대비해, while(!uploadDirectory.isDone())을 통해 업로드 끝날 때까지 대기하도록 설정했다.
이왕 기다리는 김에 업로드 진행 상황을 보면 좋겠다 생각했고, 위 코드를 돌리면 다음과 같은 로그가 찍힌다.
[upload] upload progressing... start
[upload] 0.35% upload progressing...
[upload] 0.70% upload progressing...
[upload] 1.06% upload progressing...
[upload] 1.44% upload progressing...
[upload] 1.80% upload progressing...
[upload] 2.15% upload progressing...
...
[upload] 100.00% upload progressing...
[upload] upload success!
+) 물론 업로드 파일의 용량이 작은 경우 100%가 바로 찍히거나, 로그가 안찍히기도 한다.
(5)
// (5)
// 로컬 디렉토리 삭제
FileUtil.remove(localDirectory);
finally 문에 속하는 부분으로 업로드가 성공하던, 중간에 오류가 생기던 로컬 용량을 아끼기 위해 로컬 디렉토리는 꼭 삭제해 줘야 한다.
테스트
이제 실제 파일을 업로드해보자. 업로드할 전체 디렉토리 구조는 다음과 같다.
- 디렉토리: v1/
- 디렉토리: v2-1/
- 파일: one.jpg
- 파일: two.jpg
- 디렉토리: v2-2/
- 파일: three.jpg
- 파일: four.jpg
여기서 v1/을 압축해서 upload.zip이라는 압축 파일을 만들어 form-data에 입력했다.
테스트는 Postman으로 진행했다.
성공적으로 업로드가 되면 S3에 다음과 같은 구조로 파일이 업로드된다.
버킷: danuri
-디렉토리: upload/
- 디렉토리: v1/
- 디렉토리: v2-1/
- 파일: one.jpg
- 파일: two.jpg
- 디렉토리: v2-2/
- 파일: three.jpg
- 파일: four.jpg
만약 upload라는 디렉토리가 S3에 없는 경우 알아서 생성해주니 안심하자.
대용량 파일로도 테스트해봤다.
네트워크 상황에 따라 @RequestPart에 파일이 올라가는 속도가 다르기 때문에, 어떤 환경에서 업로드하느냐에 따라 시간이 다를 것이다.
약 3.7GB 파일로 진행했고,
로컬(최신 LG그램) 서버에서 진행했을 때 시간은 다음과 같다.
총 4분 (@RequestPart 업로드 3분 + S3 업로드 1분)
TransferManager의 성능 답게 로컬 -> S3 업로드 자체는 정말 빠른 시간에 진행됐다.
결국 성능을 좌우하는 것은 압축 파일이 @RequestPart, 즉, API에 얼마나 빠른 시간에 탑재되는지다.
실제로 같은 용량(3.7GB)에 대해 로컬이 아닌 EC2에서 테스트했을 때 시간은 다음과 같다.
총 10분 (@RequestPart 업로드 9분 + S3 업로드 1분)
로컬과 무려 6분 차이이다.
upload.zip 파일이 로컬에 존재하기 때문에 확실히 로컬 서버보다는 네트워크 차이가 있나보다.
여기서 궁금한 점이 생겼다.
현재 EC2 인스턴스는 t3.xlarge 유형인데, 혹시 대용량 업로드에 걸맞는 다른 인스턴스 유형이 있는지 찾아보았다.
테스트한 인스턴스 유형은 다음과 같다.
가격 | CPU | 메모리 | 네트워크 대역폭 | 유형 | |
t3.xlarge | 0.208USD | 4CPU | 16GB | 5GB | 범용 |
c5.xlarge | 0.192USD | 4CPU | 8GB | 10GB | 컴퓨팅 최적화 |
c6gn.xlarge | 0.195USD | 4CPU | 8GB | 25GB | 컴퓨팅 최적화 |
r6g.xlarge | 0.244USD | 4CPU | 32GB | 10GB | 메모리 최적화 |
3.7GB로 테스트한 결과는 다음과 같다.
시간 | |
t3.xlarge | 총 10분 (@RequestPart 업로드 9분 + S3 업로드 1분) |
c5.xlarge | 측정불가, 너무 오래걸림 |
c6gn.xlarge | 총 11분 (@RequestPart 업로드 10분 + S3 업로드 1분) |
r6g.xlarge | 총 12분 (@RequestPart 업로드 11분 + S3 업로드 1분) |
예상 밖의 결과가 나왔다.
그래도 범용보다는 네트워크 대역폭이 높고 IO, 메모리가 큰 컴퓨팅 최적화, 메모리 최적화 인스턴스가 시간이 덜 걸릴 것으로 생각했는데,
그냥 범용이 제일 빠르다.
아마도 T3 인스턴스는 CPU, 네트워크 버스트가 지원되기 때문에 필요에 따라 써있는 스펙에 비해 추가적인 처리량에 액세스할 수 있는 것으로 보인다.
결국, 엔터프라이즈 급 수준이 아니라면,,, 앵간하면 범용(t3) 쓰자...
개인적인 호기심으로 여러 인스턴스를 테스트한 내용을 소개했는데, 결국 핵심은 S3에 압축 파일을 풀어서 업로드가 가능하다는 것이다. 많은 파일을 한 번에 S3에 업로드하고자 할 때 유용하게 사용할 수 있는 API라고 생각한다.
참고자료
https://docs.aws.amazon.com/ko_kr/sdk-for-java/v1/developer-guide/examples-s3-transfermanager.html
'java > spring' 카테고리의 다른 글
[Spring] PostgreSQL - PostGIS, JPA를 통해 공간 데이터 다루기 (0) | 2023.01.12 |
---|---|
[Spring] Mockito when으로 repository save 리턴받기 (0) | 2022.11.21 |
[Spring] AWS S3 파일 압축해서 다운로드 - 여러가지 방법 비교분석 (2) | 2022.08.05 |
[Spring] AWS S3 객체 삭제 (0) | 2022.08.04 |
[Spring] 엑셀 다운로드 API (0) | 2022.08.04 |