이번 포스팅은 식구하자 프로젝트
msa상에서 분산 트랜잭션
을 제어하는 방법 중 하나인 saga-pattern
을 활용한 과정을 포스팅 해보겠습니다.
아래 그림은 결제 프로세스를 MSA 분산 환경으로 간략하게 나타냈습니다. 각 도메인은 각각의 DB를 바라보고 있으며, 결제 프로세스는 2개의 서비스(plant-coupon-service, plant-pay-service)와 DB를 거쳐야 완료됩니다.
문제없이 항상 완료만 되는 상황이면 좋겠지만, 쿠폰 마이크로서비스
에서 쿠폰을 사용해서 DB에 저장한 후에, Payment 마이크로서비스
에서 예외처리 혹은 장애가 나서 롤백해야한다면 어떻게 처리해야할까요?
Saga Pattern
은 마이크로 서비스에서 데이터 일관성을 관리하는 방법입니다.
각 서비스는 로컬 트랜잭션을 가지고 있으며, 해당 서비스 데이터를 업데이트하며 메시지 또는 이벤트를 발행해서, 다음 단계 트랜잭션을 호출하게 됩니다.
만약, 해당 프로세스가 실패하게 되면 데이터 정합성을 맞추기 위해 이전 트랜잭션에 대해 보상 트랜잭션
을 실행합니다.
NoSQL 같이 분산 트랜잭션 처리를 지원하지 않거나, 각기 다른 서비스에서 다른 DB 밴더사를 이용할 경우에도 Saga Pattenrn을 이용해서 데이터 일관성을 보장 받을 수 있습니다.
저의 프로젝트 분산 트랙잰셕은 workflow가 간단하며, 추가적인 Orchestration-Based Saga을 위한 Manager 추가 서비스 구현보다는 Choreography 방식을 선택했습나다!
1. 결제 마이크로 서비스에서 트랜잭션 실패
2. 결제 마이크로 서비스에서 쿠폰 사용 롤백 이벤트 발행
3. 쿠폰 마이크로 서비스에서 쿠폰 사용 롤백 이벤트 구독
4. 쿠폰 사용 취소
kafka 설정 파일 및 참고 코드가 궁금하신 분은 GitHub을 참고해주세요.
Github 주소 : https://github.com/LminWoo99/PlantBackend/tree/msa-master
/**
* 쿠폰 사용 메서드
* 쿠폰 , 결제 마이크로서비스 saga pattern 적용
* coupon status가 미적용이면 바로 결제 이벤트
* 적용이면 쿠폰 사용후 결제 이벤트
* @param : PaymentRequestDto paymentRequestDto
*/
@Transactional
public void useCouponAndPayment(PaymentRequestDto paymentRequestDto) {
if (paymentRequestDto.getCouponStatus()== CouponStatus.쿠폰미사용){
log.info("======couponUseConsumer Data :{}======", paymentRequestDto);
paymentProducer.create(paymentRequestDto);
return;
}
Coupon coupon = couponRepository.findByMemberNoAndCouponNo(paymentRequestDto.getMemberNo(), paymentRequestDto.getCouponNo());
//사용완료 및 변경감지
coupon.useCoupon();
log.info("======couponUseConsumer Data :{}======", paymentRequestDto);
//쿠폰 적용완료후 결제 요청
paymentProducer.create(paymentRequestDto);
}
/**
* 쿠폰 롤백 메서드
* 보상 트랜잭션
* 결제 실패시 쿠폰 상태를 update
* @param : PaymentRequestDto paymentRequestDto
*/
@Transactional
public void revertCouponStatus(PaymentRequestDto paymentRequestDto) {
Coupon coupon = couponRepository.findByMemberNoAndCouponNo(paymentRequestDto.getMemberNo(), paymentRequestDto.getCouponNo());
if (coupon.getType() == CouponStatusEnum.사용완료) {
//다시 쿠폰 상태 롤백 및 변경감지
coupon.revertCoupon();
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentProducer {
private final KafkaTemplate<String, PaymentRequestDto> kafkaTemplate;
public void create(PaymentRequestDto paymentRequestDto) {
log.info("쿠폰 사용 이벤트: {}", paymentRequestDto.getCouponNo());
kafkaTemplate.send("payment", paymentRequestDto);
}
}
@Service
@Slf4j
@RequiredArgsConstructor
public class CouponRollbackConsumer {
private final CouponService couponService;
@KafkaListener(topics = "coupon-rollback", containerFactory = "couponUseListenerContainerFactory")
public void handleCouponRollbackEvent(PaymentRequestDto event) {
log.info("======보상 트랜잭션 동작 , 쿠폰 번호 :{} =====" ,event.getCouponNo());
couponService.revertCouponStatus(event);
}
}
/**
*
* 식구페이 거래 메서드
* 판매자 상대 멤버 번호를 통해 해당 조회 후
* 판매자 paymoney += 거래할 금액
* 구매자 Paymoney -= 거래할 금액
* 분산 트랜잭션 Saga Pattern 적용
* 에러 발생시 쿠폰 마이크로서비스로 보상 트랜잭션 시작, Rollback
* @param : PaymentRequestDto paymentRequestDto, Integer sellerNo
*/
@Transactional
public void tradePayMoney(PaymentRequestDto paymentRequestDto) {
try {
Payment buyerPayment = paymentRepository.findByMemberNo(paymentRequestDto.getMemberNo());
Integer buyerPayMoney = paymentRequestDto.getPayMoney();
if (paymentRequestDto.getCouponStatus() == CouponStatus.쿠폰사용) {
//쿠폰 사용시 구매자 결제정보만 쿠폰금액 차감
buyerPayMoney += paymentRequestDto.getDiscountPrice();
}
// errorPerHalf();
paymentRepository.tradePayMoney(paymentRequestDto.getSellerNo(), buyerPayment.getMemberNo(), paymentRequestDto, buyerPayMoney);
} catch (CustomException e) {
// 결제 실패 시 쿠폰 사용 취소 이벤트 발행
log.error("===[결제 요청 오류] -> coupon-rollback , 쿠폰 번호 :{} / {}====",paymentRequestDto.getCouponNo(), e.getMessage());
couponRollbackProducer.rollbackCouponStatus(paymentRequestDto);
}
}
@Component
@RequiredArgsConstructor
public class CouponRollbackProducer {
private final KafkaTemplate<String, PaymentRequestDto> kafkaTemplate;
public void rollbackCouponStatus(PaymentRequestDto couponRequestDto) {
kafkaTemplate.send("coupon-rollback", couponRequestDto);
}
}
Payment 마이크로 서비스에서 트랜잭션이 실패해서 보상 트랜잭션 발생상황을 테스트 해보도록 하겠습니다!
기대 (1) : Payment 서비스에서 쿠폰 롤백
이벤트 발행
기대 (2) : Coupon 서비스에서 쿠폰 롤백
이벤트 구독 후 쿠폰 롤백
private void errorPerHalf() {
int zeroOrOne = new Random().nextInt(BETWEEN_ZERO_AND_ONE);
if (zeroOrOne == 0) {
throw ErrorCode.paymentProcessingError();
}
}
50% 확률
로 CustomException을 발생rollbackCouponStatus()
메서드 실행GitHub Repository : https://github.com/LminWoo99/PlantBackend/tree/msa-master
참고 : https://azderica.github.io/01-architecture-msa/
https://csy7792.tistory.com/349