포스트

전달 보장 - at-most-once, at-least-once, exactly-once가 각각 무엇을 약속하는가

“Kafka는 exactly-once를 지원한다”는 문장이 오해를 만든다. 지원하는 것은 맞고, 그 보장의 범위가 생각보다 좁은 것도 맞다. 세 가지 보장이 각각 어디서 어디까지를 약속하는지 경계를 그어 두면, 언제 트랜잭션을 켜고 언제 멱등 소비자를 만들지가 정해진다.

세 가지가 나뉘는 지점은 재시도다

메시지 하나가 프로듀서에서 컨슈머까지 가는 동안 실패할 수 있는 곳이 여럿이다. 실패했을 때 다시 보내는가 아닌가가 세 보장을 가른다.

보장재시도결과
at-most-once안 함유실 가능, 중복 없음
at-least-once함유실 없음, 중복 가능
exactly-once함 + 중복 제거유실 없음, 중복 없음 (범위 한정)

at-most-once는 로그나 지표처럼 일부 유실을 감당할 수 있는 곳에 쓴다. 금액이 움직이는 경로에서는 선택지가 아니다.

실무의 기본값은 at-least-once다. 유실을 막으려면 재시도해야 하고, 재시도하면 중복이 생긴다. 중복은 소비자가 흡수한다.

중복이 생기는 두 곳

프로듀서 쪽. 브로커가 기록했는데 응답만 유실되면 프로듀서는 실패로 보고 다시 보낸다. 같은 메시지가 두 번 기록된다. Kafka의 멱등 프로듀서(enable.idempotence=true)가 이것을 막는다. 프로듀서 ID와 시퀀스 번호를 붙여 브로커가 중복을 걸러낸다. 지금은 기본값이다.

컨슈머 쪽. 메시지를 처리하고 오프셋을 커밋하기 전에 죽으면, 재시작 후 같은 메시지를 다시 받는다. 처리와 커밋이 원자적이지 않은 한 이 창은 항상 열려 있다.

ParityPay 8편에서 이것을 직접 재현했다. 소비자를 종료시켜 중복 30~1,909건이 도착했고, 커밋 방식(자동/수동)을 바꿔도 중복의 양만 달라지고 발생 자체는 사라지지 않았다. 결과가 정확히 1회였던 것은 멱등 소비자(consumed_event 테이블 + 업무 유니크 키) 덕이다.

Kafka의 exactly-once가 덮는 범위

Kafka 트랜잭션은 “읽고-처리하고-쓰기”가 Kafka 안에서 일어날 때 원자성을 준다. 컨슈머가 토픽 A에서 읽고 토픽 B에 쓰고 오프셋을 커밋하는 것을 하나의 트랜잭션으로 묶는다. 스트림 처리(Kafka Streams의 processing.guarantee=exactly_once_v2)가 이 위에 있다.

덮지 못하는 것이 분명하다.

  • 외부 시스템에 쓰는 순간 깨진다. 메시지를 읽고 DB에 쓰고 오프셋을 커밋하는 것은 Kafka 트랜잭션에 들어가지 않는다. 두 시스템에 걸친 원자성은 2PC 없이는 없다.
  • 외부 부작용은 되돌릴 수 없다. 메일 발송, 외부 결제 승인, 파일 업로드. 트랜잭션이 롤백돼도 그것들은 이미 일어났다.
  • 컨슈머가 read_committed여야 한다. 기본값은 read_uncommitted라 중단된 트랜잭션의 메시지도 읽는다.

그래서 현실적인 구성은 이렇게 된다. 전송은 at-least-once로 두고, 효과를 멱등하게 만든다.

멱등 소비자를 만드는 방법

세 가지가 쓰인다.

메시지 ID 기록. 처리한 메시지의 ID를 테이블에 남기고, 이미 있으면 건너뛴다. 기록과 업무 처리가 같은 트랜잭션이어야 한다. 아니면 기록 후 죽었을 때 처리가 빠진다.

업무 유니크 키. “결제 1234의 승인 원장은 하나”처럼 도메인 제약을 DB에 둔다. 중복 메시지가 와도 제약 위반으로 막힌다. 별도 테이블이 필요 없고 도메인 규칙과 일치한다는 점에서 더 견고하다.

조건부 상태 전이. WHERE status = 'PENDING'을 붙여 갱신한다. 이미 처리된 건이면 영향 행 수가 0이다. 상태 기계가 있는 도메인에 잘 맞는다.

ParityPay는 첫 번째와 두 번째를 함께 썼다. 중복 1,934건이 도착해도 결과는 정확히 1회였다.

반대 방향: DB에서 Kafka로

애플리케이션이 DB에 쓰고 Kafka에 발행하는 경우, 두 작업 사이에 죽으면 둘이 어긋난다. DB만 커밋되고 발행이 안 되거나, 발행만 되고 DB가 롤백된다.

Transactional Outbox가 이것을 푼다. 발행할 이벤트를 같은 트랜잭션으로 outbox 테이블에 쓰고, 별도 프로세스가 그 테이블을 읽어 발행한다. DB 커밋이 곧 발행 의도의 커밋이다.

이것도 exactly-once는 아니다. 발행 후 상태 갱신 전에 죽으면 다시 발행한다. outbox가 보장하는 것은 “커밋된 발행 의도를 잃지 않는 것”까지이고, 중복은 여전히 소비자가 흡수한다.

이 설명이 깨지는 곳

  • acks 설정이 유실을 정한다. outbox가 있어도 acks=1이면 리더 교체 중 유실이 가능하다. ParityPay 8편에서 acks=all은 0건, acks=1과 0은 배치 단위로 잃었다. Outbox는 이 유실을 막지 못한다.
  • exactly-once는 성능 비용이 있다. 트랜잭션 코디네이터 왕복과 read_committed의 지연이 붙는다.
  • 순서와 중복은 다른 문제다. 멱등 소비자는 중복을 흡수하지만 순서를 고치지 않는다. 순서는 파티션 키가 맡는다.
  • “정확히 한 번”이라는 말이 가리키는 것은 전달이 아니라 효과여야 한다. 메시지가 두 번 와도 결과가 한 번이면 목적은 달성된다.

무엇을 재면 확인되는가

  1. 소비자를 강제 종료해 중복을 만들고, 멱등 장치가 있을 때와 없을 때의 최종 상태를 비교한다.
  2. acks를 바꿔 가며 브로커를 죽이고 유실 건수를 센다.
  3. 트랜잭션을 켰을 때의 처리량과 지연 변화.

1번과 2번은 ParityPay 8편에서 했고, 3번과 다중 브로커 조건은 아직 남아 있다.

정리

  • 세 보장을 가르는 것은 재시도 여부다. 유실을 막으면 중복이 생긴다.
  • 실무의 기본값은 at-least-once이고, 중복은 소비자가 흡수한다.
  • 중복은 프로듀서 쪽(응답 유실)과 컨슈머 쪽(커밋 전 종료) 두 곳에서 생긴다.
  • Kafka의 exactly-once는 Kafka 안의 읽고-처리하고-쓰기에 한정된다. 외부 DB나 부작용에는 미치지 않는다.
  • 멱등 소비자는 메시지 ID 기록, 업무 유니크 키, 조건부 상태 전이 중 하나로 만든다.
  • Outbox는 발행 의도를 잃지 않게 할 뿐이고, 브로커 유실은 acks가 막는다.
  • 목표는 “정확히 한 번 전달”이 아니라 “정확히 한 번의 효과”다.

참고

Kafka와 메시징 결제·정산 정합성
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다