파일 업로드(I/O 작업)과 DB 파일 정보 데이터의 생명 주기를 맞춰 Aws S3를 관리할 수 있는 방법을 소개하고자 한다.
서비스에서 이미지와 같은 파일들을 등록하고 보여주거나 다운로드하는 기능을 일반적을 구현해야 한다.
Aws S3를 사용하여 파일을 관리하는 방식을 사용하고 해당 파일에 접근할 수 있도록 AttachFile
이라는 DB Table에 파일에 대한 정보(파일 경로, 파일명...)를 저장한다.
여기서의 문제점은 AttachFile
은 트랜잭션으로 관리되지만 S3 업로드는 I/O작업으로서 트랜잭션으로 관리되지 못한다. 즉, S3 업로드 성공 응답을 받고 AttachFile
을 만드는 과정 중에 트랜잭션이 롤백이 발생한다면 해당 S3 오브젝트는 서비스에서 관리할 수 없는 오브젝트가 되고 만다.
이번 포스트에서는 S3 오브젝트를 놓치지 않고 최대한 서비스 레벨에서 관리하도록 변경해보고자 한다.
/**
* S3 업로드 및 AttachFile 생성
**/
@Transactional
public AttachFile uploadS3AndCreateAttachFile(MultipartFile file, String prefixPath) {
S3ObjectDto s3object = awsS3Component.upload(file, prefixPath);
return createAttachFile(file.getOriginalFilename(), file.getSize(), s3object);
}
public S3ObjectDto upload(MultipartFile multipartFile, String prefixPath) {
checkExtensionIsAllowed(multipartFile.getOriginalFilename());
String s3FilePath = AwsS3Utils.convertFilePath(prefixPath);
String s3FileName = AwsS3Utils.convertFileName(multipartFile.getOriginalFilename());
putObjectOnS3Bucket(multipartFile, s3FilePath, s3FileName);
return S3ObjectDto.create(s3FilePath, s3FileName);
}
/**
* S3 오브젝트 업로드
**/
public void putObjectOnS3Bucket(MultipartFile multipartFile, String s3FilePath, String s3FileName) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentEncoding(StandardCharsets.UTF_8.name());
metadata.setContentLength(multipartFile.getSize());
final String bucketName = awsProperty.bucketName();
String absolutePath = StringUtils.applyRelativePath(bucketName + FOLDER_SEPARATOR, s3FilePath);
try {
awsS3Client.putObject(absolutePath, s3FileName, multipartFile.getInputStream(), metadata);
} catch (IOException | SdkClientException e) {
log.error("S3 Error : ", e);
throw new FailedS3UploadException();
}
}
기존에 해당 문제를 개선하기 위해 AttachFile 생성 -> S3 업로드 -> 트랜잭션 커밋 순서로 롤백될 확률을 줄일 수 있도록 대처했었다. 하지만 필자가 채용 사이트 빌더 프로젝트
를 개발하게 되면서 하나의 트랜잭션의 크기가 매우 크고, 대량의 S3 파일을 복사하여 업로드(100개 이상도 가능...)하는 과정도 많이 포함되어 있다. 트랜잭션의 크기가 커질수록 롤백될 경우의 수도 많아지고 관리하지 못하게 되는 S3 오브젝트들도 많아지고 있다는 점을 발견할 수 있었다. 이것이 크리티컬하진 않아도 Aws 인프라 비용으로 직결되는 문제기 때문에 관리되지 못한다면 프로젝트의 지속성에 영향을 미칠 수 있어 보완이 필요하다고 판단했다.
파일을 생성, 복사하는 시점마다 새로운 트랜잭션을 생성하여 롤백 확률을 줄여보기도 했지만 근본적인 해결책도 아니었고 추가적인 트랜잭션이 관리되는 시간까지 API에서 감당하여 응답 속도에 영향을 미쳤다.
트랜잭션의 상태를 감지하여 이벤트를 실행하는 어노테이션이다. DB / S3 파일의 생명주기를 관리하기 위해 어노테이션을 활용하여 트랜잭션의 롤백을 감지하고 업로드 되어버린 S3 오브젝트의 추가적인 처리를 진행할 수 있었다.
트랜잭션의 상태를 감지하는 단계는 4가지가 존재한다.
그 중 실행되고 있는 트랜잭션의 롤백 시점을 잡아 S3 오브젝트를 삭제할 수 있는 시점을 마련했다.
// 이벤트 핸들러
@Component
@RequiredArgsConstructor
public class UploadS3ObjectEventHandler {
private final AwsS3Component awsS3Component;
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void rollbackS3Upload(UploadS3ObjectEvent event) {
awsS3Component.deleteObject(event.objectPath(), event.objectName())
}
}
// 이벤트
public record UploadS3ObjectEvent(
String objectPath,
String objectName
) {
}
// 이벤트 호출
public void putObjectOnS3Bucket(MultipartFile multipartFile, String s3FilePath, String s3FileName) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentEncoding(StandardCharsets.UTF_8.name());
metadata.setContentLength(multipartFile.getSize());
try {
awsS3Client.putObject(s3FilePath, s3FileName, multipartFile.getInputStream(), metadata);
eventPublisher.publishEvent(new UploadS3ObjectEvent(s3FilePath, s3FileName)); // 추가
} catch (IOException | SdkClientException e) {
log.error("S3 Error : ", e);
throw new FailedS3UploadException();
}
}
// Object Copy 로직도 Event 호출
필자가 개발하는 프로젝트는 대량의 S3 파일을 생성, 복사하는 과정이 포함되어 있다는 점을 다시 상기해보자. 상황을 예를 들어 100개의 파일을 복사하는 과정이 하나의 트랜잭션에 포함되어 있고 파일 복사 후에 롤백이 발생되었다.
한번의 롤백으로 인해 100개의 Event가 발생하고 삭제 요청을 위해 100번의 S3 접근(I/O 작업)으로 성능 하락을 야기하게 된다. @Async를 사용하여 비동기로 처리할 수 있지만 S3에 접근하는 횟수는 동일하다는 부분을 개선해야 한다.
롤백 이벤트 발생 시 S3 삭제 요청을 즉시 보내는 것이 아닌 GarbageFile이라는 DB Table을 만들어 삭제 예정의 파일 정보를 저장하도록 변경하였다.
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void rollbackS3Upload(UploadS3ObjectEvent event) {
GarbageFile garbageFile = GarbageFile.create(event.objectPath(), event.objectName());
garbageFileRepository.save(garbageFile);
}
// 삭제 예정의 파일 정보
create table garbage_file
(
sn bigint auto_increment primary key comment '고유키',
created_date_time datetime default current_timestamp() not null comment '생성일시',
file_path varchar(255) not null comment '파일 경로',
file_uid varchar(255) not null comment '파일 식별자'
);
이후 스케줄링을 통해 특정 시점마다(매일 자정) 삭제하도록 한다.
/**
* 매일 자정
**/
@Scheduled(cron = "0 0 0 * * *")
@Transactional
public void deleteGarbageFile() {
List<GarbageFile> garbageFileList = garbageFileRepository.findAll();
List<S3ObjectDto> s3ObjectList = garbageFileList.stream()
.map(it -> S3ObjectDto.create(it.getFilePath(), it.getFileUid()))
.toList();
garbageFileRepository.deleteAll(garbageFileList);
awsS3Component.deleteS3Objects(s3ObjectList);
}
참고로, S3 오브젝트 한번에 최대 1000개까지의 삭제 요청을 진행할 수 있기 때문에 Chunk Size(1000)로 나눠 요청하도록 한다.
/**
* Aws S3 Object 다중 삭제
* - chunkSize 최대 1000
**/
public void deleteS3Objects(List<S3ObjectDto> s3ObjectList) {
final String bucketName = awsProperty.bucketName();
Collection<List<S3ObjectDto>> chunkedFileName = PartitionUtils.chunking(s3ObjectList, CHUNK_SIZE);
for (List<S3ObjectDto> chunkedUnit : chunkedFileName) {
String[] destS3FilePaths = chunkedUnit.stream()
.map(it -> it.objectPath() + FOLDER_SEPARATOR + it.objectName())
.map(it -> removeFirstIfStartsWith(it, FOLDER_SEPARATOR))
.toArray(String[]::new);
awsS3Client.deleteObjects(new DeleteObjectsRequest(bucketName).withKeys(destS3FilePaths));
}
}
/**
* Chunk Size만큼 List 분할
**/
public <T> Collection<List<T>> chunking(List<T> collection, int chunkSize) {
return IntStream.range(0, collection.size()).boxed()
.collect(Collectors.groupingBy(
idx -> idx / chunkSize,
Collectors.mapping(collection::get, Collectors.toList())
))
.values();
}
멋있어요!! 잘 배우고 갑니다.