[트러블슈팅] 이벤트와 비동기 메소드로 외부 api 호출 롤백하기 (그리고 테스트하기)

종미(미아)·2024년 6월 10일
10

🌱 Spring

목록 보기
9/9
post-thumbnail

들어가며

이번 게시글도 문제 상황을 가정하고 고민해본 해결 방법을 기록한 것이다. 정답은 없다는 말! 이 게시글에서 다룬 이벤트, 이벤트리스너 등의 동작 원리를 상세하게 다루지 않았다.

저번주부터는 방탈출 예약 프로그램에 결제 시스템이 추가됐다.
결제 시스템을 직접 구현하는 건 아니고, 외부 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에 결제 승인 요청을 하는 부분이다. 다음과 같은 단점을 생각해보았다.

  1. payment(결제 내역) 저장에 실패해도 예약 저장은 롤백되지만 paymentClient로 외부 api에 요청한 부분(즉, 결제)은 롤백되지 않는다. 이미 내 어플리케이션의 관리 범위(트랜잭션) 밖이다.
  2. 길게는 몇 십 초 걸릴 수 있는 외부 api 요청이 트랜잭션 범위 안에 있다. db connection이 외부 api 요청-응답 시간동안 유지된다.

해결 방법을 각각 생각해보고, 합쳐보자.

1. 트랜잭션 관리를 어떻게 할 것인가?

payment(결제 내역)를 저장하는 트랜잭션을 다른 서비스 클래스의 메소드(PaymentService)로 분리하고 해당 트랜잭션이 실패한다면(롤백된다면) 예약을 삭제하고 결제 취소 api에 취소 요청을 한다.

2. 외부 api 호출을 트랜잭션 안에 두지 않는 방법

  1. 트랜잭션 애너테이션 없이 외부 api 호출을 하면 되지 않을까? 싶지만 트랜잭션은 한 스레드 안에서 전파되기 때문에 다른 트랜잭션을 사용하는 메소드에서 호출하면 무조건 트랜잭션 안에서 외부 api가 호출된다. 다른 스레드를 생성해서 외부 api 호출을 하면 되는데, 스프링의 @Async를 활용할 수 있다.

  2. 트랜잭션을 사용하지 않는 컴포넌트에서 트랜잭션을 분리한다.

결론

  1. 예약 저장, 결제 내역 저장이 하나의 작업으로 동작해야 하는 건 명백하다. 그래서 PaymentService의 메소드로 결제 내역 저장을 분리한다고 하더라도 저장이 실패하면 예약까지 삭제해야 한다.

  2. [이번에 시도해 본 방법] 결제를 먼저 한 후, 예약 저장과 결제 내역 저장을 한 트랜잭션 안에 두고 이 트랜잭션이 롤백될 경우 결제 취소 요청을 한다면 '방탈출 예약 생성'을 한 단위로 처리할 수 있다. 이 때 트랜잭션 롤백은 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));
}

paymentServiceconfirm이 결제 요청을 하는 부분이다. 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을 비동기로 구현하였다.

비동기 메소드로 결제 취소 api 호출

	@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 호출 클라이언트에 테스트 더블 사용

테스트할 때 실제로 외부 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이 무사히 호출되는지"이다. bookingManageServicecreate는 검증 및 예약 생성 로직을 가진 서비스 메소드이고, 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]

TransactionalEventListener

[2]

@Async

profile
BE 개발자 지망생 🪐

0개의 댓글