RabbitMQ만 쓰다가 Kafka를 만났다 — '전달'과 '적재'는 달랐다
TL;DR 7주차 과제: 이벤트 기반 아키텍처 + Kafka. 회사에서 RabbitMQ만 쓰다가 Kafka를 처음 도입하면서, 둘의 차이가 “디스크에 저장하느냐”가 아니라 “소비 후에도 남아있느냐”라는 걸 깨달았다. 이 차이 하나가 Outbox 패턴, auto.offset.reset, 실패 보상 전략까지 전부 바꿔놓았다.
우체통과 게시판
RabbitMQ는 우체통이다. 브로커가 Consumer에게 메시지를 밀어주고(push), Consumer가 ACK을 보내면 메시지는 큐에서 사라진다. 편지를 꺼내면 우체통은 비어있다. 회사에서 1년 넘게 이 구조를 써왔다. 리뷰가 등록되면 큐에 넣어서 검수 시스템으로 넘기고, 상품이 저장되면 검색 인덱싱 큐로 보내고, 자동 푸시 발송도 큐를 통해 비동기로 처리했다.
Kafka는 게시판이었다. Consumer가 브로커에서 직접 가져가고(pull), 읽었다고 떼어가지 않는다. 공지는 retention 기간(기본 7일)까지 붙어있고, 새 사람이 와도 처음부터 읽을 수 있다. Consumer는 자기가 어디까지 읽었는지(offset)만 기록한다.
| RabbitMQ (우체통) | Kafka (게시판) | |
|---|---|---|
| 전달 방식 | 브로커가 Consumer에게 밀어줌 (push) | Consumer가 브로커에서 가져감 (pull) |
| 소비 후 메시지 | ACK 하면 큐에서 삭제 | 로그에 그대로 남음 |
| 재처리 | 불가 (이미 삭제됨) | offset 리셋으로 재소비 가능 |
| 새 구독자 추가 | 과거 메시지 못 봄 | Consumer Group 추가로 처음부터 소비 |
멘토링에서 한 마디가 정리해줬다.
“RabbitMQ는 전달, Kafka는 적재.”
전달은 받는 사람이 있어야 의미가 있다. 적재는 쌓아놓는 것 자체가 목적이다. 7주차 과제를 하면서, 이 차이가 생각보다 많은 설계 판단을 바꿔놓았다.
적재라는 관점이 바꾼 첫 번째 — Outbox 패턴
주문이 생성되면 Kafka로 이벤트를 보내야 했다. DB와 Kafka는 별개의 시스템이라, 둘 다 성공하거나 둘 다 실패하는 걸 보장할 수 없다.
회사에서 RabbitMQ를 쓸 때는 @TransactionalEventListener(AFTER_COMMIT)으로 DB 커밋 후에 발행하는 구조였다. DB 롤백 시 메시지가 안 나가는 건 보장되지만, 반대로 DB는 커밋됐는데 RabbitMQ 발행이 실패하면 이벤트가 유실된다. 솔직히 이 문제를 크게 의식하지 않고 써왔다. 발행 실패 자체가 드물었고, 유실돼도 치명적이지 않은 알림/로그 성격의 메시지가 대부분이었으니까.
Kafka에서는 이 문제가 더 신경 쓰였다. metrics 집계나 쿠폰 발급 같은 이벤트가 유실되면 데이터 정합성이 깨진다. “적재”가 핵심인 구조에서 적재 자체의 원자성을 보장해야 했다.
그래서 Outbox 패턴을 도입했다. 비즈니스 데이터와 이벤트를 같은 DB 트랜잭션에 저장하고, 별도 Relay가 DB에서 꺼내 Kafka로 보낸다.
1
2
3
4
5
TX {
주문 저장 (orders INSERT)
이벤트 저장 (outbox_events INSERT) ← 같은 트랜잭션
}
// TX 커밋 후, Relay가 outbox_events → Kafka 발행
슬랙에서 “Outbox 패턴이 어디까지 범위냐”는 논의가 있었는데, D 멘토님이 깔끔하게 정리해줬다.
“보낼 메일함, 보낸 메일함에 가까운 것. 릴레이를 어떻게 구현하느냐는 자율.”
적재가 핵심이고, 배달은 전략이다. Relay가 폴링이든 CDC든, 중요한 건 같은 TX에 이벤트가 들어갔느냐다.
적재라는 관점이 바꾼 두 번째 — 실패해도 괜찮다
RabbitMQ에서는 메시지가 소비되면 사라지니까, 실패하면 안 됐다. Consumer가 처리에 실패하면 메시지를 nack 해서 큐에 되돌리거나, DLQ로 보내야 했다. “한 번에 제대로 처리”가 기본 전제였다.
Kafka에서는 전제가 달라졌다. 메시지가 남아있으니까, 실패해도 나중에 다시 볼 수 있다. 이 관점이 이번 과제에서 쿠폰 보상 전략을 설계할 때 크게 영향을 줬다.
주문을 만들 때 쿠폰 USED 마킹을 AFTER_COMMIT으로 분리했다. 주문은 커밋됐는데 쿠폰 마킹이 실패하면?
세 가지 방안을 고민했다:
| 방안 | 동작 | 문제 |
|---|---|---|
| A. 주문 취소 | 쿠폰 마킹 실패 → 주문 롤백 | “결제 성공”을 봤는데 취소되는 UX |
| B. 쿠폰 미적용 전환 | 할인 없이 전액 결제로 변경 | 이미 계산된 결제 금액이 달라짐 |
| C. 재시도 + 보상 스케줄러 | 3회 재시도, 실패 시 기록 → 스케줄러가 복구 | 일시적 불일치 허용 |
RabbitMQ 관점에서는 A가 자연스러웠다. 실패하면 롤백. 깔끔하다. 하지만 이미 주문이 커밋된 상태에서 롤백은 사용자 경험이 나빴다.
C를 선택했다.
100% 방지가 아니라 감지+보정. 메시지가 남아있는 구조에서는 이런 관점이 가능해진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
OrderFacade.createOrder()
├── 쿠폰 검증 + 할인 계산 (동기)
├── 재고 차감 (동기)
├── 주문 저장 (동기)
└── [TX COMMIT]
↓ AFTER_COMMIT
OrderEventListener
├── 쿠폰 USED 마킹 (재시도 3회)
│ └── 소진 시 coupon_compensation에 PENDING 기록
└── 로깅 (실패 무시)
CouponCompensationScheduler (5분 간격)
└── PENDING 건 재시도 → 성공 시 RESOLVED
같은 DB, 같은 JVM이니까 쿠폰 마킹이 실패할 확률 자체가 낮다. 재시도 3회면 거의 다 해결된다. 그래도 실패하면 compensation 테이블에 기록해두고 스케줄러가 잡는다. unique constraint가 이중 사용도 막아주니까, 일시적 불일치는 수용할 수 있었다.
이런 설계가 가능한 건 Kafka의 “적재” 특성 덕이기도 하다. 이벤트가 남아있으니까 Consumer 쪽에서 재처리할 수 있고, offset을 돌려서 다시 볼 수 있다. 전달 후 사라지는 구조였다면 보상 설계가 더 보수적이어야 했을 것 같다.
적재라는 관점이 바꾼 세 번째 — earliest
Consumer 설정 중 auto.offset.reset이라는 게 있다. 새 Consumer Group이 배포될 때 어디서부터 읽을지를 정한다.
latest: 지금부터 새로 들어오는 메시지만 읽음earliest: 토픽의 처음부터 읽음
RabbitMQ에서는 이런 고민이 없었다. 큐에 남아있는 메시지를 순서대로 꺼내면 그만이다. 소비된 메시지는 이미 없으니까.
Kafka에서는 메시지가 남아있기 때문에 이 선택이 의미가 있다. latest로 하면 배포 시점에 아직 안 읽힌 메시지를 건너뛴다. earliest로 하면 전부 다시 읽는다.
처음에는 latest로 설정했다. 이미 처리한 메시지를 다시 읽으면 중복 처리되잖아? 하는 생각이었다.
근데 event_handled 테이블로 멱등 처리를 하고 있으니까, 중복은 걸러진다. 반면 latest에서 메시지가 유실되면 복구할 수 없다.
중복은 멱등성으로 방어 가능하지만, 유실은 복구 불가.
earliest로 바꿨다. 적재된 메시지가 남아있다는 전제가 있으니까 가능한 판단이었다.
직렬화도 달라졌다
Outbox 패턴을 도입하면서 Kafka 직렬화 설정도 바꿔야 했다.
기존 kafka 모듈은 JsonSerializer로 설정돼 있었다. 객체를 넘기면 알아서 JSON으로 변환해주는 구조. RabbitMQ에서도 비슷하게 Jackson으로 직렬화해서 보냈으니까 익숙한 패턴이었다.
그런데 Outbox는 DB에 JSON 문자열로 저장해놓고 그대로 발행하는 구조다. 이미 문자열인 걸 JsonSerializer에 넘기니까 이중 직렬화가 발생했다.
1
2
DB payload: {"requestId":"abc","userId":1}
JsonSerializer 후: "\"{"requestId":"abc","userId":1}\""
StringSerializer로 바꾸니 해결됐다. Relay는 DB에서 꺼낸 문자열을 그대로 Kafka에 넣기만 하면 된다. Order인지 Like인지 Relay가 알 필요 없다. 이것도 “적재” 관점의 연장선이다 — Relay는 집배원이지, 편지 내용을 읽을 필요가 없다.
다시, 우체통과 게시판
RabbitMQ와 Kafka의 차이를 처음에는 “디스크 저장”이라고 생각했다. 근데 RabbitMQ도 durable queue + persistent message면 디스크에 저장된다.
진짜 차이는 소비 후에도 남아있느냐였다.
| 판단 지점 | 우체통 (RabbitMQ) | 게시판 (Kafka) |
|---|---|---|
| 발행 원자성 | AFTER_COMMIT 발행 (유실 가능) | Outbox로 DB-Kafka 원자성 보장 |
| 실패 대응 | 한 번에 제대로 처리 | 감지 + 보정 (compensation) |
| offset 정책 | 해당 없음 | earliest (유실 방지, 중복은 멱등으로) |
| 직렬화 | 객체 직렬화 | 문자열 그대로 (Relay가 도메인을 모르도록) |
| 새 구독자 | 과거 메시지 못 봄 | Consumer Group 추가로 전체 재소비 |
우체통에서 편지를 꺼내면 우체통은 비어있다. 게시판에서 공지를 읽어도 공지는 그대로 붙어있다. 이번 주에 가장 많이 배운 건 Kafka API가 아니라, 이 한 가지 차이가 설계를 얼마나 바꿔놓는지였다.
