Messaging

Kafka 멱등 소비자란?

한 줄로

멱등 소비자(Idempotent Consumer)는 같은 메시지를 두 번 받아도 결과가 한 번 받았을 때와 같은 소비자다. 골프 크롤러에 Kafka 예약 이벤트를 붙이면서, 소비자는 event_id UNIQUE 제약과 ON CONFLICT DO NOTHING 으로 중복 메시지를 그냥 넘기게 했다. 생산자 쪽은 아웃박스를 두지 않았다. Kafka 가 없거나 죽어도 크롤러는 멈추지 않는다. 그래서 중복은 소비자가 막고, 유실은 감수하는 설계가 됐다.

멱등 소비자란?

메시지 브로커는 보통 메시지를 ‘최소 한 번(at-least-once)’ 전달한다. 소비자가 메시지를 처리하고 나서 처리했다는 확인을 남기기 전에 죽을 수 있다. Kafka 에서는 이 확인을 오프셋 커밋이라고 부른다. 이 경우 소비자가 다시 뜨면 같은 메시지를 또 받는다. 브로커 입장에서는 메시지를 잃는 것보다 두 번 보내는 쪽이 낫기 때문이다.

그래서 중복을 걸러내는 일은 소비자가 맡는다. 메시지마다 고유한 ID 를 붙여 두고, 소비자가 그 ID 로 이미 처리한 메시지인지 판단하는 방식이다. DB 에 저장하는 소비자라면 가장 간단한 형태는 다음과 같다.

  • 이벤트 테이블의 event_id 에 UNIQUE 제약을 건다
  • INSERT ... ON CONFLICT DO NOTHING 으로 넣는다. 이미 있는 ID 면 아무 일도 일어나지 않는다

이렇게 하면 같은 메시지가 몇 번 오든 행은 하나만 남는다. 처리 기록을 따로 관리하지 않고 DB 제약 하나로 해결된다.

같이 알아둘 것: 아웃박스 패턴

멱등 소비자는 ‘중복’을 막는다. 아웃박스 패턴(Transactional Outbox)은 ‘유실’을 막는다. 비즈니스 데이터를 저장하는 트랜잭션 안에서, 보낼 이벤트도 DB 의 아웃박스 테이블에 함께 적는다. 그러면 별도 프로세스가 그 테이블을 읽어 브로커로 보낸다. 브로커가 죽어 있어도 이벤트는 DB 에 남으니 나중에 보내면 된다.

그 대신 테이블 하나와, 그 테이블을 읽어 브로커로 보내는 릴레이(relay) 프로세스가 더 생긴다. 이번 작업에서는 아웃박스를 쓰지 않았다.

실제로 겪은 것: 골프 크롤러 예약 이벤트

Kafka 를 연습해 볼 프로젝트로 golf-crawler 의 예약 이벤트를 골랐다. 설계 문서와 계획 문서는 아래에 있다.

  • 설계: docs/superpowers/specs/2026-10-01-kafka-booking-events-design.md (2e15404)
  • 계획: docs/superpowers/plans/2026-10-01-kafka-booking-events.md (5b40377)

생산자: Kafka 장애로 크롤러가 멈추면 안 된다

Kafka 는 연습하려고 붙이는 것이다. 그런데 Kafka 때문에 원래 잘 돌던 크롤러나 알림이 멈추면 본말이 뒤집힌다. 그래서 생산자 쪽 규칙을 이렇게 정했다.

  • 환경변수 KAFKA_BOOTSTRAP 이 없으면 이벤트를 발행하지 않는다(no-op, 아무것도 하지 않음). Kafka 없이 띄워도 크롤러는 전과 똑같이 돈다.
  • 아웃박스는 쓰지 않는다. 그래서 브로커가 5분 넘게 죽어 있으면 그동안의 이벤트는 유실된다.

이벤트를 잃는 위험은 받아들이고, 본 기능이 계속 돌아가는 쪽을 지킨 선택이다.

소비자: 중복은 DB 제약으로 막는다

중복은 반대로 확실히 막았다. 소비자는 event_id UNIQUE 와 ON CONFLICT DO NOTHING 을 써서, 같은 메시지를 다시 받으면 무시한다. 앞 절에서 설명한 가장 간단한 형태를 그대로 쓴 것이다.

이벤트 스키마: round_date 에 연도가 없다

기존 round_date 에는 연도가 없다. 이 빈 부분을 메우려고 이벤트에 play_date 를 추가했다.

매물 수명 뷰(booking_lifetimes)는 이렇게 쌓인 이벤트 가운데 가장 최근 이벤트를 보고 매물 상태를 판정한다.

테스트 기준

테스트는 전용 컨테이너에서 돌린다. 테스트 588개 중 8개는 원래부터 실패한다(DB 연결이 필요한 테스트). 그래서 통과 기준을 ‘그 8개를 빼고 새로 생긴 실패 0개’로 잡았다.

구현과 배포

계획의 Task 0~7 은 구현과 리뷰를 거쳤다(Task 6 은 6b48635). 배포한 뒤에는 약 7천 건으로 재생 연습을 했다. 재생은 같은 데이터를 다시 흘려보내는 작업이다. 소비자가 중복을 걸러낸다는 전제가 있어야 부담 없이 할 수 있다.

남은 부담: 배포에 생긴 의존성

코드에서는 Kafka 가 없어도 되도록 만들었지만, 배포에는 의존성이 생겼다. 이제 배포할 때마다 Kafka compose 와 공유 네트워크 kafka-net 이 준비돼 있어야 한다.

이 레포는 push 하면 pre-push 훅이 바로 배포한다. 그래서 미커밋 작업이 섞여 들어가지 않도록 배포는 표준 루트에서 한다.

정리

상황 선택
Kafka 설정이 없을 때 KAFKA_BOOTSTRAP 이 없으면 발행하지 않음(no-op)
브로커가 죽었을 때 아웃박스 없음 → 5분 넘게 죽으면 이벤트 유실
같은 메시지를 다시 받을 때 event_id UNIQUE + ON CONFLICT DO NOTHING
연도 없는 round_date 이벤트에 play_date 추가
매물 상태 판정 booking_lifetimes 가 가장 최근 이벤트로 판정

중복과 유실을 둘 다 막으려면 멱등 소비자와 아웃박스가 모두 필요하다. 이번에는 중복만 소비자에서 막았다. 유실은 ‘브로커가 5분 넘게 죽는 경우’라는 조건을 붙여 감수했다.