Engineering Note

Kafka 트랜잭션은 왜 DB를 묶지 못할까: KIP-939가 여는 2PC 참여

Kafka 트랜잭션이 지금 묶을 수 있는 작업과 DB·외부 시스템이 남는 이유를 짚고, KIP-939가 Kafka를 외부 2PC 참여자로 만들려는 방식을 정리합니다.

2026년 7월 21일 · Pletor Engineering kafkatransactionsexactly-oncearchitecturereliability

주문 서비스가 결제 상태를 DB에 저장하고, 배송 서비스가 처리할 수 있도록 이벤트를 Kafka에 발행해야 한다고 해보겠습니다.

DB 커밋은 성공했는데 Kafka 발행은 실패했다.
Kafka 발행은 성공했는데 DB 커밋은 실패했다.

둘 중 어느 쪽도 허용할 수 없다면, 자연스럽게 “Kafka 트랜잭션에 DB도 넣으면 되지 않을까?”라는 질문이 나옵니다.

현재 Kafka 트랜잭션이 이 문제를 직접 해결하지는 않습니다. 여러 Kafka 쓰기와 컨슈머 그룹 offset은 한 트랜잭션으로 묶을 수 있습니다. 그러나 일반 DB 트랜잭션이나 HTTP API 호출은 그 경계 밖에 있습니다. 그래서 실무에서는 여전히 Outbox, Inbox, 멱등성 키처럼 각 시스템의 로컬 원자성을 연결하는 계약이 필요합니다.

Kafka는 이 경계를 넓히기 위한 제안도 내놓았습니다. KIP-939는 Kafka를 외부 2단계 커밋(2PC)의 참여자로 만들려는 KIP입니다. 이 글에서는 지금 바로 쓸 수 있는 Kafka 트랜잭션과 아직 제안·구현 단계인 KIP-939를 구분해 살펴봅니다.

짙은 남색과 따뜻한 크림색 종이 조각이 중앙에서 부드럽게 맞물린 편집 일러스트
서로 다른 시스템을 안전하게 연결하려면 각 부분의 상태와 최종 결정이 어디에 남는지부터 분명해야 합니다.

결론부터: Kafka 트랜잭션이 묶는 범위

Kafka 트랜잭션은 다음 작업을 하나의 커밋 또는 취소 단위로 묶습니다.

한 트랜잭션에 담을 수 있는 작업결과
여러 토픽·파티션에 레코드 쓰기모두 커밋되거나 read_committed 컨슈머에는 하나도 보이지 않음
읽은 입력의 다음 offsetKafka 출력과 같은 트랜잭션에서 커밋
Kafka Streams의 상태 변경 로그와 출력exactly_once_v2에서 입력 처리 결과와 함께 확정

반대로 일반 DB의 COMMIT, 결제·알림 HTTP API 호출, 파일 쓰기는 표준 Kafka 트랜잭션에 자동으로 들어가지 않습니다.

현재 Kafka 트랜잭션에는 Kafka 입력 offset과 출력 레코드가 들어가지만 DB는 바깥에 있고, KIP-939에서는 외부 조정자가 준비된 Kafka 트랜잭션을 완료하는 비교도
현재의 원자성 경계는 Kafka 안입니다. KIP-939는 그 경계를 없애는 기능이 아니라, Kafka가 외부 2PC에 참여하는 방식을 제안합니다.

이것이 Kafka 트랜잭션이 약하다는 뜻은 아닙니다. Kafka 안에서 읽고 변환한 뒤 다시 Kafka로 쓰는 흐름은 원자적으로 반영할 수 있습니다. 다만 어떤 시스템까지 묶어야 하는지 먼저 정해야 올바른 도구를 고를 수 있습니다.

대표적인 용도 1: 여러 Kafka 쓰기를 함께 확정하기

하나의 주문 승인 결과를 orders-confirmedbilling-audit 두 토픽에 모두 기록한다고 해보겠습니다. 둘 중 한 토픽에만 레코드가 보이면, 이를 읽는 서비스들은 같은 주문을 서로 다른 상태로 처리할 수 있습니다.

transactional.id를 설정한 트랜잭션 프로듀서는 initTransactions()를 한 번 호출한 뒤, 전송 작업을 beginTransaction()commitTransaction() 사이에 넣을 수 있습니다.

Properties properties = new Properties();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-confirmation-0");

try (KafkaProducer<String, String> producer = new KafkaProducer<>(properties)) {
    producer.initTransactions();

    try {
        producer.beginTransaction();
        producer.send(new ProducerRecord<>("orders-confirmed", "order-42", "confirmed"));
        producer.send(new ProducerRecord<>("billing-audit", "order-42", "approved"));
        producer.commitTransaction();
    } catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException fatal) {
        producer.close();
        throw fatal;
    } catch (KafkaException abortRequired) {
        producer.abortTransaction();
    }
}

commitTransaction()이 성공하면 두 토픽의 레코드가 함께 커밋됩니다. 트랜잭션을 취소해도 레코드가 로그에 물리적으로 남아 있을 수 있지만, isolation.level=read_committed 컨슈머는 이를 읽지 않습니다. 이 보장을 소비자까지 전하려면 출력 토픽을 읽는 쪽도 read_committed를 사용해야 합니다.

KafkaProducer API 문서에 따르면, 트랜잭션 프로듀서에는 transactional.id가 필요합니다. 같은 ID로 새 인스턴스가 시작되면 이전 인스턴스는 펜싱될 수 있습니다. 이 값은 비즈니스 주문 ID가 아니라 프로듀서 소유권을 나타내는 안정적인 식별자여야 합니다.

대표적인 용도 2: 읽기·처리·쓰기를 하나로 만들기

Kafka에서 입력을 읽어 변환한 뒤 다른 Kafka 토픽으로 발행할 때, 출력과 입력 offset을 따로 확정하면 그 사이에 실패 구간이 생깁니다.

출력 발행 성공
offset 커밋 전 프로세스 중단
같은 입력을 다시 읽음
출력을 다시 발행할 수 있음

이때 sendOffsetsToTransaction(nextOffsets, consumer.groupMetadata())는 일반적인 컨슈머 offset 커밋을 대신합니다. 소비한 레코드의 다음 offset을 현재 프로듀서 트랜잭션에 넣으므로 Kafka 출력과 소비 위치가 함께 커밋되거나 함께 취소됩니다.

이 패턴에서는 자동 커밋을 끄고, 취소된 트랜잭션의 레코드를 읽지 않도록 컨슈머를 설정해야 합니다.

Properties consumerProperties = new Properties();
consumerProperties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
consumerProperties.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
consumer.subscribe(List.of("orders"));

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

    try {
        producer.beginTransaction();

        for (ConsumerRecord<String, String> record : records) {
            String result = transform(record.value());
            producer.send(new ProducerRecord<>("order-projections", record.key(), result));
        }

        // enable.auto.commit=false. commitSync()나 commitAsync()로 따로 커밋하지 않는다.
        producer.sendOffsetsToTransaction(records.nextOffsets(), consumer.groupMetadata());
        producer.commitTransaction();
    } catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException fatal) {
        producer.close();
        throw fatal;
    } catch (KafkaException abortRequired) {
        producer.abortTransaction();
    }
}

이 흐름에서 트랜잭션을 수행하는 주체는 컨슈머가 아니라 프로듀서입니다. 프로듀서가 출력 레코드를 쓰면서 컨슈머 그룹의 다음 위치도 함께 확정합니다. 따라서 enable.auto.commit=false를 사용하고 commitSync()·commitAsync()를 섞지 않아야 합니다.

트랜잭션이 취소되면 같은 입력을 다시 처리할 수 있습니다. 여기서 한 번만 보장되는 것은 처리 함수의 실행 횟수가 아니라, 커밋된 Kafka 출력과 offset의 조합입니다. 이 구분은 Kafka EOS는 무엇을 한 번만 보장하는가에서 더 자세히 다룹니다.

DB와 외부 API가 남는 이유

다음 코드는 한 덩어리처럼 보이지만, 실제로는 서로 독립된 두 커밋입니다.

database.commit();
producer.commitTransaction();

첫 줄과 둘째 줄 사이에서 프로세스가 멈추면 둘 중 하나만 반영될 수 있습니다. 순서를 바꿔도 불일치가 생기는 방향만 달라집니다. 표준 Kafka 트랜잭션 코디네이터는 일반 DB나 HTTP 서버의 트랜잭션 상태를 알지 못합니다.

그래서 현재의 기본 선택지는 다음과 같습니다.

  • 서비스 DB 변경과 발행할 이벤트 레코드를 같은 DB 트랜잭션에 넣는 Outbox
  • 소비한 event_id와 업무 처리 결과를 같은 DB 트랜잭션에 기록하는 Inbox
  • 외부 API가 제공하는 멱등성 키와 결과 조회
  • 같은 요청을 다시 처리해도 결과가 달라지지 않는 업무 로직과, 필요할 때의 보상 절차

Kafka는 왜 Outbox와 Inbox 사이에 잘 맞을까는 이 계약을 Kafka의 양쪽 끝에서 어떻게 설계하는지 설명합니다. Kafka 트랜잭션과 Outbox는 경쟁하는 대안이 아니라, 보호하는 경계가 다른 도구입니다.

KIP-939: Kafka가 외부 2PC에 참여한다면

KIP-939의 목표는 Kafka가 XA를 제공하거나 2PC의 조정자가 되는 것이 아닙니다. 목표는 외부 조정자가 이끄는 2PC에서 Kafka가 참여자가 되는 것입니다. 제안은 Kafka 트랜잭션을 준비(prepared) 상태로 남겨 두고, 외부 조정자가 나중에 커밋 또는 취소를 결정하게 합니다.

아래는 KIP가 설명하는 순서를 보여 주는 의사 코드입니다. prepareTransaction()completePreparedTransaction()은 현재 공개 Kafka 클라이언트에서 호출할 수 있는 API가 아니므로 실행 가능한 코드가 아닙니다.

외부 조정자의 트랜잭션 시작
Kafka 트랜잭션 시작
Kafka에 레코드 발행
Kafka 트랜잭션 준비              ← KIP-939가 제안하는 단계
조정자가 DB 변경과 준비 상태를 영속화
조정자가 DB 커밋
조정자가 준비된 Kafka 트랜잭션 완료 ← 커밋 또는 취소 결정
외부 2PC 조정자가 Kafka와 DB를 준비 단계에서 조정하고, 복구 시 영속화한 결정에 따라 Kafka 트랜잭션을 완료하는 흐름도
2PC의 핵심은 커밋 호출 하나가 아니라, 준비된 상태와 최종 결정을 장애 뒤에도 복구할 수 있게 보존하는 데 있습니다.

이 방식이 실현돼도 “Kafka와 어떤 DB든 자동으로 하나의 트랜잭션”이 되는 것은 아닙니다. 반대편 시스템도 준비·커밋·복구를 지원해야 하며, 외부 조정자는 결정 기록과 재시도 절차를 책임져야 합니다. KIP-939는 Kafka가 외부 2PC에 참여하기 위한 계약을 다루는 제안입니다.

이 글을 쓰는 2026-07-21 기준으로 KIP-939는 Accepted 상태이지만, 관련 구현 이슈 KAFKA-15370은 Open 상태입니다. 일부 하위 작업은 완료됐지만 준비 상태 유지와 시나리오 처리는 진행 중입니다. 따라서 이를 제품 설계의 전제로 삼거나 예제 API를 만들어 사용해서는 안 됩니다.

2PC가 추가하는 운영 비용

현재 Kafka 트랜잭션에는 타임아웃이 있습니다. 프로듀서가 사라졌을 때 Kafka가 미완료 트랜잭션을 정리해야 하기 때문입니다. 그러나 외부 조정자가 최종 결정을 내리는 2PC에서는 Kafka가 타임아웃만으로 독자적으로 취소하면 다른 참여자의 결정과 어긋날 수 있습니다.

그래서 KIP-939는 2PC 참여 트랜잭션에서 자동 타임아웃을 없애는 방향을 제안합니다. 그 대가로 준비 상태가 오래 남는 상황은 운영자가 직접 관리해야 합니다.

준비 상태가 오래 남으면운영에서 필요한 대응
read_committed 컨슈머가 해당 파티션의 뒤쪽 레코드를 읽지 못할 수 있음준비 상태로 머문 시간과 소비 지연을 함께 관찰
로그 컴팩션이 진행되지 못할 수 있음토픽의 보존 설정·컴팩션 압력과 준비 상태를 함께 진단
조정자 장애 뒤 최종 결정을 잃을 수 있음결정 기록을 내구성 있게 저장하고 재개 절차 마련
무분별한 강제 취소가 데이터 불일치를 만들 수 있음권한 있는 관리 절차와 감사 이력 마련

Kafka를 운영할 때는 브로커, 토픽, 파티션, 컨슈머 그룹을 코드 개념으로만 보지 말고 운영 신호로도 읽어야 합니다. Konduo는 Kafka 같은 클러스터형 리소스를 플러그인으로 연결해 상태, 메트릭 근거, 진단, 알림 대응을 하나의 운영 흐름에서 확인할 수 있도록 합니다. 다만 2PC의 최종 결정과 업무 복구 규칙은 애플리케이션과 조정자가 책임져야 합니다.

오늘 무엇을 선택할까

상황먼저 선택할 도구
여러 Kafka 토픽·파티션의 결과가 함께 보여야 함Kafka 트랜잭션과 read_committed
Kafka 입력을 읽어 Kafka 출력으로 변환함Kafka 트랜잭션에 offset 포함
서비스 DB 변경과 이벤트 발행을 맞춰야 함Outbox와 재시도 가능한 relay
Kafka 소비 결과를 DB에 반영함Inbox 또는 결과와 처리 위치의 원자적 저장
결제·알림 같은 외부 API를 호출함안정적인 멱등성 키, 결과 조회, 보상 정책
여러 시스템이 준비·복구 가능한 2PC를 실제로 지원함KIP-939의 구현·릴리스 상태를 확인한 뒤 별도 검토

Kafka 트랜잭션은 이미 아주 유용합니다. 강점은 모든 시스템을 한 번에 묶는 데 있지 않습니다. Kafka 안에서 어떤 결과를 함께 보이게 할지를 정확히 제어하는 데 있습니다.

KIP-939가 구현되고 릴리스되면 선택지가 넓어질 수 있습니다. 그때도 가장 중요한 질문은 바뀌지 않습니다.

누가 최종 결정을 기록하는가?
장애 뒤 누가 어떤 근거로 커밋 또는 취소를 재개하는가?

함께 읽기 좋은 글