At-most-once, At-least-once, Exactly-once

메시지 전달 보장(Delivery Semantics)은 분산 시스템에서 메시지가 어떻게 전달되는지를 정의하는 핵심 개념입니다.

메시지 전달 보장 개요

세 가지 전달 보장 수준

┌─────────────────────────────────────────────────────────────────┐
│                     Message Delivery Semantics                   │
├─────────────────┬─────────────────┬─────────────────────────────┤
│  At-most-once   │  At-least-once  │       Exactly-once          │
│  (최대 1회)      │  (최소 1회)      │       (정확히 1회)           │
├─────────────────┼─────────────────┼─────────────────────────────┤
│  0 or 1 번 전달  │  1+ 번 전달      │       정확히 1번 전달        │
│  손실 가능       │  중복 가능       │       손실/중복 없음         │
│  구현 간단       │  구현 중간       │       구현 복잡              │
└─────────────────┴─────────────────┴─────────────────────────────┘

비교 요약

특성At-most-onceAt-least-onceExactly-once
메시지 손실가능없음없음
메시지 중복없음가능없음
성능최고높음중간
구현 복잡도낮음중간높음
사용 사례로그, 메트릭일반 비즈니스금융, 결제

At-most-once (최대 1회 전달)

정의

메시지가 한 번 이하 전달됩니다. 메시지 손실이 발생할 수 있지만 중복은 없습니다.

동작 방식

Producer                    Broker                    Consumer
   │                          │                          │
   │──── send message ───────>│                          │
   │     (no ack wait)        │                          │
   │                          │──── deliver message ────>│
   │                          │     (commit before       │
   │                          │      processing)         │
   │                          │                          │

메시지 손실 시나리오:
- Producer → Broker 전송 실패 (감지 못함)
- Consumer 커밋 후 처리 실패

Producer 구현

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "0");  // 브로커 응답 대기하지 않음
props.put("retries", "0");  // 재시도 없음
 
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
 
// Fire-and-forget 전송
producer.send(new ProducerRecord<>("topic", "key", "value"));
// 결과 확인하지 않음

Consumer 구현

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "at-most-once-group");
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "100");  // 빠른 커밋
 
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("topic"));
 
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    // 오프셋이 먼저 커밋됨 (auto commit)
 
    for (ConsumerRecord<String, String> record : records) {
        try {
            processRecord(record);  // 실패해도 이미 커밋됨
        } catch (Exception e) {
            log.error("Processing failed, message lost", e);
            // 메시지 손실 발생
        }
    }
}

사용 사례

  • 로그 수집: 일부 로그 손실이 허용되는 경우
  • 메트릭/통계: 통계적으로 의미 있는 데이터
  • 실시간 모니터링: 최신 데이터가 중요한 경우
  • 센서 데이터: 주기적으로 중복 수집되는 데이터

장단점

장점:

  • 가장 빠른 성능
  • 구현 간단
  • 리소스 사용 최소

단점:

  • 메시지 손실 가능
  • 데이터 정확성 보장 불가

At-least-once (최소 1회 전달)

정의

메시지가 한 번 이상 전달됩니다. 손실은 없지만 중복이 발생할 수 있습니다.

동작 방식

Producer                    Broker                    Consumer
   │                          │                          │
   │──── send message ───────>│                          │
   │<──── ack ────────────────│                          │
   │     (retry if no ack)    │                          │
   │                          │──── deliver message ────>│
   │                          │     (process before      │
   │                          │      commit)             │
   │                          │<──── commit offset ──────│
   │                          │                          │

중복 발생 시나리오:
- Producer: ack 손실 → 재전송 → 중복 저장
- Consumer: 처리 후 커밋 전 장애 → 재시작 → 중복 처리

Producer 구현

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "all");  // 모든 ISR의 응답 대기
props.put("retries", Integer.MAX_VALUE);  // 무한 재시도
props.put("retry.backoff.ms", "100");
 
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
 
// 동기 전송으로 전달 보장
try {
    RecordMetadata metadata = producer.send(
        new ProducerRecord<>("topic", "key", "value")
    ).get();
 
    log.info("Message sent to partition {} offset {}",
        metadata.partition(), metadata.offset());
} catch (Exception e) {
    // 재시도 로직 또는 에러 처리
    log.error("Failed to send message", e);
}

Consumer 구현

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "at-least-once-group");
props.put("enable.auto.commit", "false");  // 수동 커밋
 
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("topic"));
 
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
 
    for (ConsumerRecord<String, String> record : records) {
        // 먼저 처리
        processRecord(record);
    }
 
    // 처리 완료 후 커밋
    consumer.commitSync();
}

중복 처리 전략

1. 멱등성 처리 (Idempotent Processing)

public void processRecord(ConsumerRecord<String, String> record) {
    String messageId = extractMessageId(record);
 
    // 이미 처리된 메시지인지 확인
    if (processedMessages.contains(messageId)) {
        log.info("Duplicate message skipped: {}", messageId);
        return;
    }
 
    // 실제 처리
    doProcess(record);
 
    // 처리 완료 기록
    processedMessages.add(messageId);
}

2. 유니크 제약 조건

-- 데이터베이스 레벨에서 중복 방지
CREATE TABLE orders (
    order_id VARCHAR(50) PRIMARY KEY,  -- 메시지 키를 PK로
    ...
);
 
-- INSERT 시 중복 무시
INSERT INTO orders (order_id, ...)
VALUES (?, ...)
ON CONFLICT (order_id) DO NOTHING;

3. 버전 기반 업데이트

public void processUpdate(ConsumerRecord<String, String> record) {
    Order order = parseOrder(record);
 
    // 버전이 높은 경우에만 업데이트
    int updated = jdbcTemplate.update(
        "UPDATE orders SET data = ?, version = ? " +
        "WHERE id = ? AND version < ?",
        order.getData(), order.getVersion(),
        order.getId(), order.getVersion()
    );
 
    if (updated == 0) {
        log.info("Skipped older version: {}", order);
    }
}

사용 사례

  • 대부분의 비즈니스 애플리케이션
  • 이벤트 처리: 멱등성으로 중복 처리 가능
  • 알림 시스템: 중복 알림이 손실보다 나은 경우
  • 데이터 동기화: 최종 일관성 보장

장단점

장점:

  • 메시지 손실 없음
  • 구현 비교적 간단
  • 성능과 신뢰성 균형

단점:

  • 중복 처리 가능
  • 멱등성 구현 필요

Exactly-once (정확히 1회 전달)

정의

메시지가 정확히 한 번만 전달됩니다. 손실도 중복도 없습니다.

구현 방식

Kafka에서 Exactly-once를 달성하는 방법:

┌─────────────────────────────────────────────────────────────────┐
│                    Exactly-once 구현 방법                        │
├─────────────────────────────────────────────────────────────────┤
│  1. Idempotent Producer                                         │
│     - Producer → Broker 간 중복 방지                             │
│     - enable.idempotence = true                                 │
├─────────────────────────────────────────────────────────────────┤
│  2. Transactional Producer + Consumer                           │
│     - Producer + Consumer 전체 트랜잭션                          │
│     - isolation.level = read_committed                          │
├─────────────────────────────────────────────────────────────────┤
│  3. External System + Idempotent Consumer                       │
│     - 외부 시스템에서 멱등성 보장                                  │
│     - 오프셋과 데이터를 원자적으로 저장                            │
└─────────────────────────────────────────────────────────────────┘

Idempotent Producer

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("enable.idempotence", "true");  // 멱등성 활성화
// 자동으로 설정됨: acks=all, retries=MAX, max.in.flight.requests.per.connection<=5
 
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
 
// 재시도해도 중복 저장 없음
producer.send(new ProducerRecord<>("topic", "key", "value"));

Transactional Producer

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("transactional.id", "my-transactional-id");
props.put("enable.idempotence", "true");
 
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();
 
try {
    producer.beginTransaction();
 
    producer.send(new ProducerRecord<>("topic1", "key", "value1"));
    producer.send(new ProducerRecord<>("topic2", "key", "value2"));
 
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
    throw e;
}

Exactly-once Consumer (Kafka Streams)

Properties props = new Properties();
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "exactly-once-app");
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
    StreamsConfig.EXACTLY_ONCE_V2);  // Exactly-once 보장
 
StreamsBuilder builder = new StreamsBuilder();
builder.stream("input-topic")
    .mapValues(value -> processValue(value))
    .to("output-topic");
 
KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();

외부 시스템과 Exactly-once

// 트랜잭션으로 데이터와 오프셋을 함께 저장
public void processExactlyOnce(ConsumerRecords<String, String> records) {
    for (ConsumerRecord<String, String> record : records) {
        try {
            database.beginTransaction();
 
            // 1. 비즈니스 데이터 저장
            database.saveData(record.key(), record.value());
 
            // 2. 오프셋 저장 (같은 트랜잭션)
            database.saveOffset(
                record.topic(),
                record.partition(),
                record.offset()
            );
 
            database.commit();
        } catch (Exception e) {
            database.rollback();
            throw e;
        }
    }
}

사용 사례

  • 금융 거래: 이중 결제 방지
  • 재고 관리: 정확한 수량 관리
  • 이벤트 소싱: 이벤트 정확성 보장
  • Kafka Streams: 스트림 처리 파이프라인

장단점

장점:

  • 완벽한 데이터 정확성
  • 손실/중복 없음

단점:

  • 구현 복잡
  • 성능 오버헤드
  • 추가 리소스 필요

전달 보장 선택 가이드

결정 플로우차트

메시지 손실이 허용되는가?
    │
    ├── Yes → At-most-once
    │         (로그, 메트릭, 센서 데이터)
    │
    └── No → 메시지 중복이 허용되는가?
              │
              ├── Yes → At-least-once
              │         (대부분의 비즈니스 로직)
              │         + 멱등성 처리 권장
              │
              └── No → Exactly-once
                        (금융, 결제, 재고)

시나리오별 권장 설정

로그 수집 (At-most-once)

// Producer
props.put("acks", "0");
props.put("retries", "0");
 
// Consumer
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "100");

주문 처리 (At-least-once + 멱등성)

// Producer
props.put("acks", "all");
props.put("retries", Integer.MAX_VALUE);
props.put("enable.idempotence", "true");
 
// Consumer
props.put("enable.auto.commit", "false");
// + 멱등성 처리 로직

결제 처리 (Exactly-once)

// Producer
props.put("transactional.id", "payment-producer");
props.put("enable.idempotence", "true");
 
// Consumer
props.put("isolation.level", "read_committed");
props.put("enable.auto.commit", "false");

End-to-End 전달 보장

전체 파이프라인 고려

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│ Producer │───>│  Kafka   │───>│ Consumer │───>│ External │
│          │    │  Broker  │    │          │    │  System  │
└──────────┘    └──────────┘    └──────────┘    └──────────┘
     │               │               │               │
     └───────────────┴───────────────┴───────────────┘
              전체 파이프라인에서 보장 필요

각 구간별 보장

구간At-most-onceAt-least-onceExactly-once
Producer → Brokeracks=0acks=all + retriesIdempotent
Broker Storage-replicationreplication
Broker → Consumerauto-commitmanual committransaction
Consumer → External-idempotentatomic write

관련 문서