1. 개요
이전 글에서 결제가 성공적으로 이루어지면 Kafka 이벤트를 발행하여 예매 상태를 변경하는 방식을 적용했습니다.
하지만 여기서 치명적인 문제가 발생할 수 있습니다.
현재 MSA 환경에서는 각 서비스가 독립적인 데이터베이스를 가지고 있으며, 트랜잭션이 완료되기 전에 이벤트 메시지를 발행하게 됩니다.
애초에 결제가 실패할 경우에는 문제가 되지 않으나 네트워크 장애나 Kafka의 장애, 혹은 DB에 특정한 이유로 인해 예외가 발생한다면 다음과 같은 문제가 생기게 됩니다.
📌 1. Kafka 메시지 유실 문제
- 네트워크 장애나 Kafka 장애로 인해 메시지가 정상적으로 발행되지 않는다면?
- 예매 서비스는 결제가 완료된 사실을 알지 못하고, 사용자의 예매 상태는 변경되지 않은 채로 남아버립니다.
- 이는 고객이 결제를 성공했지만 예매 상태는 여전히 결제 대기 상태로 남는 데이터 정합성 문제로 이어질 수 있습니다.
📌 2. Kafka 이벤트는 발행되었지만 DB 특정 이슈로 인한 예외가 발생할 경우 문제
- Kafka 이벤트는 정상적으로 발행되지만 DB 커밋과정에서 문제가 생긴다면?
- 결제는 롤백이 되었지만 이벤트는 롤백되지 않는 상황이 발생할 수 있습니다.
- 이는 고객의 결제가 실패했지만 예매 상태가 결제완료로 변경되는 데이터 정합성 문제로 이어질 수 있습니다.
이러한 문제를 해결하기 위해 Transactional Outbox Pattern을 적용하여 안정적으로 Kafka 이벤트를 발행하는 방법을 살펴보겠습니다.
2. Transactional Outbox Pattern란?
위에서 살펴보았듯이 분산 시스템에서는 트랜잭션과 함께 이벤트를 발행해야 하는 경우, 두 가지 문제가 발생할 수 있습니다.
- 트랜잭션 롤백 후 이벤트 발행 문제
- 트랜잭션이 실패하면 데이터는 롤백되지만, 이미 발행된 이벤트는 그대로 남아 있어 데이터 정합성 문제가 발생할 수 있습니다.
- 메시지 전송 실패로 인한 원자성 보장 문제
- 트랜잭션이 성공했더라도 Kafka 장애나 네트워크 문제로 인해 메시지 전송이 실패하면 원자성이 깨질 가능성이 있습니다.
이러한 문제를 해결하기 위해 Transactional Outbox Pattern이 등장하게 되었습니다.
Transactional Outbox Pattern은 분산시스템에서 데이터베이스 트랜잭션과 메시지 브로커를 조합해 데이터의 일관성과 메시지 전송의 원자성을 보장하는 패턴으로 다음과 같은 방식으로 진행됩니다.
1. Outbox 테이블에 이벤트 저장
- 서비스가 중요한 데이터 변경을 수행할 때, 즉시 Kafka로 이벤트를 발행하는 대신 같은 트랜잭션 내에서 Outbox 테이블에 이벤트 데이터를 함께 저장합니다.
- 이렇게 하면 트랜잭션이 롤백될 경우 이벤트 데이터도 함께 롤백되어 데이터 정합성 문제를 방지할 수 있습니다.
2. 별도 프로세스를 통한 이벤트 발행
- 별도의 이벤트 처리 프로세스(ex 스케줄러 or CDC)가 Outbox 테이블을 모니터링하며, 새로운 이벤트가 있으면 Kafka로 발행합니다.
- 이 과정에서 재시도 로직을 적용해 네트워크 장애나 Kafka 장애로 인해 메시지 전송이 실패하더라도 안정적으로 재전송할 수 있습니다.
3. 이벤트 발행 후 Outbox 테이블 정리
- Kafka로 메시지가 정상적으로 발행된 후, 해당 이벤트 데이터를 Outbox 테이블에서 삭제하거나 처리 완료 상태로 업데이트합니다.
- 이를 통해 불필요한 데이터가 계속 쌓이는 것을 방지합니다.
이러한 과정들을 통해 데이터베이스 트랜잭션과 메시지 발행을 분리하면서 데이터 일관성과 원자성을 보장할 수 있습니다.
3. Transactional Outbox Pattern 적용하기
그럼 본격적으로 Transactional Outbox Pattern을 적용해보도록 하겠습니다.
3-1 Outbox 테이블 생성
이벤트 데이터 저장을 위한 Outbox 테이블을 만들어줍니다.
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long aggregateId;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private PaymentEventType eventType;
@Column(nullable = false)
private String payload;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private EventStatus status;
@Column(nullable = false)
private LocalDateTime createAt;
@Builder
public OutboxEvent(Long aggregateId, PaymentEventType eventType, String payload) {
this.aggregateId = aggregateId;
this.eventType = eventType;
this.payload = payload;
this.createAt = LocalDateTime.now();
this.status = EventStatus.PENDING;
}
}
3-2 PaymentService 로직 변경
이전에 Kafka로 이벤트를 발행하던 부분을 OutboxEvent 테이블에 이벤트를 저장하는 로직으로 변경해줍니다.
public PaymentConfirmResponse confirmPayment(final PaymentConfirmRequest paymentConfirmRequest, final Long reservationId, final Long userId) throws JsonProcessingException {
//~~~~~~
final PaymentConfirmResponse paymentConfirmResponse = paymentClient.confirmPayment(paymentConfirmRequest);
final Payment payment = paymentConfirmResponse.toPayment(reservationId, userId);
paymentRepository.save(payment);
//이벤트 정보 DB에 저장하기!!!
//paymentCompletedProducer.paymentCompletedEvent(reservationId);
OutboxEvent outboxEvent = OutboxEvent.builder()
.aggregateId(reservationId)
.eventType(PaymentEventType.PAYMENT_COMPLETED)
.payload(objectMapper.writeValueAsString(new PaymentCompletedMessage(reservationId)))
.build();
outboxRepository.save(outboxEvent);
return paymentConfirmResponse;
}
3-3 Polling Publisher 방식 구현하기
Transactional Outbox Pattern을 구현하는 방법으로는 대표적으로 Polling Publisher 방식과 Transaction Log Tailing 방식이 존재합니다.
그 중 Polling Publisher 방식은 주기적으로 Outbox 테이블을 조회(Polling) 하여 새로운 이벤트가 존재하는 경우 메시지 브로커로 발행하는 방식으로 트랜잭션 메시지 발행의 일관성을 보장하면서도 구현이 단순해 많은 서비스에서 사용되는 방식입니다.
Polling Publisher 방식의 흐름을 간단히 요약하면 다음과 같습니다.
- 이벤트 저장 : 애플리케이션이 이벤트 Outbox를 저장합니다. 이 때 비즈니스 데이터와 이벤트 데이터를 동일 트랜잭션에 처리해 일관성을 보장합니다.
- 폴링 : 별도의 Polling Publisher 프로세스가 주기적으로 Outbox 테이블을 조회합니다.
- 메시지 발행 : 조회된 이벤트를 메시지 브로커로 발행하고, 성공적으로 발행된 이벤트는 Outbox 테이블에서 삭제하거나 상태를 업데이트합니다.
스프링에서 스케줄링 기능을 활용하여 주기적으로 Outbox 테이블을 조회하는 Polling Publisher를 구현할 수 있으며, @Scheduled 애노테이션을 제공하여 간편하게 스케줄링을 처리할 수 있습니다. 이를 활성화하고 적용하기 위한 설정을 구현해보도록 하겠습니다.
3-3-1 스케줄링 활성화를 위한 Config 클래스를 만들어 줍니다.
스케줄링 기능을 사용하려면 먼저 @EnableScheduling 애노테이션을 설정해야 합니다. 이 애노테이션은 스프링 애플리케이션 내에서 스케줄링 기능을 활성화해 줍니다. 이를 활성화한 뒤, 별도의 스케줄링 작업을 설정할 수 있습니다.
@EnableScheduling
@Configuration
public class SchedulingConfig {
}
3-3-2 EventPublishingJob 구현하기
스케줄링이 활성화되었으면, 이제 Outbox 테이블을 주기적으로 조회하고 이벤트를 발행하는 EventPublishingJob 클래스를 구현해줍니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class EventPublishingJob {
private static final String PAYMENT_COMPLETED_TOPIC = "payment-completed-topic";
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
/**
* 주기적으로 Outbox 테이블을 조회하여 상태가 PENDING 이벤트를 발행
*/
@Scheduled(fixedDelay = 1000) //1초마다 실행
public void publishOutboxEvents() {
outboxRepository.findByStatus(EventStatus.PENDING)
.forEach(event -> {
try {
kafkaTemplate.send(PAYMENT_COMPLETED_TOPIC, event.getPayload());
event.updateStatus();
outboxRepository.save(event);
log.info("Kafka 메시지 전송 완료: {}", event.getPayload());
} catch (Exception e) {
log.error("Kafka 메시지 전송 실패: {}", e.getMessage(), e);
}
});
}
}
위 코드에서 @Scheduled(fixedDelay = 1000)은 1초마다 publishOutboxEvents() 메서드를 실행하게 됩니다. 이 메서드는 OutboxRepository를 사용하여 상태가 PENDING인 이벤트들을 조회하고, 해당 이벤트를 Kafka로 발행합니다.
3-3-3 동작 확인하기
동작이 제대로 이뤄지는지 실제 테스트를 통해 확인해보겠습니다.
결제가 성공하면 결제내역과 함께 OutboxEvent 테이블에 이벤트를 저장합니다.
스케쥴러는 1초마다 OutboxEvent 테이블을 확인하여 보낼 이벤트가 있는지 체크합니다.
PENDING상태의 이벤트가 존재하면 Kafka 이벤트를 발행합니다.
PENDING상태의 이벤트의 상태가 변경된 것을 확인할 수 있습니다.
이 후 reservation-service에서도 kafka 메시지를 정상적으로 수신한 것을 확인할 수 있습니다.
3-4 Polling Publisher 방식의 한계와 보완점
지금까지 Polling Publisher 방식을 통해 Transactional Outbox Pattern을 구현해보았습니다. Polling Publisher 방식은 간단하게 구현할 수 있다는 장점이 있지만 아래와 같은 단점 또한 존재합니다.
1. Polling 비용
주기적으로 데이터베이스를 조회하는 Polling 방식은 이벤트가 많아질수록 DB 부하를 일으킬 수 있습니다. 특히 데이터가 많고, 조회 주기가 짧을 경우 성능에 미치는 영향이 커집니다. DB에서 데이터를 자주 조회하는 만큼 트래픽이 증가하고, 서버 자원 소모가 커질 수 있습니다.
2. 지연 시간
Polling Publisher 방식은 주기적인 조회를 기반으로 하기 때문에 이벤트 발행에 몇 초의 지연이 발생할 수 있습니다. 예를 들어, 1초마다 조회하는 경우라도, 이벤트가 발생한 후 메시지가 실제로 발행되기까지 최대 1초의 지연이 발생합니다. 이로 인해 실시간 처리가 중요한 서비스에는 적합하지 않을 수 있습니다.
3. 재시도 및 오류 처리
이벤트 발행에 실패하는 경우, 재시도 로직을 구현하거나 일정 횟수의 실패가 발생하면 Dead Letter Queue 를 활용하여 실패한 이벤트를 따로 보관하고 재처리하는 방식이 필요합니다.
따라서, 이 방식을 사용할 때는 서비스의 실시간 처리 요구 사항, 시스템 성능 및 장애 처리 요구를 고려하여 적절한 보완책을 함께 적용하는 것이 중요할 것 같습니다!

'Backend > MSA 전환' 카테고리의 다른 글
[MSA 전환하기 8편] Kafka를 활용한 이벤트 기반 아키텍처 구축하기 (0) | 2025.02.12 |
---|---|
[MSA 전환하기 7편] 서킷 브레이커 적용하기 (Resilence4J) (1) | 2025.01.31 |
[MSA 전환하기 6편] OpenFeign을 활용한 서비스 간 통신하기 (0) | 2025.01.29 |
[MSA 전환하기 5편] Spring Cloud Config 도입하기 (+ Spring Cloud Bus) (0) | 2025.01.20 |
[MSA 전환하기 4편] Spring Cloud Gateway 구현하기 (API Gateway) (0) | 2025.01.15 |