주문 도중에 문제가 발생했을 때, 저장했던 주문 정보들을 롤백할 필요가 있다.
이 과정을 테스트하기 위해 통합테스트를 작성했고, 주문 완료 후 장바구니를 비우는 과정에서 문제가 발생했을 때를 시나리오로 작성했다.
@Test
@DisplayName("장바구니를 비우는 과정에서 문제 발생 시, 저장된 주문 데이터를 롤백한다.")
void whenThrowAtClearCart_thenRollbackOrder() {
// 유저, 매장 등등 정보 생성 후 장바구니에 담음
addToCart();
// 승인된 결제 정보 저장
UUID orderId = UUID.randomUUID();
String paymentKey = "tgen_20250102210202h9Oy0";
savePayment(orderId, paymentKey, 26_000, true);
// User, UserRepository 모킹
UserJpaEntity spyUser = Mockito.spy(userJpaRepository.findByUsername("username").get());
doThrow(RuntimeException.class)
.when(spyUser)
.clearCart();
when(userJpaRepository.findByUsername("username"))
.thenReturn(Optional.of(spyUser));
// 주문
OrderSaveRequest orderSaveRequest = OrderSaveRequest.builder()
.paymentKey(paymentKey)
.deliveryFee(0)
.payMethod(PayMethod.TOSS)
.discount(0)
.build();
Assertions.assertThrows(RuntimeException.class, () -> {
orderService.registerOrder(orderSaveRequest, "username");
});
Optional<Order> result = orderJpaRepository.findById(1L);
assertThat(result).isNotPresent();
}
테스트에 필요한 데이터들을 미리 저장하고나서 User
와 UserJpaRepository
의 메서드를 모킹했다.
해당 유저의 clearCart()
메서드로 장바구니를 비우는 과정에서 RuntimeException이 발생하게 되고, 이에 따라 저장됐던 주문 데이터가 롤백되어 주문 데이터가 존재하지 않으면 성공이다.
하지만 예상과 다르게 롤백이 되지 않았고, 주문 객체 조회에 성공하는 결과가 나왔다.
나의 지식이 틀리지 않다면,
@Transactional
이 붙은 이상, 예외가 발생하게 되면 해당 트랜잭션 내의 작업들이 롤백되어야 한다.Unchecked Exception
이 발생한 경우에 롤백 처리한다.주문 생성 코드는 다음과 같이 @Transactional
이 잘 선언되어 있으며, Unchecked Exception
인 RuntimeException
을 발생시키기 때문에 내가 아는 모든 조건에 부합한다.
@Transactional
public void registerOrder(OrderSaveRequest saveRequest, String username) {
UserJpaEntity user = userRepository.findByUsername(username)
.orElseThrow(UserNotExistsException::new);
List<CartJpaEntity> carts = user.getCarts();
if (carts.isEmpty()) {
throw new CartEmptyException();
}
Payment payment = paymentRepository.findById(saveRequest.getPaymentKey())
.orElseThrow(() -> new PaymentNotExistsException(HttpStatus.BAD_REQUEST, "결제 정보가 존재하지 않습니다."));
if (!payment.isPaid()) {
throw new PaymentNotConfirmedException(HttpStatus.BAD_REQUEST, "승인되지 않은 결제입니다.");
}
ShopJpaEntity shop = carts.getFirst().getShop();
Order order = generateOrder(shop, user, payment, saveRequest);
if (!order.validatePayAmount()) {
throw new InvalidPaymentInfoException(HttpStatus.BAD_REQUEST, "결제 정보가 일치하지 않습니다.");
}
orderRepository.save(order);
user.clearCart();
}
위에서 말했듯, 현재 내가 아는 롤백 조건에는 모두 부합하기 때문에 다른 부분에 놓친 것이 있을 것이다.
그리고 두 가지의 원인 가능성이 생각났다.
실제로 예전 프로젝트에서 @Transactional
이 선언된 메서드를 프록시 객체가 아닌 오리지날 객체에서 self-invocation하는 바람에 트랜잭션이 적용되지 않았던 적이 있었다.
또한 @Transactional
이 붙은 프록시 객체의 메서드를 호출하더라도 기존 호출자가 이미 트랜잭션을 가지고 있었다면, 트랜잭션 전파 옵션의 기본 값인 REQUIRED
에 의해 기존 트랜잭션에 합쳐지게 된다.
Mockito와 함께 사용하면서 “혹시 (프록시 객체가 아닌)오리지날 객체의 메서드를 호출하는 상황이 있을 수도 있나?” 라는 가능성을 열어두긴 했지만, 두 번째 원인이 유력해 보였다.
왜냐하면, 각 테스트 후 롤백을 위해 테스트 코드에도 @Transactional
을 선언했었고, 주문 생성 트랜잭션이 해당 트랜잭션에 병합될 것이 분명해 보였기 때문이다.
두 가지 원인에 대해 모두 가능성을 열어두고 확인해봐야 했고, 이를 한 번에 확인하기 위해 현재 트랜잭션 이름을 출력하는 방법을 사용했다.
TransactionSynchronizationManager.getCurrentTransactionName();
해당 메서드로 현재 속한 트랜잭션의 이름을 출력할 수 있다.
이제 테스트코드에서 한 번, 주문 생성 코드에서 한 번 출력해보면
아니나 다를까 서비스 코드의 트랜잭션이 새로 시작되지 않고, 테스트 코드의 트랜잭션이 그대로 전파되어있다.
OK. 트랜잭션이 전파된다.
근데 다시 생각해보면 트랜잭션이 전파되든 안되든 트랜잭션은 존재하기 때문에 결국 예외가 발생했으면 롤백되어야 하고, 주문 데이터가 조회되면 안된다.
근데 왜 롤백이 되지 않는가?
분명 또다른 문제가 있을 것이므로 코드를 쭉 살펴보다가 의심스러운 부분을 발견했다.
테스트코드를 보면 주문 로직에서 RuntimeException
이 발생했는지 검증하고 있다.
내 생각엔, assertThrows()
내부에서 try-catch 처럼 예외를 잡아서 처리하는 로직이 들어있을 것이고, 이 때문에 예외가 테스트코드까지 전파되지 않아 롤백되지 않는 것은 아닐까? 라고 생각했다.
내부 코드를 살펴볼 필요가 있었고, 쭉쭉 찾아 들어가다가 다음과 같은 로직을 발견했다.
로직이 정말 단순하다.
executable
을 실행한 결과, 예외가 발생하면 expectedType
과 동일한지 확인하고 맞으면 해당 예외 객체를 반환한다.
아무튼 결론은 registerOrder()
에서 발생한 런타임에러는 try-catch에 의해 처리되는 것이었고, 이 때문에 테스트코드까지 예외가 전파되지 않으면서 롤백이 되지 않은 것이다.
최선의 해결 방법은 잘 모르겠지만, 가장 명확하고 간단한 해결 방법은 트랜잭션을 분리하는 것이라고 생각했다.
따라서 @Transactional
의 전파 옵션을 REQUIRES_NEW
로 설정하여 새로운 트랜잭션을 실행하도록 변경했다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
트랜잭션 분리도 잘 되었고
테스트도 성공했다!
위처럼 문제를 해결을 한 뒤 테스트를 한 결과, 기존에 잘 통과하던 성공 테스트 케이스가 깨지는 현상이 발생했다.
유저가 없다는 오류인데… 확인해보면 users 테이블에 insert 쿼리가 날라가는 것을 확인할 수 있었다.
뭐지..?
차근차근 정리해보자.
insert
쿼리 로그는 찍혔다.insert
하는 트랜잭션과 select
하는 트랜잭션은 별도의 트랜잭션이다. (REQUIRES_NEW
이므로)의심되는 원인이 하나 생각났다.
즉, 유저를 생성했더라도 아직 해당 트랜잭션이 커밋되지 않았으므로, 트랜잭션 격리수준에 의해 유저를 조회하는 트랜잭션에서는 유저를 찾을 수 없는 것이다.
게다가 MySQL
은 기본적으로 MVCC
방식의 REPEATABLE_READ
격리수준을 제공하기 때문에 합리적인 의심이라고 생각되었다.
위에서 트랜잭션 격리수준에 의해 Dirty Read
를 제공하기 않으므로 발생하는 것이라고 원인을 예측했다.
이제 검증을 통해 예측이 맞는지 확인해보자.
가장 간단한 검증 방법은, 분리되었던 트랜잭션을 다시 합쳐보는 것이다.
@Transactional
의 전파 옵션을 기본 값인 REQUIRED
로 바꾸고 테스트를 다시 실행해보자.
성공했다..!
이 실험으로 트랜잭션 격리수준에 의한 문제라는 것이 가시적으로 보이지는 않지만, 충분히 예측한 내용이 맞다는 결론을 지을 수는 있다고 생각한다.
각각의 트랜잭션이 서로 다른 트랜잭션이라는 것과 적용된 격리수준을 확인하는 것 외에는, 더 명확하게 검증할 방법이 생각나진 않았다.
따라서 트랜잭션 격리수준을 확인하는 것 까지만 진행하기로 했다.
다음 코드를 통해 격리수준을 애플리케이션에서 출력할 수 있다.
Session session = entityManager.unwrap(Session.class);
System.out.println("Isolation Level: " +
session.doReturningWork(Connection::getTransactionIsolation));
그리고 적용한 테스트 코드는 다음과 같다.
@Test
@DisplayName("결제부터 주문 성공까지의 프로세스를 성공한다.")
void successPaymentAndOrder() {
// 유저, 매장 등등 정보 생성 후 장바구니에 담음
addToCart();
// 승인된 결제 정보 저장
UUID orderId = UUID.randomUUID();
String paymentKey = "tgen_20250102210202h9Oy0";
savePayment(orderId, paymentKey, 26_000, true);
// 주문
OrderSaveRequest orderSaveRequest = OrderSaveRequest.builder()
.paymentKey(paymentKey)
.deliveryFee(0)
.payMethod(PayMethod.TOSS)
.discount(0)
.build();
entityManager.flush();
entityManager.clear();
Session session = entityManager.unwrap(Session.class);
System.out.println("Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("Isolation Level: " + session.doReturningWork(Connection::getTransactionIsolation));
Assertions.assertDoesNotThrow(() -> {
orderService.registerOrder(orderSaveRequest, "username");
});
}
위에서 보였던 트랜잭션 이름과 테스트 메서드에서 트랜잭션 격리수준을 출력해보았다.
JDBC에서는 Level 4가 REPEATABLE_READ
라고 한다. 즉, 예상했던 MVCC 기반의 REPEATBLE_READ
격리수준이 적용되는 것을 확인할 수 있다.
이외에도 직접 쿼리를 통해 확인할 수 있다.
SELECT @@GLOBAL.transaction_isolation;
마찬가지로 REPEATABLE_READ
이다.
해결에 앞서, 격리수준을 READ_UNCOMMITTED
로 바꾸면 더티 리드(Dirty Read)
에 의해 유저 데이터를 읽을 수 있을테니 성공하지 않을까? 궁금해졌다.
@Transactional(propagation = Propagation.REQUIRES_NEW,
isolation = Isolation.READ_UNCOMMITTED)
따라서 위 코드처럼 각 트랜잭션 설정을 바꾸고 실행해 보았다.
그런데 프로그램이 무한 대기 상태에 있더니
타임아웃이 발생하면서 실패했다.
아무래도 데드락인 것 같다.
개발하다가 실제로 데드락을 겪은 것은 처음이라 신기했다.
(아직 원인 규명을 성공하지 못한 상태이다.)
이제 데드락이 걸린 이유를 알아보자. 예상컨데 서비스 트랜잭션에서 테스트 트랜잭션의 커밋되지 않은 데이터를 읽는 것과 관련이 있을 것 같다.
오류 로그를 잘 보면, Lock wait timeout exceeded 라는 말과 함께 Order 데이터 삽입에 실패했다고 되어있다.
즉, Order를 저장해야하는데 어떠한 락을 대기하는 과정에서 타임오버가 발생한 것이다.
정말 직접 SQL로 상황을 재현해보기도 하면서 삽질을 했지만, 결국 왜 이런 문제가 생기는지 알아내지 못했다…
분명 데드락 같은데, 현재 나의 지식으로는 데드락이 걸릴만한 이유를 찾지 못했다.
이번 기회에 좀 더 자세히 공부해보고 언젠가는 꼭 이유를 찾을 수 있도록 노력해보겠다.
아무래도 @Transactional
애노테이션을 사용하면 트랜잭션에서 직접 커밋하는 것이 까다롭기 때문에 PlatformTransactionManager
를 통해 직접 트랜잭션 범위와 동작을 정의하도록 변경하였다.
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private EntityManager entityManager;
@Test
@DisplayName("결제부터 주문 성공까지의 프로세스를 성공한다.")
void successPaymentAndOrder() {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setName("Test code TX");
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 유저, 매장 등등 정보 생성 후 장바구니에 담음
addToCart();
// 승인된 결제 정보 저장
UUID orderId = UUID.randomUUID();
String paymentKey = "tgen_20250102210202h9Oy0";
savePayment(orderId, paymentKey, 26_000, true);
// 주문
OrderSaveRequest orderSaveRequest = OrderSaveRequest.builder()
.paymentKey(paymentKey)
.deliveryFee(0)
.payMethod(PayMethod.TOSS)
.discount(0)
.build();
entityManager.flush();
entityManager.clear();
System.out.println("Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
Session session = entityManager.unwrap(Session.class);
System.out.println("Isolation Level: " + session.doReturningWork(Connection::getTransactionIsolation));
transactionManager.commit(status);
Assertions.assertDoesNotThrow(() -> {
orderService.registerOrder(orderSaveRequest, "username");
});
} catch (Exception e) {
transactionManager.rollback(status);
}
}
위처럼 테스트 코드를 변경하고 격리수준과 각각의 트랜잭션 이름을 출력해보았고, 다음과 같이 잘 분리가 되며 테스트를 성공했다.
그러나 문제가 하나 있다.
이미 커밋을 했기 때문에 롤백은 불가능하다. 때문에 다른 테스트를 실행할 때도 이미 데이터가 존재하기 때문에 실패하는 문제가 발생한다.
때문에 @Transactional
을 함께 사용하면서 내부 트랜잭션을 수동으로 조작하는 방식을 적용하려 했으나 실패했다.
내키는 해결 방법은 아니지만 일단 @DirtiesContext
를 사용해서 각 메서드마다 새로 컨텍스트를 띄우도록 하여 해결했다…
이것도 나중에 좀 수정해봐야겠다.
재밌는건, 어차피
DirtiesContext
를 사용할 거였으면 각 테스트마다 트랜잭션을 걸 필요도 없으니, 트랜잭션을 분리한다고 이렇게 삽질할 필요도 없었을 것이다..:)
고작 롤백 테스트 하나 성공시키는 작업에서 꽤나 많은 오류들을 만났지만, 덕분에 공부했던 내용을 적용해서 해결해 보는 경험과 새로운 지식들을 얻을 수 있었던 것 같다.