- Published on
Kafka MSA 중복처리 막는 Outbox 패턴 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 Kafka를 쓰다 보면, 결국 한 번은 “왜 같은 주문 이벤트가 두 번 처리됐지?”를 마주칩니다. 원인은 다양합니다. 프로듀서 재시도, 네트워크 타임아웃, 컨슈머 리밸런스, 오프셋 커밋 타이밍, 혹은 단순한 운영 실수까지요. Kafka 자체는 기본적으로 at-least-once 전달을 제공하므로, 중복 메시지는 “정상적인” 시나리오에 속합니다.
문제는 비즈니스 로직이 중복을 견디지 못할 때입니다. 결제 승인, 포인트 적립, 재고 차감처럼 한 번만 일어나야 하는 작업이 두 번 실행되면 장애가 됩니다. 이 글에서는 중복처리를 막기 위한 대표적인 해법인 Outbox 패턴을 Kafka MSA에서 어떻게 구현하는지, 그리고 “Outbox만 넣으면 끝”이 아니라 컨슈머 멱등성까지 어떻게 설계해야 하는지 실무 관점에서 정리합니다.
또한 Outbox 테이블이 커지면 성능 문제가 생기므로, 운영 단계에서 PostgreSQL 유지보수까지 함께 고려해야 합니다. 관련해서는 PostgreSQL VACUUM·AUTOVACUUM 튜닝 - bloat로 느려질 때, PostgreSQL 느린 쿼리 튜닝 - auto_explain+pg_stat_statements도 같이 보면 좋습니다.
Kafka에서 중복이 생기는 대표 시나리오
Kafka에서 중복은 주로 다음 조합에서 발생합니다.
- 프로듀서
retries로 재전송이 발생했는데, 실제로는 브로커에 이미 기록된 경우 - 프로듀서가 성공 응답을 못 받고 재시도했지만, 첫 요청은 성공했던 경우
- 컨슈머가 처리 후 커밋 전에 죽어서, 재시작 후 같은 레코드를 다시 읽는 경우
- 컨슈머 리밸런스 중 처리 중이던 파티션이 다른 인스턴스로 이동하면서 재처리되는 경우
Kafka 트랜잭션(Exactly-once semantics)으로 어느 정도 완화할 수 있지만, 마이크로서비스 간 DB 트랜잭션과 Kafka 트랜잭션을 완전하게 묶기는 어렵고, 운영 복잡도도 큽니다. 그래서 많은 팀이 “발행은 Outbox로 신뢰성 있게, 소비는 멱등하게”라는 조합으로 현실적인 일관성을 달성합니다.
Outbox 패턴이 해결하는 것과 해결하지 못하는 것
Outbox 패턴의 핵심은 “도메인 상태 변경과 이벤트 기록을 같은 DB 트랜잭션으로 묶는다”입니다.
해결하는 것
- DB 업데이트는 됐는데 Kafka 발행이 실패해서 이벤트가 유실되는 문제
- Kafka 발행은 됐는데 DB 업데이트가 롤백되어 이벤트가 허위로 나가는 문제
해결하지 못하는 것
- Kafka 중복 전달 자체
- 컨슈머 측 중복 처리
즉 Outbox는 “유실/허위 발행”을 막고, 중복은 “컨슈머 멱등성”으로 막는 게 정석입니다.
구현 아키텍처: Transactional Outbox + Publisher + Idempotent Consumer
구성 요소는 보통 3개입니다.
서비스의 비즈니스 트랜잭션
- 주문 생성 같은 도메인 변경
- 같은 트랜잭션에서 outbox 테이블에 이벤트 row insert
Outbox Publisher(폴링 또는 CDC)
- DB에서 미발행 이벤트를 읽어 Kafka로 발행
- 발행 성공 시 outbox row를
SENT로 마킹하거나 삭제
Consumer의 멱등 처리
- 이벤트 ID를 기준으로 “이미 처리했으면 스킵”
- 처리와 dedup 기록을 같은 트랜잭션으로 묶음
이 글에서는 가장 보편적인 폴링 기반 Transactional Outbox를 예시로 다룹니다. CDC 기반(Debezium 등)은 운영/인프라 장점이 있지만, 도입 난이도가 더 높습니다.
1) Outbox 테이블 설계
PostgreSQL 예시입니다.
event_id: 전역 유일 ID(예: UUID)aggregate_type,aggregate_id: 어떤 도메인 엔티티에서 나온 이벤트인지event_type:OrderCreated같은 이벤트 이름payload: JSONstatus:PENDING,SENT,FAILEDcreated_at,sent_at
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx
ON outbox_events (status, created_at, id);
여기서 event_id를 유니크로 두는 이유는, 애플리케이션이 같은 이벤트를 실수로 두 번 insert 하는 것을 1차 방어하기 위함입니다.
2) 비즈니스 트랜잭션에서 Outbox 기록하기
예: 주문 생성 API에서 주문 row insert와 outbox insert를 같은 트랜잭션으로 처리합니다.
BEGIN;
INSERT INTO orders (order_id, user_id, total_amount, status)
VALUES ($1, $2, $3, 'CREATED');
INSERT INTO outbox_events (event_id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
$4,
'Order',
$1,
'OrderCreated',
jsonb_build_object(
'orderId', $1,
'userId', $2,
'totalAmount', $3
)
);
COMMIT;
이렇게 하면 “주문은 생성됐는데 이벤트가 안 나감” 또는 “이벤트는 나갔는데 주문이 없음” 같은 불일치가 사라집니다.
3) Outbox Publisher: 폴링 + 락 + 배치 발행
폴링 방식의 핵심은 동시 실행 안전성입니다. 여러 인스턴스가 동시에 outbox를 퍼블리시해도 같은 row를 중복 발행하지 않도록 해야 합니다.
PostgreSQL에서는 FOR UPDATE SKIP LOCKED가 실무에서 많이 쓰입니다.
퍼블리셔 쿼리 예시
BEGIN;
SELECT id, event_id, event_type, payload
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100;
-- 애플리케이션에서 Kafka 발행 수행
-- 발행 성공한 row만 SENT 처리
UPDATE outbox_events
SET status = 'SENT', sent_at = now()
WHERE id = ANY($1);
COMMIT;
이 패턴의 장점
- 여러 퍼블리셔 인스턴스가 있어도 row 단위로 락을 잡고 스킵하므로 중복 발행 가능성이 크게 줄어듭니다.
- 트랜잭션 범위를 짧게 유지하면 DB 부하도 관리 가능합니다.
주의점
- Kafka 발행이 “트랜잭션 밖”에서 일어나므로, 발행 성공 후
SENT업데이트 실패 같은 엣지 케이스가 있습니다. - 이 엣지 케이스는 결국 “중복 발행 가능성”으로 귀결되며, 그래서 컨슈머 멱등성이 필수입니다.
Node.js 퍼블리셔 의사 코드
// pseudocode
async function publishBatch(db, kafkaProducer) {
const tx = await db.begin();
const rows = await tx.query(`
SELECT id, event_id, event_type, payload
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100
`);
if (rows.length === 0) {
await tx.commit();
return;
}
// 트랜잭션은 오래 잡지 않는 게 좋지만,
// 여기서는 흐름 설명을 위해 단순화
const sentIds: number[] = [];
for (const r of rows) {
await kafkaProducer.send({
topic: 'order-events',
messages: [
{
key: r.event_id, // 파티션 키로도 활용 가능
value: JSON.stringify({
eventId: r.event_id,
type: r.event_type,
payload: r.payload,
}),
headers: {
'event-id': r.event_id,
'event-type': r.event_type,
},
},
],
});
sentIds.push(r.id);
}
await tx.query(
`UPDATE outbox_events SET status = 'SENT', sent_at = now() WHERE id = ANY($1)`
, [sentIds]
);
await tx.commit();
}
실무에서는 Kafka 발행이 느리면 DB 트랜잭션을 오래 잡게 되므로, 다음과 같은 변형을 많이 씁니다.
- 1단계:
PENDING을IN_PROGRESS로 바꾸고 커밋 - 2단계: 커밋 후 Kafka 발행
- 3단계: 성공 시
SENT, 실패 시FAILED또는 다시PENDING
이 경우에도 중복 가능성은 남고, 결국 컨슈머 멱등성이 최종 방어선입니다.
4) 컨슈머 멱등성: dedup 테이블로 “처리한 이벤트” 기록
중복처리를 막는 가장 확실한 방법은 “이 이벤트 ID는 이미 처리했는가?”를 DB에 유니크 제약으로 강제하는 것입니다.
예: 결제 서비스가 OrderCreated를 받아 결제 워크플로를 시작한다고 합시다.
dedup 테이블
CREATE TABLE consumed_events (
consumer_name TEXT NOT NULL,
event_id UUID NOT NULL,
consumed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
컨슈머 처리 트랜잭션 예시
핵심은 “업무 처리”와 “dedup 기록”을 같은 트랜잭션으로 묶는 것입니다.
BEGIN;
-- 1) 먼저 이벤트 처리 여부를 기록 (유니크로 중복 방지)
INSERT INTO consumed_events (consumer_name, event_id)
VALUES ($1, $2);
-- 2) 여기까지 왔다는 건 처음 처리하는 이벤트라는 뜻
-- 업무 로직 수행 (예: payment row 생성)
INSERT INTO payments (payment_id, order_id, status)
VALUES ($3, $4, 'INIT');
COMMIT;
만약 같은 이벤트가 다시 들어오면 consumed_events의 PK 충돌이 나고, 애플리케이션은 “이미 처리됨”으로 간주하고 커밋 없이 종료하거나 롤백 후 스킵하면 됩니다.
애플리케이션에서의 처리 흐름
- 메시지에서
eventId를 추출 - DB 트랜잭션 시작
consumed_events에 insert 시도- 성공하면 업무 로직 수행 후 커밋
- 유니크 위반이면 롤백 후 ack(또는 커밋)하고 종료
이 방식은 Kafka 오프셋 커밋이 늦어 재처리되더라도 비즈니스 중복이 발생하지 않습니다.
5) Kafka 키, 파티셔닝, 순서 보장까지 함께 설계하기
Outbox와 멱등성만으로 “중복”은 막을 수 있지만, 이벤트 순서가 중요한 도메인도 있습니다.
- 주문 상태 변경이
CREATED다음에PAID가 와야 한다 - 같은
aggregate_id에 대한 이벤트는 순서대로 처리되어야 한다
이 경우 Kafka 메시지 key를 aggregate_id로 두어 같은 주문 이벤트가 같은 파티션으로 가게 만들면 순서 보장에 유리합니다.
다만 event_id를 키로 쓰면 파티션이 랜덤에 가까워져 순서 보장이 깨질 수 있습니다. 실무에서는 다음 중 하나를 선택합니다.
key는aggregate_id, 그리고headers나 payload에event_id를 포함- 또는
key를aggregate_id로 하되, value에eventId포함
중복 방지는 event_id로 하고, 순서 보장은 aggregate_id로 하는 식의 역할 분리가 깔끔합니다.
6) 운영 관점: Outbox 테이블이 커지면 반드시 성능 이슈가 온다
Outbox는 이벤트가 계속 쌓이는 구조라, 운영을 시작하면 다음 문제가 생깁니다.
outbox_events가 커져 인덱스가 비대해짐PENDING조회가 느려짐SENTrow가 많아져 vacuum 부담 증가
대응 전략
SENT이벤트는 일정 기간 후 삭제하는 정리 작업(배치) 운영- 파티셔닝(월별 파티션 등)으로 drop이 쉽게 만들기
status, created_at, id인덱스 유지- autovacuum 설정 점검
PostgreSQL에서 bloat가 쌓이면 체감 성능이 급격히 떨어질 수 있으니, PostgreSQL VACUUM·AUTOVACUUM 튜닝 - bloat로 느려질 때를 참고해 outbox 같은 고삽입 테이블을 별도로 관리하는 것이 좋습니다.
7) 자주 하는 실수 체크리스트
Outbox만 도입하고 컨슈머 멱등성을 빼먹는다
Outbox는 유실을 막는 패턴이지, 중복을 “0”으로 만드는 패턴이 아닙니다. 컨슈머 dedup은 필수입니다.
퍼블리셔가 같은 row를 중복으로 집어가도 괜찮다고 생각한다
SKIP LOCKED가 있어도 장애 상황에서 중복 발행은 충분히 가능합니다. 멱등 소비로 흡수해야 합니다.
dedup을 Redis로만 한다
Redis는 빠르지만, 장애나 eviction 정책에 따라 기록이 사라질 수 있습니다. “중복이 치명적인 도메인”이라면 DB의 유니크 제약처럼 강한 저장소에 남기는 편이 안전합니다.
이벤트 ID가 진짜로 유일하지 않다
event_id를 도메인 키로 대충 만들면 충돌 가능성이 생깁니다. UUID v4 같은 전역 유일 ID를 쓰거나, aggregate_id와 시퀀스를 조합해 충돌 불가능하게 만드세요.
정리: Outbox는 시작점, 멱등성이 종착점
Kafka MSA에서 중복처리를 막으려면 다음 2가지를 함께 가져가야 합니다.
- Transactional Outbox로 “DB 변경과 이벤트 기록”을 원자적으로 묶어 유실/허위 발행을 제거
- 컨슈머 멱등성(dedup 테이블 + 유니크 제약)으로 중복 전달을 안전하게 흡수
이 조합은 구현 난이도 대비 효과가 크고, Kafka의 at-least-once 특성과도 잘 맞습니다. 결국 목표는 “중복 메시지가 오더라도 비즈니스는 한 번만 실행된다”이며, Outbox는 그 목표를 달성하기 위한 가장 실용적인 기반입니다.