전달 보장 - 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의 지연이 붙는다. - 순서와 중복은 다른 문제다. 멱등 소비자는 중복을 흡수하지만 순서를 고치지 않는다. 순서는 파티션 키가 맡는다.
- “정확히 한 번”이라는 말이 가리키는 것은 전달이 아니라 효과여야 한다. 메시지가 두 번 와도 결과가 한 번이면 목적은 달성된다.
무엇을 재면 확인되는가
- 소비자를 강제 종료해 중복을 만들고, 멱등 장치가 있을 때와 없을 때의 최종 상태를 비교한다.
acks를 바꿔 가며 브로커를 죽이고 유실 건수를 센다.- 트랜잭션을 켰을 때의 처리량과 지연 변화.
1번과 2번은 ParityPay 8편에서 했고, 3번과 다중 브로커 조건은 아직 남아 있다.
정리
- 세 보장을 가르는 것은 재시도 여부다. 유실을 막으면 중복이 생긴다.
- 실무의 기본값은 at-least-once이고, 중복은 소비자가 흡수한다.
- 중복은 프로듀서 쪽(응답 유실)과 컨슈머 쪽(커밋 전 종료) 두 곳에서 생긴다.
- Kafka의 exactly-once는 Kafka 안의 읽고-처리하고-쓰기에 한정된다. 외부 DB나 부작용에는 미치지 않는다.
- 멱등 소비자는 메시지 ID 기록, 업무 유니크 키, 조건부 상태 전이 중 하나로 만든다.
- Outbox는 발행 의도를 잃지 않게 할 뿐이고, 브로커 유실은
acks가 막는다. - 목표는 “정확히 한 번 전달”이 아니라 “정확히 한 번의 효과”다.
댓글
아직 댓글이 없습니다