비동기 메시징을 도입하면 거의 필연적으로 “같은 메시지가 두 번 처리됐다”는 사고를 겪습니다. 결제가 두 번 청구되고 재고가 두 번 차감됩니다. 대부분의 브로커가 기본 보장하는 것이 at-least-once(최소 한 번)이기 때문입니다. 컨슈머는 처리 직후 오프셋 커밋 전에 죽을 수 있고, 프로듀서는 응답을 못 받아 재전송합니다. 이 상황에서 중복은 예외가 아니라 정상 동작입니다.
그래서 “정확히 한 번(exactly-once)”은 브로커가 마법처럼 제공하는 기능이 아니라, 멱등성(idempotency)을 애플리케이션 레벨에서 설계해 만들어 내는 결과에 가깝습니다. 이 글에서는 exactly-once 전달이 왜 어려운지부터 멱등 키 설계, 저장소 기반 중복 제거, 트랜잭셔널 아웃박스, Kafka 트랜잭션까지 실무 패턴을 정리합니다. 목표는 “이론상 완벽한 전달”이 아니라 “중복이 와도 결과가 달라지지 않는 시스템”입니다.
왜 exactly-once 전달은 물리적으로 어려운가
메시지 처리는 소비 → 실행 → 확정(ack/커밋) 세 단계로 나뉘고, 이 단계들은 서로 다른 시스템(브로커, DB)에 걸쳐 있어 사이 어디서든 프로세스가 죽을 수 있습니다. 확정 순서를 어떻게 잡아도 완벽하지 않습니다. 처리 후 ack하면 ack 직전에 죽었을 때 재전달되어 중복이 나고(at-least-once), ack를 먼저 하면 처리 전에 죽었을 때 유실됩니다(at-most-once). “처리”와 “확정”을 하나의 원자적 연산으로 묶을 수 없기 때문에, 전달 레벨의 순수한 exactly-once는 도달 불가능합니다.
- at-most-once: 중복은 없지만 유실 가능 — 처리 확정을 먼저 함
- at-least-once: 유실은 없지만 중복 가능 (대부분의 기본값)
- effectively-once: at-least-once + 컨슈머 멱등성으로 결과적으로 한 번
현실적인 해법은 “at-least-once를 받아들이되 결과가 한 번 처리한 것과 동일하도록 만든다”입니다. 그 핵심 도구가 멱등성입니다.
멱등 키 설계가 모든 것의 출발점
멱등성을 구현하려면 먼저 “같은 메시지”를 판별할 멱등 키(idempotency key)가 필요합니다. 흔한 실수는 브로커가 부여한 메시지 ID를 쓰는 것입니다. 프로듀서가 재전송하면 브로커 레벨에서는 다른 메시지가 되어 ID가 바뀌므로 중복을 못 잡습니다. 멱등 키는 “동일한 의도의 요청은 동일한 키”를 만족하도록 비즈니스 의미에서 생성되어야 합니다. 주문 결제라면 payment:{order_id}, 재고 차감이라면 stock-deduct:{order_id}:{line_item_id}가 자연 키입니다. 자연 키가 없다면 요청 시작점에서 UUID를 생성해 실어 보내고 재전송 시에도 같은 UUID를 유지합니다.
# 프로듀서: 멱등 키는 요청 시작점에서 1회 생성하고 재전송해도 고정
def build_payment_event(order_id: str, amount: int) -> dict:
return {
"idempotency_key": f"payment:{order_id}", # 재시도해도 안정적
"order_id": order_id,
"amount": amount,
"request_id": str(uuid.uuid4()), # 추적용, 멱등 판별엔 X
}
# 잘못된 예: 매 전송마다 새 UUID → 재전송이 "다른 메시지"가 되어 중복 유발
def bad_build(order_id, amount):
return {"idempotency_key": str(uuid.uuid4()), "order_id": order_id}
멱등 키의 스코프도 중요합니다. 하나의 이벤트가 여러 부수효과(결제·재고·알림)를 일으킨다면 각 부수효과마다 독립적인 키를 둡니다. 알림 키가 결제 키와 얽혀 있으면, 알림만 재시도되는 상황에서 결제까지 재시도하게 됩니다.
저장소 기반 중복 제거: dedup 테이블
가장 견고한 멱등성 구현은 관계형 DB의 유니크 제약을 이용하는 것입니다. 멱등 키(idempotency_key PK)와 status(PROCESSING/DONE), created_at·updated_at을 가진 processed_messages 테이블에 처리 시작 시 INSERT를 시도하고, 이미 존재하면 발생하는 유니크 위반을 “이미 처리됨” 신호로 해석합니다.
핵심은 중복 제거 기록과 비즈니스 변경을 같은 트랜잭션에 넣는 것입니다. 둘이 분리되면 틈이 생깁니다. 비즈니스 로직은 성공했는데 dedup 기록 전에 죽으면 재전달 시 중복 처리되고, 반대로 dedup만 기록하고 커밋 전에 죽으면 “처리 표시는 됐지만 실제로는 안 된” 유령 상태가 됩니다.
# 컨슈머: dedup INSERT 와 비즈니스 변경을 하나의 트랜잭션으로 묶는다
from psycopg2.errors import UniqueViolation
def process_payment(conn, event: dict):
with conn, conn.cursor() as cur: # 트랜잭션 경계 (성공 시 커밋)
try: # 1) 멱등 키 선점
cur.execute("INSERT INTO processed_messages (idempotency_key, status) "
"VALUES (%s, 'DONE')", (event["idempotency_key"],))
except UniqueViolation: # 이미 처리됨 → ack 만
conn.rollback()
return "DUPLICATE_SKIPPED"
# 2) 같은 트랜잭션 안에서 비즈니스 변경 → 원자적으로 함께 확정
cur.execute("UPDATE accounts SET balance = balance - %s WHERE order_id = %s",
(event["amount"], event["order_id"]))
return "PROCESSED"
이 방식의 강력함은 원자성에 있습니다. 커밋되면 dedup 기록과 비즈니스 변경이 둘 다 반영되고 롤백되면 둘 다 사라지므로, 메시지가 몇 번 재전달되든 잔액은 정확히 한 번만 차감됩니다.
긴 처리와 동시성: PROCESSING 상태
비즈니스 로직이 오래 걸리거나 트랜잭션에 묶을 수 없는 부수효과(외부 API 호출 등)가 있으면, 같은 메시지가 동시에 두 컨슈머로 전달될 때 둘 다 처리를 시작할 수 있습니다. 이때는 처리 전에 INSERT ... ON CONFLICT DO NOTHING RETURNING으로 키를 PROCESSING으로 선점하고 처리가 끝나면 DONE으로 전이합니다. 단, 워커가 도중에 죽으면 그 키가 영원히 PROCESSING으로 남아 재처리를 막으므로, updated_at이 타임아웃을 넘긴 레코드에 한해 재선점을 허용합니다.
트랜잭셔널 아웃박스: 발행의 원자성
지금까지는 컨슈머 측 중복 제거였습니다. 프로듀서 측에도 같은 문제가 있습니다. “DB에 주문을 저장하고 그 사실을 이벤트로 발행”하는 흐름에서 DB 커밋과 브로커 발행은 원자적이지 않아, 커밋 후 발행 전에 죽으면 이벤트가 유실되고 발행 후 롤백되면 존재하지 않는 주문의 이벤트가 나갑니다.
트랜잭셔널 아웃박스(transactional outbox) 패턴은 이벤트를 브로커에 직접 보내지 않고 비즈니스 변경과 같은 트랜잭션으로 outbox 테이블에 기록하고, 별도 발행기(relay)가 아웃박스를 폴링하거나 CDC로 읽어 브로커에 전달합니다. 트랜잭션이 원자적이므로 “주문 저장”과 “이벤트 기록”은 함께 성공하거나 함께 실패합니다.
# 아웃박스: outbox(id, event_type, payload, published, created_at)
# 비즈니스 변경과 아웃박스 기록을 같은 트랜잭션에
def create_order(conn, order):
with conn, conn.cursor() as cur:
cur.execute("INSERT INTO orders (id, user_id, amount) VALUES (%s,%s,%s)",
(order["id"], order["user_id"], order["amount"]))
# 같은 트랜잭션 → 주문과 이벤트가 원자적으로 함께 확정된다
cur.execute("INSERT INTO outbox (event_type, payload) "
"VALUES ('OrderCreated', %s)", (json.dumps(order),))
# 이후 relay 가 published=false 인 행을 읽어 브로커로 발행 후 true 로 마킹
주의할 점은 아웃박스 발행기 역시 at-least-once라는 것입니다. 발행 후 published=true 마킹 전에 죽으면 같은 이벤트를 다시 보냅니다. 즉 아웃박스는 유실을 막지만 중복은 여전히 발생하므로 컨슈머 멱등성과 함께 써야 하며, 아웃박스 이벤트의 id나 페이로드의 멱등 키를 컨슈머 dedup 키로 재사용하면 자연스럽게 연결됩니다.
Kafka 트랜잭션과 read-process-write
Kafka는 스트림 처리에 한해 브로커 차원의 exactly-once 시맨틱(EOS)을 제공합니다. “토픽에서 읽어 처리하고 다른 토픽에 쓰는” read-process-write 루프에서 오프셋 커밋과 프로듀서 쓰기를 하나의 트랜잭션으로 묶어 주며, 고정 transactional.id와 멱등 프로듀서를 켜면 재시도로 인한 중복 쓰기가 제거됩니다.
# Kafka: 오프셋 커밋 + 출력 토픽 쓰기를 하나의 트랜잭션으로
# producer: enable.idempotence=True, transactional.id="enricher-1", acks="all"
# consumer: enable.auto.commit=False, isolation.level="read_committed"
producer.init_transactions()
while (msgs := consumer.consume(num_messages=100, timeout=1.0)):
producer.begin_transaction()
for m in msgs:
producer.produce("orders-enriched", value=transform(m.value()))
# 소비 오프셋을 같은 트랜잭션에 포함 → 처리와 커밋이 원자적
producer.send_offsets_to_transaction(
consumer.position(consumer.assignment()),
consumer.consumer_group_metadata())
producer.commit_transaction()
다만 EOS가 원자적으로 묶는 것은 Kafka 내부의 읽기·쓰기·오프셋뿐입니다. 처리 도중 외부 DB를 갱신하거나 HTTP 요청을 보내면 그 부수효과는 트랜잭션 밖이라 보호받지 못합니다. 컨슈머 isolation.level을 read_committed로 두지 않으면 미완결 트랜잭션 메시지까지 읽어 보장이 깨지는 것도 흔한 함정입니다.
- 보호됨: Kafka 토픽 → 처리 → Kafka 토픽 + 오프셋 (순수 스트림)
- 보호 안 됨: Kafka → 외부 DB/API (여전히 컨슈머 멱등성 필요)
- 필수 조건:
transactional.id고정, 컨슈머read_committed, 오프셋 수동 커밋
재시도와 데드레터
멱등성이 갖춰지면 중복이 와도 안전하므로 재시도를 마음 놓고 할 수 있습니다. 일시적 실패(네트워크 순단, DB 데드락)는 지수 백오프로 재시도하되, 무한 재시도는 큐를 막으므로 상한을 두고 초과하면 데드레터 큐(DLQ)로 격리해 수동 재처리 대상으로 넘깁니다.
# 지수 백오프 + 재시도 상한 + DLQ 격리
def consume_with_retry(msg, max_attempts=5):
attempt = msg.headers.get("x-attempt", 0)
try:
process_payment(conn, msg.value) # 멱등하므로 재시도 안전
ack(msg)
except TransientError: # 일시적 → 재시도
if attempt + 1 >= max_attempts:
send_to_dlq(msg, reason="max_retries"); ack(msg)
else:
delay = min(2 ** attempt, 60) # 1,2,4,8,...60초 상한
requeue(msg, headers={"x-attempt": attempt + 1}, delay=delay)
except PermanentError as e: # 영구적 → 즉시 DLQ
send_to_dlq(msg, reason=str(e)); ack(msg)
핵심은 일시적 오류와 영구적 오류를 구분하는 것입니다. 검증 실패나 스키마 불일치를 계속 재시도하면 백로그만 쌓이고, 반대로 일시적 데드락을 즉시 DLQ로 보내면 정상 처리 가능한 메시지를 버리게 됩니다.
정리: 계층으로 쌓는 effectively-once
정확히 한 번 처리는 단일 스위치가 아니라 여러 계층의 합입니다. 발행 측은 아웃박스로 유실을 막고, 컨슈머는 멱등 키 + dedup 트랜잭션으로 중복을 흡수합니다. Kafka 순수 스트림이라면 EOS 트랜잭션이 상당 부분을 대신해 주지만, 외부 부수효과가 끼는 순간 다시 애플리케이션 멱등성이 주역이 됩니다.
실무에서 가장 흔한 실패는 “브로커가 exactly-once를 해 준다”고 믿고 컨슈머 멱등성을 생략하는 것입니다. 중복은 반드시 온다고 가정하고, 멱등 키를 비즈니스 의미에서 설계하며, 중복 제거와 비즈니스 변경을 하나의 트랜잭션으로 원자화하는 것 — 이 세 가지가 흔들리지 않으면 결과는 정확히 한 번입니다.
자주 묻는 질문
Q. 멱등 키를 브로커의 메시지 ID로 쓰면 안 되나요?
A. 위험합니다. 프로듀서가 응답을 못 받아 재전송하면 브로커 레벨에서는 새 메시지가 되어 ID가 바뀌므로, 정작 잡아야 할 “재시도로 인한 중복”을 놓칩니다. 멱등 키는 요청의 비즈니스 의도에서 파생되어야 하며(예: payment:{order_id}), 자연 키가 없으면 요청 시작점에서 UUID를 한 번 생성해 고정합니다.
Q. Redis의 SETNX로 중복 제거를 해도 되나요?
A. 순수 알림 억제 같은 저위험 케이스에는 쓸 수 있지만, 결제·재고처럼 정합성이 중요하면 권장하지 않습니다. Redis 중복 제거는 비즈니스 DB 변경과 같은 트랜잭션에 묶이지 않아 “Redis에는 기록됐지만 DB 커밋은 실패”하는 틈이 생깁니다. 강한 정합성이 필요하면 관계형 DB 유니크 제약 기반 dedup이 안전합니다.
Q. dedup 테이블이 무한히 커지는데 어떻게 관리하나요?
A. 멱등 키는 “중복 메시지가 도착할 수 있는 최대 창” 이상만 유지하면 됩니다. 브로커 보존 기간과 재시도 상한을 고려해 보통 며칠에서 몇 주면 충분합니다. created_at 인덱스로 배치 삭제하거나 시간 기준 파티션으로 만들어 오래된 파티션을 통째로 DROP하면 효율적입니다. 단, 삭제 주기가 브로커 최대 재전달 지연보다 짧으면 처리한 메시지를 다시 처리할 수 있으니 여유를 둡니다.