주문 서비스에서 주문을 저장했다. Kafka에 이벤트를 발행했다. 결제 서비스가 이벤트를 못 받았다. 주문은 됐는데 결제가 안 됐다. @Transactional 하나로 전부 해결되던 모놀리식이 그리워지는 순간이다.
MSA에서 트랜잭션이 왜 어려운가? 모놀리식에서는 됐는데
모놀리식에서 @Transactional이 마법처럼 동작하는 이유가 있다. 주문 테이블과 결제 테이블이 같은 DB에 있고, 같은 DB 커넥션을 공유하기 때문이다.
// 모놀리식: 하나의 DB 커넥션이 두 작업을 원자적으로 묶어준다
@Transactional
public void processOrder(Long orderId) {
orderRepository.updateStatus(orderId, "PAID"); // 주문 테이블
paymentRepository.save(new Payment(orderId)); // 결제 테이블 (같은 DB)
// 커밋 하나로 둘 다 반영. 예외 발생 시 둘 다 롤백.
}
MSA에서는 주문 서비스와 결제 서비스가 각각 자신의 DB를 가진다. 서로 다른 DB에 걸친 원자성은 DB 수준에서 보장할 수 없다. 네트워크 장애가 언제든 발생할 수 있고, 각 서비스는 독립적으로 배포되고 독립적으로 장애가 난다.
그럼 2PC라는 게 있다고 들었는데?
2PC(Two-Phase Commit)가 있다는데 왜 MSA에서 안 쓰나?
이론상 2PC는 분산 트랜잭션을 해결한다.
- Phase 1 (Prepare): Coordinator가 모든 참가자에게 “커밋할 준비됐냐?” 물어본다. 모두 Yes면 계속.
- Phase 2 (Commit): Coordinator가 커밋 명령을 내린다. 모두 실행.
현실에서 세 가지 문제가 있다.
문제 1: Coordinator가 Phase 2 직전에 죽으면 데드락
참가자들은 “Prepare 완료” 상태에서 락을 걸고 Coordinator의 커밋 명령을 기다린다. Coordinator가 죽으면 참가자들은 락을 건 채로 무한 대기한다. 어떤 참가자는 커밋했고 어떤 참가자는 못 했을 수도 있다. 수동으로 개입하기 전까지 해당 레코드들은 잠긴 채로 남는다.
문제 2: 성능 저하
네트워크 왕복 2회 + 각 참가자의 디스크 동기 I/O가 필요하다. 게다가 Phase 1에서 잡은 락을 Phase 2 커밋이 끝날 때까지 모든 참가자가 유지한다. 코디네이터 왕복과 잠금 유지가 겹치면서 처리량이 크게 떨어진다.
문제 3: Kafka와 절대 안 맞는다
Kafka는 XA 트랜잭션을 지원하지 않는다. DB 커밋과 Kafka 발행을 하나의 2PC 트랜잭션으로 묶는 건 불가능에 가깝다. MSA에서 Kafka가 낀 구조라면 2PC는 선택지에서 빠진다.
결론: 2PC는 XA 호환 DB끼리만 쓸 수 있고, Kafka가 있는 MSA 환경에서는 쓰지 않는다.
Saga 패턴이 뭔가? 2PC 없이 어떻게 일관성을 맞추나
Saga의 핵심 아이디어: 하나의 큰 분산 트랜잭션 대신, 여러 개의 작은 로컬 트랜잭션으로 쪼개고, 실패하면 보상 트랜잭션으로 되돌린다.
완전한 원자성을 포기하는 대신 최종적 일관성(Eventual Consistency)을 목표로 한다.
구현 방식이 두 가지다.
Choreography (코레오그래피): 중앙 지휘자 없이 이벤트로 연결
주문 서비스 → [order.created] → Kafka
결제 서비스 ← [order.created] 수신 → 결제 처리
결제 서비스 → [payment.completed] → Kafka
재고 서비스 ← [payment.completed] → 재고 차감
재고 서비스 → [inventory.reserved] → Kafka
배송 서비스 ← [inventory.reserved] → 배송 예약
-- 결제 실패 시:
결제 서비스 → [payment.failed] → Kafka
주문 서비스 ← [payment.failed] → 주문 취소 (보상 트랜잭션)
서비스 간 결합이 없고 단순하다. 서비스가 3개 이하의 단순한 흐름이라면 이게 낫다.
Orchestration (오케스트레이션): 중앙 지휘자가 전체 흐름을 제어
Saga Orchestrator
├─→ 결제 서비스에 결제 요청 → 성공
├─→ 재고 서비스에 예약 요청 → 실패
└─→ 결제 서비스에 취소 요청 (보상 트랜잭션)
전체 상태가 Orchestrator에 집중되어 디버깅이 쉽다. 서비스가 4개 이상이거나 보상 트랜잭션이 복잡한 경우에 유리하다.
Choreography가 왜 디버깅 지옥인가?
“주문 완료 상태인데 배송이 시작이 안 됐어요.” 버그가 들어왔다.
Choreography에서 이걸 추적하는 과정:
- 주문 서비스 로그에서
order.created이벤트 발행 확인 - Kafka 토픽에 이벤트가 들어갔는지 확인
- 결제 서비스 로그에서 이벤트 수신 및 처리 확인
payment.completed이벤트 발행 확인- 재고 서비스가 이벤트를 수신했는지 확인
- … 이하 반복
각 단계가 서로 다른 서버 로그, 서로 다른 Kafka 토픽에 흩어져 있다. 주문 ID 하나를 기준으로 여러 시스템을 수동으로 연결해야 한다. 서비스가 10개면 10개 서버의 로그를 뒤진다.
Distributed Tracing(Jaeger, Zipkin)으로 모든 이벤트에 traceId를 심어두지 않으면 사실상 운영이 불가능하다. 그리고 Tracing이 있어도 이벤트 기반 비동기 흐름은 HTTP 추적보다 훨씬 어렵다.
Choreography를 선택했다면 처음부터 Tracing을 설계에 포함시켜야 한다. 나중에 붙이면 코드 전체를 다 고쳐야 한다.
”DB에 저장하고 Kafka에 발행”이 왜 위험한가?
가장 자연스럽게 짜는 코드다:
@Transactional
public void createOrder(OrderCommand command) {
Order order = orderRepository.save(new Order(command));
kafkaTemplate.send("order.created", OrderCreatedEvent.from(order));
}
이 코드에는 세 가지 실패 시나리오가 있다.
시나리오 1: 이벤트 유실 (그나마 낫다)
Kafka 브로커가 일시적 장애. kafkaTemplate.send()가 예외를 던진다. @Transactional에 의해 DB도 롤백된다. 주문도 없고 이벤트도 없다. 사용자가 재시도하면 해결된다.
시나리오 2: 유령 이벤트 (가장 위험)
kafkaTemplate.send()는 성공해서 Kafka에 이벤트가 들어갔다. 그런데 DB 커밋 직전에 서버가 죽었다. DB는 롤백됐지만 Kafka에는 이벤트가 이미 들어가 있다.
결과: 결제 서비스가 존재하지 않는 주문에 대해 결제를 처리하려 한다. 결제는 됐는데 주문이 없다. 데이터가 오염된다.
시나리오 3: 무음 유실 (가장 조용하게 죽는다)
kafkaTemplate.send()를 비동기로 쏘고 결과를 확인하지 않았다. Kafka 브로커 장애로 실제로는 발행에 실패했지만 예외가 잡히지 않았다.
결과: DB에는 주문이 있는데 결제 서비스는 아무것도 모른다. 고객은 주문 완료 화면을 봤다. 주문은 됐는데 결제가 영원히 안 된다.
이 세 시나리오가 공통적으로 가지는 근본 문제: DB 트랜잭션 커밋과 Kafka 발행은 서로 독립적인 작업이다. 두 작업을 원자적으로 묶을 수 없다. 커밋은 됐는데 발행이 안 됐거나, 발행은 됐는데 커밋이 안 된 구간이 항상 존재한다.
Outbox Pattern이 이 문제를 어떻게 해결하나?
아이디어는 단순하다. Kafka에 직접 발행하는 대신, 같은 DB 트랜잭션 안에 이벤트를 저장한다.
같은 DB 트랜잭션이니까 둘 다 성공하거나, 둘 다 실패한다. 분리될 수 없다.
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_id VARCHAR(100) NOT NULL, -- 이벤트를 발생시킨 엔티티 ID
aggregate_type VARCHAR(100) NOT NULL, -- 예: 'Order'
event_type VARCHAR(100) NOT NULL, -- 예: 'order.created'
payload JSON NOT NULL, -- Kafka로 보낼 데이터
status ENUM('PENDING','SENT','FAILED') DEFAULT 'PENDING',
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
sent_at DATETIME(6) NULL,
INDEX idx_status_created (status, created_at)
);
@Transactional
public void createOrder(OrderCommand command) {
Order order = orderRepository.save(new Order(command));
// Kafka에 직접 발행하지 않는다
// 같은 DB 트랜잭션 안에 이벤트를 저장한다
outboxRepository.save(OutboxEvent.builder()
.aggregateId(String.valueOf(order.getId()))
.aggregateType("Order")
.eventType("order.created")
.payload(objectMapper.writeValueAsString(OrderCreatedPayload.from(order)))
.status(OutboxStatus.PENDING)
.build());
// order 저장 + outbox 저장 = 하나의 DB 트랜잭션
// 서버가 커밋 직전에 죽어도 둘 다 롤백. 유령 이벤트가 없다.
}
이제 유령 이벤트 시나리오가 사라진다. PENDING 이벤트를 Kafka로 릴레이하는 게 Outbox Relay의 역할이다.
Debezium CDC vs Polling Publisher — 어떤 게 낫나?
Debezium CDC: binlog를 실시간으로 읽어서 발행
outbox_event 테이블에 INSERT 발생
↓
MySQL binlog에 변경 기록
↓
Debezium Connector (Kafka Connect)가 binlog를 읽어 이벤트 생성
↓
Kafka Topic: outbox.Order
↓
결제 서비스, 알림 서비스 등이 구독
{
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql-host",
"table.include.list": "mydb.outbox_event",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "outbox.${routedByValue}"
}
장점: INSERT 즉시 전파. 폴링 지연 없음. outbox 테이블의 status를 SENT로 업데이트할 필요도 없다.
단점: Kafka Connect 클러스터와 Debezium 커넥터를 운영해야 한다. binlog retention을 잘못 관리하면 커넥터가 binlog 위치를 잃는다. 인프라 복잡도가 높아진다.
Polling Publisher: @Scheduled로 폴링해서 발행
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxEventPublisher {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 1000) // 1초마다
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepository
.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING);
for (OutboxEvent event : pending) {
try {
kafkaTemplate.send(
"outbox." + event.getAggregateType().toLowerCase(),
event.getAggregateId(),
event.getPayload()
).get(5, TimeUnit.SECONDS); // 동기 확인 필수. 비동기로 쏘면 실패를 모름
event.markAsSent(LocalDateTime.now());
} catch (Exception e) {
event.markAsFailed();
log.error("Outbox relay failed: eventId={}", event.getId(), e);
}
}
}
}
멀티 인스턴스에서 중복 발행 방지:
// SELECT FOR UPDATE로 동시 접근 제어
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM OutboxEvent e WHERE e.status = 'PENDING' ORDER BY e.createdAt")
List<OutboxEvent> findPendingForUpdate(Pageable pageable);
선택 기준
| Debezium CDC | Polling Publisher | |
|---|---|---|
| 발행 지연 | 수백ms | 1~5초 |
| 인프라 복잡도 | 높음 (Kafka Connect 필요) | 낮음 |
| 운영 부담 | binlog retention 관리 | 낮음 |
| 적합한 경우 | 지연 민감한 주문-결제 흐름 | 이메일 알림, 통계 집계 |
같은 이벤트를 두 번 받으면 어떻게 처리하나?
Kafka는 기본적으로 at-least-once 보장이다. 컨슈머가 이벤트를 처리하고 커밋하기 직전에 죽으면, 재시작 후 같은 이벤트를 다시 받는다.
멱등성이 없으면 어떤 일이 생기나:
- 결제 서비스가 같은 주문 이벤트를 두 번 받음 → 결제 두 번 처리 → 이중 과금
- 재고 서비스가 같은 이벤트를 두 번 받음 → 재고 두 번 차감 → 재고 음수
결론: 컨슈머는 반드시 멱등(Idempotent)하게 설계해야 한다.
방법 1: 처리 이력 테이블
@KafkaListener(topics = "outbox.Order", groupId = "payment-service")
@Transactional
public void handleOrderCreated(ConsumerRecord<String, String> record) {
String eventId = record.headers().lastHeader("eventId").value().toString();
if (processedEventRepository.existsById(eventId)) {
log.info("Duplicate event ignored: {}", eventId);
return;
}
OrderCreatedEvent event = objectMapper.readValue(record.value(), OrderCreatedEvent.class);
paymentService.processPayment(event);
// 처리 이력 저장 — 실제 처리와 같은 트랜잭션이어야 함
// 처리는 성공했는데 이력 저장이 실패하면, 재처리 시 다시 처리하게 됨
processedEventRepository.save(new ProcessedEvent(eventId));
}
방법 2: DB 유니크 제약 (가장 단순하고 강력)
// Payment 테이블에 (order_id) UNIQUE 제약이 있다면
try {
paymentRepository.save(new Payment(event.getOrderId(), event.getAmount()));
} catch (DataIntegrityViolationException e) {
// 중복 이벤트 — DB가 거부함. 무시하면 된다
log.warn("Duplicate payment for order: {}", event.getOrderId());
}
비즈니스 유니크 제약이 제대로 설계되어 있으면 별도 이력 테이블 없이도 멱등성이 보장된다. 이 방법이 가장 간결하다.
Outbox 테이블이 계속 쌓이면 어떻게 하나?
Outbox Pattern을 도입하고 운영하면 반드시 만나는 문제다. SENT 상태 레코드가 삭제되지 않으면 테이블이 계속 커진다.
없으면 어떤 고통?
- 테이블이 커질수록 INSERT 속도가 느려진다 (인덱스 유지 비용)
- 디스크 용량 소진
- 나중에 한꺼번에 삭제하려고
DELETE FROM outbox_event WHERE status = 'SENT'를 실행하면 수백만 건 삭제 시 InnoDB 락이 폭발하고 서비스 응답이 멈춘다
해결: 배치 삭제를 주기적으로
-- 7일 이상 된 SENT 이벤트 배치 삭제
DELETE FROM outbox_event
WHERE status = 'SENT'
AND sent_at < NOW() - INTERVAL 7 DAY
LIMIT 1000; -- 한 번에 많이 삭제하면 락 발생. 소량씩 반복
@Scheduled(cron = "0 0 * * * *") // 매 시간
@Transactional
public void cleanupSentEvents() {
int deleted;
do {
deleted = outboxEventRepository.deleteSentEventsOlderThan(
LocalDateTime.now().minusDays(7), 1000
);
} while (deleted == 1000); // 1,000개 삭제됐으면 더 있을 수 있음
}
PENDING이 5분 이상 쌓이면 릴레이 장애 징후다. 모니터링을 걸어두어야 한다:
SELECT COUNT(*)
FROM outbox_event
WHERE status = 'PENDING'
AND created_at < NOW() - INTERVAL 5 MINUTE;
-- 이 값이 0이 아니면 알람
결국 어떤 방식을 선택해야 하나?
이벤트 유실이 허용되지 않는다 → Outbox Pattern 필수
Kafka에 직접 발행하는 방식(유령 이벤트, 무음 유실 위험)은 쓰면 안 된다.
서비스 규모별 현실적인 권장사항
| 상황 | 릴레이 방식 | 이유 |
|---|---|---|
| 초기 서비스, 팀 소규모 | Polling Publisher | 인프라 복잡도 낮음 |
| 트래픽 증가, 이벤트 지연 민감 | Debezium CDC | 실시간 전파 필요 |
| Kafka Connect 없음 | Polling Publisher + 배치 size 조정 | 현실적 대안 |
Choreography vs Orchestration
| 상황 | 선택 | 이유 |
|---|---|---|
| 서비스 3개 이하, 단순 흐름 | Choreography | 단순함이 장점 |
| 서비스 4개 이상 또는 복잡한 보상 | Orchestration | 디버깅 가능성 |
| Tracing 인프라 없음 | Orchestration | 상태 추적이 중앙화됨 |
혼자 또는 소규모 팀이 운영하는 서비스라면: Polling Publisher + 멱등한 컨슈머 + DB 유니크 제약 조합이 현실적으로 가장 안정적이다. 1~2초 발행 지연은 대부분의 비동기 처리에서 허용 가능하고, Debezium 인프라를 유지보수하는 비용이 없다.
최적화 정리
| 항목 | 설정 | 기준값 |
|---|---|---|
| Polling 간격 | @Scheduled(fixedDelay) | 1,000ms |
| 한 번에 처리할 이벤트 수 | findTop100By... | 100개 |
| Kafka 발행 타임아웃 | .get(5, TimeUnit.SECONDS) | 5초 |
| Outbox 보관 기간 | 삭제 배치 기준 | 7일 |
튜닝: 무엇을 측정하고 무엇을 바꾸나
Outbox 테이블 모니터링
-- 상태별 분포 확인
SELECT status, COUNT(*), MIN(created_at), MAX(created_at)
FROM outbox_event
GROUP BY status;
Kafka Consumer Lag 모니터링
kafka-consumer-groups.sh \
--bootstrap-server kafka:9092 \
--describe \
--group payment-service
Lag이 지속적으로 증가하면 컨슈머 파티션 수를 늘리거나 처리 로직의 병목을 찾아야 한다.
운영상 주의사항
트랜잭션 밖에서 outbox 저장하는 실수
// 위험: Order 저장과 OutboxEvent 저장이 다른 트랜잭션
@Transactional
public void createOrder(OrderCommand command) {
Order order = orderRepository.save(new Order(command));
// 여기서 트랜잭션 커밋
}
// 별도 메서드 (다른 트랜잭션)
public void saveOutboxEvent(Long orderId) {
outboxRepository.save(OutboxEvent.from(orderId));
// Order 저장은 성공했는데 이게 실패하면? 이벤트 영원히 없음
}
Order 저장과 OutboxEvent 저장이 반드시 같은 @Transactional 안에 있어야 한다.
Kafka 발행 후 DB 상태 업데이트 누락
Polling Publisher에서 SENT 처리를 잊으면 같은 이벤트를 계속 발행한다. 컨슈머가 멱등하면 기능적 오류는 없지만 불필요한 처리와 Kafka 트래픽이 계속 발생한다.
Payload에 개인정보 저장 주의
Outbox 테이블은 사실상 이벤트 로그다. 이름, 이메일, 카드번호 등 PII를 그대로 저장하면 GDPR/개인정보보호법 위반 가능성이 있다. 민감 정보는 ID만 저장하고 컨슈머가 조회하게 하거나, AES로 암호화해서 저장한다.