MSA 환경에서 분산 트랜잭션의 정합성을 보장하기 위해 코레오그래피 기반 사가 패턴과 보상 트랜잭션을 구현했다.
1. 문제 - 분산 트랜잭션
모놀리식 아키텍처에서는 하나의 DB 트랜잭션으로 모든 작업을 묶을 수 있다.
하나의 트랜잭션
→ 결제 처리
→ 예매 확정
→ 성공 시 commit, 실패 시 rollback
MSA에서는 각 서비스가 독립적인 DB를 가져서 하나의 트랜잭션으로 묶을 수 없다.
Payment DB ─┐
├── 두 DB를 하나의 트랜잭션으로 묶을 수 없음 💀
Booking DB ─┘
이런 상황에서 정합성을 보장하려면 어떻게 해야 할까?
2. 사가 패턴이란?
사가 패턴은 MSA에서 분산 트랜잭션을 처리하는 패턴이다.
각 서비스의 로컬 트랜잭션을 순서대로 실행하고 실패 시 보상 트랜잭션으로 이전 상태로 되돌린다.
3. 코레오그래피 vs 오케스트레이션
사가 패턴에는 두 가지 방식이 있다.
코레오그래피 (Choreography)
각 서비스가 이벤트를 발행/수신해서 스스로 처리
중앙 조율자 없음
Payment → payment.completed 발행
Booking → 수신 후 예매 확정
실패 시 → booking.payment.compensation 발행
Payment → 수신 후 자동 환불
장점: 서비스 간 결합도가 낮음 단점: 전체 흐름 파악이 어려움
오케스트레이션 (Orchestration)
중앙 오케스트레이터가 각 서비스에 명령
오케스트레이터 → Payment에 결제 요청
오케스트레이터 → Booking에 예매 확정 요청
실패 시 → 오케스트레이터가 보상 지시
장점: 전체 흐름 파악이 쉬움 단점: 오케스트레이터에 대한 의존성 높음
4. 코레오그래피를 선택한 이유
우리 서비스는 이미 Kafka 이벤트 기반으로 통신하고 있음
별도 오케스트레이터 구현 없이 기존 구조를 활용 가능
서비스 간 결합도를 낮추는 방향과 일치
5. 전체 흐름
정상 흐름
사용자 결제 성공
→ Payment: PENDING → SUCCESS
→ payment.completed 이벤트 발행
→ Booking: 예매 확정 처리
→ 완료!
실패 시 보상 흐름
사용자 결제 성공
→ Payment: PENDING → SUCCESS
→ payment.completed 이벤트 발행
→ Booking: 예매 확정 처리 실패 💀
→ Booking: booking.payment.compensation 이벤트 발행
→ Payment: 수신 후 자동 환불
→ Payment: SUCCESS → REFUNDED
→ payment.refund.completed 이벤트 발행
→ Booking: 예매 취소 처리
→ 정합성 복구!
6. 환불 토픽 분리
기존에는 환불 관련 이벤트가 하나의 토픽으로 관리됐다.
기존
booking.payment.refund → 좌석 선점 만료 + 사용자 취소 모두 처리
Booking 담당자 피드백으로 토픽을 분리했다.
변경 후
booking.refund.request → 좌석 선점 시간 만료
booking.cancel.request → 사용자 예매 취소
토픽을 분리하면 각 케이스별로 다른 처리가 필요할 때 유연하게 대응할 수 있다.
7. PaymentKafkaConsumer 구현
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentKafkaConsumer {
private final PaymentCommandService paymentCommandService;
private final ObjectMapper objectMapper;
// 좌석 선점 시간 만료
@KafkaListener(topics = "booking.refund.request", groupId = "payment-service")
public void handleBookingRefund(ConsumerRecord<String, String> record) {
try {
BookingRefundPayload payload = objectMapper.readValue(
record.value(), BookingRefundPayload.class);
log.info("booking.refund.request 수신 - paymentId: {}", payload.paymentId());
paymentCommandService.refundPayment(
new RefundPaymentCommand(
payload.paymentId(),
payload.userId(),
payload.reason()
)
);
} catch (Exception e) {
log.error("booking.refund.request 처리 실패 - {}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
// 사용자 예매 취소
@KafkaListener(topics = "booking.cancel.request", groupId = "payment-service")
public void handleBookingCancel(ConsumerRecord<String, String> record) {
try {
BookingRefundPayload payload = objectMapper.readValue(
record.value(), BookingRefundPayload.class);
log.info("booking.cancel.request 수신 - paymentId: {}", payload.paymentId());
paymentCommandService.refundPayment(
new RefundPaymentCommand(
payload.paymentId(),
payload.userId(),
payload.reason()
)
);
} catch (Exception e) {
log.error("booking.cancel.request 처리 실패 - {}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
// 보상 트랜잭션
@KafkaListener(topics = "booking.payment.compensation", groupId = "payment-service")
public void handleBookingCompensation(ConsumerRecord<String, String> record) {
try {
BookingCompensationPayload payload = objectMapper.readValue(
record.value(), BookingCompensationPayload.class);
log.info("booking.payment.compensation 수신 - paymentId: {}, reason: {}",
payload.paymentId(), payload.reason());
paymentCommandService.refundPayment(
new RefundPaymentCommand(
payload.paymentId(),
payload.userId(),
payload.reason()
)
);
} catch (Exception e) {
log.error("booking.payment.compensation 처리 실패 - {}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
}
8. DLQ (Dead Letter Queue) 설정
Kafka 메시지 처리가 계속 실패할 때 메시지를 유실하지 않고 별도 토픽에 보관한다.
메시지 수신
→ 처리 실패
→ 1초 간격으로 3회 재시도
→ 3회 모두 실패
→ DLT 토픽에 보관 (예: booking.refund.request.DLT)
→ 나중에 확인 후 재처리 가능
@Configuration
public class KafkaConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> kafkaTemplate) {
DeadLetterPublishingRecoverer recoverer =
new DeadLetterPublishingRecoverer(kafkaTemplate);
FixedBackOff backOff = new FixedBackOff(1000L, 3L); // 1초 간격 3회
DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, backOff);
errorHandler.setRetryListeners((record, ex, deliveryAttempt) ->
log.warn("Kafka 재시도 - topic: {}, attempt: {}, error: {}",
record.topic(), deliveryAttempt, ex.getMessage())
);
return errorHandler;
}
}
DLQ가 없으면 재시도 후 실패한 메시지가 그냥 유실된다. DLQ를 적용하면 실패한 메시지를 보관해서 나중에 원인 파악 및 재처리가 가능하다.
9. 예외 재전파가 중요한 이유
} catch (Exception e) {
log.error("처리 실패 - {}", e.getMessage(), e);
throw new RuntimeException(e); // 예외 재전파 필수!
}
예외를 삼키면(catch만 하고 throw 안 하면) Kafka가 메시지 처리 성공으로 판단해서 offset을 커밋한다.
예외 삼킴 → offset 커밋 → 메시지 유실 → DLQ에도 안 쌓임 💀
예외 재전파 → Spring Kafka 재시도 → 실패 시 DLQ 저장
10. 전체 이벤트 흐름 정리
결제 승인 성공
Payment → payment.completed → Booking 예매 확정
결제 최종 실패 (선점 시간 만료)
Payment → payment.failed → Booking 예매 취소 + 선점 해제
좌석 선점 시간 만료
Booking → booking.refund.request → Payment 환불 처리
사용자 예매 취소
Booking → booking.cancel.request → Payment 환불 처리
환불 완료
Payment → payment.refund.completed → Booking 예매 취소
보상 트랜잭션 (예매 확정 실패)
Booking → booking.payment.compensation → Payment 자동 환불
마무리
사가 패턴을 구현하면서 MSA의 핵심 과제인 분산 트랜잭션을 다뤘다.
핵심 포인트
첫째, 코레오그래피 방식으로 서비스 간 결합도를 낮췄다. 둘째, 보상 트랜잭션으로 실패 시 정합성을 복구한다. 셋째, DLQ로 메시지 유실을 방지한다. 넷째, 예외 재전파로 Kafka 재시도 메커니즘을 활용한다.
MSA에서 완전한 ACID를 포기하고 최종 일관성(Eventual Consistency)을 보장하는 방향으로 설계했다.
'Spring 개발일지' 카테고리의 다른 글
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - Kafka 토픽명 설정값 분리 / DB 인덱스 추가 / 테스트 코드 보완 (0) | 2026.06.01 |
|---|---|
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 환불 완료 이벤트 발행 (0) | 2026.05.28 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - Dockerfile 및 CI workflow 작성 (0) | 2026.05.27 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 만료 스케줄러 구현 (0) | 2026.05.26 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 서비스 코드 리팩토링 (0) | 2026.05.25 |