in-process 이벤트는 같은 애플리케이션 내에서만 동작한다. 시스템 간 이벤트 전파가 필요하면 메시지 브로커를 도입해야 한다. 이때 가장 큰 문제는 '도메인 변경은 저장됐는데 이벤트 발행은 실패하는 상황'과 '같은 이벤트가 두 번 처리되는 상황'이다.
발행 보장 — Transactional Outbox 패턴
문제 구조
도메인 변경과 이벤트 발행이 별개의 시스템에서 이루어지면 원자성을 보장할 수 없다. 데이터베이스에 주문은 저장됐는데 Kafka 발행이 실패하면 소비자는 주문이 생성된 사실을 알 수 없다.
두 가지 접근이 가능하다.
- 이벤트를 먼저 발행하고 도메인 변경을 나중에 한다 → 발행은 됐는데 저장이 실패하면 존재하지 않는 주문에 대한 이벤트가 돌아다닌다
- 도메인 변경을 먼저 하고 이벤트를 나중에 발행한다 → 저장은 됐는데 발행이 실패하면 이벤트가 유실된다
어느 쪽이든 두 시스템 간 원자성 문제가 남는다.
Outbox 패턴의 핵심 원리
Outbox 패턴은 이벤트를 Kafka에 직접 발행하지 않고, 같은 데이터베이스의 별도 테이블에 저장한다. 도메인 변경과 이벤트 저장이 같은 데이터베이스 트랜잭션에 묶이므로 원자성이 보장된다.
별도 스케줄러가 주기적으로 Outbox 테이블을 조회하여 미발행 이벤트를 Kafka로 발행한다. 발행 성공 시 상태를 갱신한다.
이 구조에서 이벤트 유실은 발생하지 않는다. Outbox에 저장된 이벤트는 발행될 때까지 남아 있기 때문이다. 대신 중복 발행은 가능하다. 스케줄러가 발행 후 상태 갱신 전에 실패하면 같은 이벤트를 다시 발행할 수 있다. 따라서 수신 측의 멱등 처리가 반드시 필요하다.
Outbox 테이블 설계
Outbox 테이블이 담아야 하는 정보는 아래와 같다.
- 집합 타입과 ID: 어떤 도메인 객체에 대한 이벤트인지 (ORDER, PRODUCT 등)
- 이벤트 타입: 무엇이 발생했는지 (ORDER_CREATED, PRODUCT_LIKED 등)
- 토픽과 파티션 키: Kafka 어디로 보낼지
- 페이로드: 이벤트 내용 (JSON)
- 상태: PENDING → PUBLISHED / FAILED / DEAD
- 재시도 횟수: 실패 시 몇 번 재시도했는지
상태 인덱스는 필수다. 스케줄러가 PENDING과 FAILED 상태를 주기적으로 조회하므로, 상태와 생성 시각에 복합 인덱스를 거는 편이 좋다.
Outbox 테이블을 앱별로 분리하는 이유
여러 애플리케이션이 같은 Outbox 테이블에 쓰면 문제가 생긴다.
- INSERT와 SELECT가 같은 테이블에서 경합한다
- 이벤트량이 증가하면 AUTO_INCREMENT gap lock과 relay 조회가 충돌한다
- 한 앱의 장애가 다른 앱의 이벤트 발행에 영향을 준다
앱별로 테이블을 분리하면 이 문제가 사라진다. 코드 로직은 공유 모듈에 두고, 테이블과 엔티티만 앱별로 분리하면 실질적 중복은 없다.
Relay 스케줄러와 실패 처리
스케줄러는 주기적으로 미발행 이벤트를 조회하여 Kafka로 발행한다. 실패 처리 정책은 운영 안정성에 직접 영향을 준다.
- PENDING: 최초 발행 대기
- FAILED: 발행 실패, 재시도 대상
- PUBLISHED: 발행 성공
- DEAD: 재시도 횟수 초과, 운영자 수동 처리 필요
무한 재시도는 장애를 확산시킨다. 일정 횟수(예: 3회)를 초과하면 DEAD로 전환하고 운영자가 모니터링하여 수동 재처리한다.
발행 시에는 이벤트를 공통 봉투(Envelope) 형태로 감싸서 보낸다. 봉투에는 이벤트 ID, 타입, 집합 정보, 실제 페이로드, 발생 시각이 포함된다. 소비자는 봉투를 파싱하여 이벤트 ID로 멱등 처리하고, 타입으로 분기한다.
Producer 설정
발행 측에서 메시지 유실을 방지하기 위한 핵심 설정은 두 가지다.
- acks=all: 모든 ISR(In-Sync Replica)이 메시지를 저장해야 발행 성공으로 간주한다. 하나의 브로커에만 저장되면 해당 브로커 장애 시 메시지가 유실될 수 있다
- idempotence=true: Producer ID와 시퀀스 번호로 중복 저장을 방지한다. 네트워크 재시도로 같은 메시지가 두 번 도달해도 한 번만 저장된다
Outbox 패턴에서 Relay 스케줄러가 이미 직렬화된 JSON 문자열을 발행하므로, Producer의 값 직렬화기는 문자열 직렬화기가 맞다. JSON 직렬화기를 사용하면 이미 직렬화된 문자열을 다시 JSON으로 감싸는 이중 직렬화 문제가 발생한다.
수신 보장 — Consumer 설계
멱등 처리
At Least Once 발행은 중복 수신을 허용한다. 따라서 소비자는 같은 이벤트를 여러 번 받아도 한 번만 처리해야 한다.
가장 단순한 방식은 이벤트 식별자를 별도 테이블에 기록하는 것이다.
- 처리 전: 이 이벤트 ID가 이미 기록되어 있는지 확인한다
- 기록되어 있으면: 이미 처리된 이벤트이므로 건너뛴다
- 기록되어 있지 않으면: 이벤트를 처리하고 ID를 기록한다
중요한 것은 '이벤트 처리와 ID 기록이 같은 트랜잭션에 묶여야 한다'는 점이다. 이벤트는 처리했는데 ID 기록이 실패하면 다음에 같은 이벤트를 다시 처리한다. ID는 기록했는데 이벤트 처리가 실패하면 다음에 건너뛰게 된다.
소비자 그룹별로 멱등 판정이 독립적이어야 한다. 같은 이벤트를 metrics-collector와 user-action-logger가 각각 처리해야 하므로, 이벤트 ID와 소비자 그룹의 복합으로 판정한다.
멱등 테이블과 로그 테이블의 분리
멱등 판정 테이블은 '이 이벤트를 처리했는가'만 빠르게 답하면 된다. 이벤트 ID, 소비자 그룹, 처리 시각 세 컬럼이면 충분하다.
로그 테이블은 '이 이벤트를 어떻게 처리했는가'를 기록한다. 페이로드, 이벤트 타입, 처리 결과, 에러 메시지 등 디버깅에 필요한 상세 정보를 담는다.
두 테이블을 합치면 멱등 판정 쿼리가 대용량 로그 데이터에 영향을 받는다. 멱등 테이블의 행 수는 소비된 이벤트 수에 비례하지만 로그 테이블의 행 크기는 페이로드를 포함하므로 훨씬 크다. 분리하면 멱등 테이블은 항상 가볍게 유지되고, 로그 테이블은 독립적으로 증가해도 멱등 판정에 영향을 주지 않는다.
인덱스 설계도 달라진다. 멱등 테이블은 (event_id, consumer_group) 유니크 인덱스 하나면 된다. 로그 테이블은 event_id, event_type + handled_at 등 다양한 조회 패턴에 맞춘 인덱스가 필요하다.
순서 보장 — PartitionKey 전략
같은 대상에 대한 이벤트는 순서대로 처리되어야 한다. 같은 상품에 대해 좋아요 → 좋아요 취소가 발생했는데 순서가 뒤바뀌면 좋아요 수가 음수가 될 수 있다.
Kafka의 파티션 키로 이를 해결한다. 같은 키를 가진 이벤트는 같은 파티션에 적재되고, 같은 파티션은 하나의 소비자가 순서대로 처리한다.
토픽별 파티션 키 전략은 아래와 같다.
- catalog-events: productId — 같은 상품의 좋아요/조회 이벤트가 순서대로 처리된다
- order-events: orderId — 같은 주문의 생성/결제 이벤트가 순서대로 처리된다
- coupon-issue-requests: couponTemplateId — 같은 쿠폰의 발급 요청이 순서대로 처리된다
- product-metrics-snapshots: productId — 같은 상품의 스냅샷이 순서대로 전파된다
manual Ack
자동 커밋은 이벤트를 수신한 시점에 오프셋을 커밋한다. 처리 중 실패하면 이벤트가 유실된다.
수동 커밋은 이벤트 처리가 완료된 후에 오프셋을 커밋한다. 처리 중 실패하면 오프셋이 커밋되지 않으므로 재배달된다.
수동 커밋의 순서는 아래와 같다.
- 이벤트 수신
- 멱등 검사
- 비즈니스 로직 처리
- 멱등 기록
- 데이터베이스 트랜잭션 커밋
- 오프셋 커밋 (ack)
3번과 4번이 같은 데이터베이스 트랜잭션에 묶이고, 6번은 트랜잭션 커밋 이후에 수행된다. 오프셋 커밋이 실패해도 데이터베이스에는 이미 처리 결과가 저장되어 있으므로, 재배달 시 멱등 검사에서 걸러진다.
배치 처리
소비자가 이벤트를 한 건씩 처리하면 네트워크 왕복과 데이터베이스 접근이 이벤트 수만큼 발생한다. 배치 리스너로 여러 건을 한꺼번에 수신하면 이 비용을 줄일 수 있다.
배치 처리의 흐름은 아래와 같다.
- 한 번에 여러 건의 이벤트를 수신한다
- 이미 처리된 이벤트 ID를 일괄 조회하여 필터링한다 (한 번의 데이터베이스 조회)
- 같은 대상에 대한 변경분을 합산한다
- 합산 결과로 데이터베이스를 일괄 갱신한다 (한 번의 데이터베이스 쓰기)
- 처리한 이벤트 ID를 일괄 기록한다
- 오프셋을 커밋한다
합산의 예를 보면, 한 배치에 같은 상품에 대한 좋아요 3건과 좋아요 취소 1건이 들어오면, 좋아요 수 +2만 반영하면 된다. 데이터베이스 접근 횟수가 이벤트 수가 아니라 대상 수에 비례하게 된다.
Consumer Group 분리
같은 토픽을 여러 관심사가 구독해야 할 때 소비자 그룹을 분리한다. 각 그룹은 독립적으로 오프셋을 관리하므로, 한 그룹의 장애가 다른 그룹에 영향을 주지 않는다.
분리 기준은 관심사다.
- metrics-collector: 집계 처리. catalog-events와 order-events를 구독하여 좋아요 수, 조회 수, 판매량을 product_metrics에 반영한다
- read-model-sync: 조회 모델 동기화. product-metrics-snapshots를 구독하여 조회 모델에 반영한다
- user-action-logger: 로그 적재. catalog-events와 order-events를 구독하여 중앙 로그에 적재한다
- coupon-issuer: 쿠폰 발급. coupon-issue-requests를 구독하여 선착순 발급을 처리한다
각 그룹을 독립적으로 스케일링할 수 있는 것도 장점이다. 집계 처리가 느리면 metrics-collector의 소비자 수만 늘리면 된다.
DLQ (Dead Letter Queue)
이벤트 처리에 실패했을 때 무한 재시도는 다른 이벤트의 처리를 막는다. 일정 횟수 재시도 후에도 실패하면 별도 토픽으로 옮기고 정상 이벤트는 계속 처리되게 한다.
DLQ 토픽의 이름 규칙은 원본 토픽 이름에 접미사를 붙이는 방식이 일반적이다. 예를 들어 catalog-events의 DLQ는 catalog-events.DLT다.
재시도 간격은 지수 백오프를 사용한다. 1초 → 2초 → 4초처럼 간격이 늘어나면 일시적 장애가 자연 회복될 시간을 확보할 수 있다. 고정 간격보다 서버 부하가 적다.
DLQ 토픽도 사전에 생성해야 한다. 브로커의 자동 생성이 꺼져 있는 환경에서는 DLQ 토픽이 없으면 첫 번째 DLQ 전송부터 실패한다.
DLQ 토픽의 파티션 수는 원본과 다르게 설정하는 경우가 많다. DLQ는 처리량이 낮으므로 1 파티션이면 충분하다. 다만 DLQ로 보낼 때 원본 파티션 번호를 그대로 전달하면, DLQ의 파티션 수를 초과하여 전송이 실패할 수 있다. DLQ 전송 시 파티션을 0으로 고정하는 편이 안전하다.
2단 구조 — Delta 수집과 Snapshot 전파
Delta 이벤트의 특성
카운터 원천 이벤트는 '몇 건 발생했다'는 사실 이벤트다. 좋아요 1건, 조회 1건, 판매 N개와 같은 delta가 자연스럽다. 발행 측은 현재 합계를 알 필요 없이 사실만 발행하면 된다.
delta 정합성은 '모든 이벤트를 중복 없이 순서대로 반영'하는 것이다. 이를 위해 파티션 키로 순서를 보장하고, manual ack과 멱등 처리로 중복을 방지한다.
Snapshot 이벤트의 특성
delta를 집계한 결과를 snapshot 이벤트로 발행한다. snapshot은 '현재 상태가 이것이다'라는 절대값 이벤트다. 좋아요 수 42, 조회 수 1500, 판매 수 37과 같은 형태다.
snapshot은 delta와 다른 정합성 전략이 필요하다. delta는 모든 이벤트를 누락 없이 처리해야 하지만, snapshot은 최신 것만 반영하면 된다. 구버전 snapshot을 뒤늦게 받아도 무시하면 된다.
이를 위해 snapshot에 version을 포함한다. 수신 측은 현재 보유한 version보다 큰 version의 snapshot만 반영하고, 같거나 작은 version은 건너뛴다.
왜 2단 구조가 필요한가
delta만으로 조회 모델을 직접 갱신하면 아래 문제가 생긴다.
- 조회 모델 갱신 측이 delta 수집과 집계를 직접 해야 한다
- 여러 소비자가 같은 카운터를 delta로 갱신하면 경쟁 상태가 발생한다
- 집계 결과의 기준 원본이 불명확해진다
2단 구조에서는 역할이 명확하게 분리된다.
- 1단(MetricsCollector): delta 수집 + 집계. product_metrics가 기준 원본(SoT)
- 2단(ReadModelSync): snapshot 수신 + 조회 모델 갱신. version 비교로 최신만 반영
기준 원본이 product_metrics 하나로 통일되고, 조회 모델의 writer도 ReadModelSync 하나로 통일된다. 여러 경로에서 같은 데이터를 갱신하는 문제가 사라진다.
SoT 단일화의 중요성
기존에 좋아요 수 갱신 경로가 두 개 존재했다.
- in-process 경로: 좋아요 → 이벤트 리스너 → 동기적으로 조회 모델 갱신
- Kafka 경로: 좋아요 → Outbox → Kafka → Consumer → product_metrics → snapshot → 조회 모델 갱신
두 경로가 공존하면 같은 데이터를 두 곳에서 갱신하게 되어 데이터 불일치가 발생한다. Kafka 경로가 완성되면 in-process 경로를 반드시 제거하여 writer를 하나로 통일해야 한다.
Batch 안전망
Consumer 장애, 이벤트 유실, 버그 등으로 product_metrics와 조회 모델 사이에 누적 drift가 생길 수 있다. 배치 작업이 주기적으로 두 테이블을 비교하여 불일치를 보정한다.
이 배치는 실시간 동기화가 아니라 안전망이다. 주 경로는 Consumer이고, 배치는 하루 1회 실행하여 drift를 감지하고 보정한다.
정리
Kafka 파이프라인의 핵심은 세 가지 보장이다.
- 발행 보장: Outbox 패턴으로 도메인 변경과 이벤트 저장을 원자적으로 묶는다
- 수신 보장: 멱등 처리와 manual ack으로 중복 처리와 유실을 방지한다
- 순서 보장: 파티션 키로 같은 대상의 이벤트가 순서대로 처리되게 한다
이 세 보장이 확보되면 나머지는 이 위에 쌓는 구조다. 배치 처리, Consumer Group 분리, DLQ, 2단 구조 모두 이 기본 보장 위에서 동작한다.
'이 이벤트는 유실되면 안 되는가, 이 처리는 중복되면 안 되는가, 이 순서가 뒤바뀌면 안 되는가'