지난 글에서는 트랜잭션 전파 옵션(REQUIRED, REQUIRES_NEW, NESTED 등)을 통해 트랜잭션이 어떻게 전파되고 분리되는지 살펴봤다. 이번 글에서는 한 걸음 더 나아가, 트랜잭션이 실패했을 때 어떻게 처리되는가를 다룬다.
이번 글에서 다룰 핵심 질문은 다음과 같다.
Spring은 예외를 두 종류로 구분하고 다르게 처리한다.
RuntimeException을 상속하는 예외다. NullPointerException, IllegalArgumentException, IllegalStateException 등이 여기에 속한다.
Spring은 이 예외를 예측 불가능한 버그나 시스템 오류로 간주한다. 데이터 정합성이 깨졌을 가능성이 높으므로, 즉시 롤백하는 것이 안전하다.
Exception을 상속하지만 RuntimeException은 아닌 예외다. IOException, SQLException 등이 여기에 속한다.
Spring은 이 예외를 예측 가능한 비즈니스 상황으로 간주한다. 예를 들어 "파일을 못 찾음", "네트워크 일시 단절" 같은 상황은 시스템 버그가 아니다. 롤백 여부를 개발자에게 위임하기 위해 기본적으로 커밋한다.
Unchecked Exception (RuntimeException 상속) → 기본 롤백
Checked Exception (Exception 상속, RuntimeException 제외) → 기본 커밋
체크 예외가 발생했을 때 강제로 롤백시키고 싶다면 rollbackFor를 사용한다.
// 음수 가격은 비즈니스 규칙 위반 → 반드시 롤백
@Transactional(rollbackFor = CustomCheckedException.class)
public void updateProductPrice(Long productId, BigDecimal newPrice) throws CustomCheckedException {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
product.setPrice(newPrice);
productRepository.save(product);
if (newPrice.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomCheckedException("가격은 음수가 될 수 없습니다.");
}
}
CustomCheckedException이 던져지면 트랜잭션 관리자는 커밋 대신 롤백을 수행한다.
반대로 언체크 예외가 발생해도 특정 상황에서는 커밋이 필요할 때 사용한다. 예를 들어 재고 부족 예외가 발생해도, 그 이전에 기록한 로그는 남겨야 하는 경우다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void reduceProductStockNoRollback(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
// 예외 발생 전 로그 저장 → noRollbackFor 덕분에 커밋됨
// logRepository.save(new Log("재고 차감 시도..."));
if (product.getStock() < quantity) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
product.reduceStock(quantity);
productRepository.save(product);
}
정리하면 이렇다.
| 속성 | 대상 | 효과 |
|---|---|---|
rollbackFor | Checked Exception | 해당 예외 발생 시 롤백 |
noRollbackFor | Unchecked Exception | 해당 예외 발생 시 커밋 |
@Transactional은 AOP Proxy가 메서드 밖으로 던져진 예외를 감지하는 방식으로 동작한다. 예외를 try-catch로 잡고 다시 던지지 않으면, 트랜잭션 관리자는 예외 발생 자체를 모른다.
// 잘못된 예제 — 롤백 안 됨
@Transactional
public void process() {
try {
throw new RuntimeException("심각한 오류!");
} catch (RuntimeException e) {
log.error("오류 내부 처리");
// 예외가 여기서 사라짐 → 트랜잭션 관리자는 정상 종료로 판단 → 커밋
}
}
해결책은 두 가지다.
// 방법 1: 예외를 다시 던지기
catch (RuntimeException e) {
log.error("오류 처리");
throw e;
}
// 방법 2: 프로그래밍 방식으로 롤백 마킹
catch (RuntimeException e) {
log.error("오류 처리");
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
DB 작업은 트랜잭션으로 롤백할 수 있다. 하지만 외부 API 호출은 그렇지 않다. 이미 외부 시스템에 요청이 전달된 이후에는 되돌릴 수 없다.
1. 결제 API 호출 → 성공 (돈이 빠져나감)
2. DB에 주문 저장 → 실패
3. 트랜잭션 롤백 → DB는 원상복구
4. 결제는 이미 완료된 상태 → 불일치 발생
DB 저장을 먼저 수행하고, 성공한 경우에만 외부 API를 호출한다. DB 롤백은 쉽지만 외부 API 취소는 어려우므로, 위험한 호출을 마지막으로 미루는 것이다.
DB 저장 → (성공 시) 외부 API 호출
외부 API 호출 후 DB 작업이 실패했을 때, 외부 API를 취소하는 별도의 로직을 추가한다.
외부 API 호출 → DB 저장 실패 → 외부 API 취소 호출
결제로 예를 들면, 결제 API 호출 성공 → 주문 저장 실패 → 결제 취소 API 호출의 흐름이다.
네트워크 오류나 외부 시스템 과부하는 일시적인 경우가 많다. 즉시 실패 처리하는 대신 잠깐 기다렸다가 재시도하면 성공률을 높일 수 있다.
Spring Retry를 사용하면 @Retryable로 선언적으로 처리할 수 있다.
// build.gradle
implementation 'org.springframework.retry:spring-retry'
@SpringBootApplication
@EnableRetry
public class Application { ... }
@Transactional
@Retryable(
value = DomainException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000) // 1초 간격
)
public void save() {
ExternalProductResponse responses = externalShopClient.getProducts(1, 10);
List<ExternalResponse> contents = responses.getMessage().getContents();
if (contents.isEmpty()) {
throw new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT);
}
Category category = categoryRepository.findById(1L)
.orElseThrow(() -> new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT));
List<Product> products = contents.stream()
.map(ext -> Product.builder()
.name(ext.getName())
.description(ext.getDescription())
.stock(ext.getStock())
.price(ext.getPrice())
.category(category)
.build())
.toList();
productRepository.saveAll(products);
}
여기서 중요한 점이 있다. @Retryable이 재시도를 하려면 예외가 메서드 밖으로 던져져야 한다. 예외가 던져지는 순간 트랜잭션은 이미 롤백되고 종료된다. 따라서 재시도마다 새로운 트랜잭션이 시작된다.
@Retryable (바깥 Proxy)
└── @Transactional (안쪽 Proxy)
└── 실제 메서드
1회 시도 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
2회 시도 → 새 트랜잭션 시작 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
3회 시도 → 새 트랜잭션 시작 → 성공 or 최종 실패
@Retryable이 @Transactional보다 바깥을 감싸야 하는 이유가 여기에 있다. 순서가 반대이면 이미 롤백 마킹된 트랜잭션을 재사용하려다 오류가 발생한다.
외부 API가 페이징을 지원하는 경우, 모든 페이지를 순회하며 저장하는 패턴이다.
@Transactional
@Retryable(value = DomainException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void saveAllExternalProducts() {
int page = 0;
int pageSize = 10;
boolean lastPage = false;
while (!lastPage) {
ExternalProductResponse responses = externalShopClient.getProducts(page, pageSize);
if (responses == null || responses.getMessage() == null) {
throw new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT);
}
List<ExternalProductResponse.ExternalResponse> contents =
responses.getMessage().getContents();
if (contents == null || contents.isEmpty()) {
break;
}
Category category = categoryRepository.findById(1L)
.orElseThrow(() -> new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT));
List<Product> products = contents.stream()
.map(ext -> Product.builder()
.name(ext.getName())
.description(ext.getDescription())
.stock(ext.getStock())
.price(ext.getPrice())
.category(category)
.build())
.toList();
productRepository.saveAll(products);
ExternalProductResponse.ExternalPageable pageable =
responses.getMessage().getPageable();
lastPage = (pageable != null) ? pageable.isLast() : contents.size() < pageSize;
page++;
}
}
@Transactional이 전체 while 루프를 감싸고 있으므로, 중간 페이지에서 예외가 발생하면 이전에 저장된 모든 데이터가 함께 롤백된다.
Spring의 롤백 기준은 두 가지다. 예외의 종류(Unchecked vs Checked)와, 예외가 메서드 밖으로 나왔는가. 외부 API는 트랜잭션 밖에 있으므로, 순서 조정 또는 보상 트랜잭션으로 별도 전략이 필요하다.