[Spring] Spring 트랜잭션 롤백 전략과 외부 API 연동

Raha·2026년 4월 25일

Spring

목록 보기
7/7

들어가며

지난 글에서는 트랜잭션 전파 옵션(REQUIRED, REQUIRES_NEW, NESTED 등)을 통해 트랜잭션이 어떻게 전파되고 분리되는지 살펴봤다. 이번 글에서는 한 걸음 더 나아가, 트랜잭션이 실패했을 때 어떻게 처리되는가를 다룬다.

이번 글에서 다룰 핵심 질문은 다음과 같다.

  • 예외 종류에 따라 롤백 여부가 달라지는 이유는 무엇인가?
  • 체크 예외도 롤백시키거나, 언체크 예외를 커밋시키고 싶으면 어떻게 하는가?
  • 외부 API 호출이 포함된 트랜잭션에서 데이터 정합성을 어떻게 유지하는가?

1. Spring의 기본 롤백 규칙

Spring은 예외를 두 종류로 구분하고 다르게 처리한다.

Unchecked Exception → 자동 롤백

RuntimeException을 상속하는 예외다. NullPointerException, IllegalArgumentException, IllegalStateException 등이 여기에 속한다.

Spring은 이 예외를 예측 불가능한 버그나 시스템 오류로 간주한다. 데이터 정합성이 깨졌을 가능성이 높으므로, 즉시 롤백하는 것이 안전하다.

Checked Exception → 기본 커밋

Exception을 상속하지만 RuntimeException은 아닌 예외다. IOException, SQLException 등이 여기에 속한다.

Spring은 이 예외를 예측 가능한 비즈니스 상황으로 간주한다. 예를 들어 "파일을 못 찾음", "네트워크 일시 단절" 같은 상황은 시스템 버그가 아니다. 롤백 여부를 개발자에게 위임하기 위해 기본적으로 커밋한다.

Unchecked Exception (RuntimeException 상속) → 기본 롤백
Checked Exception (Exception 상속, RuntimeException 제외) → 기본 커밋

2. 롤백 규칙 커스터마이징

2.1 rollbackFor — 체크 예외를 롤백시키기

체크 예외가 발생했을 때 강제로 롤백시키고 싶다면 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이 던져지면 트랜잭션 관리자는 커밋 대신 롤백을 수행한다.

2.2 noRollbackFor — 언체크 예외를 커밋시키기

반대로 언체크 예외가 발생해도 특정 상황에서는 커밋이 필요할 때 사용한다. 예를 들어 재고 부족 예외가 발생해도, 그 이전에 기록한 로그는 남겨야 하는 경우다.

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

정리하면 이렇다.

속성대상효과
rollbackForChecked Exception해당 예외 발생 시 롤백
noRollbackForUnchecked Exception해당 예외 발생 시 커밋

3. 주의해야 할 함정 — 예외를 삼키면 롤백되지 않는다

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

4. 외부 API 연동과 트랜잭션

4.1 문제 — 외부 API는 롤백 대상이 아니다

DB 작업은 트랜잭션으로 롤백할 수 있다. 하지만 외부 API 호출은 그렇지 않다. 이미 외부 시스템에 요청이 전달된 이후에는 되돌릴 수 없다.

1. 결제 API 호출 → 성공 (돈이 빠져나감)
2. DB에 주문 저장 → 실패
3. 트랜잭션 롤백 → DB는 원상복구
4. 결제는 이미 완료된 상태 → 불일치 발생

4.2 전략 1 — 순서 조정

DB 저장을 먼저 수행하고, 성공한 경우에만 외부 API를 호출한다. DB 롤백은 쉽지만 외부 API 취소는 어려우므로, 위험한 호출을 마지막으로 미루는 것이다.

DB 저장 → (성공 시) 외부 API 호출

4.3 전략 2 — 보상 트랜잭션

외부 API 호출 후 DB 작업이 실패했을 때, 외부 API를 취소하는 별도의 로직을 추가한다.

외부 API 호출 → DB 저장 실패 → 외부 API 취소 호출

결제로 예를 들면, 결제 API 호출 성공 → 주문 저장 실패 → 결제 취소 API 호출의 흐름이다.


5. 일시적 실패 대응 — Retry 전략

네트워크 오류나 외부 시스템 과부하는 일시적인 경우가 많다. 즉시 실패 처리하는 대신 잠깐 기다렸다가 재시도하면 성공률을 높일 수 있다.

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

@Transactional과 @Retryable의 관계

여기서 중요한 점이 있다. @Retryable이 재시도를 하려면 예외가 메서드 밖으로 던져져야 한다. 예외가 던져지는 순간 트랜잭션은 이미 롤백되고 종료된다. 따라서 재시도마다 새로운 트랜잭션이 시작된다.

@Retryable (바깥 Proxy)
    └── @Transactional (안쪽 Proxy)
            └── 실제 메서드

1회 시도 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
2회 시도 → 새 트랜잭션 시작 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
3회 시도 → 새 트랜잭션 시작 → 성공 or 최종 실패

@Retryable@Transactional보다 바깥을 감싸야 하는 이유가 여기에 있다. 순서가 반대이면 이미 롤백 마킹된 트랜잭션을 재사용하려다 오류가 발생한다.


6. 전체 페이지 외부 데이터 저장 — 페이징 처리

외부 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는 트랜잭션 밖에 있으므로, 순서 조정 또는 보상 트랜잭션으로 별도 전략이 필요하다.

profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글