이번 게시글도 문제 상황을 가정하고 고민해본 해결 방법을 기록한 것이다. 정답은 없다는 말! 이 게시글에서 다룬 이벤트, 이벤트리스너 등의 동작 원리를 상세하게 다루지 않았다.
저번주부터는 방탈출 예약 프로그램에 결제 시스템이 추가됐다.
결제 시스템을 직접 구현하는 건 아니고, 외부 Payment gateway를 이용한다. 예약 시 결제를 하고, 결제 내역을 payment 테이블에 저장한다.
아래와 같은 서비스 코드(BookingManageService
)를 보자.
@Transactional
public Reservation createWithPayment(Reservation reservation, NewPayment newPayment) {
// 검증 로직 생략
Reservation savedReservation = reservationRepository.save(reservation);
ConfirmedPayment confirmedPayment = paymentClient.confirm(newPayment);
Payment payment = confirmedPayment.toModel(savedReservation);
pamentRepository.save(payment);
return savedReservation;
}
paymentClient.confirm
이 외부 api에 결제 승인 요청을 하는 부분이다. 다음과 같은 단점을 생각해보았다.
해결 방법을 각각 생각해보고, 합쳐보자.
payment(결제 내역)를 저장하는 트랜잭션을 다른 서비스 클래스의 메소드(PaymentService
)로 분리하고 해당 트랜잭션이 실패한다면(롤백된다면) 예약을 삭제하고 결제 취소 api에 취소 요청을 한다.
트랜잭션 애너테이션 없이 외부 api 호출을 하면 되지 않을까? 싶지만 트랜잭션은 한 스레드 안에서 전파되기 때문에 다른 트랜잭션을 사용하는 메소드에서 호출하면 무조건 트랜잭션 안에서 외부 api가 호출된다. 다른 스레드를 생성해서 외부 api 호출을 하면 되는데, 스프링의 @Async
를 활용할 수 있다.
트랜잭션을 사용하지 않는 컴포넌트에서 트랜잭션을 분리한다.
예약 저장, 결제 내역 저장이 하나의 작업으로 동작해야 하는 건 명백하다. 그래서 PaymentService
의 메소드로 결제 내역 저장을 분리한다고 하더라도 저장이 실패하면 예약까지 삭제해야 한다.
[이번에 시도해 본 방법] 결제를 먼저 한 후, 예약 저장과 결제 내역 저장을 한 트랜잭션 안에 두고 이 트랜잭션이 롤백될 경우 결제 취소 요청을 한다면 '방탈출 예약 생성'을 한 단위로 처리할 수 있다. 이 때 트랜잭션 롤백은 try-catch문이 아니라 이벤트와 TransactionalEventListener를 활용했다. 밑에서 자세히 설명하겠지만 롤백 처리 로직과 기존 로직을 분리할 수 있고, reservation과 payment 간의 의존성도 낮출 수 있다.
우선 컨트롤러에서 가장 먼저 결제 api에 결제 요청을 한다.
@PostMapping
public ResponseEntity<ReservationResponse> createReservation(@RequestBody @Valid ReservationPayRequest request,
Member loginMember) {
// 생략
ConfirmedPayment confirmedPayment = paymentService.confirm(request.newPayment());
Reservation createdReservation = bookingManageService.createWithPayment(newReservation, confirmedPayment);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ReservationResponse.from(createdReservation));
}
paymentService
의 confirm
이 결제 요청을 하는 부분이다. bookingManageService
에서 reservation과 payment를 저장한다.
// BookingManageService.java
@Transactional
public Reservation createWithPayment(Reservation reservation, ConfirmedPayment confirmedPayment) {
eventPublisher.publishEvent(new ReservationFailedEvent(confirmedPayment));
// 생성하는 로직
}
그리고 createWithPayment
메소드 시작 부분에 이벤트를 발행한다. 실패했을 때 발행하는 건 불가능하다. (이미 예외가 터졌을 것이므로..)
public record ReservationFailedEvent(ConfirmedPayment confirmedPayment) {
}
이벤트 클래스에서 이벤트리스너로 전달할 데이터를 담는다.
@TransactionalEventListener(value = ReservationFailedEvent.class, phase = TransactionPhase.AFTER_ROLLBACK)
public void cancelCasedByRollBack(ReservationFailedEvent event) {
ConfirmedPayment confirmedPayment = event.confirmedPayment();
PaymentCancelInfo paymentCancelInfo = new PaymentCancelInfo(
confirmedPayment.getPaymentKey(), CANCEL_REASON_CAUSED_BY_ROLL_BACK);
CompletableFuture<PaymentCancelResult> future = paymentClient.cancel(paymentCancelInfo);
handleCancellationException(future);
}
TransactionalEventListener
에서 phase를 AFTER_ROLLBACK
으로 설정해 이벤트가 발행된 트랜잭션이 롤백된다면 cancelCasedByRollBack
가 실행되도록 한다.
여기서 주의할 점은, 트랜잭션이 커밋 혹은 롤백된 이후에 리스너가 실행되지만 여전히 db connection을 가지고 있다는 것이다. TransactionSynchronizationManager.isActualTransactionActive()
을 출력해보아도 'true'를 확인할 수 있다. db에 커밋/롤백 되었다 뿐이지 @Transactional
의 범위 안이다. 이 때 스프링의 @Async
를 활용하면 새로운 스레드를 생성해 비동기로 로직을 수행한다. @Transactional
은 새로 생성된 스레드에는 적용되지 않기 때문에 db connection 없이 메소드 로직을 실행할 수 있다.
해당 이벤트(ReservationFailedEvent
)에 대해 리스너가 추가된다면 비동기 처리를 하는게 유의미하고, 외부 api 호출을 db connection 안에서 하지 않는게 목표였으므로 결제 취소 요청을 하는 paymentClient.cancel
을 비동기로 구현하였다.
@Async
@Override
public void cancel(PaymentCancelInfo paymentCancelInfo) {
String uri = String.format("/%s/cancel", paymentCancelInfo.paymentKey());
PaymentCancelResult paymentCancelResult = restClient.post()
.uri(uri)
.body(paymentCancelInfo)
.retrieve()
.onStatus(errorHandler)
.body(PaymentCancelResult.class);
validateCanceledPayment(paymentCancelResult);
}
고민해볼 만한 부분은 비동기 메소드에서 터진 예외는 호출 스레드로 전달되지 않는 다는 것이다. 따라서 아래처럼 인터셉터를 추가해서 로그를 남겨야만 한다.
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncExceptionHandler();
}
}
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass().getSimpleName());
@Override
public void handleUncaughtException(Throwable e, Method method, Object... params) {
log.error("[Line number] " + e.getStackTrace()[0].getClassName() + " " + e.getStackTrace()[0].getLineNumber() +
" [Exception] " + e.getClass() +
" [Message] " + e.getMessage(), e);
}
}
테스트할 때 실제로 외부 api를 호출하면 안되기 때문에 테스트 더블을 사용했다.
public class StubPaymentClient implements PaymentClient {
@Override
public ConfirmedPayment confirm(NewPayment newPayment) {
return new ConfirmedPayment("paymentKey", "orderId", 10, PGCompany.TOSS);
}
@Override
public CompletableFuture<PaymentCancelResult> cancel(PaymentCancelInfo paymentCancelInfo) {
return CompletableFuture.completedFuture(new PaymentCancelResult("CANCELED"));
}
}
위와 같이 Stub을 활용했다.
@TestConfiguration
public class TestClientConfiguration {
@Bean
public PaymentClient paymentClient() {
return new StubPaymentClient();
}
}
그리고 테스트 configuration으로 빈을 등록하여 오버라이딩한다. PaymentClient
빈을 테스트용 클래스로 교체한 것이다. 테스트 코드에서 @Import
로 사용할 수 있다.
@SpyBean
을 활용한 서비스 테스트 @RecordApplicationEvents
@Import(TestClientConfiguration.class)
class BookingCreateFailTest extends ReservationServiceTest {
@SpyBean
private PaymentService paymentService;
@SpyBean
private BookingManageService bookingManageService;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private ApplicationEvents events;
@Test
@DisplayName("예약 생성에 실패하면 PG 결제 취소 API에 취소 요청을 한다.")
void cancelCasedByRollBackWhenReservationCreateFailed() {
// given
BDDMockito.willThrow(TestException.class)
.given(bookingManageService)
.create(any()); // (1)
Reservation reservation = MIA_RESERVATION(miaReservationTime, wootecoTheme, mia, BOOKING);
// when & then
assertThatThrownBy(() -> bookingManageService.createWithPayment(reservation, confirmedPayment))
.isInstanceOf(TestException.class); // (2)
long count = events.stream(ReservationFailedEvent.class).count(); // (3)
assertThat(count).isEqualTo(1);
verify(paymentService, times(1)).cancelCasedByRollBack(any()); // (4)
}
테스트하고 싶은 건 "예약과 결제 내역을 저장하는 트랜잭션(createWithPayment
)이 롤백되었을 때 이벤트 리스너인 cancelCasedByRollBack
이 무사히 호출되는지"이다. bookingManageService
의 create
는 검증 및 예약 생성 로직을 가진 서비스 메소드이고, createWithPayment
에서 아래와 같이 호출하고 있다.
@Transactional
public Reservation createWithPayment(Reservation reservation, ConfirmedPayment confirmedPayment) {
eventPublisher.publishEvent(new ReservationFailedEvent(confirmedPayment));
Reservation savedReservation = super.create(reservation); // 호출 부분
/* 생략 */
}
따라서 (1)에서 Mockito를 사용해서 mocking하고, (2)에서 "예약과 결제 내역을 저장하는 트랜잭션(createWithPayment
)"을 테스트한다. 이 때 BookingManageService
는 mocking한 메소드를 제외하고 정상 동작해야 되기 때문에 @MockBean
이 아니라 @SpyBean
으로 주입 받아 사용한다.
ReservationFailedEvent
가 잘 발행되었는지는 (3)에서 확인한다. @RecordApplicationEvents
, ApplicationEvents
를 사용했다.
마지막으로 이벤트 리스너가 호출된 횟수를 (4)에서 확인하면 내가 테스트하고자 했던 부분은 모두 테스트한 것이다. 테스트 통과! 결제 내역(payment) 저장에 실패했을 때도 비슷하게 테스트할 수 있다.
이 방식은 결제 api 호출을 예약 생성 로직보다 앞에서 하기 때문에 예약 생성 시 진행되는 검증 로직(서비스 클래스의 날짜 검증 등)이 존재한다면 사용자는 이런 예외 상황에서 예외 메세지와 함께 결제 취소를 받는다. 가장 이상적인건 예외 메세지를 받고 결제되지 않는 것이다.
'예약 -> 외부 api에 결제 요청 -> 결제 내역 저장' 순으로 구현해서 결제가 실패한다면 예약을 삭제하고, 결제 내역 저장이 실패한다면 결제 취소 요청과 예약 삭제를 할 수 있다. 외부 api 트랜잭션을 분리하고자 한다면 이렇게 수동 롤백 처리가 필요하다.
마지막 언급한 단점 때문에 100퍼센트 마음에 드는 해결 방안은 아니었지만, 내가 기존에 알고 있던 비동기 관련 클래스 (Future
등), 이벤트 리스너, 트랜잭션 개념, 테스트 더블 등을 활용해서 가설을 세우고 적용하는게 재밌었다.
[1]
[2]