개요
♣️ Poker 프로젝트 후 회고중..
2024년 1월, 포커 웹 프로젝트를 진행하면서 트랜잭션에 대한 지식 부족을 절실히 느꼈다.
Spring의 선언적 트랜잭션을 사용했지만, 트랜잭션 흐름을 제대로 제어하지 못해 여러 문제가 발생했다. 특히 실시간 게임 특성상 플레이어는 항상 보드의 최신 상태를 확인해야 했는데, 커밋 이후에도 과거 상태의 보드가 조회되는 문제가 있었다. 이는 트랜잭션 격리 수준(Isolation Level) 에 대한 이해 부족에서 비롯된 것으로, 트랜잭션 간 데이터 일관성을 유지하려면 이에 대한 명확한 이해가 필요하다는 점을 깨달았다.
또한, 서비스 계층 간 트랜잭션 전파(Propagation) 속성을 명확히 이해하지 못해 의도하지 않은 롤백 누락이나 중첩 트랜잭션이 발생했고, 이로 인해 게임 상태의 비정합까지 이어졌다. 전파 속성을 어떻게 설계에 반영할지 고민하는 것이 특히 어려웠다.
이후 트랜잭션 전파를 직접 제어해보고 싶었고, 2025년 1월 NHN 아카데미 쇼핑몰 프로젝트에서 그 기회가 찾아왔다. 그러나 MSA 인프라 구성에 집중하느라 직접 구현에는 참여하지 못한 점이 아쉬움으로 남았다.
그 경험을 바탕으로, 이후에는 직접 테스트 환경을 구성해 다양한 전파 및 롤백 시나리오를 실험하며 트랜잭션 동작 원리를 심층적으로 학습했다.
환경
프로젝트 환경은 Java 21, SpringBoot 3.4.x 최신 버전을 사용했다.
도메인
시나리오 환경의 도메인은 주문/결제를 채택하였다.
시나리오
주문/결제 도메인에서 총 4가지 시나리오를 다룬다.
다른 전파속성에 따라 추가적인 시나리오를 구성할 수 있지만, 실제로 많이 사용하는 Required, RequiredNew과 같은 전파속성을 테스트했다.
시나리오 번호 | 설명 | 전파 속성 & 옵션 | 결과 |
1 | 포인트 적립에서 예외가 발생했을 때 포인트 적립은 롤백되고 주문로직은 커밋 | REQUIRED → REQUIRES_NEW | 기본 로직 롤백 X |
2 | 결제 실패 시 주문도 롤백 | REQUIRED → REQUIRED | 기본 로직 롤백 O |
3 | Checked Exception은 기본적으로 롤백되지 않음 | REQUIRED → REQUIRED
rollbackFor 옵션 | 롤백 X (기본설정)
rollbackFor 옵션으로 달라짐 |
4 | 포인트 적립에서 예외가 발생하고 부모 트랜잭션에서 try-catch | REQUIRED → REQUIRED | 롤백 O |
시나리오 테스트
기본 시나리오에서 전파 속성과, 비즈니스 로직이 조금씩 변경된다.
기본 시나리오는 다음과 같다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// 재고 차감
productService.decreaseOrderItemsStock(order.getOrderItems());
log.info("decrease stock success");
// 결제 진행
paymentService.pay(order);
log.info("pay success");
// 포인트 추가
try {
pointService.increasePoint(order.getMember(), 100);
log.info("increase point success");
} catch (RuntimeException e) {
log.error("error : {}", e.getMessage());
}
}
주문은 다음과 같이 처리된다.
- 재고차감
- OrderItem의 수량만큼 Product의 재고를 차감한다.
- 결제 진행
- Member의 Money를 감소한다.
- 포인트 추가
- Member의 Point를 100만큼 증가시킨다.
그럼 이제 각각의 시나리오를 보면서, 롤백을 확인해보자
시나리오 1
포인트 적립에서 예외가 발생했을 때 포인트 적립은 롤백되고 주문로직은 커밋되어야 한다.
테스트 코드
테스트 코드 보기
//테스트 로직
@Test
@DisplayName("포인트 적립이 실패했을 때 전체 주문 로직은 커밋되어야 한다.")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void point_fail_does_not_rollback_payment_and_stock() {
log.info("handle test");
// given
doThrow(new RuntimeException("포인트 적립 실패"))
.when(pointService).increasePoint(any(), anyInt());
// when
Assertions.assertDoesNotThrow(() -> orderService.processOrder(order.getId()));
// then
// 1. 재고 차감 확인
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 (int i = 0; i < products.size(); i++) {
int expectedStock = originalStocks[i] - order.getOrderItems().get(i).getQuantity();
int actualStock = productRepository.findById(products.get(i).getId()).orElseThrow().getStock();
Assertions.assertEquals(expectedStock, actualStock,
"Product " + products.get(i).getId() + " stock mismatch");
}
}
);
// 2. Money 차감 확인
List<OrderItem> orderItems = order.getOrderItems();
long expectMemberMoney = order.getMember().getMoney();
for (OrderItem orderItem : orderItems) {
expectMemberMoney -= (long) orderItem.getProduct().getPrice() * orderItem.getQuantity();
}
Member member = memberRepository.findById(order.getMember().getId()).orElseThrow();
Assertions.assertEquals(expectMemberMoney, member.getMoney());
}
PointService
//PointService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void increasePoint(Member member, int point) {
member.increasePoint(point);
}
테스트 설명
포인트 적립 시 RuntimeException이 발생한다.
비즈니스 로직이 끝나고 데이터베이스에서 데이터를 가져와 3가지를 검증한다
- 재고 차감 커밋 검증
- Money 커밋 검증
- Point 롤백 검증
테스트 결과

트랜잭션 Debug 로그
- 포인트 적립 트랜잭션 롤백

- ProcessOrder 트랜잭션 커밋

increasePoint()
는 새로운 트랜잭션 환경에서 실행되므로, 부모 트랜잭션과 분리된다.따라서 포인트 적립이 실패하면, 포인트 적립 로직만 롤백되고 전체 비즈니스 로직은 커밋된다.
시나리오 2
결제 실패 시 주문도 롤백되어야 한다.
테스트 코드
테스트 코드 보기
@Test
@DisplayName("결제 실패 시 주문도 롤백되어야 한다 (분리된 트랜잭션 검증)")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void should_rollback_order_when_payment_fails() {
// given
order.getPayment().setPaymentStatus(PaymentStatus.COMPLETED);
doThrow(new AlreadyProcessOrderException())
.when(paymentService).pay(any());
// when
Assertions.assertThrows(AlreadyProcessOrderException.class, () -> {
orderService.processOrder(order.getId());
});
// then: 서비스 트랜잭션이 끝나고 롤백되었는지 검증
// product 재고 롤백 검증
int initialStock = order.getOrderItems().get(0).getProduct().getStock();
Product product = productRepository.findById(order.getOrderItems().get(0).getProduct().getId())
.orElseThrow();
Assertions.assertEquals(initialStock, product.getStock(), "재고가 롤백되지 않았습니다");
}
PaymentService
@Transactional(propagation = Propagation.REQUIRED)
public void pay(Order order) {
if (!order.getPayment().getPaymentStatus().equals(PaymentStatus.PENDING)) {
throw new AlreadyProcessOrderException();
}
Member member = memberRepository.findById(order.getMember().getId()).orElseThrow();
Payment payment = paymentRepository.findById(order.getPayment().getId()).orElseThrow();
long totalRequiredMoney = 0L;
for (OrderItem orderItem : order.getOrderItems()) {
totalRequiredMoney += (long) orderItem.getProduct().getPrice() * orderItem.getQuantity();
}
member.decreaseMoney(totalRequiredMoney);
payment.setPaymentStatus(PaymentStatus.COMPLETED);
}
테스트 설명
결제 중에
AlreadyProcessOrderException
이 발생한다.비즈니스 로직이 끝나고 데이터베이스에서 데이터를 가져와 재고가 차감되었는지 검증한다.
테스트 결과

트랜잭션 Debug 로그
- Pay 트랜잭션 롤백
PaymentService rollback-only flag on.

- ProcessOrder 트랜잭션 롤백

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)
void should_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 (int i = 0; i < products.size(); i++) {
int expectedStock = originalStocks[i] - order.getOrderItems().get(i).getQuantity();
int actualStock = 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)
void should_rollback_checked_exception() throws MessagingException {
// given
doThrow(new MessagingException("이메일 전송 오류")).when(emailService).sendEmail(anyString());
// when
Assertions.assertThrows(MessagingException.class, () -> {
orderService.processOrderV3(order.getId());
});
// then
// product 재고 롤백 검증
int initialStock = order.getOrderItems().getFirst().getProduct().getStock();
Product product = productRepository.findById(order.getOrderItems().getFirst().getProduct().getId()).orElseThrow();
Assertions.assertEquals(initialStock, product.getStock(), "재고가 롤백되지 않았습니다");
}
OrderService
// V2
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOrderV2(Long orderId) throws MessagingException {}
// V3
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = MessagingException.class)
public void processOrderV3(Long orderId) throws MessagingException {}
V3는 rollbackFor을 지정하여 롤백을 의도하였다.
또한 주문 중에 Email을 보내는 로직을 마지막에 추가되었다.
테스트 설명
주문 중 이메일 전송에 실패했을 때 MessagingException(Checked Exception)이 발생한다.
2가지 테스트를 진행한다.
- CheckedException 발생 시 스프링의 기본 전략(커밋)으로 트랜잭션을 처리
- rollbackFor 옵션을 지정해 롤백으로 트랜잭션을 처리
각각의 테스트마다 재고차감등을 검증 하면서 롤백, 커밋 여부를 확인했다.
이제 2가지 테스트의 결과를 확인해보자.
1번 테스트 결과

1번 테스트 트랜잭션 Debug 로그
ProcessOrder 트랜잭션 커밋

잘리긴 했는데 첫번째 줄을 보면 MessagingException 오류가 발생한것과, 2번째 줄에 커밋이 된걸 로그에서 확인할 수 있다.
2번 테스트 결과

2번 테스트 트랜잭션 Debug 로그
ProcessOrder 트랜잭션 롤백

1번 테스트와 다르게, rollbackFor을 지정하여 롤백이 된것을 확인할 수 있다.
시나리오 4
포인트 적립에서 예외가 발생하고 부모 트랜잭션에서 try-catch로 예외를 처리했을 때도 오류가 발생한다. (REQUIRED → REQUIRED)
이건 배민 기술블로그를 보고 추가로 작성한 시나리오이다.
‣
결론적으로 새로운 트랜잭션 환경이 아니라, 편승한 상황에서는 부모 트랜잭션에서 예외를 잡아서 처리했을 때도 롤백이 된다. 공부하는 입장에선 당연히 기존 트랜잭션에 편승한 상황이니 롤백이 되는 당연한? 논리인데, 실제 환경에서 헷갈리지 않도록 정리해봤다.
테스트 코드
테스트 코드 보기
@Test
@DisplayName("부모 트랜잭션에서 try catch로 자식 트랜잭션에서 발생한 예외를 잡았어도 롤백이 되어야 한다.")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void shouldRollbackParent_whenChildSetsRollbackOnly_evenIfExceptionCaught() {
// given
// when
Assertions.assertThrows(RuntimeException.class, () -> {
orderService.processOrderV4(order.getId());
});
// then
// product 재고 롤백 검증
int initialStock = order.getOrderItems().getFirst().getProduct().getStock();
Product product = productRepository.findById(order.getOrderItems().getFirst().getProduct().getId()).orElseThrow();
Assertions.assertEquals(initialStock, product.getStock(), "재고가 롤백되지 않았습니다");
}
PointService
@Transactional(propagation = Propagation.REQUIRED)
public void increasePointV2(Member member, int point) {
member.increasePoint(point);
throw new RuntimeException();
}
트랜잭션 전파 속성은 REQUIRED이다.
또한 사용자의 포인트를 증가시키고, 바로 예외가 발생하도록 서비스를 구성했다.
테스트 설명
포인트 적립 중에
increasePointV2
에서 RuntimeException
이 발생한다.비즈니스 로직이 끝나고 데이터베이스에서 데이터를 가져와 재고가 차감되었는지 검증한다.
테스트 결과

트랜잭션 Debug 로그
- increasePointV2 트랜잭션 롤백
increasePointV2 rollback-only flag on.

트랜잭션의 rollback-only 플래그가 설정된다.
- ProcessOrder 트랜잭션 롤백

로그를 보면 예외를 잡아서 던져지는 예외가 없다. 그래서 2번째 줄에 commit으로 저장한다.
하지만 4번째 줄의 로그를 보면, roll-back only 플래그로 인해, 롤백이 된다.
마무리
사실 트랜잭션 전파속성, 격리수준 등은 기본중에 기본이다. 기본이 가장 어렵다
이번 기회에 지금이라도 테스트해보고 정리해볼 수 있어서 좋았다.
하지만 아직 끝난게 아니다. 다음에는 나중에 해보고 싶었던 동시성 제어를 공부해보고자 한다.
Reference
https://techblog.woowahan.com/2606 우아한 기술블로그
Share article