나의 개발일지

[SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 2 ( 문제 발생 및 해결 ) 본문

Language/SpringBoot

[SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 2 ( 문제 발생 및 해결 )

사각분무기 2024. 2. 22. 16:29

앞서 WebClient를 도입한 배경과 결과를 말씀드렸습니다.

 

아래와 같이 Service 클래스를 작성해뒀는데요.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TossService {
    private final WebClient webClient;

    private static String authorization;

    @Value("${custom.tossPayments.widget.secretKey}")
    private void setTossPaymentsWidgetSecretKey(String tossPaymentsWidgetSecretKey) {
        String encodedKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes(UTF_8));
        authorization = "Basic " + encodedKey;
    }

    @Transactional
    public Mono<TossPaymentRequest> confirmTossPayment(final TossConfirmRequest tossConfirmRequest) {

        return webClient.post()
                .uri("https://api.tosspayments.com/v1/payments/confirm")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", authorization)
                .bodyValue(tossConfirmRequest)
                .retrieve()
                .onStatus(
                        HttpStatusCode::isError,
                        clientResponse -> Mono.error(new PaymentException(PAYMENT_API_CALL_FAILED)))
                .bodyToMono(TossPaymentRequest.class);
    }
}

 

비동기가 가지는 장점을 여럿 알아본 저는 최대한 비동기 흐름을 통해 코드를 이어나가고 싶었는데요.

 

처음 작성할 땐 문제가 없다고 생각하고 있었습니다.

@PostMapping("/payByToss/{reserveId}")
public Mono<ResponseEntity<ResponseDto<CashLogIdResponse>>> payByTosss(
        @RequestBody final TossConfirmRequest tossConfirmRequest,
        @PathVariable final Long reserveId
) {      
    return tossService.confirmTossPayment(tossConfirmRequest)
            .flatMap(tossPaymentRequest -> {
                Reservation reservation = reservationService.findUnpaidById(reserveId).orElseThrow(() -> new ReservationException(NOT_FOUND_RESERVATION_ID));
                if (!cashLogService.canPay(reservation, Long.parseLong(tossConfirmRequest.getAmount())))
                    return Mono.error(new PaymentException(INSUFFICIENT_DEPOSIT));
                Long cashLogId = cashLogService.payByTossPayments(tossConfirmRequest, reservation).getId();
                CashLogIdResponse cashLogIdResponse = cashLogService.getCashLogIdById(cashLogId);
                return Mono.just(ResponseEntity.ok(
                        new ResponseDto<>(
                                HttpStatus.OK.value(),
                                "토스페이먼츠 결제가 완료되었습니다.", null,
                                null, cashLogIdResponse)
                ));
            });
}

 

이와 같이 코드를 작성할 경우 CRUD 의 네가지 작업 중 생성과 읽기는 가능하지만 수정과 삭제가 불가능합니다.

 

분명 Transactional 어노테이션을 Service 딴에 붙였는데 왜 동작하지 않는걸까요?

 

JPA 에서 데이터의 입력과 수정은 더티 체킹을 통해서 이루어집니다.

 

더티 체킹이 이루어지기 위해선 쓰레드에 트랜잭션을 연결하고 작업 후 커밋을 하는 과정을 거쳐야합니다.

 

메소드 단위로 생성된 트랜잭션이 여러개 호출될 경우 보통 트랜잭션 전파를 통해 통합됩니다.

 

문제는 트랜잭션 전파가 한 쓰레드 안에서만 이루어진다는 부분입니다.

 

 

 

WebClient를 활용한 비동기 흐름을 사용할 경우 위와 같이 쓰레드가 나뉘게 됩니다.

 

비동기 흐름 내부에서 트랜잭션을 활용하는 작업은 할 수 없다는 것이죠.

 

알아보니 비동기 환경에선 NoSQL 을 활용하는 MongoDB 과 같은 비동기를 위한 db들을 활용해야 된다고 합니다.

 

결국 비동기 흐름을 생성하기 전에 모든 작업들을 마친 후 마지막에 비동기 흐름을 사용하는 방식으로 코드를 사용했습니다.

@PostMapping("/payByToss/{reserveId}")
public Mono<ResponseEntity<ResponseDto<CashLogIdResponse>>> payByToss(
        @RequestBody final TossConfirmRequest tossConfirmRequest,
        @PathVariable final Long reserveId
) {
    Reservation reservation = reservationService.findUnpaidById(reserveId).orElseThrow(() -> new ReservationException(NOT_FOUND_RESERVATION_ID));
    if (!cashLogService.canPay(reservation, Long.parseLong(tossConfirmRequest.getAmount())))
        throw new PaymentException(INSUFFICIENT_DEPOSIT);

    Mono<TossPaymentRequest> tossPaymentResponseMono = tossService.confirmTossPayment(tossConfirmRequest);

    Long cashLogId = cashLogService.payByTossPayments(tossConfirmRequest, reservation).getId();

    return tossPaymentResponseMono
            .flatMap(tossPaymentRequest -> {
                CashLogIdResponse cashLogIdResponse = cashLogService.getCashLogIdById(cashLogId);
                return Mono.just(ResponseEntity.ok(
                        new ResponseDto<>(
                                HttpStatus.OK.value(),
                                "토스페이먼츠 결제가 완료되었습니다.", null,
                                null, cashLogIdResponse)
                ));
            });
}

 

이 과정 중에서 WebClient의 api 호출에서 오류가 발생할 경우에 다른 트랜잭션과 다른 쓰레드에서 발생한 오류이기 때문에 분명 오류가 났음에도 불구하고 롤백이 이루어지지 않는 등 당시엔 그저 당황스럽던 헤프닝도 있었네요

 

추가로 이를 해결하기 위한 다른 방법으로는 JdbcTemplate을 이용한 벌크 연산을 활용하는 방법이 있지만 이 경우 데이터 무결성을 해친다거나 @CreatedDate, @LastModifedDate 와 같은 어노테이션이 적용되지 않기 때문에 사용하지 않았습니다.