카프카의 정확히 한 번 처리 보장(Exactly-Once Semantics) 알아보기: 종합 가이드

멱등적 프로듀서, 트랜잭션, read_committed 컨슈머, 오프셋 커밋을 통한 Kafka 정확히 한 번 전송 의미론 이해하기

Kafka의 정확히 한 번 전송 의미론 완벽 가이드

Kafka의 정확히 한 번 전송 의미론은 프로듀서 재시도, 브로커 장애 조치, 애플리케이션 재시작 시 스트림 처리 파이프라인이 중복 출력 레코드를 생성하지 않도록 보호할 수 있습니다. 이 보장은 강력하지만, 그 이름이 암시하는 것보다 범위가 좁습니다. Kafka는 Kafka 쓰기와 소비된 오프셋을 트랜잭션으로 만들 수 있습니다. 외부 데이터베이스, 결제 게이트웨이 또는 HTTP API를 자동으로 정확히 한 번 처리하도록 만들 수는 없습니다.

중복 출력이 비용이 많이 들거나 정리하기 어려운 경우(예: 재고 조정, 계좌 잔액 이벤트, 다른 서비스에서 소비하는 파생 상태 토픽) 정확히 한 번 전송 의미론을 사용하세요.

일반 언어로 설명하는 전달 보장

Kafka 애플리케이션은 일반적으로 세 가지 전달 모델에 대해 이야기합니다.

  • 최대 한 번(at-most-once): 애플리케이션이 레코드를 손실할 수 있지만, 동일한 레코드를 두 번 처리해서는 안 됩니다. 이는 처리가 완료되기 전에 오프셋이 커밋될 때 발생할 수 있습니다.
  • 최소 한 번(at-least-once): 애플리케이션이 레코드를 손실해서는 안 되지만, 재시도 또는 재시작 후에 레코드를 두 번 이상 처리할 수 있습니다.
  • 정확히 한 번(exactly-once): Kafka 읽기-처리-쓰기 루프가 출력 레코드와 소비된 오프셋을 하나의 트랜잭션으로 커밋합니다.

마지막 요점이 핵심입니다. 정확히 한 번 전송 의미론은 애플리케이션이 Kafka에서 읽고, 결과를 Kafka에 다시 쓰고, 동일한 트랜잭션 내에서 오프셋을 커밋할 때 가장 강력합니다.

멱등적 프로듀서

멱등적 프로듀서는 프로듀서 재시도로 인한 중복 쓰기를 방지합니다. Kafka는 프로듀서에 ID를 할당하고 각 프로듀서와 파티션에 대한 시퀀스 번호를 추적합니다. 브로커가 이미 배치를 수락한 후 재시도를 받으면, 중복을 다시 추가하는 대신 거부할 수 있습니다.

최신 Kafka 클라이언트의 경우, 충돌하는 프로듀서 설정을 구성하지 않으면 멱등성이 기본적으로 활성화됩니다. 명시적으로 설정할 수도 있습니다:

enable.idempotence=true
acks=all

acks=all은 리더가 쓰기를 승인하기 전에 모든 동기화된 복제본이 응답할 때까지 기다린다는 의미입니다. 멱등성은 호환 가능한 재시도 및 진행 중인 요청 설정에도 의존하므로, 클라이언트 버전에서의 영향을 알지 못한다면 프로듀서 신뢰성 설정을 재정의하지 마십시오.

멱등성은 프로듀서 재시도를 보호하지만, 전체 처리 워크플로를 원자적으로 만들지는 않습니다. 애플리케이션이 한 토픽에서 소비하고 다른 토픽에 생성하는 경우, 출력과 오프셋 커밋을 함께 묶기 위해 트랜잭션이 필요합니다.

Kafka 트랜잭션

트랜잭션을 사용하면 하나의 프로듀서가 여러 쓰기를 원자적 단위로 그룹화할 수 있습니다. 프로듀서는 안정적인 transactional.id가 필요합니다.

transactional.id=inventory-adjuster-0
enable.idempotence=true
acks=all

일반적인 트랜잭션 흐름은 다음과 같습니다:

  1. 애플리케이션 시작 시 트랜잭션을 초기화합니다.
  2. 트랜잭션을 시작합니다.
  3. 입력 토픽에서 레코드를 소비합니다.
  4. 출력 레코드를 생성합니다.
  5. 소비된 오프셋을 트랜잭션에 전송합니다.
  6. 트랜잭션을 커밋하거나 실패 시 중단합니다.

커밋 전에 프로세스가 중단되면 Kafka는 커밋되지 않은 출력을 read_committed 컨슈머에 노출하지 않습니다. 재시작 시 애플리케이션은 동일한 입력 레코드를 다시 읽고 하나의 커밋된 결과를 생성할 수 있습니다.

중요한 컨슈머 설정

트랜잭션 출력을 읽는 컨슈머는 다음을 사용해야 합니다:

isolation.level=read_committed
enable.auto.commit=false

read_committed는 중단된 트랜잭션의 레코드를 숨깁니다. enable.auto.commit=false는 컨슈머가 트랜잭션 외부에서 오프셋을 커밋하는 것을 방지합니다.

속성 이름이 중요합니다. Kafka의 컨슈머 설정은 enable.auto.commit이지 auto.commit.enable이 아닙니다.

수동 컨슈머-프로듀서 애플리케이션의 경우 오프셋 커밋은 프로듀서 트랜잭션의 일부여야 합니다. Java 클라이언트에서는 트랜잭션 프로듀서 API를 사용해야 하며, 여기에는 커밋하기 전에 오프셋을 트랜잭션에 전송하는 것이 포함됩니다.

구체적인 시나리오

orders 토픽과 inventory-events 출력 토픽이 있다고 가정해 보겠습니다. 서비스는 주문을 읽고, SKU를 확인한 후 재고 차감 이벤트를 작성합니다.

트랜잭션이 없으면 출력을 작성한 후 입력 오프셋을 커밋하기 전에 충돌이 발생하면 재시작 후 중복 차감이 발생할 수 있습니다. 트랜잭션을 사용하면 출력 이벤트와 입력 오프셋 커밋이 함께 성공하거나 실패합니다. 재시작 시 주문을 다시 읽을 수 있지만, 하나의 커밋된 재고 이벤트만 다운스트림 read_committed 컨슈머에 표시됩니다.

명심해야 할 한계

Kafka의 정확히 한 번 전송 의미론은 직접 설계하지 않는 한 Kafka 외부의 부작용을 다루지 않습니다. 동일한 서비스가 PostgreSQL에 쓰거나 결제 API를 호출하는 경우, 해당 외부 부작용에는 자체 멱등성 키, 고유 제약 조건, 트랜잭션 전략 또는 아웃박스 패턴이 필요합니다.

트랜잭션은 또한 조정 오버헤드를 추가합니다. 중복이 허용되는 간단한 로그 수집의 경우 멱등적 프로듀서와 최소 한 번 컨슈머로 충분할 수 있습니다.

실용적인 체크리스트

애플리케이션 인스턴스 또는 태스크당 안정적인 transactional.id를 사용하세요. 두 개의 활성 프로듀서가 동시에 동일한 트랜잭션 ID를 사용하지 않도록 하세요.

트랜잭션 출력의 컨슈머를 read_committed로 설정하세요. 트랜잭션 처리 루프에서 자동 오프셋 커밋을 비활성화하세요.

트랜잭션을 짧게 유지하세요. 대규모 트랜잭션은 지연 시간을 증가시키고 복구 속도를 느리게 할 수 있습니다.

외부 시스템은 별도로 처리하세요. Kafka는 Kafka 상태를 보호할 수 있지만, 데이터베이스 쓰기에는 여전히 멱등적 설계가 필요합니다.

유용한 결론: 정확히 한 번 전송 의미론은 마법 같은 스위치가 아닙니다. 이는 Kafka 간 스트림 처리에 가장 적합한 프로듀서, 컨슈머 및 트랜잭션 선택의 집합입니다.