저는 현재 Java와 Spring을 활용한 이커머스 프로젝트를 만들고 있는데요. 상품 정보와 상품의 이미지를 AWS S3에 함께 저장하는 도중 문제가 생겼습니다. 관련 코드를 간략히 나타내면 다음과 같습니다.
@Transactional
public ProductResponse createProduct(
CreateProductRequest request, Image mainImage, Image descriptionImage) {
String mainImageName = imageService.upload(mainImage);
String descriptionImageName = imageService.upload(descriptionImage);
Product product = request.toProduct(mainImageName, descriptionImageName);
productRepository.save(product);
return ProductResponse.of(product);
}
상품 이미지를 먼저 저장하고 저장된 이미지 이름을 받아서 상품 정보와 함께 저장합니다. 상품 테이블의 칼럼 정보는 다음과 같습니다.
ID | S3에 저장된 메인이미지 이름 | S3에 저장된 상세 설명 이미지 이름 | 상품 이름 |..
그런데 만약 상품 정보 저장에 실패하면 어떻게 될까요? S3의 경우 롤백 기능이 없기 때문에 상품 저장이 실패해도 앞서 저장된 이미지는 롤백이 되지 않고 그대로 S3에 남아있습니다.
단순히 상품 정보를 먼저 저장하고 이미지를 저장하면 되지 않을까? 라고도 생각해봤지만 이미지가 1개가 아닌 2개이기 때문에, 첫 이미지 저장에 성공하고 두번째 이미지 저장에 실패하면 여전히 같은 문제가 발생할 수 있습니다.
이렇게 어디서도 참조되지 않는 고아(Orphaned) 이미지가 생기는 상황은 상품 저장 외에도 상품 업데이트, 리뷰 저장, 리뷰 업데이트 등에서 나타날 수 있는데요.
문제는 사용자가 리뷰를 삭제했을 때나 Seller가 상품을 삭제했을 때, 삭제된 줄 알고 있는 관련 이미지가 여전히 이미지 저장소에 남아 있을 수 있다는 부분입니다. 왜냐하면 상품을 삭제했을 때 상품 테이블에 기록되어 있는 연관 이미지는 삭제되겠지만, 만약 그 상품이 등록될 때 몇 번의 실패로 고아 이미지가 생겼었다면, 이 고아 이미지는 삭제되지 않은 채로 S3에 남아 있기 때문입니다. 확실하진 않지만 개인정보보호법과 관련해서 이런 부분이 문제될 수 있다고 판단했습니다.
쉽게 생각할 수 있는 방법은 이미지 저장에 성공하고 상품 정보 저장에는 실패했을 때, 저장된 이미지를 삭제하는 롤백 코드를 작성하는 방법입니다. 코드로 작성하면 대략 다음과 같습니다.
@Transactional
public ProductResponse createProduct(
CreateProductRequest request, Image mainImage, Image descriptionImage) {
String mainImageName;
String descriptionImageName;
Product product;
try {
mainImageName = imageService.upload(mainImage);
descriptionImageName = imageService.upload(descriptionImage);
product = request.toProduct(mainImageName, descriptionImageName);
productRepository.save(product);
} catch (Exception e) { //롤백: 문제가 일어났으면 앞서 저장된 이미지 삭제
imageService.delete(mainImageName);
imageService.delete(descriptionImageName);
}
return ProductResponse.of(product);
}
그런데 만약 이미지를 삭제하는 롤백 로직이 실패하게 된다면 어떻게 될까요?
이를 해결하기 위해서는 롤백 로직에 대한 재시도 로직을 작성해야 할텐데, 몇 번이나 재시도를 해야 할까요? 만약 롤백 도중 서버가 다운된다면?
.. 코드의 가독성이 안 좋아지는 것은 둘째치고 완벽하지 않은 방식이라는 생각이 듭니다.
두번째 방법은 배치 서버를 활용해 주기적으로 고아 이미지를 판별하고 제거하는 방법입니다. 어떤 날에 삭제 작업에 실패했더라도 다른 날에 삭제에 성공하면 결국은 완벽하게 제거되기 때문에 첫번째 방법보다 더 낫다고 생각됩니다.
첫번째 방식은 주기적으로 S3의 모든 상품 관련 이미지 이름과 MySQL의 저장되어 있는 상품 테이블의 이미지 이름을 같이 불러와서 상호 비교하여 알아내는 방법입니다.
그런데 S3에 저장된 파일들의 정보를 얻어오는 AWS S3의 SDK에서 제공하는 listObject라는 메소드는 이 정보를 1000개씩 끊어서 가져와야 합니다. 만약 이미지가 100만개라면 1000번의 네트워크 호출이 필요하므로 매우 비효율적이라 할 수 있습니다.
한 번에 정보를 얻어오는 방법이 없을까 고민하던 도중 AWS S3 Inventory와 AWS Athena에 대해서 알게 되었습니다. AWS S3 Inventory는 하루 혹은 일주일 단위로 AWS S3의 저장된 파일들의 메타 정보를 하나의 파일로 생성해주는 기능입니다. 그리고 AWS Athena는 S3에 저장된 정보를 SQL을 통해 조회할 수 있게 도와주는 서비스입니다.
Inventory 기능을 통해 얻어낸 메타 정보 파일을 Athena를 통해 조회하는 방식으로 간단히 이미지 이름 정보를 얻어낼 수 있습니다.
public void deleteOrphanedImages() {
List<String> imageNamesInS3 = s3ProductNameService.getAllImageNames(); // 1
Set<String> imageNamesInDb = dbProductNameService.getAllImageNames(); // 2
//SELECT main_image_name
//FROM product
//UNION
//SELECT description_image_name
//FROM product
List<String> orphanedImageNames = new ArrayList<>();
for (String imageNameInS3 : imageNamesInS3) { // 3
if (!imageNamesInDb.contains(imageNameInS3)) {
orphanedImageNames.add(imageNameInS3);
}
}
s3ProductNameService.deleteImages(orphanedImageNames); // 4
}
첫 번째 방식의 문제점은 이미지가 많아질수록 네트워크를 이용한 작업이 부담스러워진다는 점입니다. 네트워크를 이용하지 않고 DB에서 처리해보면 어떨까요?
이를 위해 이미지를 저장할 때 이미지 이름을 DB에 같이 넣습니다.
ImageService.java
@Transactional
public String upload(Image image){
ImageName imageName = new ImageName(image.getImageDomain(), image.getStoredName());
imageNameRepository.save(imageName); // S3에 저장하는 이미지의 이름을 DB에 저장
s3.putObject(bucketName, image.getImageInputStream(), image.getStoreKey());
return image.getStoredName();
}
DB에는 다음의 칼럼 정보를 가진 image_name
이라는 테이블이 필요합니다.
| id | domain | name |
| 1 | product| name1 |
| 2 | review | name2 |
| 3 | product| name3 |
.
.
.
배치 서버에서 처리할 때는 DB에서 고아 이미지를 판별하는 쿼리를 날린 후, 고아 이미지 이름을 리턴 받아 S3에서 제거합니다.
public void deleteOrphanedImages() {
List<String> names = imageNameRepository.getOrphanedImageNames();
// SELECT * FROM image_name
// WHERE domain = 'product'
// AND name NOT IN (
// SELECT main_image_name
// FROM product
// UNION
// SELECT description_image_name
// FROM product
// );
s3.deleteObjects(names);
}
첫번째와 두번째 방식은 상품 이미지가 많아질수록 작업이 어려워진다는 단점이 있습니다. 상품 이미지가 매우 많은 상황에서도 동작 가능한 방법을 고민하던 도중 이 문제가 트랜잭션과 관련이 있다고 생각되어 트랜잭션의 동작 원리에 대해 찾아보았습니다. DBMS는 어떻게 트랜잭션을 관리할까? 라는 글에서 로그 활용에 대한 아이디어를 얻었고 세번째 방식을 고안해 내었습니다.
마지막 세번째 방식은 로그를 이용해 탐색 범위를 줄이는 방식입니다. 전체 이미지가 아닌 하루 혹은 일주일 동안 저장된 이미지를 탐색합니다.
상품 정보를 저장하는 코드는 다음과 같습니다.
@Transactional
public ProductResponse createProduct(
CreateProductRequest request, Image mainImage, Image descriptionImage) {
String mainImageName = mainImage.getStoredName();
String descriptionImageName = descriptionImage.getStoredName();
Product product = request.toProduct(mainImageName, descriptionImageName);
productRepository.save(product); // 1
imageService.saveLog(product.getId(), mainImageName, descriptionImageName);// 2
imageService.upload(mainImage);
imageService.upload(descriptionImage);
return ProductResponse.of(product);
}
DB에는 다음의 칼럼 정보를 가진 product_image_save_log
라는 테이블이 필요합니다.
id | product_id | main_image_name | description_image_Name |
1 | 30 | mainImageName1 | descriptionImageName1 |
2 | 59 | mainImageName2 | descriptionImageName2 |
.
.
.
배치 서버에서 실행할 코드는 다음과 같습니다.
public void deleteOrphanedImages() {
List<ImageSaveLogWithProduct> logsWithProduct =
imageService.findAllImageSaveLogWithProduct(); // 1
//SELECT l.id, l.mainImageName, l.descriptionImageName,
// p.mainImageName, p.descriptionImageName
//FROM product_image_save_log as l
//LEFT OUTER JOIN product as p on l.product_id = p.id
List<ImageSaveLogWithProduct> completedLogs = new ArrayList<>();
for (ImageSaveLogWithProduct logWithProduct : logsWithProduct) {
if(logWithProduct.isImageNameSame()){ //2
completedLogs.add(logWithProduct);
continue;
}
try { // 3
imageService.delete(logWithProduct.getLoggedMainImageName);
imageService.delete(logWithProduct.getLoggedDescriptionImageName);
completedLogs.add(logWithProduct);
} catch (Exception e){
//...
}
}
imageService.deleteLog(completedLogs); // 5
}
상품 업데이트시에는 기존 이미지와 새로운 이미지 이름을 로그로 저장한 후 위와 비슷한 방식으로 고아 이미지 처리가 가능합니다. 리뷰 또한 마찬가지 방법으로 적용 가능합니다.
저는 가장 간단한 배치 처리 서버에서 고아 이미지를 판별하는 첫번째 방식을 사용하기로 하였습니다. 2번째와 3번째 방식 모두 첫번째 방식보다 코드가 복잡하고 또 여러 컴포넌트들에 책임이 분산되어 유지보수 및 변경이 어렵습니다. 서비스를 이제 시작하는 상황이라고 가정했을 때 이미지가 10억개가 넘는 상황을 가정해서 구조를 복잡하게 만드는 방식은 오버 엔지니어링이라고 판단했습니다. 초기에는 최대한 단순한 방식을 지향하고 상황에 맞춰 변화시키는 것이 좋다고 생각합니다.
같은 관점에서 배치 처리 코드를 실행하는 것은 AWS Lambda에서 하기로 결정하였습니다. AWS Lambda는 함수를 작성해서 업로드하면 특정 이벤트에 반응하여 작성한 코드를 실행할 수 있는 기능을 제공합니다. 다만 15분 안에 모든 작업이 완료되야 한다는 점과, 함수를 실행하는 환경의 메모리는 최대 10GB라는 제한이 있습니다.
따라서 길고 무거운 작업의 경우 AWS Batch를 사용하는 것이 좋고, 복잡한 배치 작업을 하면서 여러 에러 핸들링이나 부가 작업이 필요한 경우는 Spring Batch를 함께 쓰는 것이 좋습니다. 하지만 이 2가지 모두 러닝 커브가 어느 정도 있으며 현 상황에서는 AWS Lambda를 이용하여 간단하게 처리하는 것으로도 충분하다 판단하였습니다.
이미지 갯수가 점점 늘어날수록 배치 작업의 부담이 심해질 것이고 배치 작업 수행 도중 실패할 확률도 늘어나게 됩니다. AWS Lambda의 경우 여러 제한들로 인해 어느 순간부터는 제가 선택한 방식이 통하지 않을 것이라 생각됩니다.
따라서 이러한 상황이 왔을 때는 로그를 활용한 3번째 방식과 함께 AWS Batch 및 Spring Batch를 사용하여 처리하도록 고도화하면 좋을 것 같습니다. 물론 그 때가 되면 아키텍처의 변화로 인해 완전히 새로운 방식을 생각해내야 될 수도 있겠죠 :)
처음에는 간단하게 생각했던 문제가 만약을 여러 번 가정하다 보니 생각보다 쉽지 않은 문제로 변해버렸습니다..
피드백이나 더 좋은 아이디어가 있다면 댓글 남겨주시면 좋을 것 같습니다 !