본문 바로가기

Data/Kafka

[Kafka] 메시지 신뢰성 보장 — At-most-once, At-least-once, Exactly-once

Kafka는 메시지를 "어느 수준까지 보장할 것인가"를 선택할 수 있는 시스템이다.
이 선택을 Delivery Semantics라 하고, 세 가지 수준이 있다.
어떤 게 정답이 아니라 서비스 특성에 따라 선택하는 것이고, 수준마다 설정과 구현 전략이 달라진다.

 


1. 세 가지 전달 보장 수준 개요

 

세 가지는 트레이드오프 관계다. 유실과 중복을 동시에 없애는 Exactly-once가 가장 강력하지만, 복잡도와 성능 비용도 가장 크다.

 


2. At-most-once — 최대 한 번

메시지를 최대 한 번만 전달한다. 재시도 없이 한 번 보내고 끝이므로 유실이 생길 수 있지만, 중복은 없다.

 

설정

# Producer
acks=0

# Consumer
enable.auto.commit=true
auto.commit.interval.ms=5000

acks=0은 브로커 응답을 기다리지 않고 바로 다음 메시지로 넘어간다. 네트워크 오류나 브로커 장애 시 유실이 그냥 발생한다.

Consumer의 자동 커밋은 poll()을 호출할 때 이전 배치의 오프셋을 커밋하는 방식으로 동작한다. 처리가 끝나지 않아도 다음 poll()이 호출되는 순간 커밋이 나가므로, 처리 중에 Consumer가 죽으면 해당 메시지는 건너뛰어진다.

 

언제 쓰는가

메시지 하나가 빠져도 전체 결과에 큰 영향이 없는 경우다. 실시간 지표 수집, 로그 집계처럼 약간의 유실이 허용되면서 처리량이 최우선인 상황에 적합하다.

 


3. At-least-once — 최소 한 번

메시지를 반드시 한 번 이상 전달한다. 유실은 없지만 재시도나 재처리로 인해 중복이 생길 수 있다. 실무에서 가장 많이 사용하는 수준이다.

 

설정

# Producer
acks=all
enable.idempotence=true
retries=2147483647          # 사실상 무한 재시도
delivery.timeout.ms=120000  # 실제 제어는 타임아웃으로
retry.backoff.ms=100

# Consumer
enable.auto.commit=false

acks=all로 ISR 전체 복제를 확인하고, 실패 시 재시도한다. 재시도 횟수 자체는 무한으로 열어두고 delivery.timeout.ms로 "이 시간 안에 못 보내면 포기"를 제어하는 것이 일반적인 패턴이다.

 

try {
    while (running) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
            process(record);        // 처리 먼저
        }
        consumer.commitAsync();     // 평소: 비동기 커밋
    }
} finally {
    consumer.commitSync();          // 종료 시: 동기 커밋
    consumer.close();
}

 

"멱등성 설정 + 수동 커밋이면 Exactly-once 아닌가?"

자주 오해하는 부분이라 명확히 짚고 넘어간다.

 

enable.idempotence=trueProducer → Broker 구간의 중복만 막는다. Producer가 재시도할 때 Sequence Number로 Broker가 중복 전송을 걸러내는 것이다.

수동 커밋은 유실을 막는 설정이지, 중복을 막는 설정이 아니다.

[Producer] --재시도--> [Broker] --수동 커밋--> [Consumer]
     idempotence로              여기는
     중복 제거 가능             막을 수 없음

Consumer가 메시지를 처리하고 commitSync() 직전에 crash되면, 재시작 후 같은 메시지를 다시 처리하게 된다. 이건 idempotence나 수동 커밋으로는 막을 수 없다. 이 구간까지 보장하는 것이 Exactly-once이고, 트랜잭션이 필요한 이유다.

 

중복 대응 — Consumer 멱등성 설계

At-least-once에서 중복은 피할 수 없다. 따라서 같은 메시지를 두 번 처리해도 결과가 동일하도록 Consumer 로직을 설계해야 한다.

// 멱등성 O: 같은 주문 ID로 upsert → 두 번 실행해도 결과 동일
UPDATE orders SET status = 'PAID' WHERE order_id = :orderId;

// 멱등성 X: 잔액 차감 → 두 번 실행하면 잔액이 두 번 빠짐
UPDATE accounts SET balance = balance - :amount WHERE user_id = :userId;

 

멱등하게 만들기 어려운 로직이라면 처리 여부를 외부 저장소에 기록해두는 방법을 쓴다. 인메모리 자료구조는 재시작 시 초기화되므로 Redis나 DB 같은 영속성 있는 저장소를 사용해야 한다.

// Redis 등 영속 저장소로 중복 체크
if (redis.exists("processed:" + record.key())) {
    return;  // 이미 처리한 메시지 → 스킵
}
process(record);
redis.set("processed:" + record.key(), "1", TTL);

 

파티션 키로 순서 보장

At-least-once에서 또 하나 고려해야 할 것이 순서다. Kafka는 파티션 내에서만 순서를 보장하므로, 순서가 중요한 메시지는 같은 파티션으로 보내야 한다.

 

 

파티션 키를 지정하면 같은 키를 가진 메시지는 항상 같은 파티션으로 라우팅된다. 사용자 ID를 키로 쓰면 해당 사용자의 메시지는 항상 같은 파티션에 순서대로 쌓인다.

producer.send(new ProducerRecord<>(
    "orders",
    userId,     // key → 파티션 결정
    orderJson   // value
));

키가 없으면 라운드로빈으로 파티션이 결정되어 순서가 뒤섞인다.

핫스팟 주의: 파티션 키가 특정 값에 편중되면 한 파티션만 과부하가 걸린다. 카디널리티가 높은 값(사용자 ID, 주문 ID 등)을 키로 쓰는 것이 좋다.

 


4. Exactly-once — 정확히 한 번

메시지를 정확히 한 번만 전달하고 처리한다. 유실도 없고 중복도 없다.

앞서 설명했듯이, Producer의 멱등성 설정만으로는 Consumer 재처리로 인한 중복을 막을 수 없다. Exactly-once는 트랜잭션 Producerisolation.level=read_committed 를 함께 사용해야 온전히 보장된다.

 

어떻게 동작하는가

트랜잭션 Producer는 메시지 쓰기와 오프셋 커밋을 하나의 트랜잭션으로 묶는다. Consumer가 메시지를 처리하고 오프셋을 커밋하는 과정이 원자적으로 처리되므로, 처리 완료와 커밋이 동시에 일어나거나 동시에 롤백된다. 중간에 crash가 나도 "처리했는데 커밋 못 한" 상태가 없다.

 

read_committed는 이 트랜잭션이 완료된 메시지만 Consumer에게 노출한다. 롤백된 트랜잭션의 메시지는 Consumer 눈에 보이지 않는다.

 

트랜잭션 Producer 구현

Properties props = new Properties();
props.put("transactional.id", "order-service-producer-1");  // 인스턴스별 고유 ID
props.put("enable.idempotence", "true");                     // 트랜잭션 사용 시 자동 활성화

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

try {
    producer.beginTransaction();

    producer.send(new ProducerRecord<>("orders", orderId, orderJson));
    producer.send(new ProducerRecord<>("inventory", itemId, stockJson));

    // Consumer 오프셋 커밋도 트랜잭션 안에 포함시킬 수 있음
    producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);

    producer.commitTransaction();       // 전부 성공 → 원자적 커밋

} catch (ProducerFencedException e) {
    // 같은 transactional.id를 가진 새 Producer가 시작된 경우 (좀비 방지)
    producer.close();
} catch (KafkaException e) {
    producer.abortTransaction();        // 전부 롤백
}

 

transactional.id는 Producer 인스턴스를 식별하는 고유 ID다. 같은 ID로 새 Producer가 시작되면 기존 Producer는 자동으로 fencing되어 더 이상 쓰기가 불가능해진다. 이를 통해 재시작된 Producer가 이전 Producer와 동시에 쓰는 좀비 상황을 막는다.

 

sendOffsetsToTransaction()을 사용하면 메시지 쓰기와 Consumer 오프셋 커밋을 같은 트랜잭션으로 묶을 수 있다. 이것이 Consumer 재처리 중복까지 막는 핵심이다.

 

Consumer 설정

isolation.level=read_committed

이 설정 없이는 트랜잭션이 완료되기 전에 메시지가 노출될 수 있어 Exactly-once가 보장되지 않는다.

 

단일 파티션 트레이드오프

Exactly-once에서 강한 순서 보장이 필요할 때 단일 파티션을 사용하기도 한다. 순서는 완벽하게 보장되지만 Consumer도 하나만 붙일 수 있어 처리량이 제한된다.

파티션 1개 → Consumer 1개 → 순서 완벽 보장, 처리량 제한
파티션 N개 → Consumer N개 → 파티션 내 순서 보장, 처리량 스케일 가능

Exactly-once가 필요하더라도 단일 파티션보다는 파티션 키로 순서를 제어하면서 파티션 수를 늘리는 방향이 처리량 측면에서 유리하다.

 

Exactly-once의 비용

  • Broker에 트랜잭션 코디네이터가 추가로 개입한다
  • read_committed Consumer는 트랜잭션 완료를 기다리므로 지연이 생긴다
  • transaction.timeout.ms 안에 커밋/어보트가 되지 않으면 자동 어보트된다

At-least-once + 멱등성이 Exactly-once와 결과적으로 동일한 경우도 많다. 하지만 한계가 있는 상황도 있다. 5절에서 자세히 다룬다.

 


5. 어떤 수준을 선택할 것인가

At-least-once + 멱등성이 충분한 경우

"At-least-once + 멱등성 설계를 하면 결국 Exactly-once랑 같은 거 아닌가?"라는 의문이 생길 수 있다. DB에만 쓰고 외부 호출이 없는 단순한 파이프라인이라면 맞다. upsert처럼 멱등한 DB 쓰기는 같은 메시지가 두 번 들어와도 결과가 동일하므로, 트랜잭션 없이도 사실상 Exactly-once와 동일한 결과를 낸다.

 

At-least-once + 멱등성의 한계

그러나 두 가지 상황에서 한계가 드러난다.

 

첫째, 외부 시스템 호출이 있는 경우.

1. 주문 메시지 처리 → DB upsert 성공 (멱등)
2. 이메일 발송 API 호출
3. commitSync() 직전 crash
4. 재시작 → 같은 메시지 재처리
5. DB upsert → 중복 없음 (멱등)
6. 이메일 발송 → 중복 발송 발생

DB 쓰기는 멱등하게 만들 수 있어도, 이메일·알림·결제 API 같은 외부 시스템 호출은 멱등하게 만들기 어렵거나 불가능한 경우가 많다.

 

둘째, 여러 토픽에 걸친 원자성이 필요한 경우.

1. orders 토픽 쓰기 성공
2. inventory 토픽 쓰기 직전 crash
→ 주문은 생성됐는데 재고는 차감 안 된 상태로 남음

트랜잭션은 여러 토픽 쓰기를 원자적으로 묶기 때문에 이 문제가 없다.

 

선택 기준

상황 권장 수준
메시지가 조금 빠져도 괜찮다 At-most-once
DB에만 쓰고, 멱등한 로직으로 설계 가능하다 At-least-once + 멱등성
외부 API 호출이 있거나, 여러 토픽에 원자적 쓰기가 필요하다 Exactly-once

Exactly-once는 도입 자체가 목적이 되면 안 된다. "중복이 발생했을 때 멱등성 처리로 해결할 수 없는가?" 를 먼저 따져보고, 그게 불가능한 경우에만 선택하는 것이 현실적이다.