본문 바로가기

Backend/스프링(spring)

Spring-Retry를 활용한 상품 이미지 업로드 트랜잭션 처리 방법

Spring-Retry를 사용하여 재시도 로직 구현하기

 

라이브러리

@Retryable, @EnableRetry는 AOP 프록시 기반으로 동작하므로 AOP 의존성이 필요하다.

// build.gradle
dependencies {
    implementation 'org.springframework.retry:spring-retry'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

Bean 등록

@EnableRetry
@SpringBootApplication
public class CommerceApplication {

    public static void main(String[] args) {
        SpringApplication.run(CommerceApplication.class, args);
    }
}

@Retryable 사용 예시

외부 I/O를 수행하는 실제 메서드에 @Retryable 애너테이션을 사용한다.

  • value: 재시도 로직을 수행할 예외 클래스
    • 지정하지 않으면 RuntimeException 및 하위 클래스를 대상으로 수행
  • maxAttempts: 최대 재시도 횟수
    • maxAttempts=3일 경우 메서드 호출을 최대 3회 실행. 재시도가 아닌 메서드 최초 실행도 1회로 포함.
    • 재시도 3회 모두 실패 시, 더이상 호출하지 않고 @Recover 애너테이션을 사용하는 메서드 호출
  • backoff: 재시도 간 지연 설정
    • multiplier는 재시도를 할 때마다 지연 시간 x 2 적용.
    • maxAttempts=3, dely=500, multiplier=2 일 때:
      • 최초 호출: 0ms
      • 두 번째 호출: 500ms
      • 세 번째 호출: 1000ms
@Retryable(
        value = {IOException.class, S3Exception.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 500, multiplier = 2)
)
public void uploadFile(String objectKey, MultipartFile file, String contentType) throws IOException {
    PutObjectRequest objectUploadRequest = PutObjectRequest.builder()
            .bucket(s3Properties.getBucket())
            .key(objectKey)
            .contentType(contentType)
            .build();

    try (var in = file.getInputStream()) {
        s3Client.putObject(objectUploadRequest, RequestBody.fromInputStream(in, file.getSize()));
    }
    log.info("S3 putObject success: {}", objectKey);
}

@Recover 사용 예시

@Recover 가 호출되는 시점은 @Retryable 메서드의 최대 재시도 횟수 모두 실패 시 호출된다.

@Retryable과 @Recover 메서드가 매칭되는 기준:

  • @Recover 메서드의 첫 번째 파라미터는 예외 타입이어야 한다.
  • 두 번째 파라미터부터는 @Retryable 메서드의 인자 목록과 동일한 순서 및 타입이어야 한다.
  • 여러 @Recover가 존재할 때는 예외 타입이 가장 구체적인 메서드가 호출된다.
@Retryable(
        value = {IOException.class, S3Exception.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 500, multiplier = 2)
)
public void uploadFile(String objectKey, MultipartFile file, String contentType) throws IOException {}

// 모든 재시도 실패 시 호출되어 사위 트랜잭션 롤백 처리
@Recover
public void recover(Exception e, String key, MultipartFile file, String contentType) {
    log.error("S3 putObject failed: {}", key, e);
    throw new RuntimeException("S3 putObject failed: " + key, e);
}

트랜잭션 롤백을 위해서는 @Recover 메서드에서 예외를 던져야한다.

@Recover 메서드에서 예외를 던지지 않으면 해당 트랜잭션은 성공으로 간주되고 커밋된다. 따라서 재시도 로직이 모두 실패하여 트랜잭션을 롤백하기 위해서는 @Recover 메서드에서 예외를 던져야 한다. 이 때, 예외는 RuntimeException 또는 하위 클래스이어야 한다. 물론, @Transactional(rollbackFor = IOException)처럼 rollbackFor을 통해 체크 예외를 지정한 경우에는 @Recover 메서드에서 체크 예외를 던질 시 트랜잭션 롤백이 수행된다. (스프링은 기본적으로 언체크 예외(RuntimeException) 발생 시 트랜잭션 롤백)

// 모든 재시도 실패 시 호출되어 사위 트랜잭션 롤백 처리
@Recover
public void recover(Exception e, String key, MultipartFile file, String contentType) {
    log.error("S3 putObject failed: {}", key, e);
    throw new RuntimeException("S3 putObject failed: " + key, e);
}

로직 재시도 및 트랜잭션 롤백 시나리오

  1. 사용자는 상품 등록 시 여러 장의 이미지를 등록할 수 있으므로, 3장의 이미지를 등록한다. (1:N)
  2. 상품을 먼저 DB에 저장해 product_id를 반환하고, 이 product에 여러 장의 이미지를 등록한다. (product_id를 fk로 사용)
  3. S3에 이미지를 업로드하는 메서드에 @Retryable을 사용해 최대 3회까지 재시도를 하고, 3회 모두 실패한다면 @Recover 메서드 호출

1. 상품 등록

@Transactional
public CreateProductResponse createProduct(
        String name,
        String description,
        UUID sellerId,
        UUID categoryId,
        ProductStatus status,
        List<MultipartFile> files
) {
        // 1. 판매자 조회
    User seller = getSeller(sellerId);
    validateDuplicateProduct(name, seller);

        // 상품을 등록할 카테고리 조회 및 상품 등록
    Category category = categoryRepository.findById(categoryId)
            .orElseThrow(() -> new NotFoundCategoryException("카테고리 조회에 실패했습니다. id = " + categoryId));
    Product product = Product.builder()
            .name(name)
            .description(description)
            .seller(seller)
            .status(status)
            .build();
    Product savedProduct = productRepository.save(product);

    // 이미지 업로드
    s3UploadService.upload(product, files);

    return new CreateProductResponse(savedProduct.getId());
}

2. 업로드에 성공한 이미지를 uploadedKeys 리스트에 저장

uploadedKeys 리스트는 이미지 업로드에 성공한 S3 Object Key를 저장한다. 이 리스트는 재시도 로직에 모두 실패하여 상품 등록을 취소할 때, 이미 S3에 업로드된 객체를 모두 제거할 때 사용한다.

public void upload(Product product, List<MultipartFile> files) {
    if (files == null || files.isEmpty()) {
        return;
    }

    List<String> uploadedKeys = new ArrayList<>();

    try {
        for (int pos = 0; pos < files.size(); pos++) {
            MultipartFile file = files.get(pos);

            String contentType = getContentType(file);
            String extension = getExtension(file, contentType);
            String objectKey = String.format(
                    "products/%s/images/%02d-%s.%s",
                    product.getId(), pos, UUID.randomUUID(), extension
            );

            // 메타데이터 추출 (width/height)
            ImageMeta meta = readImageMeta(file);
            s3ObjectUploader.uploadFile(objectKey, file, contentType);
            uploadedKeys.add(objectKey);

            // DB 저장
            productMediaService.create(
                    product, MediaType.IMAGE,
                    s3Properties.getBucket(), objectKey,
                    pos, meta.width(), meta.height()
            );
        }
    } catch (Exception ex) {
        deleteUploadedObjects(uploadedKeys); // 업로드된 이미지 삭제
        throw new FileUploadException("이미지 등록에 실패하여 상품 등록에 실패하였습니다.");
    }
}

3. 이미지 업로드

uploadFile()는 상품의 이미지를 S3에 업로드하는 메서드이다. @Retryable을 사용하여 이미지 업로드 실패 시 최대 3회 재시도를 수행하도록 하였다. 이때, 이미지 업로드 3회 모두 실패했을 경우 uploadFile()에서 발생한 예외 타입 그리고 메서드 인자 타입이 동일한 @Recover 메서드를 호출한다.

여러 이미지 중에 하나의 이미지라도 업로드 실패 시 상품 등록을 취소하기 위해 트랜잭션 롤백을 수행해야 한다. 트랜잭션 롤백을 위해 @Recover 메서드에서 언체크 예외를 던져 DB 롤백을 수행할 수 있도록 한다. ⇒ 상품 등록이 취소됨.

@Retryable(
        value = {IOException.class, S3Exception.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 500, multiplier = 2)
)
public void uploadFile(String objectKey, MultipartFile file, String contentType) throws IOException {
    PutObjectRequest objectUploadRequest = PutObjectRequest.builder()
            .bucket(s3Properties.getBucket())
            .key(objectKey)
            .contentType(contentType)
            .build();

    try (var in = file.getInputStream()) {
        s3Client.putObject(objectUploadRequest, RequestBody.fromInputStream(in, file.getSize()));
    }
    log.info("S3 putObject success: {}", objectKey);
}

// 모든 재시도 실패 시 호출되어 상위 트랜잭션 롤백 처리
@Recover
public void recover(Exception e, String key, MultipartFile file, String contentType) {
    log.error("S3 putObject failed: {}", key, e);
    throw new RuntimeException("S3 putObject failed: " + key, e);
}