문제
결제 API 쪽 비즈니스 로직의 트랜잭션 생명주기가 길다.
결제 API 쪽을 한번 살펴보자.
@Component
@RequiredArgsConstructor
public class PaymentFacade {
private final PaymentService paymentService;
private final UserService userService;
private final ConcertService concertService;
private final WaitingQueueService waitingQueueService;
/**
* 결제 요청하는 유즈케이스를 실행한다.
*
* @param command reservationId, userId 정보
* @return PaymentResponse 결제 결과를 반환한다.
*/
@Transactional
@DistributedLock(key = "'userLock'.concat(':').concat(#command.userId())")
public Payment pay(PaymentCommand.Create command) {
// 1. 예약 완료
ConcertReservationInfo completeReservation = concertService.completeReservation(command);
// 2. 결제 내역 생성
Payment payment = paymentService.createPayment(completeReservation);
// 3. 잔액 차감
User user = userService.usePoint(completeReservation.getUserId(),
completeReservation.getSeatPrice());
// 4. 토큰 만료
waitingQueueService.forceExpireToken(command.token());
// 5. 결제 정보 반환
return Payment.builder()
.paymentId(payment.getPaymentId())
.paymentPrice(payment.getPaymentPrice())
.status(payment.getStatus())
.balance(user.getBalance())
.paidAt(payment.getPaidAt())
.build();
}
}
비즈니스 로직은 흐름은 다음과 같다.
1. 예약 완료 -> 2. 결제 내역 생성 -> 3. 잔액 차감 -> 4. 토큰 만료 -> 5. 결제 정보 반환
원인
그럼 이렇게 트랜잭션의 범위가 큰 이유는 무엇일까?
하나의 트랜잭션에서 여러가지 비즈니스 로직을 처리하려고 하기 때문이다.
그럼 이렇게 트랜잭션의 범위가 클 경우 어떤 문제들이 발생할 수 있을까?
- 긴 생명 주기의 Transaction 의 경우, 오랜 시간은 소요되나 후속 작업에 의해 전체 트랜잭션이 실패할 수 있음
- 혹은 트랜잭션 범위 내에서 DB 와 무관한 작업을 수행하고 있는 경우(외부 API 호출), 외부 API 로직이 실패한다면 우리 로직이 성공했어도 롤백 처리가 될 수 있다.
해결방법
다양한 해결방법이 있겠지만, 관점 지향적으로 문제를 살펴보면 문제를 쉽게 해결할 수 있다.
즉, 애플리케이션 이벤트를 통해 관심사를 분리한다면 트랜잭션 범위도 작아지고, 각 Event 에 의해 본인의 관심사만 수행하도록 하여 비즈니스 로직간의 의존을 줄일 수 있다!
여기서 주의할 점은, 이벤트를 나눌 때 각 작업의 관계나 의존이 어떻게 되는지를 잘 고려해야 한다.
→ 보상 트랜잭션이나 SAGA 패턴을 도입 가능하다.
적용
우선 주요 로직과 부가 로직을 생각해 보았다.
- 주요 로직
- 예약 완료
- 결제 내역 생성
- 포인트 차감
- 토큰 만료
- 부가 로직
- 푸쉬 이벤트
- 결제 정보 전달
부가 로직은 결제의 주요 로직에 영향을 끼치면 안된다.
이를 토대로 이벤트를 나누면 다음과 같이 나눌 수 있다.
(1. 예약 완료 -> 2. 결제 내역 생성) -> event publish!
-> 3. 잔액 차감 (event listen, before commit)
-> 4. 토큰 만료 (event listen, before commit)
-> 5. 푸쉬 이벤트 (event listen, after commit, @async)
-> 6. 결제 정보 전달 (event listen, after commit, @async)
여기서 트랜잭션 event listener 로 @TransactionalEventListener 사용할 수 있는데, 주요 로직의 문제가 생겼을 때 함께 Rollback이 발생해야함으로 TransactionPhase.*BEFORE_COMMIT* 옵션으로 설정하였다.
실제 코드로 적용된 걸 보면 다음과 같다.
- PaymentFacade
@Component
@RequiredArgsConstructor
public class PaymentFacade {
private final PaymentService paymentService;
private final ConcertService concertService;
/**
* 결제 요청하는 유즈케이스를 실행한다.
*
* @param command reservationId, userId 정보
* @return PaymentResponse 결제 결과를 반환한다.
*/
@Transactional
@DistributedLock(key = "'userLock'.concat(':').concat(#command.userId())")
public Payment pay(PaymentCommand.Create command) {
// 1. 예약 완료
ConcertReservationInfo reservation = concertService.completeReservation(command);
// 2. 결제 진행 및 결제 정보 반환
return paymentService.pay(reservation, command.token());
}
...
}
- PaymentService
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final ApplicationEventPublisher publisher;
/**
* 결제를 요청하면 결제 정보를 반환한다.
*
* @param reservationInfo 결제 요청 정보
* @return PaymentResponse 결제 정보를 반환한다.
*/
@Transactional
public Payment pay(ConcertReservationInfo reservationInfo, String token) {
Payment payment = Payment.builder()
.concertReservationInfo(reservationInfo)
.paymentPrice(reservationInfo.getSeatPrice())
.paidAt(now())
.status(Payment.PaymentStatus.COMPLETE).build();
// 1. 결제 내역 생성
Optional<Payment> completePayment = paymentRepository.savePayment(payment);
if (completePayment.isEmpty()) {
throw new CustomException(PAYMENT_IS_FAILED, "결제 완료 내역 생성에 실패하였습니다");
}
// 2. 결제 완료 이벤트 발행
publisher.publishEvent(new PaymentEvent(this, reservationInfo, payment, token));
return completePayment.get();
}
...
}
이렇게 이벤트를 발행하면 PaymentEvent 를 수신하는 모든 Listener들이 동작을 한다.(브로드캐스팅 방식이라고도 함)
- UserEventListener
@Component
@RequiredArgsConstructor
public class UserEventListener {
private final UserService userService;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onPaymentEvent(PaymentEvent event) {
// 잔액 차감
userService.usePoint(event.getReservationInfo().getUserId(),
event.getReservationInfo().getSeatPrice());
}
}
- QueueEventListener
@Component
@RequiredArgsConstructor
public class QueueEventListener {
private final WaitingQueueService waitingQueueService;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onPaymentEvent(PaymentEvent event) {
waitingQueueService.forceExpireToken(event.getToken());
}
}
- PaymentEventListener
@Component
@RequiredArgsConstructor
public class PaymentEventListener {
private final DataPlatformClient dataPlatformClient;
private final PushClient pushClient;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaymentEvent(PaymentEvent event) {
// 결제 정보 전달
dataPlatformClient.sendPaymentResult(event.getPayment());
// kakaotalk 알람 전달
pushClient.pushKakaotalk();
}
}
이렇게 이벤트를 나눠서 관리함으로써 코드의 모듈성을 높이고, 각 클래스가 특정 작업에만 집중하도록 도와줍니다.(응집력을 높여줌)
MSA 형태로 서비스 분리 설계
만약 지금의 서비스를 MSA로 분리한다면 어떻게 설계가 되어야 할까?
서비스 분리
일단 지금의 서비스가 MSA로 분리된다면, 4개의 서비스와 하나의 부가 모듈로 총 5개로 나눠질 것이다.
- Payment Service: 결제 처리와 관련된 처리
- User Service: 사용자 관리 및 포인트 처리
- Concert Service: 공연, 좌석 예약 관리
- Waiting Queue Service: 대기열 관리 및 토큰 처리
- Client Module : 외부 서비스와의 통신을 위한 모듈
분산 트랜잭션의 한계
이렇게 MSA로 서비스로 분리하면 트랜잭션도 하나로 관리되는 것이 아니라 분산되어 관리가 된다. 그럼 어떤 문제점들이 발생할까?
- MSA에서 트랜잭션이 여러 서비스에 걸쳐 있을 때, ACID 트랜잭션 보장이 어렵다. 특히, 데이터 일관성과 원자성 유지에 문제가 발생할 수 있다.
- 서비스 간 통신에 네트워크 지연이 발생할 수 있으며, 이는 전체 트랜잭션 생명 주기를 늘릴 수 있다.
- 한 서비스에서 실패가 발생할 경우, 다른 서비스에 대한 롤백 처리 또는 보상 트랜잭션 구현이 필요하다.
해결 방안
여러가지 해결방안이 있지만, Saga 패턴을 활용하면 분산 트랜잭션을 관리할 수 있다고 한다.
Saga 패턴
그럼 Saga 패턴이란 무엇일까?
- Saga Pattern은 마이크로 서비스에서 데이터 일관성을 관리하는 방법이다.
- 각 서비스는 로컬 트랜잭션을 가지고 있으며, 해당 서비스 데이터를 업데이트하며 메시지 또는 이벤트를 발행해서, 다음 단계 트랜잭션을 호출하게 된다.
- 만약, 해당 프로세스가 실패하게 되면 데이터 정합성을 맞추기 위해 이전 트랜잭션에 대해 보상 트랜잭션을 실행한다.
- NoSQL 같이 분산 트랜잭션 처리를 지원하지 않거나, 각기 다른 서비스에서 다른 DB 밴더사를 이용할 경우에도 Saga Pattenrn을 이용해서 데이터 일관성을 보장 받을 수 있다.
→ 결국 정리하자면, Saga 패턴을 이용하면 각기 다른 분산 서베에 다른 DB 벤더사를 이용하고 있더라도 데이터 일관성을 보장받을 수 있다. 또한 트랜잭션 실패 시, 보상 트랜잭션을 통해 데이터 정합성을 보장할 수 있다.
Saga 패턴은 크게 Choreography 방식과 Orchestration 방식이 있다고 한다.
Orchestration 방식의 경우, 따로 트랜잭션을 관리하는 Saga 인스턴스가 별도로 존재해야 하기 때문에 좀 더 구현이 간단한 Choreography 방식으로 설계를 해보려고 한다.
Choreography 방식이란?
- Choreography 방식은 서비스끼리 직접적으로 통신하지 않고, 이벤트 Pub/Sub을 활용해서 통신하는 방식을 말한다.
- 프로세스를 진행하다가 여러 서비스를 거쳐 서비스(Payment, User)에서 실패(예외처리 혹은 장애)가 난다면 보상 트랜잭션 이벤트를 발행한다.
- 장점으론, 간단한 workflow에 적합하며 추가 서비스 구현 및 유지관리가 필요하지 않다.
- 단점으론, 트랜잭션을 시뮬레이션하기 위해 모든 서비스를 실행해야하기 때문에 통합테스트와 디버깅이 어려운 점이 있다.
Kafka를 도입하여 결제 프로세스를 진행한다면 아마 이런식으로 진행 될 것이다.
- 정상적인 분산 트랜잭션 프로세스
- 사용자가 결제 요청
- 결제 내역 생성
- 결제 완료 이벤트 발행
- 결제 완료 이벤트 리슨
- 콘서트 예약 완료
- 유저 포인트 차감
- Active 토큰 만료
- 실패 분산 트랜잭션 프로세스
만약 포인트 차감에서 실패가 발생한다면
- 생성된 결제 내역 삭제
- 콘서트 상태 변경
- 토큰 상태 변경
이렇게 하면 보상 트랜잭션을 통해 주요로직에 문제가 발생하더라도, 모두 롤백이 되어 데이터의 일관성을 보장할 수 있다.
'세션 > 항해플러스' 카테고리의 다른 글
장애 대응 문서 (0) | 2024.11.25 |
---|---|
부하 테스트 (0) | 2024.11.17 |
Query 분석 및 DB Index 설계 (0) | 2024.11.03 |
캐시 및 Redis를 통한 성능 개선 (5) | 2024.09.05 |
대기열 서비스 구현(+Redis) (3) | 2024.09.02 |