아키텍처+MSA

[아키텍처/Kafka] 메세지 전송 보장 방식(At most once, At least once, Exactly once): 특징과 EOS 구현 방법

깃짱 2025. 9. 17. 20:00
반응형
반응형

🌏 인트로

Kafka와 같은 메시징 시스템에서는 메시지가 언제, 어떻게 소비되는지를 보장하는 수준이 중요합니다. (데이터베이스의 트랜잭션 격리 수준과 비슷한 포지션이랄까…?)

네트워크 지연, 장애, 재시도 같은 상황에서 메시지가 유실되거나 중복 처리될 수 있기 때문에, 전송 보장 방식(Delivery Semantics)을 우리 서비스의 특성에 맞게 잘 설정해야 합니다.

🌏 메시지 전송 방식

먼저 간단하게나마 카프카가 어떻게 메세지를 애플리케이션으로부터 받는지에 대해 알아보겠습니다

Kafka에서 “메시지 전송”
= 사건(Event)이 애플리케이션에서 발생했을 때,
애플리케이션 코드가 Kafka Producer API를 호출해서 Broker에 메시지를 보내는 것

  1. 애플리케이션에서 사건 발생
    • 예: 주문 서비스에서 “주문 생성됨” 이벤트 발생
  2. 애플리케이션이 Producer 코드 실행
    • 애플리케이션은 Kafka Producer 클라이언트 라이브러리를 사용해서 메시지를 Kafka Broker로 전송
    • 메시지에는 Topic 이름, Key(선택), Value 등이 포함됨
    • ProducerRecord<String, String> record = new ProducerRecord<>("order-topic", "orderId=12345", "주문 생성됨"); // topic, key, value producer.send(record);
  3. Broker 저장
    • 메시지는 지정된 Topic의 Partition에 순차적으로 저장됨
    • *acks 설정에 따라, Leader/Follower가 확인 응답*
  4. Consumer가 꺼내 처리
    • Consumer Group이 해당 Topic을 구독 중이라면, 메시지를 가져오고 보장 방식 설정에 따라 메세지 처리 전/후에 Offset 을 커밋한다

🌏 메시지 전송 보장 방식

✅ At most once (최대 한 번)

  • 메시지가 0번 또는 1번 전달되는 모델
  • 중복은 절대 발생하지 않지만, 메시지가 유실될 수 있음

동작 방식

  1. Consumer가 메시지를 가져오면 바로 Offset 커밋 (“읽었다” 표시)
  2. 처리 도중 장애 발생한다면 메시지는 이미 읽은 걸로 표시됐으니 재처리 불가

특징

  • 장점: 빠름, 중복 없음
  • 단점: 장애 시 데이터 손실 발생 가능
  • 활용 사례: 로그 수집, 모니터링 (조금 빠져도 큰 문제 없는 경우)

✅ At least once (최소 한 번)

  • 메시지가 최소 1번 이상 전달되는 모델
  • 유실은 없지만, 중복 처리될 수 있음

동작 방식

  1. Consumer가 메시지를 가져옴
  2. 처리를 끝낸 뒤에 Offset을 커밋
  3. 만약 처리 후 커밋 전에 장애가 나면 같은 메시지를 다시 읽어와서 중복 처리

특징

  • 장점: 데이터 손실 없음
  • 단점: 중복 처리 가능하기 때문에 애플리케이션에서 Idempotency(멱등성) 처리 필요
  • 활용 사례: 결제, 포인트 적립 (중복 적립을 막기 위해 DB에서 unique key 등으로 보정)

✅ Exactly once (정확히 한 번)

  • 메시지가 유실도 없고, 중복도 없음 ⇒ 단 한 번만 처리
  • 가장 이상적인 모델이지만 구현이 가장 복잡함

특징

  • 장점: 유실이나 중복 없는 완벽한 처리
  • 단점: 복잡도와 성능 비용 ↑
  • 활용 사례: 금융, 송금, 재고 관리 (중복/유실이 치명적인 경우)

🌏 카프카에서는 Exactly Once를 어떻게 구현하나?

최대한 예시를 들어 설명해 보겠습니다.

카프카의 Exactly-once 옵션은 크게 2가지 트랜잭션을 조율하는 과정입니다.

  1. Producer 애플리케이션의 로컬 트랜잭션
  2. Consumer 애플리케이션의 로컬 트랜잭션

Producer 측 (Idempotent Producer)

문제: 네트워크 장애나 재시도로 인해 같은 메시지가 중복 전송될 수 있음.

주문 애플리케이션 서버(=Producer)"주문 생성됨(orderId=12345)" 메시지를 보냈지만 네트워크 지연으로 인해 Producer가 응답(ACK)을 못 받았다면 Producer는 "메시지가 실패했나?" 하고 같은 메시지를 재전송하게 됩니다.

Broker는 그대로 두 번 저장하게 되고, 그러면 결제 서비서(=Consumer)가 두 번 읽게 되어, 중복 결제 발생 위험이 발생합니다

해결: Idempotent Producer 사용 (enable.idempotence=true)

  • Producer가 메시지를 보낼 때 (PID=111, seq=1, orderId=12345) 라는 메타데이터를 함께 전송합니다
    • PID: Producer가 브로커와 연결될 때 카프카가 부여하는 고유 ID (프로듀서별로 고정됨)
    • Sequence number: Producer가 각 Partition에 메시지를 보낼 때마다 자동 증가하는 번호 (auto-increment처럼 관리)
      • 정상적으로 저장된 메시지는 seq가 증가하지만, ACK 못 받아서 재전송하는 경우는 seq를 유지한 채로 보내게 됩니당
  • Broker는 메세지를 저장하기 전에 항상 (PID, seq) 조합이 중복되는지 확인합니다
    • 만약에 Producer가 두번 메세지를 전송하게 된다면 두 번째로 온 건 중복이니까 무시

결과: 정확성 보장

Kafka에 "orderId=12345" 메시지가 한 번만 저장되고, Consumer(결제 서비스)도 한 번만 처리할 수 있습니다

✅ 2. Transactional Producer

문제: Producer가 자신의 로컬 DB에 처리를 했는데 메세지를 전송하는 과정에서 에러가 난다면, 데이터 불일치가 발생할 수 있음.

  1. Producer가 자신의 비즈니스 로직을 처리하면서 DB에 변경을 함.
  2. Producer가 Kafka에 메세지를 전송함
  3. Kafka는 브로커에 메세지를 저장함.

여기서 만약에 2번까지는 해서 Producer DB에는 변화가 생겼는데, 3번 전에 장애가 발생했다면???
데이터 불일치(inconsistency)가 발생할 수 있습니다.

해결: *Transactional Producer***

(메세지 쓰기 + Producer 서버 내 DB 커밋)을 하나의 트랜잭션으로 묶어야만 합니다.

  1. 트랜잭션 시작Producer가 트랜잭션 안에서 메세지를 보내겠다고 선언합니다
    (애플리케이션 코드에서 직접 설정해야 합니다)
  2. Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); // 트랜잭션 활성화 props.put("enable.idempotence", "true"); props.put("transactional.id", "order-tx-1"); // 반드시 고유해야 함
  3. producer.initTransactions(); producer.beginTransaction();
  4. Producer의 메시지 전송
    • 예: 주문 서비스에서 DB insert 후 "주문 생성됨" 이벤트 Kafka 전송한다면 DB 트랜잭션과 Kafka 메시지 전송이 원자적으로 묶여야
    • 메시지가 브로커에 기록되지만, 아직 Producer의 트랜잭션이 commit될지 abort될지 모르기 때문에 Consumer에 노출하지 않습니다. (= prepare 상태)
      • Consumer는 "확정된 데이터만 본다"라는 보장(원자성)을 위해 잠시 숨겨둡니다.
    • Producer의 DB 트랜잭션 커밋 시점에 맞춰서 같이 Kafka commitTransaction() 호출하면 브로커가 메시지를 확정(commit) 처리하고, 이제부터는 Consumer가 읽을 수 있습니당
      • 만약에 DB에서 rollback 되면 Kafka도 abortTransaction() 호출해 메시지는 폐기됩니다

✅ 3. Transactional Consumer

문제: Consumer가 메시지를 처리한 뒤 커밋 전에 장애가 나면, 다시 같은 메시지를 읽어 중복 처리될 수 있음

  1. consumer가 메세지를 읽음
  2. consumer가 자신의 처리를 하고 DB에 저장 (예를 들어 주문했다는 정보를 Insert)
  3. Kafka에 offset 커밋 (이 메세지는 다 읽었다는 표시)

여기서 만약에 2번까지는 해서 서비 DB에는 변화가 생겼는데, 3번 전에 장애가 발생했다면???
데이터 불일치(inconsistency)가 발생할 수 있습니다.

해결: Transactional Consumer (read-process-write 패턴에서 EOS 지원)

Offset을 +1 해서 "여기까지 읽었다"는 표시를 하는 주체는 Consumer 입니다.

다만 일반적인 commitSync()commitAsync()로 단독으로 커밋하는 게 아니라,

  1. Consumer가 메시지를 처리함
  2. offset 커밋 작업을 새로운 Producer 트랜잭션 안에 포함시켜서 (sendOffsetsToTransaction) Broker에 전달합니다.

*Consumer가 "offset 저장"이라는 이벤트를 Kafka에 보내는 순간, 그 행위는 Producer처럼 메시지를 보내는 것과 똑같이 동작합니다.*

평소의 세팅에서는 Consumer는 "읽기 전용"으로, 토픽에서 메시지만 가져오는 존재이지만, 빡빡한 exactly once 세팅을 구현하기 위해서는 처리 이후에 Producer로 offset 커밋 메세지를 브로커에 보내야 합니다.

예를 들어서 offset=101의 메세지를 처리했다면, "offset=101까지 읽음"이라는 특별한 메시지를 __consumer_offsets 토픽에 써 넣는 Producer 역할을 하게 됩니다.

✅ 구현방법을 정리하자면!

  • Producer 실패 케이스
    • 같은 메시지를 여러 번 보내는 문제 ⇒ Idempotent Producer (PID + seq)로 해결
    • 반쪽짜리 쓰기*(DB는 커밋됐는데 Kafka는 실패 or 반대)* ⇒ Transactional Producer로 DB 트랜잭션과 Kafka 쓰기를 묶어서 해결
      • DB 저장 + 메세지 발행이 원자적으로 보장
  • Consumer 실패 케이스
    • 메시지를 처리했지만 offset 커밋 전에 죽으면 같은 메시지를 또 읽어서 중복 처리 위험
    • Consumer가 offset 커밋 자체를 Kafka 트랜잭션 안에 포함 (이때 offset 기록은 특별한 Producer 행위처럼 동작)
      • 메시지 처리 + offset 커밋이 원자적으로 보장

🌏 정리

보장 수준 의미 장점 단점 활용 사례
At most once 최대 1번 (중복 X, 유실 O) 빠름 데이터 손실 가능 로그, 모니터링
At least once 최소 1번 (중복 O, 유실 X) 유실 없음 중복 가능 → 멱등성 필요 결제, 포인트
Exactly once 정확히 1번 (중복 X, 유실 X) 완벽 복잡도/성능 비용 ↑ 금융, 재고

 

 

 

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!

 

반응형