2024년 1월, 포커 웹 프로젝트를 진행하면서 트랜잭션에 대한 지식 부족을 절실히 느꼈다.
Spring의 선언적 트랜잭션을 사용했지만, 트랜잭션 흐름을 제대로 제어하지 못해 여러 문제가 발생했다. 특히 실시간 게임 특성상 플레이어는 항상 보드의 최신 상태를 확인해야 했는데, 커밋 이후에도 과거 상태의 보드가 조회되는 문제가 있었다. 이는 트랜잭션 격리 수준(Isolation Level) 에 대한 이해 부족에서 비롯된 것으로, 트랜잭션 간 데이터 일관성을 유지하려면 이에 대한 명확한 이해가 필요하다는 점을 깨달았다.
또한, 서비스 계층 간 트랜잭션 전파(Propagation) 속성을 명확히 이해하지 못해 의도하지 않은 롤백 누락이나 중첩 트랜잭션이 발생했고, 이로 인해 게임 상태의 비정합까지 이어졌다. 전파 속성을 어떻게 설계에 반영할지 고민하는 것이 특히 어려웠다.
이후 트랜잭션 전파를 직접 제어해보고 싶었고, 2025년 1월 NHN 아카데미 쇼핑몰 프로젝트에서 그 기회가 찾아왔다. 그러나 MSA 인프라 구성에 집중하느라 직접 구현에는 참여하지 못한 점이 아쉬움으로 남았다.
그 경험을 바탕으로, 이후에는 직접 테스트 환경을 구성해 다양한 전파 및 롤백 시나리오를 실험하며 트랜잭션 동작 원리를 심층적으로 학습했다.
환경
프로젝트 환경은 Java 21, SpringBoot 3.4.x 최신 버전을 사용했다.
도메인
시나리오 환경의 도메인은 주문/결제를 채택하였다.
시나리오
주문/결제 도메인에서 총 4가지 시나리오를 다룬다.
다른 전파속성에 따라 추가적인 시나리오를 구성할 수 있지만, 실제로 많이 사용하는 Required, RequiredNew과 같은 전파속성을 테스트했다.
pay 메서드는 REQUIRED 전파 속성을 가지므로, 호출 시 트랜잭션이 존재하지 않으면 새 트랜잭션을 생성하고, 이미 존재하는 경우에는 해당 트랜잭션에 참여한다. 따라서 pay 내부에서 예외가 발생하면 트랜잭션은 rollback-only 상태로 마킹되고, 최종적으로 호출자에서 커밋을 시도할 때 전체 트랜잭션이 롤백된다.
스프링의 트랜잭션 매니저는 트랜잭션 전파 속성에 따라 내부적으로 트랜잭션 상태를 관리한다. REQUIRED 전파 속성은 기존 트랜잭션이 존재하면 해당 트랜잭션에 참여하고, 없으면 새 트랜잭션을 생성한다.
이 경우, 내부에서 예외가 발생하면 트랜잭션 상태는 rollback-only로 마킹된다. 이는 실제 롤백이 발생하는 것이 아니라, 향후 커밋 시점에 해당 트랜잭션은 더 이상 커밋이 불가능함을 나타내는 마킹일 뿐이다.
rollback-only로 설정된 트랜잭션은 커밋을 시도할 경우, 스프링은 이를 감지하고 UnexpectedRollbackException을 발생시키며 강제로 롤백을 수행한다. 따라서 내부 메서드에서 예외가 발생해도 외부에서 잡지 않으면 전체 트랜잭션이 함께 롤백된다.
시나리오 3
Checked Exception은 기본적으로 롤백되지 않는다.
Exception은 크게 두 가지로 나눌 수 있다. Checked Exception은 컴파일 시점에 반드시 예외 처리를 강제하는 예외이며, RuntimeException을 포함한 Unchecked Exception은 예외 처리를 강제하지 않는 예외이다.
스프링의 @Transactional은 기본적으로 RuntimeException이나 Error가 발생했을 때만 트랜잭션을 롤백한다. 반면, Checked Exception이 발생하면 트랜잭션은 롤백되지 않고 정상적으로 커밋된다.
이는 선언적 트랜잭션 처리 방식에서 rollbackFor 속성을 별도로 지정하지 않으면, Checked Exception에 대해서는 스프링이 트랜잭션을 롤백 대상으로 인식하지 않기 때문이다. 예를 들어, @Transactional(rollbackFor = MessagingException.class)와 같이 명시해야 롤백이 수행된다.
따라서 Checked Exception을 사용하는 경우 트랜잭션 처리에 있어 별도의 설정 없이 기대한 롤백이 발생하지 않을 수 있으므로 주의가 필요하다.
테스트 코드
2가지 테스트를 진행한다.
CheckedException 발생 시 스프링의 기본 전략(커밋)으로 트랜잭션을 처리
rollbackFor 옵션을 지정해 롤백으로 트랜잭션을 처리
테스트 코드 보기
@Test@DisplayName("Checked Exception 이 발생했을 때 커밋되어야 한다.")@Transactional(propagation = Propagation.NOT_SUPPORTED)voidshould_commit_checked_exception() {
// given// when
Assertions.assertThrows(MessagingException.class, () -> {
orderService.processOrderV2(order.getId());
});
// then// product 재고 롤백 검증
List<Product> products = order.getOrderItems().stream()
.map(item -> productRepository.findById(item.getProduct().getId()).orElseThrow())
.toList();
int[] originalStocks = order.getOrderItems().stream()
.mapToInt(item -> item.getProduct().getStock())
.toArray();
Assertions.assertAll("재고 차감 확인",
() -> {
for (inti=0; i < products.size(); i++) {
intexpectedStock= originalStocks[i] - order.getOrderItems().get(i).getQuantity();
intactualStock= productRepository.findById(products.get(i).getId()).orElseThrow().getStock();
Assertions.assertEquals(expectedStock, actualStock,
"Product " + products.get(i).getId() + " stock mismatch");
}
}
);
}
@Test@DisplayName("이메일 전송 오류 시 롤백이 되어야 한다 (rollbackFor 테스트)")@Transactional(propagation = Propagation.NOT_SUPPORTED)voidshould_rollback_checked_exception()throws MessagingException {
// given
doThrow(newMessagingException("이메일 전송 오류")).when(emailService).sendEmail(anyString());
// when
Assertions.assertThrows(MessagingException.class, () -> {
orderService.processOrderV3(order.getId());
});
// then// product 재고 롤백 검증intinitialStock= order.getOrderItems().getFirst().getProduct().getStock();
Productproduct= productRepository.findById(order.getOrderItems().getFirst().getProduct().getId()).orElseThrow();
Assertions.assertEquals(initialStock, product.getStock(), "재고가 롤백되지 않았습니다");
}
이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다. 스프링의 트랜잭션의 세부적인 동작방식을 살펴보기 때문에 코드가 많고 설명이 조금 긴 편입니다. 상황재현과 테스트를 위해 작성한 코드는 github에 있습니다. 테스트에 사용한 버전은 SpringBoot 2.1.2, MySQL 5.7입니다. 때는 지난 12월의 어느날 beta서버에서 에러로그가
결론적으로 새로운 트랜잭션 환경이 아니라, 편승한 상황에서는 부모 트랜잭션에서 예외를 잡아서 처리했을 때도 롤백이 된다. 공부하는 입장에선 당연히 기존 트랜잭션에 편승한 상황이니 롤백이 되는 당연한? 논리인데, 실제 환경에서 헷갈리지 않도록 정리해봤다.