한 줄로
멱등 소비자(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분 넘게 죽는 경우’라는 조건을 붙여 감수했다.