주문 서비스가 주문을 결제 완료로 바꾼 뒤 OrderPaid 이벤트를 발행합니다. 재고 서비스는 수량을 차감하고, 배송 서비스는 출고를 준비하며, 알림 서비스는 고객에게 메시지를 보냅니다. 서비스가 서로 직접 호출하지 않아도 확장하기 쉽고 장애가 격리되는, 전형적인 이벤트 기반 구조입니다.
그런데 운영에 들어가면 곧 이상한 사례가 나타납니다. DB에는 결제가 완료됐는데 배송이 시작되지 않거나, 취소된 주문이 잠시 다시 결제 완료로 보이거나, 같은 쿠폰이 두 번 차감됩니다. 메시지 브로커가 고장 난 것도 아니고 각 서비스의 코드도 정상인데 데이터는 어긋납니다.
원인은 대개 세 가지입니다. DB 저장과 이벤트 발행을 하나의 원자적 작업으로 착각한 것, 전체 이벤트에 불필요한 순서를 기대한 것, 재전송될 메시지를 한 번만 온다고 가정한 것입니다. 이번 글은 이 세 문제를 Transactional Outbox, 파티션 키와 멱등 소비자로 풀어가는 과정을 설명합니다.
가장 먼저 깨지는 지점: DB와 브로커에 동시에 쓰기
단순한 구현은 다음 순서를 따릅니다.
- 주문 DB의 상태를
PAID로 변경한다. - 트랜잭션을 커밋한다.
- 메시지 브로커에
OrderPaid를 발행한다.
2번 직후 프로세스가 종료되면 DB 변경은 남지만 이벤트는 사라집니다. 순서를 반대로 바꾸면 브로커 발행 후 DB 커밋이 실패할 수 있습니다. 이 경우 소비자는 실제로 존재하지 않는 변경을 전달받습니다. 재시도를 추가해도 어느 단계까지 성공했는지 확실히 알 수 없어 중복이나 누락이 생깁니다.
이를 이중 쓰기(dual write) 문제라고 부릅니다. 서로 다른 두 시스템에 대한 쓰기를 애플리케이션 코드의 연속된 두 줄로 실행해도 하나의 트랜잭션이 되지는 않습니다. 분산 트랜잭션으로 묶는 방법도 있지만 브로커와 데이터베이스의 지원, 운영 복잡도와 결합도를 고려하면 대부분의 서비스에서 부담이 큽니다.
Outbox의 핵심은 이벤트도 같은 DB에 먼저 쓰는 것
Transactional Outbox는 비즈니스 데이터와 발행할 이벤트를 같은 로컬 DB 트랜잭션에 저장합니다. 주문 상태를 바꾸는 SQL과 outbox 테이블에 이벤트 행을 추가하는 SQL이 함께 커밋되거나 함께 롤백됩니다. 이후 별도의 릴레이가 아직 전송되지 않은 행을 읽어 브로커로 보냅니다.
Outbox 행에는 보통 이벤트 ID, aggregate 종류와 ID, 이벤트 종류, payload, 스키마 버전, 생성 시각이 들어갑니다. 발행 상태나 시각을 추가할 수도 있지만, 상태를 갱신하는 순간에도 장애가 발생할 수 있다는 점은 기억해야 합니다.
이 구조는 DB와 브로커를 하나의 거대한 트랜잭션으로 만들지 않습니다. 대신 반드시 기록돼야 할 발행 의도를 신뢰할 수 있는 DB에 남깁니다. 브로커가 잠시 멈춰도 주문 처리 자체는 완료할 수 있고, 릴레이는 복구된 뒤 Outbox를 다시 읽어 전송합니다.
Outbox가 제공하는 것은 정확히 한 번이 아니라 누락 없는 재시도다
릴레이가 메시지를 발행한 직후, Outbox 행을 전송 완료로 표시하기 전에 죽었다고 가정해 봅시다. 재시작한 릴레이는 같은 행을 다시 발행합니다. 따라서 Outbox만 도입했다고 중복이 없어지는 것은 아닙니다.
현실적인 보장은 보통 at-least-once delivery, 즉 적어도 한 번 전달입니다. 누락 대신 중복 가능성을 받아들이고 소비자가 안전하게 중복을 흡수하도록 설계합니다. “정확히 한 번”이라는 제품 기능이 있더라도 범위가 브로커 내부인지, DB의 외부 부수 효과까지 포함하는지 확인해야 합니다.
예를 들어 Kafka 트랜잭션으로 입력 토픽 소비와 출력 토픽 발행을 원자적으로 묶을 수 있어도, 별도 관계형 DB에 송금 결과를 쓰는 것까지 자동으로 한 번만 처리되지는 않습니다. 보장 범위를 시스템 경계별로 정확하게 표현해야 합니다.
릴레이는 폴링과 CDC 중 무엇을 선택할까
가장 단순한 방식은 폴링 발행자입니다. 일정 개수의 미전송 Outbox 행을 잠그고 읽은 뒤 브로커로 보내고 완료 처리합니다. 구현과 이해가 쉽고 작은 규모에서 충분히 효과적입니다. 다만 폴링 주기가 짧으면 DB 부하가 늘고, 길면 이벤트 지연이 커집니다. 여러 릴레이가 동시에 같은 행을 집지 않도록 SKIP LOCKED 같은 기능이나 명확한 임대 전략도 필요합니다.
두 번째 방식은 CDC(Change Data Capture)입니다. DB 트랜잭션 로그의 Outbox 변경을 감지해 스트림으로 보냅니다. 애플리케이션 쿼리 부하를 줄이고 낮은 지연을 얻기 좋지만 커넥터, 오프셋, 스키마 변경과 장애 복구를 운영해야 합니다. CDC가 비즈니스 테이블 전체를 추론하게 하기보다, 발행 계약이 분명한 전용 Outbox 테이블을 읽게 하는 편이 결합도를 낮춥니다.
선택 기준은 유행이 아니라 처리량, 허용 지연, 팀의 운영 역량입니다. 초당 이벤트가 많지 않고 수초 지연이 괜찮다면 폴링으로 시작할 수 있습니다. 이미 CDC 플랫폼을 안정적으로 운영하고 낮은 지연이 중요하다면 로그 기반 전달이 자연스럽습니다.
순서 보장은 전체가 아니라 업무 단위로 정의한다
이벤트 시스템에서 “순서를 보장해야 한다”는 요구는 너무 넓습니다. 전 세계 모든 주문 이벤트를 한 줄로 세우면 병렬 처리가 사라지고 한 건의 느린 작업이 전체를 막습니다. 실제로 필요한 것은 대부분 같은 엔티티의 이벤트 순서입니다.
주문 A의 Created → Paid → Cancelled는 순서가 중요하지만 주문 A와 주문 B 사이의 상대 순서는 중요하지 않습니다. 계좌, 배송, 사용자처럼 상태 전이가 연결되는 aggregate ID를 파티션 키로 사용하면 같은 키의 이벤트는 같은 파티션에 모이고, 서로 다른 키는 병렬로 처리할 수 있습니다.
여기서 중요한 계약은 “토픽에 들어간 모든 메시지가 생성 시각 순서대로 도착한다”가 아닙니다. 동일한 키를 일관되게 사용하고, 해당 파티션 안에서 생산과 소비 순서를 유지한다는 것입니다. 생산자가 재시도 과정에서 순서를 바꾸는지, 파티션 수 변경이나 키 전략 변경이 어떤 영향을 주는지도 검토해야 합니다.
시간만으로 최신 이벤트를 판단하면 위험하다
분산된 서버의 시계는 완벽히 같지 않습니다. 생성 시각이 더 늦다는 이유만으로 반드시 더 최신 상태라고 볼 수 없고, 네트워크 지연 때문에 먼저 만들어진 이벤트가 나중에 도착할 수도 있습니다.
상태 전이가 엄격하다면 aggregate별 단조 증가 버전을 함께 보냅니다. 주문 버전 17을 처리한 소비자에게 버전 16이 도착하면 오래된 메시지임을 알 수 있습니다. 버전 19가 먼저 도착하고 18이 비었다면 즉시 처리할지, 잠시 보류할지, 원본 상태를 다시 조회할지를 업무 특성에 맞게 정합니다.
모든 이벤트에 연속 번호가 필요한 것은 아닙니다. 클릭 로그처럼 서로 교환 가능한 이벤트는 처리량을 위해 느슨한 순서를 허용할 수 있습니다. 반대로 잔액, 재고 예약, 워크플로 상태처럼 이전 결과에 의존한다면 버전과 허용 전이를 명시해야 합니다.
멱등 소비자는 같은 이벤트를 다시 받아도 결과가 같아야 한다
중복 메시지를 완전히 막기보다 중복되어도 안전하게 만드는 것이 실용적입니다. 각 이벤트에 전역적으로 고유한 event_id를 넣고, 소비자는 처리한 ID를 Inbox 테이블에 기록합니다. 비즈니스 변경과 Inbox 기록을 같은 로컬 트랜잭션으로 커밋하면 동일 ID가 재도착했을 때 바로 무시할 수 있습니다.
단순히 “현재 값을 1 증가”시키는 작업은 중복에 취약합니다. 가능하다면 “이 이벤트 ID에 해당하는 적립 500원을 반영”처럼 업무 식별자를 가진 명령으로 바꾸고 고유 제약을 둡니다. 이메일이나 외부 결제 API처럼 로컬 트랜잭션에 묶을 수 없는 부수 효과는 상대 시스템의 idempotency key를 사용하거나 별도 발송 테이블과 작업자로 한 번 더 분리합니다.
Inbox 데이터는 무한히 보관하기 어렵습니다. 브로커의 최대 재처리 기간, 보관 정책과 감사 요구를 기준으로 TTL을 정하되, 오래된 이벤트를 다시 재생할 가능성이 있다면 삭제 후 중복 처리 위험을 고려해야 합니다.
이벤트 payload에는 사실을 담고 명령을 숨기지 않는다
OrderPaid는 이미 일어난 사실입니다. 이벤트 이름을 UpdateInventoryAndSendEmail처럼 소비자의 행동으로 만들면 발행자가 다른 서비스의 구현을 지시하게 되고 결합도가 커집니다. 소비자는 같은 사실을 각자 목적에 맞게 해석해야 합니다.
payload를 너무 얇게 만들어 ID만 보내면 모든 소비자가 원본 서비스에 동기 조회를 반복해 이벤트 기반의 장점을 잃습니다. 반대로 주문 객체 전체를 그대로 직렬화하면 내부 DB 스키마와 계약이 같아져 변경이 어려워집니다. 소비자가 안정적으로 판단하는 데 필요한 최소 사실과 이벤트 발생 당시의 값을 명시적인 계약으로 제공합니다.
개인정보나 비밀정보를 이벤트에 무심코 복제하지 않습니다. 토픽은 여러 서비스와 장기 보관소에 복제될 수 있으므로 접근 범위가 빠르게 넓어집니다. 필요한 식별자만 전달하고 민감 값은 목적에 맞는 보호와 보존 정책을 적용합니다.
스키마 진화는 생산자와 소비자의 배포 시간을 분리하는 기술이다
이벤트는 발행 즉시 사라지는 함수 호출이 아닙니다. 큐에 머무르고 재생되며 오래된 소비자가 나중에 읽을 수 있습니다. 필드를 갑자기 삭제하거나 의미를 바꾸면 서로 다른 버전의 서비스가 공존하는 동안 장애가 발생합니다.
새 필드는 먼저 선택 사항으로 추가하고 소비자가 이해하도록 배포한 뒤 생산자가 사용합니다. 필드 이름을 바꿔야 한다면 일정 기간 구·신 필드를 함께 제공하거나 새 이벤트 버전을 명확히 구분합니다. 스키마 레지스트리와 호환성 검사는 코드 리뷰만으로 잡기 어려운 파괴적 변경을 배포 전에 차단합니다.
이벤트 봉투에는 event_id, event_type, occurred_at, aggregate_id, aggregate_version, schema_version, 추적을 위한 correlation ID 정도를 일관되게 넣으면 운영과 디버깅이 쉬워집니다.
실패 메시지를 무한 재시도하지 않는다
일시적인 DB 연결 실패와 영구적인 스키마 오류는 다르게 취급해야 합니다. 모든 실패를 즉시 재시도하면 장애 중인 의존성을 더 압박하고, 특정 poison message가 파티션 뒤의 정상 메시지를 계속 막습니다.
일시 오류는 지수 백오프와 jitter를 넣어 재시도합니다. 정해진 횟수 이후에도 실패하거나 데이터 자체가 잘못됐다면 격리 큐로 보내 원인, 원본 이벤트, 실패 코드와 시각을 남깁니다. 격리 큐는 쓰레기통이 아니라 복구 대기열이므로 알림, 담당자, 수정 후 재처리 절차가 있어야 합니다.
순서가 중요한 파티션에서 한 이벤트를 건너뛰면 이후 상태가 잘못될 수 있습니다. 업무상 건너뛰어도 되는지, 해당 키 전체를 격리할지, 원본 시스템에서 현재 상태를 다시 동기화할지 정책을 미리 결정합니다.
재처리는 정상 운영 기능으로 설계한다
새로운 검색 인덱스나 분석 모델을 만들 때 과거 이벤트를 처음부터 재생할 수 있습니다. 버그를 수정한 뒤 특정 기간만 다시 처리할 수도 있습니다. 재처리가 예외 작업이라면 매번 임시 스크립트와 수동 DB 수정이 따라오지만, 처음부터 기능으로 만들면 복구 시간이 크게 줄어듭니다.
재처리 소비자는 실시간 소비자와 별도의 consumer group과 속도 제한을 사용해 운영 트래픽을 방해하지 않게 합니다. 외부 알림이나 결제처럼 다시 실행하면 안 되는 부수 효과는 비활성화하거나 Inbox 범위를 공유해야 합니다. 시작·종료 오프셋, 대상 키와 실행자를 감사 로그로 남깁니다.
관측 지표는 브로커 상태보다 업무 지연을 보여줘야 한다
서버 CPU가 정상이어도 Outbox가 쌓이면 고객이 기대한 후속 작업은 늦어지고 있습니다. 최소한 다음 지표를 관찰합니다.
- 가장 오래된 미발행 Outbox 행의 나이와 전체 적체량
- 이벤트 생성부터 소비 완료까지의 end-to-end 지연
- 파티션별 consumer lag와 처리량 편차
- 중복 감지 횟수, 재시도율과 격리 큐 유입량
- 이벤트 종류·스키마 버전별 실패율
correlation ID와 causation ID를 사용하면 한 주문 명령에서 어떤 이벤트가 생겼고 다시 어떤 후속 이벤트로 이어졌는지 추적할 수 있습니다. 단, 로그에 전체 payload를 무조건 남기면 개인정보와 저장 비용 문제가 생기므로 식별자와 필요한 진단 정보만 구조화해 기록합니다.
도입 순서: 한 번에 완벽한 플랫폼을 만들지 않는다
- 업무 경계 정의: 어떤 상태 변경이 어떤 사실을 만들어내는지와 aggregate 키를 정합니다.
- Outbox 원자성 확보: 비즈니스 변경과 이벤트 행이 같은 트랜잭션인지 테스트합니다.
- 고유 이벤트 ID 도입: 생산부터 재처리까지 바뀌지 않는 식별자를 만듭니다.
- 소비자 멱등성 확보: Inbox, 고유 제약 또는 업무 idempotency key를 적용합니다.
- 키 기반 순서 설정: 전역 순서 대신 엔티티별 파티션 전략을 선택합니다.
- 실패와 재처리 운영화: 백오프, 격리, 알림과 안전한 replay 절차를 만듭니다.
- 지표와 계약 자동화: 적체 지연 및 스키마 호환성을 배포 파이프라인에서 확인합니다.
설계 리뷰 체크리스트
- DB 커밋은 성공했지만 이벤트 발행이 실패한 경우를 복구할 수 있는가?
- 릴레이가 같은 Outbox 행을 두 번 발행해도 소비 결과가 같은가?
- 순서가 필요한 범위가 전체 토픽이 아니라 aggregate 단위로 정의됐는가?
- 파티션 키가 모든 생산자에서 같은 규칙으로 생성되는가?
- 오래된 이벤트와 버전 간격을 소비자가 어떻게 처리하는가?
- 외부 API 호출에도 idempotency key 또는 발송 기록이 있는가?
- 스키마 변경이 구버전 소비자와 호환되는지 자동 검사하는가?
- 격리된 메시지의 담당자와 재처리 절차가 정해져 있는가?
- Outbox 적체의 행 개수뿐 아니라 가장 오래된 행의 나이를 경보하는가?
마무리: 신뢰성은 브로커 제품보다 경계 설계에서 나온다
이벤트 기반 시스템의 일관성은 “좋은 메시지 브로커를 골랐으니 해결됐다”로 끝나지 않습니다. DB와 브로커 사이에는 원자성의 틈이 있고, 전달에는 중복이 있으며, 분산 처리에는 제한된 순서만 존재합니다.
Transactional Outbox는 발행 의도를 비즈니스 변경과 함께 보존하고, 파티션 키는 필요한 범위의 순서를 지키면서 병렬성을 살립니다. 멱등 소비자와 Inbox는 재전송을 정상 상황으로 바꾸며, 버전·격리·재처리·관측성은 장애를 복구 가능한 운영 사건으로 만듭니다.
핵심은 모든 것을 정확히 한 번 처리한다고 약속하는 것이 아니라, 어디까지 원자적인지 명확히 하고 나머지는 안전하게 재시도할 수 있게 만드는 것입니다. 이 기준으로 설계하면 이벤트 시스템은 데모용 비동기 구조를 넘어 실제 업무를 맡길 수 있는 기반이 됩니다.