트랜잭션 분리 전략으로 강한/최종 일관성 보장

YoonJuHo·2025년 4월 18일

포트폴리오

목록 보기
1/9
post-thumbnail

본 프로젝트는 토스 페이먼츠 결제 API를 활용한 방탈출 예약 서비스입니다.

해당 글은 아래의 3가지 조건에 기반하여 결제 API 연동 과정에서 트랜잭션 관리, 원자성 보장, 데이터 무결성 확보 등을 위해 선택한 기술적 접근 방식에 대해 설명합니다.

  • 조건 1 : 결제 도메인의 경우, 돈이 오가는 민감한 트랜잭션을 처리하므로 데이터의 정확성과 일관성이 가장 중요하다.
  • 조건 2 : 방탈출을 예약하는 과정에서 사용자는 출금 결과와 예약 결과를 알고 넘어가야하기 때문에 동기로 구성해야 한다.
  • 조건 3 : 사용자는 예약이 즉시 완료되기를 기대하므로 Time Out을 설정해야 한다.

[요약]

해당 글에서는 조건 1조건 2를 충족하는 과정을 담고 있으며, 조건 3을 충족하는 과정은
🔗 결제 API - Connection Pool Starvation 방지 전략에서 확인 가능합니다.

결론적으로 [해결 방안 3 : 새로운 구조에 트랜잭션 분리 전략 도입]을 선택하는 것으로 2가지 조건을 모두 충족할 수 있었습니다.


[문제 상황]

결제 API를 사용하는 과정에서 외부 결제 API 호출예약 정보 & 결제 정보 DB 저장 로직을 하나의 트랜잭션으로 관리하여 데이터 일관성을 보장해야 한다고 판단했습니다.
그러나 하나의 트랜잭션 내에서 외부 결제 API 호출 후 예약 정보 & 결제 정보 DB 저장 로직을 수행하는 과정에서 문제가 발생하였습니다.

(1. 외부 결제 API 호출 → 2. 예약 정보 & 결제 정보 DB 저장 로직 실행 순서)

// ReservationApplicationService.java

@Transactional
public ReservationPaymentResponse saveReservationPayment(
		LoginMember loginMember,
		ReservationPaymentRequest reservationPaymentRequest
) {
		PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest()); // 외부 결제 API 호출
    return reservationService.saveReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), paymentResult); // 내부 DB 저장 로직
}

외부 API 호출에 실패하면 예약 정보 & 결제 정보 DB 저장 로직이 실행되지 않아 전체 트랜잭션이 롤백되므로 문제가 발생하지 않습니다.

그러나 외부 API 호출은 성공했지만, 예약 정보 & 결제 정보 DB 저장이 실패하면 데이터 일관성이 보장되지 않는 문제가 발생하게 됩니다.


[해결방안]

❌ [해결 방안 1 : 결제 취소 API 호출 로직을 구현한다.]

[해결 방안 1-1 : try-catch 로 구현]

@Transactional
public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    try {
        // 예약 정보 & 결제 정보 DB 저장
        return reservationService.saveReservationPayment(
                loginMember,
                reservationPaymentRequest.toReservationRequest(),
                paymentResult
        );
    } catch (Exception e) {
        // 예약 정보 & 결제 정보 DB 저장에 실패한 경우, 결제 취소 요청을 보냄
        paymentClient.cancel(reservationPaymentRequest.paymentKey(), new CancelReason("관리자 권한 취소"));
        throw e;
    }
}

[해결 방안 1-2 : 트랜잭션 동기화를 통한 보상 처리]

@Transactional
public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    // 트랜잭션 동기화 콜백을 등록하여, 트랜잭션이 롤백될 경우 보상 처리(결제 취소)를 수행함
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
        @Override
        public void afterCompletion(int status) {
            if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
                // 트랜잭션 롤백 시 결제 취소 요청 수행
                try {
                    paymentClient.cancel(reservationPaymentRequest.paymentKey(), new CancelReason("관리자 권한 취소"));
                } catch (Exception ex) {
                    // 보상 처리 실패 시 로그 등을 통해 모니터링
                    System.err.println("결제 취소 보상 처리 실패: " + ex.getMessage());
                }
            }
        }
    });
    return reservationService.saveReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), paymentResult);
}

보상 트랜잭션 Test code

[해결방안 1-1, 1-2 로 해결이 가능한가?]

  • 해결방안 1-1 : try-catch를 통해 예약 정보 & 결제 정보 DB 저장에 실패한 경우 결제 취소 API를 호출하는 방식
  • 해결방안 1-2 : TransactionSynchronizationManager를 통해 트랜잭션 롤백 시 결제 취소 API를 호출하는 콜백을 등록하는 방식

2가지 방식 모두 외부 API의 결제 취소 로직이 실패할 가능성이 있기 때문에 이 방법은 완벽한 해결책이 아니라고 판단하였습니다.


❌ [해결방안 2 : 구조 개선]

[새로운 구조]
1. 사전 예약 정보 & 결제 정보 DB 저장
2. 외부 결제 API 호출
3. 상세
예약 정보 & 결제 정보 DB 저장

public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
		**// 1. 사전 예약 정보 & 결제 정보 DB 저장**
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest);
    
		**// 2. 외부 결제 API 호출**
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    
    **// 3. 상세 예약 정보 & 결제 정보 DB 저장**
    return reservationService.saveDetailedReservationPayment(reservation, paymentResult);
}

DB에 외부 API 호출에 필요한 사전 정보를 저장한 후 API를 호출하고, 그 결과로 응답 데이터를 DB에 업데이트하는 방식으로 처리할 수 있으며, DB 저장에 실패하면 바로 리턴하도록 구현할 수 있습니다. 이 방식의 장점은 구현이 간단하다는 점입니다.

그러나 이 방식에서 사전 예약 정보 & 결제 정보 DB 저장이 성공하고 외부 API 호출도 성공한 후, 마지막 상세 예약 정보 & 결제 정보 DB 저장에 실패하는 경우를 생각해 보면,

세 작업을 모두 하나의 트랜잭션으로 묶어두면 마지막 DB 호출 실패 시 외부 API 결제를 롤백하기 위한 API를 호출해야 하는 문제가 발생하게 됩니다.

이는 해결방안 1에서 발생하는 문제와 동일해집니다.

따라서, 서로 다른 트랜잭션으로 처리하는 방안을 고려해 볼 필요가 있습니다.


✅ [해결 방안 3 : 새로운 구조에 트랜잭션 분리 전략 도입]

외부 결제 API 호출 전에 DB에 예약/결제 사전 정보를 저장하는 작업과 외부 결제 API 호출은 하나의 트랜잭션으로 처리하고, 그 후 예약/결제 상세 정보를 저장하는 작업별도의 트랜잭션으로 분리하는 방식을 선택하였습니다.

(이 경우, 만약 예약/결제 상세 정보 저장에 실패하더라도 해당 트랜잭션만 롤백되고, 예약/결제 사전 정보는 이미 커밋되어 남게 됩니다.)

// ReservationFacade.java

public ReservationPaymentResponse saveReservationPayment(
        LoginMember loginMember,
        ReservationPaymentRequest reservationPaymentRequest
) {
    ReservationPaymentResult reservationPaymentResult = reservationApplicationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest);
    try {
        return reservationApplicationService.saveDetailedReservationPayment(reservationPaymentResult.reservation(), reservationPaymentResult.paymentResult());
    } catch (Exception e) {
        return new ReservationPaymentResponse(
                ReservationResponse.from(reservationPaymentResult.reservation()),
                PaymentResponse.from(reservationPaymentResult.paymentResult()));
    }
}

// ReservationApplicationService.java

**// [사전 예약 정보 & 결제 정보 DB 저장]**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationPaymentResult saveAdvanceReservationPayment(LoginMember loginMember, ReservationPaymentRequest reservationPaymentRequest) {
    Reservation reservation = reservationService.saveAdvanceReservationPayment(loginMember, reservationPaymentRequest.toReservationRequest(), reservationPaymentRequest.toPaymentRequest());
    PaymentResult paymentResult = paymentClient.purchase(reservationPaymentRequest.toPaymentRequest());
    return new ReservationPaymentResult(reservation, paymentResult);
}

**// [상세 예약 정보 & 결제 정보 DB 저장]**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationPaymentResponse saveDetailedReservationPayment(
        Reservation reservation,
        PaymentResult paymentResult
) {
    return reservationService.confirmReservationPayment(reservation, paymentResult);
}

이처럼 서로 다른 트랜잭션으로 묶이게 된다면, 마지막으로 상세 예약 정보 & 결제 정보 DB 저장 로직이 실패하더라도 회원의 예약 내역이 이미 저장되어 있어 당장 서비스 제공에는 문제가 발생하지 않습니다.

  • 결제 도메인의 경우, 돈이 오가는 민감한 트랜잭션을 처리하므로 데이터의 정확성과 일관성이 가장 중요하다.

트랜잭션 분리 전략

  • 방탈출을 예약하는 과정에서 사용자는 출금 결과와 예약 결과를 알고 넘어가야하기 때문에 동기로 구성해야 한다.

모든 작업을 동기 방식으로 구성

이와 같은 상황에서는 외부 결제 DB와 내부 DB 간의 상태를 정기적으로 비교하여, 상세 정보가 올바르게 저장되지 않아 불일치가 발생할 경우 이를 신속하게 감지하고 해결할 수 있는 상태 확인 및 복구 메커니즘을 마련하는 것이 중요하다고 판단하였습니다.

또한, 외부 API를 사용할 때 예상한 응답값을 기반으로 정상적으로 로직이 수행되는지 테스트를 진행하는 것이 필수적이라고 판단하였습니다.


✅ [상태 확인 및 복구 매커니즘 - Scheduler]

@Scheduled(cron = "0 0 3 * * *")
public void checkPaymentConsistency() {
    log.info("결제 일관성 검사 시작 시각: {}", LocalDateTime.now());
    List<Reservation> pendingReservations = reservationService.findPendingDetailedPayments();

    pendingReservations.forEach(
            reservation -> {
                try {
                    Payment payment = paymentRepository.findByReservationId(reservation.getId())
                            .orElseThrow(() -> new RoomescapeException(NOT_FOUND_RESERVATION_PAYMENT, reservation.getId()));
                    PaymentResult paymentResult = paymentClient.lookup(payment.getPaymentKey());
                    if (paymentResult.paymentKey().equals(payment.getPaymentKey())) {
                        reservationService.confirmReservationPayment(reservation, paymentResult);
                    }
                } catch (Exception ex) {
                    log.error("예약번호 {} 결제 일관성 검사 중 오류 발생: {}", reservation.getId(), ex.getMessage(), ex);
                }
            }
    );
    log.info("결제 일관성 검사 완료 시각: {}", LocalDateTime.now());
}
  • 상태 확인 및 복구 메커니즘은 스케줄러를 통해 구현하였습니다. 사람이 가장 적게 이용하는 새벽 3시에 일관성 보장 작업이 수행되도록 구성하였고, 마지막으로 내부 DB 정보 업데이트 로직이 실패한 시점을 추적할 수 있도록 관련 로그를 기록하고 있습니다.

✅ [외부 결제 API 테스트]

@DisplayName("적합한 인자를 통한 결제 요청 시 성공한다.")
@Test
void purchase() throws JsonProcessingException {
    String endPoint = "/v1/payments/confirm";
    mockServer
            .expect(requestTo(url + endPoint))
            .andExpect(content().json(objectMapper.writeValueAsString(PAYMENT_REQUEST)))
            .andExpect(method(HttpMethod.POST))
            .andRespond(withSuccess(objectMapper.writeValueAsString(PAYMENT_INFO), MediaType.APPLICATION_JSON));

    assertThat(tossPaymentClient.purchase(PAYMENT_REQUEST)).isEqualTo(PAYMENT_INFO);
    mockServer.verify();
}
  • MockRestServiceServer를 활용해 외부 API 호출을 모킹하였습니다. 이를 통해 실제 외부 API에 의존하지 않고도 예상한 응답을 기반으로 테스트 환경을 구축할 수 있었습니다.

[결론]

결론적으로, 본 프로젝트에서는 결제 도메인에서 요구되는 데이터 정확성과 강한 일관성을 보장하기 위해 기존의 단일 트랜잭션 방식 대신 트랜잭션 분리 전략을 도입하였습니다. 이를 통해 외부 결제 API 호출과 예약,결제 정보의 상세 저장 작업을 별도의 트랜잭션으로 분리함으로써, 외부 API 호출 후 DB 저장 과정에서 발생할 수 있는 오류로 인한 데이터 불일치를 효과적으로 방지할 수 있었습니다.

또한, 상태 확인 및 복구 메커니즘을 스케줄러를 통해 주기적으로 실행함으로써, 내부 DB와 외부 결제 상태의 불일치 상황을 감지하고 보완할 수 있도록 하였습니다.

이러한 기술적 접근 방식은 결제, 예약 등 민감한 트랜잭션이 포함된 도메인에서 사용자에게 신뢰받는 서비스를 제공하기 위한 필수 요소로, 올바른 서비스 제공과 데이터 무결성을 동시에 확보할 수 있는 해결책이라고 생각되었습니다.

사용자 관점에서는 강한 일관성을 제공하면서 내부적으로는 독립적인 트랜잭션과 후속 보정 과정을 통해 최종 일관성 달성

강한 일관성을 매 순간 보장하려면 시스템의 확장성이나 가용성이 크게 저해될 수 있기 때문에, 일부 경우에는 약간의 지연 후에 모든 데이터가 일관된 상태에 도달하는 최종 일관성을 선택하는 것이 현실적인 타협점이라고 생각됩니다.

0개의 댓글