
라이브러리
@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);
}
로직 재시도 및 트랜잭션 롤백 시나리오
- 사용자는 상품 등록 시 여러 장의 이미지를 등록할 수 있으므로, 3장의 이미지를 등록한다. (1:N)
- 상품을 먼저 DB에 저장해 product_id를 반환하고, 이 product에 여러 장의 이미지를 등록한다. (product_id를 fk로 사용)
- 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);
}