programming language/Kakfa

[Kafka 기초 학습] Kafka 기초개념 7 - KRaft 간단 이해 및 실습 진행 + 사용 사례 조사

공대키메라 2026. 6. 20. 15:02

지난 글에서는 컨슈머에 대해서 이해하는 시간을 가졌다. 

(지난글이 궁금하면 여기 클릭!)

 

이번 시간에는 KRaft에 대해서 알아보려고 한다.

 

KRaft(Kafka Raft) 이전에는 Zookeeper를 사용했다는데, 필자의 입장에서는 지금 쓰지도 않아 흥미도 없는거 억지로 파고 들어봤자 금방 잊을거라는 생각이 들었다.

 

그렇기 때문에 KRaft를 위주로 어떻게 작동하고 설정하는지 알아보고 실습을 통해 이해할 예정이다.

 

1. Zookeeper에 대한 간단한 고찰

Apache ZooKeeper는 고도의 신뢰성을 갖춘 분산 조정을 가능하게 하는 오픈 소스 서버를 개발하고 유지 관리하기 위한 노력의 일환이다.

 

ZooKeeper는 구성 정보 유지 관리, 명명 규칙 적용, 분산 동기화 및 그룹 서비스 제공을 위한 중앙 집중식 서비스이다.

 

Kafka에서는 이 Zookeeper가 그러면 위의 말대로 구성 정보 유기 관리, 명명 규칙 적용, 분산 동기화 및 그룹 서비스 제공을 했겠지?

 

현재 최신 버전에서는 KRaft를 사용하고 Zookeeper를 사용하지 않는데, 뭔가 문제가 있고 않좋아서 그렇겠지? 하는 생각을 가진다.

 

하여간 confluent.io 사이트에서 소개하는 Zookeeper - Controller - Brokers 간의 관계도를 살펴보자.

출처 : https://www.confluent.io/blog/why-replace-zookeeper-with-kafka-raft-the-log-of-all-logs/

 

 

상단에는 3개의 Zookeeper가 있고, Controller가 중앙에 하나 있다. 하단에는 3개의 Broker가 있으며 Controller 하나가 Zookeeper와 통신하고 Broker와도 통신하는 모습이다.

 

뭔가 controller하나가 관리하기 쉽지 않을 것 같은데 Zookeeper의 단점을 AI에게 요청했다.

 

기존 주키퍼 아키텍처의 문제점 요약

  • 운영 복잡성 (시스템 두 집 살림): 카프카 클러스터 하나를 돌리기 위해 '주키퍼'라는 완전히 다른 분산 시스템을 별도로 띄우고 관리해야 합니다. 인프라 배포, 보안 설정, 모니터링 등 관리 포인트가 두 배로 늘어나 인프라 엔지니어들의 큰 원성을 샀습니다.
  • 메타데이터 병목 및 불일치 위험: 상태 정보(메타데이터)가 주키퍼에도 있고, 중앙의 컨트롤러(카프카)에도 있고, 일반 브로커들에도 퍼져 있습니다. 클러스터에 변화가 생겼을 때 [주키퍼 ↔ 컨트롤러 ↔ 브로커] 순서로 무겁게 통신하며 정보를 갱신해야 하므로 병목 현상이 발생하고, 타이밍이 안 맞으면 상태가 꼬일(불일치) 위험이 큽니다.
  • 확장성의 한계 (컨트롤러 과부하): 중앙에 있는 단 하나의 컨트롤러가 혼자서 주키퍼와 소통하며 모든 브로커를 통제합니다. 클러스터 규모가 커지고 파티션 개수가 수십만 개 단위로 늘어나면, 컨트롤러가 메타데이터를 처리하다가 과부하에 걸려 전체 성능이 뚝 떨어지는 한계가 있었습니다.

한 줄 결론

"주키퍼 관리하기도 귀찮고 동기화도 느린데, 그냥 카프카 안에서 카프카 방식으로 알아서 관리하면 안 될까?"라는 불만에서 출발하여, 저 그림 속의 복잡한 윗단(주키퍼)을 완전히 잘라내고 진화한 것이 바로 질문자님이 지금 실습하고 계신 KRaft 모드입니다!

 

초창기 Zookeeper는 여러 대의 카프카 서버를 관리하기 위해 도입된 중앙 통제실이엇지만 덩치가 커지다보니 확장성의 한계에 부딪힌 것이다.

 

이러한 문제를 해결하기 위해 Kafka 에서 KIP:500으로 2019년에 시작해서 2020년에 마무리했다.

 

하여간 그런거지 뭐. 필자가 관심이 있는 것은 KRaft이다.

 

2. KRaft 컨셉 요약

인터넷에는 정말 양질의 정보가 넘쳐난다.

 

문제는 이를 완벽하게 필자가 다 읽고 하고 싶었는데, 초심자의 입장에서 우선 적당히 이해하고 시작하는것이 낫다고 판단했다.

 

결국 기존의 Zookeeper를 버리고 KRaft를 쓰면서 어떤 점이 좋아진건가?

 

그래서 linkedin에 깔끔하게 정리한 글을 참고해서 요약본을 번역했다.

(출처 : Comparing Zookeeper and KRaft in Kafka)

 

스케일링 개선

클러스터마다 최대 파티션의 수는 주로 큰 카프카 클러스터를 스케일링하는데 주요한 병목지점이다. KRAft mode는 더 많은 수의 파티션을 지원한다.


카프카 관리 및 모니터링 간소화

주키퍼는 그 자체로 별개의 애플리케이션이기 때문에 자체적인 관리와 모니터링 부담(오버헤드)이 발생하지만, 카프카 래프트(KRaft)는 이러한 부담을 제거해 줍니다.

 

보안 모델 간소화

주키퍼의 보안 모델과 업그레이드는 일반적으로 카프카의 발전 속도에 맞춰 뒤늦게 따라가야 했고, 이로 인해 전체적인 보안 모델이 복잡해졌습니다. 구조에서 주키퍼를 제외함으로써 카프카 자체의 보안 모델이 훨씬 단순해집니다.

 

추가 의존성 없는 카프카 구동 간소화 및 단일 노드(Single-node) 배포 지원

기존 주키퍼 기반 아키텍처에서는 카프카를 구동할 때 여러 데몬(백그라운드 프로세스)을 띄워야 하는 복잡함이 있었습니다. (KRaft는 이를 하나의 프로세스로 구동할 수 있게 해 주며, 단일 노드 배포도 쉽게 지원합니다.)


장애 조치(Failover) 지연 시간 감소

과거에는 주키퍼의 메타데이터 메시지가 모든 브로커에 제때 도달하지 못하는 경우가 종종 발생했으며, 이로 인해 장애 복구(대장 재선출 등)가 느려지고 시스템이 불안정해지곤 했습니다.

 

3. KRaft 실습하기 - 세팅하기

솔직히 글이 너무 많다. 그래서 최근에는 Top-Down 방식으로 전환하려고 한다. 

 

빠르게 실습을 해보고 최소한의 노력으로 최대한 이해를 한 다음, 지식의 공백을 직접 찾아서 매우는 것이다. 

 

다음은 실습하고자 하는 docker compose 설정이다.

 

설정을 직접 찾아보고 싶으신 분들은 해당 docker kafka 공식 문서를 보면 된다.

 

다음 링크는 KRaft 설정 방법에 대해 설명한다.(Confluent Platform용 KRaft 구성)

 

docker-kraft-compose.yml

networks:
  kafka-network:
    name: kafka-kraft-net
    driver: bridge
    
services:
  kafka-kraft-1:
    image: apache/kafka:latest
    container_name: kafka-kraft-1
    user: root  # 볼륨 디렉터리 권한 충돌 방지
    ports:
      - "19092:19092"
    environment:
      # [1. 클러스터 고유 식별 키] 3대의 노드가 동일한 하나의 ID를 공유해야 같은 클러스터로 결합됩니다.
      CLUSTER_ID: 'O1N_F_D-S2K6V8-A_G-p-A'
      # [2. 노드 고유 번호] 클러스터 내부에서 이 노드를 식별하는 이름표입니다.
      KAFKA_NODE_ID: 1
      # [3. KRaft 아키텍처 역할 설정 (Combined Mode)]
      # 한 노드가 브로커(데이터 저장)와 컨트롤러(클러스터 통제망 및 투표권) 역할을 동시에 담당합니다.
      # 실무 유의사항: Confluent 및 카프카 공식 문서에서는 프로덕션 환경 운영 시 리소스 경합 및
      # 장애 전파를 방지하기 위해 두 역할을 분리(Isolated Mode)하여 서버를 독자 구축하도록 강력하게 권장합니다.
      # 다만, 현재처럼 아키텍처를 학습하고 장애 테스트를 하는 로컬 환경에서는 이 결합 모드가 매우 효율적입니다.
      KAFKA_PROCESS_ROLES: 'controller,broker'
      # [4. KRaft 이사회 명단 (Quorum Voters)]
      # 주키퍼 없이 합의(Raft 알고리즘)를 진행할 투표권자들의 통신 주소 명단입니다. (과반인 2대 이상 생존 필수)
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-kraft-1:9093,2@kafka-kraft-2:9093,3@kafka-kraft-3:9093'
      # [5. 네트워크 격리 및 리스너 매핑]
      # 일반 데이터 송수신망(INTERNAL/EXTERNAL)과 통제용 비밀 통로(CONTROLLER) 네트워크 포트를 철저히 격리합니다.
      KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19092'
      KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://kafka-kraft-1:9092,EXTERNAL://localhost:19092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'INTERNAL'
      # [6. 로그 및 디스크 메타데이터 저장 디렉토리]
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
    volumes:
      # [7. 데이터 지속성 보장 (장애 재선출 핵심 설정)]
      # 컨테이너를 강제로 중단(stop)시켰다가 재기동해도 과거 투표 에포크(Epoch) 이력이 유실되지 않도록 가상 볼륨을 매핑합니다.
      - kafka-kraft-1-data:/tmp/kraft-combined-logs
    networks:
      - kafka-network

  kafka-kraft-2:
    image: apache/kafka:latest
    container_name: kafka-kraft-2
    user: root
    ports:
      - "29092:29092"
    environment:
      CLUSTER_ID: 'O1N_F_D-S2K6V8-A_G-p-A'
      KAFKA_NODE_ID: 2
      KAFKA_PROCESS_ROLES: 'controller,broker'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-kraft-1:9093,2@kafka-kraft-2:9093,3@kafka-kraft-3:9093'
      KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:29092'
      KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://kafka-kraft-2:9092,EXTERNAL://localhost:29092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'INTERNAL'
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
    volumes:
      - kafka-kraft-2-data:/tmp/kraft-combined-logs
    networks:
      - kafka-network

  kafka-kraft-3:
    image: apache/kafka:latest
    container_name: kafka-kraft-3
    user: root
    ports:
      - "39092:39092"
    environment:
      CLUSTER_ID: 'O1N_F_D-S2K6V8-A_G-p-A'
      KAFKA_NODE_ID: 3
      KAFKA_PROCESS_ROLES: 'controller,broker'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-kraft-1:9093,2@kafka-kraft-2:9093,3@kafka-kraft-3:9093'
      KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:39092'
      KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://kafka-kraft-3:9092,EXTERNAL://localhost:39092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'INTERNAL'
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
    volumes:
      - kafka-kraft-3-data:/tmp/kraft-combined-logs
    networks:
      - kafka-network

volumes:
  kafka-kraft-1-data:
  kafka-kraft-2-data:
  kafka-kraft-3-data:

 

 

$ ls
kafka-consumer kafka-epoch-test kafka-kraft-study kafka-study

 

필자는 kafka-kraft-study 라는 폴더를 생성해서 위의 docker yml 파일을 생성했다.

 

필자는 도중에 실패를 해서 전부 다 내렸다.

docker compose -f docker-kraft-compose.yml down -v

 

이유는 volume 생성하려면 권한이 잇어야 한다는데, 그것을 설정을 안해줬었기 때문이다.

 

어찌되었든 실행해보자.

docker compose -f docker-kraft-compose.yml up -d

 

실행하면 다음과 같이 나오는 것을 확인할 수 있다.

$ docker compose -f docker-kraft-compose.yml up -d
[+] up 6/6
 ✔ Volume kafka-kraft-study_kafka-kraft-2-data Created                                                              0.0s
 ✔ Volume kafka-kraft-study_kafka-kraft-3-data Created                                                              0.0s
 ✔ Volume kafka-kraft-study_kafka-kraft-1-data Created                                                              0.0s
 ✔ Container kafka-kraft-1                     Started                                                              0.3s
 ✔ Container kafka-kraft-3                     Started                                                              0.3s
 ✔ Container kafka-kraft-2                     Started                                                              0.4s
$ docker ps
CONTAINER ID   IMAGE                 COMMAND                  CREATED         STATUS         PORTS                                                       NAMES
5b1b0e048394   apache/kafka:latest   "/__cacert_entrypoin…"   2 seconds ago   Up 2 seconds   9092/tcp, 0.0.0.0:29092->29092/tcp, [::]:29092->29092/tcp   kafka-kraft-2
7bd8b4ad230b   apache/kafka:latest   "/__cacert_entrypoin…"   2 seconds ago   Up 2 seconds   9092/tcp, 0.0.0.0:39092->39092/tcp, [::]:39092->39092/tcp   kafka-kraft-3
8cf1629f0dd4   apache/kafka:latest   "/__cacert_entrypoin…"   2 seconds ago   Up 2 seconds   9092/tcp, 0.0.0.0:19092->19092/tcp, [::]:19092->19092/tcp   kafka-kraft-1

 

 

docker 실행 모습

 

진짜 실습을 하기 전에 다음을 짚고 넘어가려고 한다.

 

카프카 공식 문서의 Configuration이 여기 있다.(https://kafka.apache.org/43/configuration/)

 

docker에서의 configuration 글자를 소문자로, _를 .으로 변경하면 공식문서의 설정과 일치함을 알 수 있다.

 

      # [1. 클러스터 고유 식별 키] 3대의 노드가 동일한 하나의 ID를 공유해야 같은 클러스터로 결합됩니다.
      CLUSTER_ID: 'O1N_F_D-S2K6V8-A_G-p-A'
      # [2. 노드 고유 번호] 클러스터 내부에서 이 노드를 식별하는 이름표입니다.
      KAFKA_NODE_ID: 1
      # [3. KRaft 아키텍처 역할 설정 (Combined Mode)]
      # 한 노드가 브로커(데이터 저장)와 컨트롤러(클러스터 통제망 및 투표권) 역할을 동시에 담당합니다.
      # 실무 유의사항: Confluent 및 카프카 공식 문서에서는 프로덕션 환경 운영 시 리소스 경합 및
      # 장애 전파를 방지하기 위해 두 역할을 분리(Isolated Mode)하여 서버를 독자 구축하도록 강력하게 권장합니다.
      # 다만, 현재처럼 아키텍처를 학습하고 장애 테스트를 하는 로컬 환경에서는 이 결합 모드가 매우 효율적입니다.
      KAFKA_PROCESS_ROLES: 'controller,broker'
      # [4. KRaft 이사회 명단 (Quorum Voters)]
      # 주키퍼 없이 합의(Raft 알고리즘)를 진행할 투표권자들의 통신 주소 명단입니다. (과반인 2대 이상 생존 필수)
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-kraft-1:9093,2@kafka-kraft-2:9093,3@kafka-kraft-3:9093'
      # [5. 네트워크 격리 및 리스너 매핑]
      # 일반 데이터 송수신망(INTERNAL/EXTERNAL)과 통제용 비밀 통로(CONTROLLER) 네트워크 포트를 철저히 격리합니다.
      KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19092'
      KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://kafka-kraft-1:9092,EXTERNAL://localhost:19092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'INTERNAL'
      # [6. 로그 및 디스크 메타데이터 저장 디렉토리]
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'

 

KAFKA_PROCESS_ROLES

=> process.roles

 

해당 설정에서는 간단한 실습을 위해서 이른바 결합 모드(Combined Mode)를 사용했는데 실무에서는 이렇게 사용하지 말라고 한다. 그러니까 broker, controller로 역할을 배정하지 말라고 한다. 

 

현재는 테스트니까 연습용으로 이렇게 해도 문제 없다고 한다.

 

Combined mode is for local experimentation only and is not supported by Confluent.

 

컨트롤러 전담 노드들의 모임을 '앙상블(Ensemble)'이라고 부르며, 이들은 반드시 2n + 1 (홀수)로 구성해야 한다고 말한다.

 

홀수 개의 컨트롤러를 사용하는 이유는 쿼럼이 리더십 선출을 위한 다수결 투표를 수행할 수 있도록 하기 위함입니다. 앙상블에서는 언제든지 최대 n개의 서버에 장애가 발생할 수 있지만, 클러스터는 쿼럼을 유지합니다.

예를 들어, 컨트롤러가 3개인 경우 클러스터는 컨트롤러 하나에 장애가 발생하더라도 견딜 수 있습니다. 만약 쿼럼이 상실되면 클러스터는 다운됩니다. 운영 환경에서는 일반적으로 3개 또는 5개의 컨트롤러를 사용하는 것이 좋지만, 최소 3개는 필요합니다.

 

KAFKA_CONTROLLER_QUORUM_VOTERS

=> controller.quorum.voters

 

이를 먼저 이해하기 위해서는 Quorum Voters에 대해 이해해야 한다.

 

Quorum은 이사회라는 뜻으로, Voters는 투표자다. 붙이면 이사회 투표자들(?) 인데 이게 뭐지?

 

Quorum은 분산 시스템에서 합의를 이루기 위한 '최소한의 다수결 기준'을 뜻한다.

 

3대로 구성된 이사회라면 과반수인 2대 이상의 동의(Quorum)가 있어야만 새로운 대장을 뽑거나 메타데이터를 변경할 수 있다.

 

위에서 설정한 명단에 있는 노드(kafka-kraft-1, 2, 3)들은 분산 합의 알고리즘인 Raft를 사용해 투표를 진행하고, 단 1대의 액티브 컨트롤러(Active Controller, 대장)를 선출한다.

 

재미있게도 이사회 라는 단어를 가져와서 리더를 선출하는 것을 투표라는 이러한 선거 시스템을 차용했다. 

 

이 설정은 이사회에 참여하는 컨트롤러들의 명단을 정적으로 박아둔다고 보면 된다.

 

형식은 {node.id}@{host}:{port} 이고, 콤마로 여러 개를 나열한다.

 

1@controller1:9093,2@controller2:9093,3@controller3:9093

 

KAFKA_LISTENERS(컨테이너 내부에서 문 열어두기)

=> listeners

KAFKA_LISTENERS: 'INTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19092'

 

  • 9092 포트로 들어오면 INTERNAL 통로로 연결해라.
  • 9093 포트로 들어오면 CONTROLLER 통로로 연결해라.
  • 19092 포트로 들어오면 EXTERNAL 통로로 연결해라.

 

KAFKA_ADVERTISED_LISTENERS(클라이언트에게 명함 건네기)

=> advertised.listeners

KAFKA_ADVERTISED_LISTENERS: 'INTERNAL://kafka-kraft-1:9092,EXTERNAL://localhost:19092'

 

카프카 브로커가 자신에게 접속한 프로듀서, 컨슈머, 혹은 다른 브로커들에게 "나한테 데이터 보내고 싶어? 그럼 이 주소로 찾아와!" 하고 건네주는 안내 주소(명함)이다.

 

KAFKA_LISTENER_SECURITY_PROTOCOL_MAP

=> listener.security.protocol.map

KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT'

 

 

우리가 지은 이름표가 실제로 어떤 보안 통신 방식을 쓰는지 짝지어주기(Map)를 한다.

 

결국 이것은 우리 카프카에서 앞으로 CONTROLLER, INTERNAL, EXTERNAL이라는 3가지 통로 이름을 쓸 거고, 전부 암호화되지 않은 일반 텍스트(PLAINTEXT) 통신을 할 거야!"라고 선언하는 것이다.

 

KAFKA_CONTROLLER_LISTENER_NAMES

=> controller.listener.names

 

이사회(컨트롤러) 전용 비밀 통로 이름이 뭔지 카프카에게 알려준다.


카프카야, 네가 여러 개의 문을 열 건데, 그중에서 이름표가 CONTROLLER라고 붙은 문으로는 절대 일반 프로듀서나 컨슈머의 데이터 트래픽을 들이지 마! 거기는 오직 컨트롤러 노드들끼리 메타데이터를 주고받고 투표할 때만 쓰는 전용 통로야!       

 

라는 거라고 한다.

KAFKA_INTER_BROKER_LISTENER_NAME

inter.broker.listener.name

브로커 형제들끼리 내부에서 데이터 복제할 때 쓸 통로 이름 알려주기

 

4. KRaft 살펴보기 - 분산합의 및 복구과정 로그 확인

linux 의 grep 명령어를 사용해서 현재 선출된 leader에 대해 알아보자. 

 

docker compose -f docker-kraft-compose.yml logs | grep -i "transition to leader"

 

실행 결과

$ docker compose -f docker-kraft-compose.yml logs | grep -i "transition to leader"
kafka-kraft-2  | [2026-06-18 06:57:34,521] INFO [RaftManager id=2] Attempting durable transition to Leader(localVoterNode=VoterNode(voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1]), epoch=1, epochStartOffset=0, highWatermark=Optional.empty, voterStates={1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)}) from CandidateState(localId=2, localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg, epoch=1, epochElection=EpochElection[voterStates={1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), 2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED), 3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)}], highWatermark=Optional.empty, electionTimeoutMs=1568) (org.apache.kafka.raft.QuorumState)
kafka-kraft-2  | [2026-06-18 06:57:34,524] INFO [RaftManager id=2] Completed transition to Leader(localVoterNode=VoterNode(voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1]), epoch=1, epochStartOffset=0, highWatermark=Optional.empty, voterStates={1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)}) from CandidateState(localId=2, localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg, epoch=1, epochElection=EpochElection[voterStates={1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), 2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED), 3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)}], highWatermark=Optional.empty, electionTimeoutMs=1568) (org.apache.kafka.raft.QuorumState)

 

 

보기 힘드니까 정렬만 좀 했다.

 

kafka-kraft-2 | [2026-06-18 06:57:34,521] INFO [RaftManager id=2] 
Attempting durable transition to Leader(
    localVoterNode=VoterNode(
        voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), 
        listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), 
        supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1]
    ), 
    epoch=1, 
    epochStartOffset=0, 
    highWatermark=Optional.empty, 
    voterStates={
        1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 
        2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 
        3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)
    }
) 
from CandidateState(
    localId=2, 
    localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg, 
    epoch=1, 
    epochElection=EpochElection[
        voterStates={
            1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), 
            2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED), 
            3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)
        }
    ], 
    highWatermark=Optional.empty, 
    electionTimeoutMs=1568
) (org.apache.kafka.raft.QuorumState)


kafka-kraft-2 | [2026-06-18 06:57:34,524] INFO [RaftManager id=2] 
Completed transition to Leader(
    localVoterNode=VoterNode(
        voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), 
        listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), 
        supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1]
    ), 
    epoch=1, 
    epochStartOffset=0, 
    highWatermark=Optional.empty, 
    voterStates={
        1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 
        2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 
        3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)
    }
) 
from CandidateState(
    localId=2, 
    localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg, 
    epoch=1, 
    epochElection=EpochElection[
        voterStates={
            1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), 
            2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED), 
            3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)
        }
    ], 
    highWatermark=Optional.empty, 
    electionTimeoutMs=1568
) (org.apache.kafka.raft.QuorumState)

 

하나도 모르겠다. Gemini에게 주석을 달아달라고 했다.

 

// ============================================================================
// [첫 번째 로그] 06:57:34,521
// 2번 노드가 투표 결과를 바탕으로 "나 이제 대장(Leader)으로 승격 시도할게!" 라고 선언
// ============================================================================
kafka-kraft-2 | [2026-06-18 06:57:34,521] INFO [RaftManager id=2] 
Attempting durable transition to Leader(        // 대장(Leader)으로의 전환을 시도합니다.
    localVoterNode=VoterNode(                   // 나(2번 노드)의 물리적인 정보들
        voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), // 내 ID는 2번, 내 디스크 식별자는 이거야.
        listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), // 이사회 전용 비밀 통로(9093포트) 열려있어.
        supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1] // 내가 지원하는 KRaft 프로토콜 버전
    ), 
    epoch=1,                                    // 이건 카프카 클러스터 역사상 '1대 대장'을 뽑는 첫 번째 임기(Epoch)야.
    epochStartOffset=0,                         // 1대 대장으로서 업무(메타데이터 쓰기)를 시작할 파일의 위치(Offset)는 0번부터야.
    highWatermark=Optional.empty,               // 아직 데이터 동기화가 확정(Commit)된 지점(High Watermark)은 없어. (이제 막 대장됐으니까)
    voterStates={                               // 현재 클러스터 멤버(1,2,3)들이 나(2번)를 대장으로 모시고 있는지에 대한 상태
        // 1번 노드: 죽어있거나 응답이 없어서 상태 모름. 나를 대장으로 인정 안 함 (false)
        1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 
        // 2번 노드(나): 나는 나 스스로를 대장으로 100% 인정함 (true)
        2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 
        // 3번 노드: 방금 나한테 찬성표는 던졌지만, 아직 내가 "대장 등극했다"고 선언하기 전이라 공식적인 리더 인정은 안 한 상태임 (false)
        3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)
    }
) 
from CandidateState(                            // 내가 대장으로 승격할 수 있었던 이유는, 직전의 '후보(Candidate)' 상태에서 투표를 진행했기 때문이야.
    localId=2,                                  // 내 ID는 2번
    localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg,    // 내 디스크 식별자
    epoch=1,                                    // 1대 임기 투표
    epochElection=EpochElection[                // [핵심] 이것이 바로 이번 선거의 개표 결과!
        voterStates={
            1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), // 1번: 기권/무응답 (표결 안 됨)
            2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED),    // 2번: 나 자신에게 찬성(GRANTED)
            3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)     // 3번: 2번 노드에게 찬성(GRANTED)
        }
    ], 
    highWatermark=Optional.empty,               // 아직 데이터 없음
    electionTimeoutMs=1568                      // 이번 선거가 타임아웃(무효) 되기까지 주어졌던 시간은 1568ms 였어.
) (org.apache.kafka.raft.QuorumState)


// ============================================================================
// [두 번째 로그] 06:57:34,524 (정확히 3밀리초 뒤)
// 2번 노드가 드디어 내부 상태값을 모두 바꾸고 "완벽하게 대장으로 등극했다!" 라고 최종 확정
// ============================================================================
kafka-kraft-2 | [2026-06-18 06:57:34,524] INFO [RaftManager id=2] 
Completed transition to Leader(                 // [핵심] 대장(Leader) 전환 작업이 완벽하게 완료(Completed)되었습니다!
    localVoterNode=VoterNode(                   // 정보는 위(Attempting)와 동일합니다. 
        voterKey=ReplicaKey(id=2, directoryId=FBuKfcr_11e4Q_8mwCHiIg), 
        listeners=Endpoints(endpoints={ListenerName(CONTROLLER)=3c3213270e02/<unresolved>:9093}), 
        supportedKRaftVersion=SupportedVersionRange[min_version:0, max_version:1]
    ), 
    epoch=1, 
    epochStartOffset=0, 
    highWatermark=Optional.empty, 
    voterStates={                               // 이 시점에도 1, 3번 노드는 아직 대장 승격 소식을 네트워크로 전달받고 처리하기 직전 찰나의 순간이라 false입니다. (곧바로 다음 로그에서 true로 바뀝니다)
        1=ReplicaState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false), 
        2=ReplicaState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=true), 
        3=ReplicaState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), endOffset=Optional.empty, lastFetchTimestamp=-1, lastCaughtUpTimestamp=-1, hasAcknowledgedLeader=false)
    }
) 
from CandidateState(                            // 후보 상태에서 올라왔다는 정보도 위와 동일합니다.
    localId=2, 
    localDirectoryId=FBuKfcr_11e4Q_8mwCHiIg, 
    epoch=1, 
    epochElection=EpochElection[
        voterStates={
            1=VoterState(replicaKey=ReplicaKey(id=1, directoryId=<undefined>), state=UNRECORDED), 
            2=VoterState(replicaKey=ReplicaKey(id=2, directoryId=<undefined>), state=GRANTED), 
            3=VoterState(replicaKey=ReplicaKey(id=3, directoryId=<undefined>), state=GRANTED)
        }
    ], 
    highWatermark=Optional.empty, 
    electionTimeoutMs=1568
) (org.apache.kafka.raft.QuorumState)

 

솔직히 너무 어렵다. 하여간 Raft 알고리즘을 따라서 리더를 선출하는 과정을 보여준다고 한다.

 

5. 실전 사례 찾아 보기

필자는 국내 대기업들의 카프카 사용 사례를 조사해서 정리하려고 한다. 유명 IT 기업들의 사용 사례를 살펴보고 필자가 구성을 한다면 어떻게 해야 하는지 고민하면 좋을것 같다.

 

5.1) 우아한 형제들의 카프카 사용 사례

다음 글은 해당 글에서 참고했다. (배민 : 우리 팀은 카프카를 어떻게 사용하고 있을까)

 

우아한 형제들에서는 하루 100만 건 생성되면 배민배달을 위해서 카프카를 사용한다.

 

출처 : https://techblog.woowahan.com/17386/

 

 

요약만 정리하면 다음과 같다.

 

1. 도메인 이벤트에 대해 카프카를 이벤트 브로커로 사용하여 이벤트 순서를 보장한다.

2. 카프카를 이벤트 버스로 활용하여 분산시스템에 알린다.

3. 분석에 적합하게 가공된 형태로 데이터를 제공한다.

 

5.2) 일 3,000만건 네이버페이 주문 메시지 처리

네이버에서는 일 3,000만 건의 네이버페이 주문 메시지를 처리하기 위해 Kafka를 이미 사용 중이었는데

 

다양한 방법을 통해서 기존의 아키텍처에서 발생하는 문제를 무중단으로 해결했다고 한다.

 

물론 이것은 초기에 왜 카프카를 도입하게 되었는지는 아니고 원래 카프카 쓰고 있었는데 나중에 또 개선했다~ 하는 내용이지만 좋은 내용이라 첨부한다.

 

(일 3,000만 건의 네이버페이 주문 메시지를 처리하는 Kafka 시스템의 무중단 전환 사례)

 

 

기존의 시스템은 주문이 발생할 때 마다 API가 데이터를 바로 만들어서 제공해야하는 구조였다.

 

트랜잭션 아웃박스 패턴을 이용해 별도 배치 서비스에 주문 메시지 발행을 위임하는 형태로 구성했으나...

 

이마저도 문제가 생겼다고 한다.

기존 아키텍처가 오랫동안 잘 동작하고 있었지만 네이버페이 주문량이 늘어나면서 몇 가지 문제점이 생겼다.
- 공용 Kafka를 사용하면서 다른 토픽들과의 Kafka 리소스 경쟁 발생
- 발행 요청 메시지가 많아지면서 메시지 발행 지연 현상 발생
- 메시지 발행이 지연되면서, 주문 데이터가 메시지 발행 당시 데이터가 아닌 변경된 최신 상태 데이터 제공

 

필자는 단순히 kafka 를 사용하면 된다고 생각했지만 기존의 서비스를 더 나은 구조로 무중단으로 이를 시행했다고 하는데 이해하기 쉽지 않았다.  

 

결국 이유는 kafka 서버가 감당하는 처리량이 많아지니 자연스럽게 지연이 발생하고 이를 분리한 것이다.

 

5.3) 마켓 컬리 Kafka Streams 도입 사례

해당 글에서는 Kafka Streams의 윈도우를 통해 정산 데이터를 처리했다고 한다.

(마켓 컬리 : Kafka Streams 윈도우 도입기)

 

Kafka는 뭐고 Kafka Streams는 또 뭐야?

 

Kafka 는 메시지 분산 스트리밍 플랫폼이고, Kakfa Streams는 실시간 데이터 처리를 도와주는 라이브러리다.

 

글을 정리하는 시점으로 5년전 영상이긴 한데, 카프카 고수로 유명한 데브원영씨의 영상을 참고해 정리하면 장점을 요약하면 다음과 같다.

(참고 : 카프카 스트림즈! 대용량, 폭발적인 성능의 실시간 데이터 처리! )

 

  1. 카프카와 완벽한 호환성 (1:51): 카프카와 동일한 릴리스 주기를 가져 항상 최신 기능을 완벽하게 지원하며, 보안 및 ACL 설정 등과도 문제없이 연동됩니다. 또한 '딱 한 번만 처리(Exactly-once)'하는 강력한 데이터 처리 기능을 제공합니다.
  2. 별도의 스케쥴링 도구가 필요 없음 (3:01): 스파크 등과 달리 별도의 클러스터 관리자나 리소스 매니저가 필요 없습니다. 일반적인 자바 애플리케이션처럼 필요한 만큼 배포하여 가볍고 유연하게 확장(Scale-out)할 수 있습니다.
  3. 강력한 기능의 DSL과 프로세서 API 제공 (4:16): 이벤트 기반 데이터 처리에 필요한 Map, Join, Window 등 다양한 메서드를 제공하는 스트림즈 DSL을 통해 편리하게 개발할 수 있으며, 복잡한 로직은 프로세서 API로 구현 가능합니다. 특히 KStream, KTable 등을 통해 카프카를 대규모 키-밸류 저장소로 활용할 수 있습니다.
  4. 로컬 상태 저장소 지원 (5:35): 상태 기반 처리(Stateful processing) 시 로컬의 RocksDB를 사용해 상태를 저장하고, 변경 사항은 카프카의 변경 로그 토픽에 기록합니다. 이를 통해 프로세스에 장애가 발생하더라도 데이터를 유실하지 않고 자연스럽게 장애 복구가 가능합니다.

하여간 이렇게 실시간 데이터를 처리하는데 window라는 개념과 실제 마켓 컬리에서 어떻게 적용해서 문제를 해결했는지를 생생하게 볼 수 있어서 참 좋은 블로그로 판단한다.

 


필자는 kafka라고 하면 그냥 하면 되는줄 알았다.

 

이게 무슨말이냐면 적용해서 그냥 쓰면 되는거 아닌 간단한 구조만 생각을 했고 참고 서적이랑 넘치는 공식문서와 사용 사례가 있겠다!? 보고 써보면 되지 않을까 했는데 원리부터 파해치니 너무 많은 양이 한 번에 밀고 들어오니 정신을 못차리겠다!

 

최대한 추리고 정리하려고 하는데 쉽지 않지만... 다음 글에서는 실습 환경을 또 구축해보도록 하겠다.


 

출처:

https://zookeeper.apache.org/

https://kafka.apache.org/43/getting-started/zk2kraft/

https://www.baeldung.com/kafka-shift-from-zookeeper-to-kraft

https://www.linkedin.com/pulse/comparing-zookeeper-kraft-kafka-mohamad-mahdi-bayat-ux83f/

https://techblog.woowahan.com/17386/

트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)