Spring 개발일지

[팀프로젝트] MSA 기반 티켓팅 프로그램 - 사가 패턴 / 보상 트랜잭션 / DLQ 구현

김둘리 2026. 5. 29. 08:07

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)을 보장하는 방향으로 설계했다.