카프카 심화편 (9~15편) 7편 (마지막). DB 트랜잭션과 Kafka 발행의 원자성 문제, Transactional Outbox 패턴이 같은 DB 트랜잭션 안에서 비즈니스 데이터 + outbox 테이블 동시 저장으로 푸는 방식, 폴링 메커니즘으로 outbox 테이블에서 읽어 Kafka 발행, at-least-once 보장과 멱등성 결합, OutboxMapper로 직렬화·역직렬화, CDC(Debezium) 비교까지 — 시리즈 마무리.
이 글은 카프카 마스터 노트 시리즈의 마지막 열다섯 번째 편입니다. 1~6편이 SCS·Saga 패턴이었다면, 이번엔 그 모든 패턴이 부딪히는 근본 문제 — DB와 Kafka의 원자성.
DB는 ACID, Kafka는 별도 시스템. 둘에 동시 쓰기는 분산 트랜잭션이지만 비싸고 불안정. Transactional Outbox가 이걸 우아하게 풉니다. 이 시리즈의 마지막 답.
처음 Outbox가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, "DB와 Kafka 원자성"이 왜 문제인지 직관적이지 않습니다. 둘째, "어차피 폴링하는 거면 직접 발행과 뭐가 다르냐" 같은 의문이 듭니다.
해결법은 한 가지예요. "우체통에 일단 넣고 우체부가 가져감" 비유로 묶는 것. 직접 발행 = 직접 우체국까지 뛰어감(DB와 Kafka 둘 다 책임). Outbox = 회사 우체통(DB)에 넣음 → 우체부(폴러)가 알아서 우체국(Kafka)에 가져감. 한 번에 한 가지만 책임.
DB + Kafka 원자성 문제
단순 패턴 — 둘 다 직접
@Transactional
public void createOrder(OrderRequest req) {
Order order = orderRepo.save(toEntity(req)); // DB
kafkaTemplate.send("order-events", toEvent(order)); // Kafka
}
문제 시나리오:
1. DB 저장 OK
2. Kafka 발행 실패 (네트워크 끊김)
3. @Transactional이 ROLLBACK
4. DB 롤백됐는데, Kafka 메시지는?
→ 이미 발행됐을 수도 있고 안 됐을 수도 있음
또는 반대:
1. DB 저장 OK
2. Kafka 발행 OK
3. 이후 다른 처리에서 실패 → @Transactional ROLLBACK
4. DB 롤백, Kafka 메시지는 그대로 → 일관성 깨짐
여기서 정말 중요한 시험 함정 — @Transactional + Kafka 발행 = 부분 실패 위험. DB는 트랜잭션, Kafka는 별개 시스템. 같은 트랜잭션에 묶을 수 없음.
2PC는 답이 아니다
이론적 해결 — 2-Phase Commit (XA 트랜잭션):
Coordinator → DB PREPARE / Kafka PREPARE
→ 둘 다 OK → COMMIT
→ 하나라도 실패 → ROLLBACK
문제:
- Kafka는 2PC 미지원 (대부분 분산 시스템도)
- 느림 (모든 노드 대기)
- Coordinator 장애 = 무한 대기
여기서 정말 중요한 시험 함정 — Kafka는 2PC 미지원. Saga·Outbox 같은 패턴이 등장한 이유.
Outbox Pattern — 핵심 아이디어
Step 1 (트랜잭션 안):
- 비즈니스 데이터 저장 (orders 테이블)
- 이벤트를 outbox 테이블에 저장
- 둘 다 같은 DB → 같은 트랜잭션 → ACID 보장
Step 2 (별도 프로세스):
- 폴러가 outbox 테이블 조회
- Kafka에 발행
- 발행 성공 시 outbox 레코드 삭제
DB 한 번만 쓰면 끝. Kafka 발행은 비동기 별도 프로세스가.
구현
1. Outbox 테이블
CREATE TABLE order_outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR NOT NULL, -- "Order"
aggregate_id VARCHAR NOT NULL, -- order_id
event_type VARCHAR NOT NULL, -- "OrderCreated"
payload BYTEA NOT NULL, -- 직렬화된 이벤트
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_outbox_created ON order_outbox(created_at);
2. 비즈니스 트랜잭션 안 outbox 저장
@Service
@Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepo;
@Autowired
private OutboxRepository outboxRepo;
@Autowired
private OutboxMapper mapper;
public void createOrder(OrderRequest req) {
// 1. 비즈니스 데이터
Order order = orderRepo.save(toEntity(req));
// 2. Outbox에 이벤트 저장 (같은 트랜잭션)
OrderEvent event = new OrderCreated(order.getId(), order.getAmount(), ...);
outboxRepo.save(new Outbox(
"Order",
order.getId(),
"OrderCreated",
mapper.serialize(event)
));
// 트랜잭션 commit 시 둘 다 영속, 실패 시 둘 다 롤백
}
}
여기서 정말 중요한 시험 함정 — 두 테이블 모두 같은 DB, 같은 트랜잭션. ACID 보장. Kafka는 일단 신경 X.
3. 폴러 (별도 프로세스)
@Service
public class OutboxPoller {
@Autowired
private OutboxRepository outboxRepo;
@Autowired
private StreamBridge streamBridge;
@Autowired
private OutboxMapper mapper;
@Scheduled(fixedDelay = 1000) // 1초마다
public void poll() {
List<Outbox> pending = outboxRepo.findAllByOrderByCreatedAtAsc(PageRequest.of(0, 100));
for (Outbox out : pending) {
try {
Object event = mapper.deserialize(out.getPayload(), out.getEventType());
String topic = topicFor(out.getAggregateType());
streamBridge.send(topic, event);
// 발행 성공 → 삭제
outboxRepo.delete(out);
} catch (Exception e) {
log.error("Publish failed, retry next cycle", e);
// 다음 폴링에서 재시도
}
}
}
}
폴링 간격 = 처리 지연과 부하 균형. 1초가 일반적.
4. OutboxMapper — 직렬화
@Component
public class OutboxMapper {
@Autowired
private ObjectMapper objectMapper;
public byte[] serialize(Object event) {
try {
return objectMapper.writeValueAsBytes(event);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public Object deserialize(byte[] data, String type) {
try {
Class<?> clazz = Class.forName(type);
return objectMapper.readValue(data, clazz);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
At-Least-Once + 멱등성
폴러 다운 → 재시작 → 재발행
1. outbox 저장 (DB)
2. 폴러가 발행 시작
3. 발행 도중 폴러 다운 (이미 발행됐을 수도)
4. 재시작 → 같은 outbox 레코드 다시 발행 → 중복!
해결 — 컨슈머 측 멱등 처리:
@Transactional
public void onOrderCreated(OrderCreated event) {
// 멱등 검사
if (paymentRepo.existsByOrderId(event.orderId())) {
return;
}
// 처리
...
}
여기서 정말 중요한 시험 함정 — Outbox = at-least-once + 멱등 = 사실상 exactly-once. 7편(1~8편)에서 본 패턴 그대로. Kafka Transaction 없이도 가능.
운영 고려사항
1. Outbox 테이블 정리
폴링 후 삭제 = 테이블 작게 유지.
2. 인덱스
created_at 인덱스로 폴링 빠르게.
3. 폴링 간격
fixedDelay = 100 # 매우 빠름, DB 부하 ↑
fixedDelay = 1000 # 일반적, 1초 지연
fixedDelay = 5000 # 가벼운 부하, 5초 지연
4. 배치 크기
PageRequest.of(0, 100) # 한 번에 100개
너무 크면 한 번 처리 시간 ↑. 균형.
5. 동시성
여러 인스턴스가 폴링 시 같은 레코드 두 번 발행 위험. SELECT FOR UPDATE SKIP LOCKED 또는 한 인스턴스만 폴링 보장.
SELECT * FROM order_outbox
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
여러 폴러가 안전하게 분산 처리.
CDC (Change Data Capture) — 대안
Debezium 같은 CDC 도구로 DB 변경 자동 감지 → Kafka 발행:
DB → CDC → Kafka
장점 — 폴링 불필요, 거의 실시간. 단점 — 도구 운영 부담·DB 의존도 ↑.
여기서 시험 함정이 하나 있어요. CDC vs Outbox. CDC는 모든 변경 자동 → 비즈니스와 무관한 변경도 발행. Outbox는 명시적으로 발행할 이벤트만. 의도가 다름.
코레오·오케스 + Outbox
5·6편의 Saga 패턴 모두 Outbox와 결합:
@Transactional
public void onOrderCreated(OrderCreated event) {
// 결제 처리
payment.deduct(event.amount());
paymentRepo.save(...);
// Outbox에 이벤트 저장 (직접 발행 X)
outboxRepo.save(new Outbox(
"Payment", event.orderId(), "PaymentDeducted", ...
));
// 트랜잭션 commit 시 둘 다 영속
}
이래서 4편에서 "Reactive Binder는 트랜잭션 미지원 → Outbox 필요"라 한 것.
시리즈 마무리 — 15편 종합
1편부터 7편까지의 흐름:
| 편 | 주제 | 한 줄 |
|---|---|---|
| 1 | SCS 기초 | Binder/Binding, Supplier/Consumer/Function |
| 2 | StreamBridge | 동적 라우팅, 콘텐츠 기반 |
| 3 | Fan-Out/In | 1→N 분배, N→1 집계 |
| 4 | SCS Tips | content-type, DLT, typeId, Native |
| 5 | Saga 코레오 | 이벤트 기반 협력, 도메인 이벤트 |
| 6 | Saga 오케스 | 중앙 지시, 명령/응답 페어 |
| 7 | Outbox | DB+Kafka 원자성, 시리즈 마무리 |
기초편(18편) + 이 시리즈(915편)까지 Kafka 마이크로서비스의 거의 모든 핵심 패턴 정리.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- DB + Kafka 원자성 문제 =
@Transactional+ 발행 부분 실패 위험 - Kafka는 2PC 미지원 → 다른 패턴 필요
- Outbox Pattern = 같은 DB 트랜잭션에 비즈니스 + outbox 테이블
- DB 한 번만 쓰면 끝 (ACID 보장)
- 폴러가 outbox 테이블 → Kafka 발행 → 삭제
- Outbox 테이블 — id·aggregate_type·aggregate_id·event_type·payload·created_at
- 비즈니스 트랜잭션 안 outbox 저장 (같은 @Transactional)
- 폴러 —
@Scheduled또는 별도 프로세스 - 1초 간격 일반적
- 발행 성공 → 삭제
- At-least-once + 멱등 = 사실상 exactly-once
- 컨슈머 측 멱등 처리 필수
- 운영 — 인덱스(created_at) / 배치 크기 / 동시성(SKIP LOCKED)
SELECT FOR UPDATE SKIP LOCKED= 여러 폴러 안전 분산- CDC (Debezium) = 대안
- 폴링 X, 거의 실시간
- 모든 변경 발행 (비즈니스와 무관한 것도)
- Outbox = 명시적, CDC = 자동
- Saga + Outbox 결합 = 표준 마이크로서비스 패턴
- Reactive Binder의 트랜잭션 미지원 해결책
시리즈 다른 편 (시리즈 마지막)
- 1편 — EDA·Kafka 기초·KRaft
- 2편 — Topic·Partition·Offset
- 3편 — Producer·Consumer 동작
- 4편 — Consumer Group·리밸런싱
- 5편 — Reactor Kafka
- 6편 — Cluster·HA·Best Practices
- 7편 — 배치·에러·트랜잭션
- 8편 — Spring Kafka·테스트·보안
- 9편 — Spring Cloud Stream 기초
- 10편 — StreamBridge 동적 라우팅
- 11편 — Fan-Out / Fan-In
- 12편 — SCS Tips & Tricks
- 13편 — Saga 코레오그래피
- 14편 — Saga 오케스트레이터
- 15편 — Transactional Outbox (현재 글, 시리즈 마지막)
공식 문서: Microservices Patterns — Transactional Outbox / Debezium Outbox Event Router 에서 더 깊이.
Kafka 마스터 노트는 기초편(18편) 8편 + Spring Cloud Stream·Saga편(915편) 7편, 총 15편으로 마무리. 두 시리즈 흐름이 머리에 남으면 이벤트 드리븐 마이크로서비스의 거의 모든 운영 패턴을 손에 잡고 시작할 토대가 됩니다.