Engineering Note
Kafka는 왜 Outbox와 Inbox 사이에 잘 맞을까
MSA 비동기 호출에서 Outbox와 Inbox 패턴이 필요한 이유와, Kafka가 이 패턴의 메시지 경로로 잘 맞는 이유를 정리합니다.
MSA에서 서비스 사이를 모두 동기 HTTP 호출로 연결하면 흐름은 이해하기 쉽습니다. 요청이 들어오고, 다음 서비스를 호출하고, 응답을 기다립니다.
하지만 실제 시스템은 그렇게 단순하게만 움직이지 않습니다.
주문이 생성되면 결제, 재고, 알림, 정산, 추천, 분석 같은 여러 시스템이 뒤따라 움직입니다. 이 모든 작업을 하나의 동기 요청 안에서 끝내려 하면 사용자 응답 시간은 길어지고, 어느 한 서비스의 장애가 전체 요청을 흔들기 쉽습니다.
그래서 많은 시스템은 중간에 MQ나 event streaming platform을 둡니다.
Order Service
-> event
-> Kafka
-> Payment Consumer
-> Inventory Consumer
-> Notification Consumer
여기서 중요한 질문이 생깁니다.
Order Service가 DB 변경과 event 발행을 어떻게 함께 안전하게 처리할 것인가
Consumer는 같은 message를 여러 번 받아도 어떻게 안전하게 처리할 것인가
이 두 질문 사이에 Outbox와 Inbox 패턴이 있습니다.
문제는 비동기가 아니라 dual-write다
예를 들어 주문 생성 API를 생각해보겠습니다.
서비스는 자기 DB에 주문을 저장하고, 동시에 OrderCreated event를 발행해야 합니다.
단순한 코드는 이렇게 보일 수 있습니다.
1. orders table에 주문 저장
2. Kafka에 OrderCreated event 발행
또는 반대로 이렇게 할 수도 있습니다.
1. Kafka에 OrderCreated event 발행
2. orders table에 주문 저장
둘 다 불안합니다.
DB commit은 성공했는데 Kafka publish가 실패하면 주문은 생겼지만 다른 서비스는 모릅니다. Kafka publish는 성공했는데 DB commit이 실패하면 다른 서비스는 존재하지 않는 주문을 보고 움직일 수 있습니다.
이 문제를 흔히 dual-write 문제라고 부릅니다. 서로 다른 두 시스템, 예를 들어 database와 message broker를 하나의 원자적 작업처럼 다루려 할 때 생기는 문제입니다.
Outbox 패턴은 이 문제를 다르게 풉니다.
비즈니스 상태 변경과 발행할 event 기록을
같은 local database transaction 안에 넣는다.
즉 서비스는 Kafka에 바로 보내지 않습니다. 먼저 자기 DB에 outbox record를 남깁니다.
BEGIN
INSERT INTO orders (...)
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
created_at
)
COMMIT
이렇게 하면 주문 저장과 event 발행 의도가 함께 commit됩니다. 이후 별도의 relay process가 outbox table을 읽고 Kafka에 publish합니다.
Outbox는 메시지를 잃지 않기 위한 local contract다
Outbox를 쓰면 서비스가 직접 Kafka에 보내는 대신 다음 흐름이 됩니다.
Application transaction
-> business table update
-> outbox table insert
-> commit
-> relay reads outbox
-> relay publishes to Kafka
-> relay marks outbox row as sent
이 구조의 장점은 명확합니다.
비즈니스 변경이 commit되었다면 발행해야 할 event도 DB에 남습니다. Kafka가 일시적으로 느리거나 사용할 수 없어도 outbox row는 사라지지 않습니다. Relay는 나중에 다시 읽어 발행할 수 있습니다.
그렇다고 Outbox가 모든 것을 exactly-once로 만들어주는 것은 아닙니다.
예를 들어 relay가 Kafka publish에는 성공했지만 outbox row를 sent로 표시하기 전에 죽을 수 있습니다. 그러면 다음 relay 실행에서 같은 outbox row를 다시 publish할 수 있습니다.
즉 Outbox relay는 보통 at-least-once 성격을 가집니다.
잃지 않는 쪽을 선택하면 중복 가능성을 받아들여야 한다.
그래서 event에는 안정적인 event_id가 있어야 합니다. 같은 비즈니스 event가 재발행되더라도 consumer가 식별할 수 있어야 하기 때문입니다.
Inbox는 consumer 쪽의 중복 방지 계약이다
Outbox가 producer 쪽의 안전장치라면, Inbox는 consumer 쪽의 안전장치입니다.
Kafka consumer는 같은 record를 다시 읽을 수 있습니다. Consumer가 처리는 성공했지만 offset commit 전에 죽을 수도 있고, rebalance 중에 같은 record가 다시 전달될 수도 있습니다. Outbox relay가 중복 publish할 수도 있습니다.
따라서 consumer는 “message는 한 번만 온다”고 가정하면 안 됩니다.
Inbox 패턴은 consumer가 처리한 message id를 자기 DB에 기록합니다. 비즈니스 처리와 inbox 기록을 같은 local transaction에 넣습니다.
BEGIN
INSERT INTO inbox_messages (
event_id,
consumer_name,
received_at
)
-- unique(event_id, consumer_name)
UPDATE payment_requests ...
COMMIT
이미 처리한 event_id라면 unique constraint에서 걸러지고 consumer는 skip할 수 있습니다.
중요한 점은 Inbox가 consumer group 전체에 하나만 있으면 안 된다는 것입니다. 같은 Kafka topic을 여러 consumer group이 각자 다른 목적에 맞게 소비할 수 있습니다. 결제 서비스, 알림 서비스, 분석 서비스는 같은 OrderCreated event를 읽어도 서로 다른 일을 합니다.
그래서 inbox key는 보통 다음처럼 잡습니다.
event_id + consumer identity
여기서 consumer identity는 consumer group, subscriber name, handler name처럼 “이 consumer가 이 event를 처리했다”는 의미를 안정적으로 표현할 수 있어야 합니다.
Kafka가 잘 맞는 이유
Outbox와 Inbox 사이에는 어떤 메시징 시스템도 들어갈 수 있습니다. 그렇다면 왜 Kafka가 특히 잘 맞을까요?
Kafka가 단순히 “빠른 MQ”라서가 아닙니다.
Kafka는 partitioned append-only log입니다. 이 특성이 Outbox/Inbox와 잘 맞습니다.
첫째, event를 오래 보관하고 다시 읽을 수 있습니다.
일반적인 queue는 message를 소비하면 사라지는 모델에 가깝습니다. Kafka는 consumer가 읽었다고 record가 바로 사라지지 않습니다. Topic의 retention 정책에 따라 일정 기간 보관되고, 필요한 경우 consumer group은 offset을 조정해 다시 읽을 수 있습니다.
Outbox relay나 consumer 처리에 문제가 생겼을 때, 이 재생 가능성은 매우 큰 장점입니다.
둘째, consumer group이 자연스럽게 여러 subscriber를 분리합니다.
같은 OrderCreated topic을 결제, 재고, 알림, 분석 서비스가 각자 자기 consumer group으로 읽을 수 있습니다. Kafka는 각 group의 offset을 따로 관리합니다. 따라서 한 consumer group의 지연이나 장애가 다른 group의 진행을 직접 막지 않습니다.
Outbox에서 발행한 event 하나가 여러 bounded context로 흘러가는 구조와 잘 맞습니다.
셋째, key 기반 ordering을 제공할 수 있습니다.
Kafka는 같은 key를 가진 record를 같은 partition으로 보내면 그 partition 안에서 순서를 유지합니다. 주문 단위로 order_id를 key로 사용하면 같은 주문의 event 순서를 consumer가 일관되게 볼 수 있습니다.
물론 이것은 전체 topic의 전역 순서를 의미하지 않습니다. Kafka의 순서는 partition 단위입니다. 하지만 대부분의 비즈니스 이벤트에서는 전역 순서보다 aggregate 단위 순서가 더 중요합니다.
넷째, 장애와 지연을 운영 지표로 볼 수 있습니다.
Kafka에는 consumer lag, produce/fetch latency, broker throughput, topic/partition 상태, consumer group 상태 같은 운영 신호가 있습니다. Outbox relay가 밀리는지, 특정 consumer group이 뒤처지는지, 특정 topic이 과부하인지 볼 수 있습니다.
비동기 아키텍처는 흐름이 눈에 보이지 않으면 금방 불안해집니다. Kafka는 message flow를 운영 대상으로 만들기 좋은 신호를 제공합니다.
다섯째, producer idempotence와 transaction 같은 기능을 활용할 수 있습니다.
Kafka producer의 idempotence는 retry 중 broker에 중복 record가 생기는 위험을 줄여줍니다. Kafka transaction은 여러 partition write와 offset commit을 하나의 Kafka transaction으로 묶는 데 도움이 됩니다.
다만 이것들이 Outbox와 Inbox를 대체하는 것은 아닙니다. Kafka transaction은 일반적인 서비스 DB transaction을 자동으로 포함하지 않습니다. 서비스 DB와 Kafka publish 사이의 dual-write 문제는 여전히 Outbox가 다룹니다. Consumer가 자기 DB에 처리 결과를 저장하는 문제도 Inbox나 idempotent handler가 다뤄야 합니다.
Kafka 기능은 패턴을 보완합니다. 패턴을 지우지는 않습니다.
Outbox relay는 생각보다 중요한 컴포넌트다
Outbox를 도입할 때 relay를 가볍게 보는 경우가 있습니다. “테이블 읽어서 Kafka로 보내면 되는 것 아닌가?” 정도로 생각하기 쉽습니다.
하지만 relay는 운영 컴포넌트입니다.
Relay는 다음 질문에 답해야 합니다.
- 어떤 순서로 outbox row를 읽을 것인가?
- 한 번에 몇 건씩 publish할 것인가?
- Kafka publish 성공과 DB 상태 변경 사이의 실패를 어떻게 처리할 것인가?
- 같은 row를 여러 relay instance가 동시에 집지 않게 할 것인가?
- 실패한 event는 계속 retry할 것인가, 별도 상태로 격리할 것인가?
- outbox table은 언제 정리할 것인가?
- schema 변경은 어떻게 다룰 것인가?
Outbox table이 곧 queue가 되어버리면 DB에 부담이 쌓입니다. Outbox는 durable handoff 기록이지, 무한히 쌓아두는 메시지 브로커가 아닙니다. Kafka로 성공적으로 넘긴 뒤에는 cleanup 정책이 필요합니다.
반대로 너무 빨리 지우면 추적과 재처리 근거를 잃을 수 있습니다. 운영 환경에서는 sent 상태 보관 기간, error 상태 보관 기간, archive 여부를 명확히 정해야 합니다.
Consumer는 offset보다 비즈니스 처리 완료를 먼저 생각해야 한다
Consumer 쪽에서 자주 생기는 실수는 offset commit을 처리 성공의 유일한 기준으로 보는 것입니다.
Kafka offset은 consumer group이 “어디까지 읽었는가”를 나타냅니다. 하지만 비즈니스 DB에 반영했는지, 외부 API 호출이 idempotent하게 처리됐는지, 같은 event를 다시 받았을 때 안전한지는 별도의 문제입니다.
Inbox는 이 간격을 줄입니다.
Consumer는 message를 읽고, 자기 DB transaction 안에서 inbox 기록과 비즈니스 상태 변경을 함께 commit합니다. 그 다음 offset을 commit합니다.
그래도 실패 지점은 남습니다.
DB commit 성공
offset commit 실패
consumer 재시작
같은 record 재처리
inbox unique constraint로 skip
이 흐름은 정상입니다. Inbox는 바로 이런 상황을 견디기 위해 둡니다.
외부 API 호출이 끼어 있으면 더 조심해야 합니다. Consumer가 결제 API나 알림 API 같은 외부 시스템을 호출한다면, 그 외부 호출에도 idempotency key를 넣는 편이 좋습니다. Inbox가 local DB 중복은 막아도, 외부 시스템에 이미 나간 side effect까지 자동으로 되돌리지는 못합니다.
설계할 때 정해야 할 것들
Outbox/Inbox와 Kafka를 함께 쓸 때는 코드보다 먼저 계약을 정해야 합니다.
event_id는 어떻게 만들 것인가
event type과 schema version은 어떻게 관리할 것인가
Kafka key는 무엇으로 할 것인가
consumer identity는 무엇으로 정의할 것인가
retry와 DLQ는 어디에서 처리할 것인가
outbox cleanup 기준은 무엇인가
inbox 보관 기간은 얼마인가
trace context와 correlation id는 어디에 넣을 것인가
특히 event_id는 중요합니다. UUID를 매번 새로 만들면 같은 비즈니스 event가 재발행될 때 consumer가 중복을 식별하기 어려울 수 있습니다. 경우에 따라 aggregate id, event sequence, event type을 조합한 deterministic id가 더 나을 수 있습니다.
Kafka key도 중요합니다. 주문 이벤트라면 order_id, 계정 이벤트라면 account_id처럼 aggregate 단위 순서를 지키는 key가 자연스럽습니다. key를 잘못 잡으면 같은 aggregate의 event가 여러 partition에 흩어져 순서 가정이 깨질 수 있습니다.
그리고 trace context도 잊지 않는 편이 좋습니다. Outbox row를 만들 때 correlation id나 trace context를 함께 저장하고, Kafka header로 넘기면 나중에 producer service, relay, consumer service 사이 흐름을 추적하기 쉬워집니다.
이 부분은 Kafka가 끼어도 Trace는 끊기지 않아야 한다에서 다룬 관점과도 이어집니다.
적절하지 않은 경우도 있다
Kafka가 Outbox/Inbox 사이에 잘 맞는다고 해서 모든 비동기 호출에 Kafka를 넣어야 한다는 뜻은 아닙니다.
다음 상황에서는 다른 선택이 더 단순할 수 있습니다.
- 단일 consumer에게 짧게 전달하고 소비 후 사라져도 되는 작업 queue
- 매우 낮은 운영 복잡도가 더 중요한 작은 시스템
- event replay나 여러 subscriber가 필요 없는 흐름
- 순서와 보관보다 request/reply 응답 시간이 더 중요한 흐름
Kafka는 강력하지만 운영해야 할 시스템입니다. Topic 설계, partition 설계, retention, schema, consumer lag, broker 용량, 장애 대응이 함께 따라옵니다. 이 비용을 감당할 이유가 있을 때 좋은 선택입니다.
Outbox/Inbox와 Kafka가 잘 맞는 경우는 보통 이런 흐름입니다.
하나의 비즈니스 변경이 여러 서비스로 전파된다.
event를 잃으면 안 된다.
consumer마다 처리 속도와 실패가 다르다.
event를 일정 기간 다시 읽을 수 있어야 한다.
aggregate 단위 ordering이 중요하다.
운영자가 흐름의 지연과 실패를 관측해야 한다.
이 조건들이 맞으면 Kafka는 단순한 queue보다 좋은 기반이 됩니다.
운영 관점에서의 마무리
Outbox와 Inbox는 “Kafka를 쓰면 자동으로 해결되는 문제”가 아닙니다. 오히려 Kafka를 제대로 쓰기 위해 애플리케이션 쪽에서 명확히 만들어야 하는 계약에 가깝습니다.
Outbox는 producer service의 DB transaction과 event 발행 의도를 묶습니다. Inbox는 consumer service가 message 중복과 재처리를 견디게 합니다. Kafka는 그 사이에서 event를 보관하고, 여러 consumer group에 독립적으로 전달하고, key 단위 ordering과 replay 가능성을 제공합니다.
Konduo처럼 Kafka, JVM, host, container, alert 신호를 함께 보는 도구는 이런 비동기 흐름을 운영할 때 특히 유용합니다. Outbox relay 지연, Kafka topic 처리량, consumer group lag, broker resource pressure가 서로 맞물려 움직이기 때문입니다. 패턴이 아무리 좋아도 운영 신호가 보이지 않으면 비동기 시스템은 금방 추측의 영역이 됩니다.
결국 이 패턴의 목적은 거창한 exactly-once를 선언하는 것이 아닙니다.
잃지 않는다.
중복은 견딘다.
다시 읽을 수 있다.
어디서 밀리는지 볼 수 있다.
이 네 가지를 현실적으로 달성하는 데 Kafka는 Outbox와 Inbox 사이에서 꽤 좋은 자리를 차지합니다.
함께 읽기 좋은 글
이 글의 관점과 이어지는 주제를 더 보려면 아래 글도 함께 읽어볼 수 있습니다.
- Kafka가 끼어도 Trace는 끊기지 않아야 한다 - Outbox relay와 consumer 사이에서 trace context를 이어가는 관점을 다룹니다.
- Consumer Lag은 건강 점수가 아니다: Kafka Consuming Pressure라는 관점 - consumer 지연을 lag 하나가 아니라 처리 압력으로 해석하는 방법을 설명합니다.
- Consumer Group인데 Consumer가 아니다: Kafka 안에서 노드들이 서로를 찾는 법 - Kafka consumer group과 coordination을 더 넓은 관점에서 다룹니다.