상품 저장 실패시 저장된 이미지에 대한 롤백 처리 문제 해결기

Shef·2023년 4월 9일
3
post-thumbnail

문제 상황

저는 현재 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에 남아 있기 때문입니다. 확실하진 않지만 개인정보보호법과 관련해서 이런 부분이 문제될 수 있다고 판단했습니다.

방법 1. 롤백 코드 작성하기

쉽게 생각할 수 있는 방법은 이미지 저장에 성공하고 상품 정보 저장에는 실패했을 때, 저장된 이미지를 삭제하는 롤백 코드를 작성하는 방법입니다. 코드로 작성하면 대략 다음과 같습니다.

@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);
}

그런데 만약 이미지를 삭제하는 롤백 로직이 실패하게 된다면 어떻게 될까요?
이를 해결하기 위해서는 롤백 로직에 대한 재시도 로직을 작성해야 할텐데, 몇 번이나 재시도를 해야 할까요? 만약 롤백 도중 서버가 다운된다면?

.. 코드의 가독성이 안 좋아지는 것은 둘째치고 완벽하지 않은 방식이라는 생각이 듭니다.

방법 2. 주기적으로 삭제하기

두번째 방법은 배치 서버를 활용해 주기적으로 고아 이미지를 판별하고 제거하는 방법입니다. 어떤 날에 삭제 작업에 실패했더라도 다른 날에 삭제에 성공하면 결국은 완벽하게 제거되기 때문에 첫번째 방법보다 더 낫다고 생각됩니다.

고아 이미지를 어떻게 판별해야 할까?

S3, DB에서 각각 이미지 정보를 가져와서 배치 서버에서 판별하기

첫번째 방식은 주기적으로 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
}
  1. Athena를 이용해 AWS S3에 저장된 상품 관련 이미지 이름들을 불러옵니다
  2. DB의 상품 테이블에 저장된 메인 이미지와 상세 설명 이미지 이름들을 불러옵니다.
  3. DB에 저장된 이름 목록에 없는 S3에 저장된 이미지 이름들을 골라내는 과정을 통해 고아 이미지를 식별합니다.
  4. 고아 이미지를 제거합니다.

장점

  • 기존 코드 베이스에 손대지 않고 적용이 가능하며 간단합니다.
  • 고아 이미지 판별 및 삭제에 대한 책임이 배치 서버 코드에만 존재합니다.

단점

  • 이미지가 많아질 경우 점점 시간이 오래 걸럽니다.
    모티브로 삼고 있는 마켓 컬리의 경우 10만개의 상품과 상품 1개 당 약 1만개의 리뷰가 있습니다. 계산해보면 리뷰 이미지는 10억개가 되고 10억개의 이미지 이름의 크기는 약 50GB 정도가 됩니다. 이 정도 크기의 데이터를 네트워크를 통해 S3와 DB에서 각각 불러오는 작업은 시간도 오래 걸리고 꽤 부담스러운 작업입니다.

이미지 저장마다 이미지 이름을 DB에 넣고 DB에서 판별하기

첫 번째 방식의 문제점은 이미지가 많아질수록 네트워크를 이용한 작업이 부담스러워진다는 점입니다. 네트워크를 이용하지 않고 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);
}

장점

  • 네트워크를 타지 않고 DB에서 고아 이미지 판별 작업을 진행하기 때문에 상대적으로 부담이 덜합니다.

단점

  • 고아 이미지 판별 및 삭제에 대한 책임이 배치 서버 코드, 웹 애플리케이션 코드, DB 등 여러 곳으로 분산되며 기존 코드베이스를 복잡하게 만듭니다.
  • 이미지가 많아질수록 DB 부하가 심해집니다.
  • S3에 저장된 이미지의 이름을 DB에 중복 저장하기 때문에 저장 공간 낭비가 있습니다.
    AWS RDS에서 10억개의 이미지 이름에 대한 약 50GB 용량의 스토리지를 추가적으로 사용할 경우 한달에 1만원 정도 더 나옵니다.

이미지 저장에 관한 로그를 DB에 넣고 배치 서버에서 판별하기

첫번째와 두번째 방식은 상품 이미지가 많아질수록 작업이 어려워진다는 단점이 있습니다. 상품 이미지가 매우 많은 상황에서도 동작 가능한 방법을 고민하던 도중 이 문제가 트랜잭션과 관련이 있다고 생각되어 트랜잭션의 동작 원리에 대해 찾아보았습니다. 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);
}
  1. 상품 정보를 먼저 저장합니다.
  2. '저장한 상품 정보의 ID'와 'S3에 저장할 이미지들의 이름'을 묶은 로그 데이터를 S3에 이미지를 저장하기 전에 DB에 저장합니다. 이 때 이 로그는 로그 저장 자체가 실패했을 때 외에는 다른 작업들의 성공 유무와 관계없이 꼭 기록되야 합니다. 따라서 트랜잭션을 분리해 주어야 합니다.

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
}
  1. join을 통해 로그 정보와 로그와 연계된 상품의 이미지 이름 정보를 가져옵니다.
  2. 로그에 기록된 이미지 이름과 실제 상품 정보에 기록된 이미지 이름이 일치하는지 확인하고, 일치하면 상품 저장이 정상적으로 수행된 것으로 봅니다. 완료된 로그 목록에 넣고 다음으로 넘어갑니다.
  3. 이름이 일치하지 않거나 로그에 기록된 상품id를 가진 상품 정보가 없다면 상품 저장 도중 트랜잭션이 실패한 것으로 보고 로그에 기록된 이미지들을 AWS S3에서 삭제합니다. 모두 삭제가 완료되면 완료된 로그 목록에 넣습니다.
  4. 모든 작업이 끝나고 나면 처리 완료된 로그들을 로그 테이블에서 지워줍니다.

상품 업데이트시에는 기존 이미지와 새로운 이미지 이름을 로그로 저장한 후 위와 비슷한 방식으로 고아 이미지 처리가 가능합니다. 리뷰 또한 마찬가지 방법으로 적용 가능합니다.

장점

  • 하루 단위로 배치 작업을 진행할 경우 하루 동안의 저장 및 업데이트된 이미지에 대해서만 작업하면 되므로 부담이 적습니다.
  • 처리가 완료된 로그는 지워주므로 저장 공간의 낭비가 적습니다.

단점

  • 고아 이미지 판별 및 삭제에 대한 책임이 배치 서버 코드, 웹 애플리케이션 코드, DB 등 여러 곳으로 분산되며 기존 코드베이스를 복잡하게 만듭니다.
  • 상품 저장, 상품 업데이트, 리뷰 저장, 리뷰 업데이트 각각에 대해 로그 테이블을 만들어야 하고, 각각 조금씩 다른 방식으로 처리해야 하기 때문에 로직이 복잡합니다.

선택의 시간

저는 가장 간단한 배치 처리 서버에서 고아 이미지를 판별하는 첫번째 방식을 사용하기로 하였습니다. 2번째와 3번째 방식 모두 첫번째 방식보다 코드가 복잡하고 또 여러 컴포넌트들에 책임이 분산되어 유지보수 및 변경이 어렵습니다. 서비스를 이제 시작하는 상황이라고 가정했을 때 이미지가 10억개가 넘는 상황을 가정해서 구조를 복잡하게 만드는 방식은 오버 엔지니어링이라고 판단했습니다. 초기에는 최대한 단순한 방식을 지향하고 상황에 맞춰 변화시키는 것이 좋다고 생각합니다.

같은 관점에서 배치 처리 코드를 실행하는 것은 AWS Lambda에서 하기로 결정하였습니다. AWS Lambda는 함수를 작성해서 업로드하면 특정 이벤트에 반응하여 작성한 코드를 실행할 수 있는 기능을 제공합니다. 다만 15분 안에 모든 작업이 완료되야 한다는 점과, 함수를 실행하는 환경의 메모리는 최대 10GB라는 제한이 있습니다.

따라서 길고 무거운 작업의 경우 AWS Batch를 사용하는 것이 좋고, 복잡한 배치 작업을 하면서 여러 에러 핸들링이나 부가 작업이 필요한 경우는 Spring Batch를 함께 쓰는 것이 좋습니다. 하지만 이 2가지 모두 러닝 커브가 어느 정도 있으며 현 상황에서는 AWS Lambda를 이용하여 간단하게 처리하는 것으로도 충분하다 판단하였습니다.

향후 발전 방향

이미지 갯수가 점점 늘어날수록 배치 작업의 부담이 심해질 것이고 배치 작업 수행 도중 실패할 확률도 늘어나게 됩니다. AWS Lambda의 경우 여러 제한들로 인해 어느 순간부터는 제가 선택한 방식이 통하지 않을 것이라 생각됩니다.

따라서 이러한 상황이 왔을 때는 로그를 활용한 3번째 방식과 함께 AWS Batch 및 Spring Batch를 사용하여 처리하도록 고도화하면 좋을 것 같습니다. 물론 그 때가 되면 아키텍처의 변화로 인해 완전히 새로운 방식을 생각해내야 될 수도 있겠죠 :)

마무리

처음에는 간단하게 생각했던 문제가 만약을 여러 번 가정하다 보니 생각보다 쉽지 않은 문제로 변해버렸습니다..

피드백이나 더 좋은 아이디어가 있다면 댓글 남겨주시면 좋을 것 같습니다 !

0개의 댓글