이 케이스에서 얻어 갈 것

상황

결제 확정 파이프라인입니다. payment-approved 토픽은 파티션 12개, 복제 계수 3, 브로커 3대에서 일 40만 건을 받습니다. 컨슈머는 이벤트를 받아 PG사 결제 확정 API를 호출하고, 응답을 payment-settled 토픽에 발행합니다.

아키텍처는 Kafka Streams 기반이고 processing.guarantee=exactly_once_v2가 설정되어 있었습니다. 팀은 이 설정 덕분에 "중복 걱정은 없다"고 이해하고 있었습니다.

streams.properties — 설정 자체는 정확합니다
application.id=payment-settlement
processing.guarantee=exactly_once_v2
# Streams 가 내부적으로 트랜잭션을 쓰고, 컨슈머는 read_committed 로 동작합니다
Processor 코드 — 문제는 여기입니다
public void process(Record<String, PaymentApproved> record) {
    PaymentApproved approved = record.value();

    // ★ 외부 호출. Kafka 트랜잭션 바깥입니다.
    PgResult result = pgClient.confirmPayment(
        approved.getOrderId(),
        approved.getAmount());          // 멱등 키 없음

    context().forward(record.withValue(toSettled(result)));
}

금요일 04:00~08:00 사이 업스트림 장애로 payment-approved 소비가 멈췄고, 복구 과정에서 오프셋이 잘못 전진해 68,000건이 처리되지 않은 채 건너뛰어졌습니다 (케이스 1과 같은 유형).

11:00, 팀은 누락을 복구하기로 결정했습니다. --reset-offsets --to-datetime 2026-07-24T04:00:00.000 --execute로 4시간분을 되돌렸습니다. 그런데 그 4시간 안에는 이미 정상 처리된 3,180건도 포함되어 있었습니다. 재처리가 시작되자 PG사 결제 확정 API가 그 3,180건에 대해 두 번째로 호출됐습니다.

13:40에 PG사에서 "동일 주문 중복 승인 요청" 알림을 받았습니다. 환불 처리와 고객 안내에 4일이 걸렸습니다.

case07 — 컨슈머에 멱등성이 없어 재처리가 이중 결제로 이어지는 흐름 정상 흐름은 poll 로 레코드를 받아 결제 API 를 호출하고 성공하면 오프셋을 커밋하는 순서입니다. 어긋나는 지점은 결제 API 호출이 성공한 뒤 커밋 전에 프로세스가 종료되는 경우로, 배포나 OOM, 리밸런스로 파티션이 회수되는 상황에서 발생합니다. 이때 오프셋은 커밋되지 않았으므로 재시작 후 같은 레코드를 다시 받아 결제 API 를 두 번 호출합니다. 중요한 점은 Kafka 트랜잭션과 exactly-once 설정을 켜도 Kafka 밖의 외부 시스템 호출에는 적용되지 않는다는 것입니다. 처방은 결제 요청에 멱등 키를 부여하고 처리 이력을 저장소에 기록해 먼저 조회하는 것입니다. 1. 정상 흐름 처리 후 커밋 (at-least-once) poll() 레코드 수신 결제 API 호출 성공 오프셋 커밋 처리 후 커밋은 유실을 막는 대신 중복 가능성을 남기는 선택입니다 (at-least-once). 반대로 처리 전에 커밋하면 중복은 없지만 유실이 생깁니다 — 둘 중 하나를 고르는 문제입니다. 2. 어긋나는 지점 성공과 커밋 사이의 틈 결제 성공 커밋 전 프로세스 종료 오프셋 미커밋 재시작 · 재할당 배포, OOM, 리밸런스로 파티션이 회수되는 순간이 모두 이 틈에 해당합니다. Kafka 는 이 레코드를 아직 처리되지 않은 것으로 봅니다 — 커밋된 오프셋만이 진실입니다. 3. 결과 결제가 두 번 일어납니다 같은 레코드 재수신 결제 API 두 번째 호출 이중 결제 EOS 로도 막히지 않음 exactly-once 설정과 트랜잭션은 Kafka 안의 쓰기와 오프셋 커밋을 원자적으로 묶는 기능입니다. 외부 결제 API 는 그 트랜잭션 경계 밖이므로 두 번 호출된 사실을 Kafka 가 되돌릴 수 없습니다. 처방 결제 요청에 멱등 키를 붙입니다 (주문 ID 또는 토픽·파티션·오프셋 조합). 처리 이력을 저장소에 기록하고 처리 전에 먼저 조회해 이미 처리한 레코드는 건너뜁니다. 외부 시스템이 멱등 API 를 제공하면 그것을 씁니다 — 재시도 안전성을 상대 시스템에 맡깁니다. Kafka 트랜잭션은 Kafka → Kafka 범위에서만 원자성을 보장한다는 점을 설계 전제로 둡니다.
EOS 경계와 외부 호출 — Kafka 트랜잭션이 커버하는 범위(입력 오프셋 + 출력 토픽)와 그 밖에서 일어나는 외부 API 호출, 그리고 재처리 시 외부 호출만 두 번 일어나는 지점

관측된 증상

메트릭이 어떻게 보였는가

출력 토픽에서 본 증거

payment-settled에는 트랜잭션 덕분에 중복 레코드가 없지만, PG 승인 번호가 다른 두 개의 레코드가 같은 주문에 대해 존재했습니다. 같은 주문을 두 번 승인받았다는 뜻입니다.

같은 주문에 대해 서로 다른 PG 승인 번호
$ kafka-console-consumer.sh --bootstrap-server kafka-1:9092 \
    --topic payment-settled --from-beginning --timeout-ms 60000 \
    --isolation-level read_committed \
    --property print.partition=true --property print.offset=true \
    --property print.key=true | grep ORD-77412

Partition:5	Offset:1180224	ORD-77412	{"orderId":"ORD-77412","pgApprovalNo":"A-8812043","amount":34900}
Partition:5	Offset:1204881	ORD-77412	{"orderId":"ORD-77412","pgApprovalNo":"A-9014772","amount":34900}

원인 분석

1단계 — 세 가지 전달 보장을 정확히 구분한다

전달 보장 세 가지와 그것을 만드는 설정 조합
보장 정의 만드는 방법
at-most-once 메시지가 유실될 수 있지만 재전달되지 않음 프로듀서 재시도를 끄고, 컨슈머는 처리 전에 오프셋을 커밋
at-least-once
Kafka 기본
메시지가 유실되지 않지만 재전달될 수 있음 컨슈머가 처리 후에 오프셋을 커밋. 별도 설정이 필요 없는 기본 동작
exactly-once 각 메시지가 정확히 한 번 처리됨 Kafka 토픽 → Kafka 토픽 경계에서 트랜잭션 사용. 외부 시스템은 별도 협력 필요

2단계 — EOS가 실제로 무엇을 원자적으로 묶는가

Kafka 트랜잭션의 특이한 점은 프로듀서만 트랜잭션 주체라는 것입니다. 컨슈머는 트랜잭션에 참여하지 않고, 대신 프로듀서가 컨슈머의 커밋 오프셋까지 트랜잭션 안에서 갱신합니다.

트랜잭션이 원자적으로 묶는 두 가지 — consume-transform-produce
producer.initTransactions();          // transactional.id 등록, 이전 인스턴스의 트랜잭션 abort

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    if (records.isEmpty()) continue;

    producer.beginTransaction();
    try {
        for (ConsumerRecord<String, String> r : records) {
            // ① 출력 토픽 쓰기 — 트랜잭션 안
            producer.send(new ProducerRecord<>("output", r.key(), transform(r.value())));
        }
        // ② 입력 오프셋 커밋 — 트랜잭션 안
        producer.sendOffsetsToTransaction(offsetsOf(records), consumer.groupMetadata());
        producer.commitTransaction();     // ①과 ②가 함께 성공하거나 함께 실패
    } catch (AbortableException e) {
        producer.abortTransaction();
        // ★ 중요: abort 후에는 컨슈머 위치를 명시적으로 되돌려야 합니다.
        //   컨슈머는 자동으로 rewind 하지 않습니다.
        resetConsumerPosition(consumer, records);
    }
}
트랜잭션의 원자성 범위
대상원자성 보장비고
출력 Kafka 토픽 쓰기보장여러 토픽·파티션에 걸쳐도 원자적
입력 오프셋 커밋보장sendOffsetsToTransaction으로 같은 트랜잭션에 포함
외부 DB 쓰기보장 없음Kafka 트랜잭션과 분리된 트랜잭션
외부 HTTP·gRPC 호출보장 없음abort 해도 호출은 취소되지 않음
이메일·푸시 발송보장 없음되돌릴 수단이 아예 없음
파일·오브젝트 스토리지보장 없음Connect처럼 오프셋을 출력과 같은 곳에 저장하는 패턴이 필요

3단계 — 멱등 프로듀서 · 트랜잭션 · 컨슈머 멱등성은 다른 것이다

이름이 비슷해 가장 많이 혼동되는 세 가지
기능 해결하는 문제 범위 설정
멱등 프로듀서 프로듀서 재시도로 인한 중복 append 파티션 단위, 하나의 프로듀서 세션 enable.idempotence=true (4.x 기본값)
트랜잭션 여러 파티션 쓰기 + 오프셋 커밋의 원자성 Kafka 내부. 여러 토픽·파티션에 걸침 transactional.id (프로듀서 전용), 컨슈머는 isolation.level=read_committed + enable.auto.commit=false
컨슈머 멱등성 외부 시스템에 대한 중복 부작용 애플리케이션이 직접 구현 Kafka 설정이 아님. 코드로 만들어야 함

4단계 — abort 는 외부 호출을 되돌리지 않는다

4.x에서는 트랜잭션 프로듀서의 예외 처리가 카테고리로 표준화되었습니다. 공식 문서가 정의하는 6개 범주입니다.

트랜잭션 프로듀서 예외 범주 (Apache Kafka 4.x)
범주애플리케이션까지 올라오는가필요한 처리
RetriableException아니요클라이언트가 자동 재시도
RefreshRetriableException아니요메타데이터 갱신 후 클라이언트가 재시도
AbortableException트랜잭션 abort + 컨슈머 위치 재설정 후 재처리
ApplicationRecoverableException애플리케이션이 복구 전략 구현. 프로듀서 재시작 포함
InvalidConfigurationException설정 수정. 프로듀서 재시작은 필수 아님
KafkaException위 범주에 속하지 않는 일반 예외

중요한 것은 AbortableException을 만나 abort 했을 때 "그 트랜잭션 안에서 이미 실행한 외부 호출"은 그대로 남는다는 점입니다. 공식 문서도 abort 후에는 애플리케이션 상태와 컨슈머 위치를 명시적으로 재설정해 재처리해야 한다고 명시합니다. 재처리하면 외부 호출은 다시 일어납니다.

전달 보장과 트랜잭션의 전체 그림은 6장 전달 보장과 트랜잭션에서, 동작하는 파이프라인은 예제 5 · Exactly-Once 파이프라인에서 다룹니다.

재현 방법

단일 노드 KRaft 클러스터로 재현합니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다 (3노드 구성은 예제 1). 트랜잭션을 쓰므로 내부 토픽 복제 계수를 1로 낮춰 둡니다.

docker-compose.yml — 단일 노드 KRaft (트랜잭션 사용 가능)
services:
  broker:
    image: apache/kafka:4.3.1
    hostname: broker
    container_name: broker
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: 'broker,controller'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
      KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
      CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
SideEffectConsumer.java — 외부 부작용을 파일 append 로 대체한 최소 재현
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.nio.file.*;
import java.time.Duration;
import java.util.List;
import java.util.Properties;

public class SideEffectConsumer {
    public static void main(String[] args) throws Exception {
        Path ledger = Path.of("external-ledger.txt");   // "외부 시스템" 역할

        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "settler");
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        p.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");

        try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
            c.subscribe(List.of("payment-approved"));
            while (true) {
                ConsumerRecords<String, String> records = c.poll(Duration.ofSeconds(1));
                for (ConsumerRecord<String, String> r : records) {
                    // ★ 멱등성 없는 외부 부작용
                    Files.writeString(ledger, "CHARGE " + r.key() + "\n",
                        StandardOpenOption.CREATE, StandardOpenOption.APPEND);
                }
                if (!records.isEmpty()) c.commitSync();
            }
        }
    }
}
재현 절차 — 오프셋을 되돌리면 원장에 같은 청구가 두 번 생깁니다
docker compose up -d
K=/opt/kafka/bin

# 1. 토픽 생성
docker exec -it broker $K/kafka-topics.sh --create --topic payment-approved \
  --partitions 1 --replication-factor 1 --bootstrap-server localhost:9092

# 2. 결제 승인 이벤트 5건 (키 = 주문 ID)
for i in 1 2 3 4 5; do
  echo "ORD-$i:{\"orderId\":\"ORD-$i\",\"amount\":10000}" | \
  docker exec -i broker $K/kafka-console-producer.sh --topic payment-approved \
    --bootstrap-server localhost:9092 \
    --property parse.key=true --property key.separator=:
done

# 3. 컨슈머를 돌려 전부 처리한다
java -cp kafka-clients-4.3.1.jar:. SideEffectConsumer &
sleep 10 && kill %1
wc -l external-ledger.txt        # → 5

# 4. 오프셋을 되돌린다 (그룹이 Empty 여야 합니다)
docker exec -it broker $K/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group settler --topic payment-approved --reset-offsets --to-earliest --execute

# 5. 다시 돌린다 → 외부 원장에 같은 청구가 또 들어갑니다
java -cp kafka-clients-4.3.1.jar:. SideEffectConsumer &
sleep 10 && kill %1
wc -l external-ledger.txt        # → 10  ★ 이중 청구
sort external-ledger.txt | uniq -c
#   2 CHARGE ORD-1
#   2 CHARGE ORD-2
#   ...

해결

즉시 조치 — 되돌리기 전에 범위를 좁힌다

멱등성 구현은 배포가 필요합니다. 그 전에 재처리를 해야 한다면 "이미 처리한 것을 다시 처리하지 않도록" 범위를 좁히는 것이 유일한 방어입니다.

즉시 조치 — 누락 구간만 정확히 골라 재처리
# 1. 외부 시스템에서 "처리 완료된 키 목록"을 먼저 확보한다
psql -c "\copy (SELECT order_id FROM pg_confirmations
                WHERE created_at BETWEEN '2026-07-24 04:00' AND '2026-07-24 08:00')
         TO 'processed.csv' CSV"

# 2. 전체 구간을 되돌리는 대신, 별도 그룹으로 재처리 전용 컨슈머를 띄운다.
#    그 컨슈머가 processed.csv 를 읽어 이미 처리된 키를 건너뛰게 만듭니다.
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
  --group settler-replay-20260724 --topic payment-approved \
  --reset-offsets --to-datetime 2026-07-24T04:00:00.000 --execute

# 3. 재처리 컨슈머는 반드시 dry-run 모드를 먼저 지원해야 합니다.
#    "몇 건이 실제로 외부 호출 대상인가"를 사람이 확인한 뒤 실행합니다.

근본 해결 1 — 외부 API의 멱등 키를 쓴다

가장 깨끗한 방법입니다. 대부분의 결제·정산 API는 멱등 키(idempotency key)를 지원합니다. Kafka 레코드에서 안정적으로 파생되는 값을 키로 써야 합니다.

같은 주문에 대해 호출할 때마다 PG사는 새 승인 요청으로 처리합니다. 재처리는 곧 이중 청구입니다.

변경 전
PgResult result = pgClient.confirmPayment(
    approved.getOrderId(),
    approved.getAmount());

멱등 키를 전달하면 PG사가 두 번째 요청에 첫 번째 결과를 그대로 반환합니다. 몇 번 재처리해도 승인은 한 번입니다.

변경 후
// 레코드에서 결정적으로 파생되는 키를 씁니다.
// UUID.randomUUID() 는 절대 쓰지 마세요 — 재처리 때 값이 달라집니다.
String idempotencyKey = "payment-approved:" + approved.getOrderId()
                      + ":" + approved.getApprovalVersion();

PgResult result = pgClient.confirmPayment(
    approved.getOrderId(),
    approved.getAmount(),
    idempotencyKey);

근본 해결 2 — 처리 이력 테이블 (외부 API가 멱등 키를 지원하지 않을 때)

공식 문서가 제시하는 방향, 즉 "오프셋(또는 처리 이력)을 출력과 같은 장소에 저장"하는 패턴입니다. DB 트랜잭션 하나로 "부작용 기록"과 "처리 표시"를 묶습니다.

처리 이력 테이블 — 유니크 제약이 멱등성을 만듭니다
CREATE TABLE processed_events (
  event_key    TEXT PRIMARY KEY,      -- 예: 'payment-approved:ORD-77412:1'
  topic        TEXT NOT NULL,
  partition_id INT  NOT NULL,
  "offset"     BIGINT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  result       JSONB
);
처리 흐름 — 먼저 선점하고, 성공하면 결과를 기록한다
void handle(ConsumerRecord<String, PaymentApproved> record) {
    String eventKey = eventKeyOf(record);   // 비즈니스 식별자 기반

    // ① 선점: 이미 처리했으면 여기서 끝난다 (0 rows affected)
    int claimed = jdbc.update(
        "INSERT INTO processed_events (event_key, topic, partition_id, \"offset\") " +
        "VALUES (?,?,?,?) ON CONFLICT (event_key) DO NOTHING",
        eventKey, record.topic(), record.partition(), record.offset());

    if (claimed == 0) {
        metrics.counter("settlement.skipped_duplicate").increment();
        return;                              // ★ 외부 호출을 하지 않습니다
    }

    // ② 외부 호출
    PgResult result = pgClient.confirmPayment(...);

    // ③ 결과 기록
    jdbc.update("UPDATE processed_events SET result = ?::jsonb WHERE event_key = ?",
                toJson(result), eventKey);
}

근본 해결 3 — 부작용을 경계 밖으로 밀어낸다

구조적으로 가장 견고한 방법은 외부 호출을 파이프라인에서 분리하는 것입니다. Kafka 안에서는 EOS를 온전히 쓰고, 외부 호출은 전용 컨슈머 하나가 담당하며 그 컨슈머에만 멱등성을 구현합니다.

구조 분리 — 외부 호출 지점을 하나로 줄인다
payment-approved
   │
   ├─(Streams, exactly_once_v2)─→ payment-command   ← Kafka 안. EOS 온전히 성립
   │                                    │
   │                                    └─(전용 컨슈머 1개)─→ PG API
   │                                          · 멱등 키 사용
   │                                          · 처리 이력 테이블
   │                                          · 이 컨슈머만 검증하면 됨
   └─→ ... 다른 소비자들
세 가지 해결책 비교
방법적용 조건평가
외부 API 멱등 키 API가 멱등 키를 지원할 때 최선 — 코드가 가장 단순하고 빈틈이 없습니다
처리 이력 테이블 멱등 키가 없고 조회 API가 있을 때 구현 가능하지만 상태 전이·복구 배치가 필요합니다
부작용 지점 분리 항상 함께 적용 가능 권장 — 검증해야 할 지점이 하나로 줄어듭니다
되돌릴 수 없는 부작용
(이메일·푸시)
멱등성 외에 발송 이력 기반 차단이 필수. 재처리 정책을 별도로 문서화하세요

예방 체크리스트

시험 포인트

공식 문서 출처