LabHub
배우기 러닝패스 코스

주문이 두 번 도착했고 한 번은 사라졌다 · 두 번째 도착 · 이론

두 번째 도착 — 재시도가 만드는 중복과 멱등 프로듀서

LabHub 에서 이어서 보기

한 줄 요약

프로듀서가 응답을 못 받으면 메시지가 커밋됐는지 알 길이 없어 다시 보내고,
원래 요청이 성공했다면 로그에 두 벌이 남는다. 멱등 프로듀서는 브로커가 준
프로듀서 ID 와 레코드 순번으로 재전송을 걸러 한 벌만 남긴다. 4.x 에서는
기본으로 켜져 있지만, 세션을 넘는 재전송은 덮지 못한다.

왜 이게 필요했나

[설계 문서](https://kafka.apache.org/43/design/design/)의 '메시지 전달 의미론'
절은 문제를 정확히 적는다. 프로듀서가 발행 중 네트워크 오류를 만나면 그 오류가
메시지가 커밋되기 에 난 것인지 에 난 것인지 알 수 없다 —
자동 생성 키를 가진 테이블에 INSERT 하다 연결이 끊긴 것과 같다. 0.11 이전의
프로듀서는 다시 보내는 것 외에 선택이 없었고, 그래서 at-least-once 였다. 원래
요청이 사실 성공했다면 재전송이 로그에 같은 메시지를 한 번 더 쓴다.

0.11 부터 멱등 전달 옵션이 생겼다. 브로커가 프로듀서마다 ID 를 주고, 프로듀서는
메시지마다 순번(sequence)을 붙여 보내며, 브로커는 같은 ID·순번의 메시지를
걸러 낸다. 같은 시기에 트랜잭션도 들어와 여러 파티션에 원자적으로 쓸 수 있게
됐다. 이 코스는 멱등까지만 다룬다 — 트랜잭션은 그 위의 층이다.

어떻게 동작하나

acks 는 무엇을 기다릴지 정한다. [프로듀서 설정 문서](https://kafka.apache.org/43/configuration/producer-configs/)
acks 항목 — 0 은 서버의 확인을 전혀 기다리지 않아 받았다는 보장이 없고
재시도도 일어나지 않는다(오프셋은 항상 -1). 1 은 리더가 자기 로그에 쓰면
답하므로 팔로워가 복제하기 전에 리더가 죽으면 잃는다. all(= -1)은 ISR
전체가 확인할 때까지 기다리며, ISR 중 하나라도 살아 있으면 잃지 않는 가장
강한 보장이다. 기본값은 all 이고, 멱등을 켜려면 all 이어야 한다.

재시도는 기본으로 거의 무한이다. retries 기본값은 2147483647 이고, 문서는
이 값을 건드리지 말고 delivery.timeout.ms(기본 120000)로 재시도의 총 시간을
다스리라고 권한다. delivery.timeout.msrequest.timeout.ms(기본 30000) +
linger.ms 이상이어야 한다. request.timeout.ms 항목에는 이런 문장이 있다 —
이 값은 브로커의 replica.lag.time.max.ms(기본 30000)보다 커야 **불필요한
재시도로 인한 메시지 중복 가능성**을 줄일 수 있다. 문서가 중복을 재시도의
결과로 명시한 자리다.

멱등은 조건이 있다. enable.idempotence 항목 — 켜면 각 메시지가 정확히
한 벌만 스트림에 쓰이고, 끄면 브로커 장애 등으로 인한 재시도가 중복을 쓸 수
있다. 켜려면 max.in.flight.requests.per.connection 이 5 이하, retries 가 0
초과, acksall 이어야 한다. **충돌하는 설정이 있고 멱등을 명시적으로
켜지 않았으면 멱등은 조용히 꺼진다.** 명시적으로 켠 채 충돌하면
ConfigException 이다. 그래서 acks=1 을 "성능 때문에" 넣은 순간 중복 방어가
사라지는데 아무 경고가 없다.

멱등 프로듀서의 배치 헤더 (kafka-dump-log.sh)  producerId: 1  producerEpoch: 0  baseSequence: 0  lastSequence: 1   ← ID 와 순번멱등을 끈 배치  producerId: -1 producerEpoch: -1 baseSequence: -1 lastSequence: -1  ← 걸러 낼 재료가 없다

브로커는 이 헤더로 재전송을 알아본다. 실측(4.3.1, 로컬 브로커에 600ms 지연을
걸고 request.timeout.ms=300, retries=2)에서 멱등을 끈 프로듀서는 같은
레코드를 세 벌 남겼고 멱등을 켠 프로듀서는 한 벌만 남겼다 — 둘 다 프로듀서는
결국 "실패" 라고 보고했다. 응답을 못 받았을 뿐 브로커에는 있었던 것이다.

멱등이 덮지 않는 것. ID 와 순번은 프로듀서 세션의 것이다. 프로세스가
새로 뜨면 새 ID 를 받고 순번은 0 부터라, 애플리케이션이 실패를 보고받고
다시 send 하는 것은 브로커 눈에 새 레코드다. transactional.id 항목이 이것을
"여러 프로듀서 세션에 걸친 신뢰성" 이라 부르며 트랜잭션의 영역으로 둔다.
그 층이 없다면 답은 업무 키(주문 번호)로 컨슈머 쪽에서 거르는 것이다.

현장에서 만나는 모습

결제 서비스에서 "같은 결제가 두 번 기록됐다" 는 신고가 오면 첫 확인은 프로듀서
설정이다. acks=1 이나 max.in.flight=10 같은 값이 있으면 멱등이 조용히 꺼져
있다. 두 번째 확인은 로그의 배치 헤더다 — producerId 가 서로 다른 두 배치에
같은 결제가 있으면 애플리케이션 층의 재전송이고, 이것은 프로듀서 설정으로
막을 수 없다.

반대로 "보냈다는데 없다" 는 acks=0 의 모양이다. 프로듀서는 소켓 버퍼에 넣는
순간 성공으로 보고, 그 뒤 무슨 일이 나도 모른다. 지연 민감한 로그 수집에는
받아들일 만한 거래이고, 결제에는 아니다.

다음 실습에서 할 것

기본 프로듀서와 멱등을 끈 프로듀서의 배치 헤더를 덤프로 비교하고, 느린
네트워크를 켜 재시도가 세 벌을 남기는 것과 멱등이 한 벌로 만드는 것을 재현한
뒤, 프로듀서를 두 번 따로 실행해 세션을 넘는 재전송은 막히지 않음을 본다.