학습 목표

시나리오

커머스 플랫폼의 주문 도메인 팀이 이벤트 기반 아키텍처로 이동하기로 했습니다. 운영 클러스터는 SRE 팀이 관리하지만, 개발자 8명이 각자 노트북에서 프로듀서 재시도·리밸런스·리더 선출 같은 장애 동작을 직접 재현해 볼 로컬 환경이 필요합니다. 단일 브로커로는 복제 계수 3, min.insync.replicas=2, ISR 축소, 리더 이동을 재현할 수 없으므로 처음부터 3노드로 만듭니다.

조건은 세 가지입니다. (1) docker compose up 한 번으로 뜬다. (2) 호스트에서 IDE로 돌리는 Java 애플리케이션도, 컨테이너 안에서 돌리는 CLI도 같은 클러스터에 붙는다. (3) 컨테이너를 재시작해도 토픽과 데이터가 남는다.

아키텍처

노드 3대 모두 process.roles=broker,controllercombined 모드입니다. 각 노드는 컨트롤러 쿼럼의 투표자이면서 동시에 파티션 데이터를 담는 브로커입니다. 메타데이터는 __cluster_metadata 라는 내부 토픽에 Raft로 복제되며, ZooKeeper 앙상블은 존재하지 않습니다.

KRaft 클러스터 전체 구조 — 컨트롤러 쿼럼, 브로커 3대, 클라이언트 위쪽은 컨트롤러 쿼럼입니다. controller 롤로 실행되는 노드 3대 가운데 하나가 액티브 컨트롤러이자 Raft 리더이고 나머지 둘은 팔로워이며, 메타데이터는 __cluster_metadata 내부 로그에 Raft 로 복제됩니다. 운영에서는 3대 또는 5대를 권장합니다. 가운데는 브로커 3대이며 토픽 orders 의 파티션 3개가 replication.factor 3 으로 배치되어 각 브로커가 하나의 리더와 두 개의 팔로워를 갖습니다. 브로커는 컨트롤러에서 메타데이터를 받아 가고, 쓰기와 읽기는 파티션 리더가 처리합니다. 아래쪽 프로듀서는 bootstrap.servers 로 접속해 메타데이터를 조회한 뒤 파티션 리더에 직접 전송하고, 컨슈머 그룹은 리더에서 fetch 하며 오프셋을 __consumer_offsets 에 커밋합니다. 클라이언트는 컨트롤러에 접속하지 않고 항상 브로커에 붙으며 ZooKeeper 는 4.0 에서 제거되었습니다. KRaft 클러스터 전체 구조 — 컨트롤러 쿼럼 · 브로커 · 클라이언트 컨트롤러 쿼럼 (process.roles=controller) — 권장 3대 또는 5대 controller-1 액티브 (Raft 리더) controller-2 팔로워 controller-3 팔로워 메타데이터는 __cluster_metadata 라는 내부 로그에 Raft 로 복제됩니다. 브로커는 컨트롤러에서 메타데이터를 받아 갑니다 (옵서버) 브로커 (process.roles=broker) — 토픽 orders · 파티션 3개 · replication.factor 3 broker-1 P0 리더 P1 팔로워 P2 팔로워 broker-2 P1 리더 P2 팔로워 P0 팔로워 broker-3 P2 리더 P0 팔로워 P1 팔로워 Producer bootstrap.servers 로 접속 → 메타데이터 조회 → 파티션 리더에 직접 전송 Consumer group 리더에서 fetch · 오프셋은 __consumer_offsets 에 커밋 초록색 = 파티션 리더. 쓰기와 읽기는 항상 리더가 처리하고 팔로워는 복제만 합니다. 클라이언트는 컨트롤러에 접속하지 않습니다 — 항상 브로커에 붙습니다. ZooKeeper 는 4.0 에서 제거되었습니다.
KRaft 클러스터 전체 구조 — 브로커 3대와 컨트롤러 쿼럼, 그리고 클라이언트가 어디로 붙는지
KRaft 노드 롤 조합 — broker, controller, combined process.roles 를 broker 로 두면 토픽 데이터를 저장하고 클라이언트 요청을 처리합니다. listeners 에는 클라이언트용 리스너만 두고, controller.listener.names 와 그 보안 설정은 필요하지만 listeners 에는 넣지 않으며 controller.quorum.bootstrap.servers 로 컨트롤러 쿼럼을 찾습니다. controller 로 두면 메타데이터만 담당하고 토픽 데이터를 저장하지 않으며, controller.listener.names 는 inter.broker.listener.name 과 같은 값일 수 없고 클라이언트는 여기에 접속하지 않습니다. 권장 대수는 3대 또는 5대입니다. broker 와 controller 를 함께 두는 combined 모드는 한 프로세스가 두 역할을 겸해 리스너를 모두 두어야 하고, 컨트롤러를 브로커와 따로 재시작하거나 확장할 수 없으며 브로커 부하로부터 격리되지 않으므로 공식 문서는 중요한 운영 환경에 권장하지 않습니다. 롤은 kafka-storage.sh format 으로 포맷할 때 정해집니다. process.roles 조합 3가지 — broker · controller · combined process.roles=broker 운영 환경 기본 · 토픽 데이터를 저장하고 클라이언트 요청을 처리합니다. listeners 에는 클라이언트용 리스너만 둡니다. · controller.listener.names 와 그 보안 설정은 필요하지만 listeners 에는 넣지 않습니다. · controller.quorum.bootstrap.servers 로 컨트롤러 쿼럼을 찾아갑니다. process.roles=controller 3대 또는 5대 · 메타데이터만 담당하고 토픽 데이터는 저장하지 않습니다. listeners 에 컨트롤러 리스너를 둡니다. · controller.listener.names 는 inter.broker.listener.name 과 같은 값일 수 없습니다. · 클라이언트는 컨트롤러에 접속하지 않습니다 — 붙어도 데이터를 읽을 수 없습니다. process.roles=broker,controller 개발 · 소규모 전용 · 한 프로세스가 두 역할을 겸합니다. listeners 에 클라이언트용과 컨트롤러용을 모두 둡니다. · 컨트롤러를 브로커와 따로 재시작하거나 확장할 수 없고, 브로커 부하로부터 격리되지 않습니다. · 공식 문서는 중요한 운영 환경에는 권장하지 않습니다. 어느 조합이든 클러스터에는 controller 롤 노드가 반드시 있어야 하고, 그 수는 홀수(3 또는 5)를 권장합니다. 롤은 포맷 시점에 정해집니다 kafka-storage.sh format --standalone -c config/server.properties
노드 롤 조합 — 이 예제가 쓰는 broker,controller combined 배치와, 프로덕션에서 권장되는 분리(isolated) 배치의 차이

리스너 설계 — 이 예제의 핵심

컨테이너 네트워크 안의 이름(kafka-1)과 호스트에서 보이는 이름(localhost)이 다르기 때문에, 리스너를 세 개로 나눕니다. 하나라도 잘못 두면 "브로커는 떠 있는데 클라이언트가 붙지 못하는" 증상이 나옵니다.

이 예제의 리스너 3종과 용도
리스너 이름 컨테이너 내부 포트 누가 쓰는가 광고 주소(advertised.listeners)
PLAINTEXT 19092 브로커 간 통신(inter.broker.listener.name), 컨테이너 안의 CLI·Connect kafka-1:19092 — 컴포즈 네트워크의 서비스 이름
PLAINTEXT_HOST 9092 (호스트로 29092/39092/49092 매핑) 호스트에서 실행하는 IDE·Java 애플리케이션 localhost:29092 — 호스트가 실제로 접속 가능한 주소
CONTROLLER 9093 컨트롤러 쿼럼(Raft) 전용. 클라이언트는 절대 쓰지 않습니다 광고하지 않습니다 — controller.listener.names에만 등장

사전 요구사항

검증 환경
항목버전비고
Apache Kafka 4.3.1 Docker 이미지 apache/kafka:4.3.1. 배포 파일명은 kafka_2.13-4.3.1.tgz이며 앞의 2.13은 Scala 버전입니다
Docker Engine 20.10.4 이상 공식 이미지 문서가 명시한 최소 버전입니다. 이보다 낮으면 컨테이너 안 /opt/kafka/config 권한 오류로 기동에 실패합니다
Docker Compose v2 docker compose (하이픈 없음). version: 키는 v2에서 불필요합니다
Java 17 이상 이후 예제의 Java 코드를 호스트에서 돌릴 때 필요합니다. Kafka 4.3은 Java 17 · 21 · 25를 완전 지원하고 Java 8은 4.0에서 제거되었습니다
메모리 Docker에 4GB 이상 할당 JVM 3개가 뜹니다. 아래 compose에서 노드당 힙을 512MB로 제한해 두었습니다

전체 코드

디렉터리 구조는 다음과 같습니다. 파일 4개가 전부입니다.

디렉터리 구조
kafka-lab/
├── docker-compose.yml     # 3노드 KRaft 클러스터
├── .env                   # 이미지 태그·클러스터 ID 등 변수
├── create-topics.sh        # 실습용 토픽 생성 (멱등)
└── kcli                    # 컨테이너 안 CLI 를 호스트에서 쓰는 래퍼

docker-compose.yml

kafka-lab/docker-compose.yml
# Apache Kafka 4.3.1 · KRaft combined 모드 3노드 클러스터
# ZooKeeper 컨테이너는 없습니다. ZooKeeper 모드는 Kafka 4.0에서 제거되었습니다.
#
# 세 노드가 모두 broker + controller 를 겸합니다(combined).
# 컨트롤러 쿼럼이 3이므로 노드 1대가 죽어도 과반(2)이 남아 클러스터가 유지됩니다.
---
name: kafka-lab                 # compose 프로젝트 이름. 볼륨/네트워크 접두어가 됩니다

# ---------------------------------------------------------------------------
# 세 노드가 공유하는 설정을 YAML 앵커로 한 번만 씁니다.
# 노드별로 다른 값(NODE_ID, ADVERTISED_LISTENERS)만 아래에서 덮어씁니다.
# ---------------------------------------------------------------------------
x-kafka-common: &kafka-common
  image: ${KAFKA_IMAGE}
  restart: unless-stopped
  environment: &kafka-env
    # --- KRaft 롤과 쿼럼 -----------------------------------------------------
    KAFKA_PROCESS_ROLES: 'broker,controller'
    # 컨트롤러 쿼럼 투표자 3명을 {node.id}@{host}:{port} 형식으로 고정합니다.
    # 3이면 1대 손실을 견딥니다(과반 2). 5는 2대까지 견디지만 커밋 지연이 늘어납니다.
    # 짝수는 의미가 없습니다 — 4대는 3대와 같은 1대 손실만 견디면서 비용만 늘어납니다.
    KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093'
    # 컨트롤러 트래픽이 흐르는 리스너 이름. 클라이언트 리스너와 반드시 분리합니다.
    KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'

    # --- 리스너 3종 ----------------------------------------------------------
    # 이름 → 보안 프로토콜 매핑. 리스너 이름은 임의로 정할 수 있으므로
    # 브로커에게 "이 이름이 어떤 프로토콜인지" 알려 주어야 합니다.
    KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
    # 실제로 bind 하는 소켓. 컨테이너 내부 관점의 포트입니다.
    KAFKA_LISTENERS: 'PLAINTEXT://:19092,CONTROLLER://:9093,PLAINTEXT_HOST://:9092'
    # 브로커끼리(복제·컨트롤러 요청) 쓸 리스너. 컨테이너 네트워크 쪽을 씁니다.
    KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'

    # --- 내부 토픽 복제 계수 --------------------------------------------------
    # 기본값은 3인데 단일 노드 예제에서는 1로 내려야 뜹니다.
    # 여기는 브로커가 3대이므로 기본값 그대로 3을 명시해 의도를 드러냅니다.
    # 이 값을 1로 두면 __consumer_offsets 를 가진 브로커 1대가 죽는 순간
    # 컨슈머 그룹 전체가 오프셋을 잃습니다.
    KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3
    KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3
    # 트랜잭션 로그의 최소 ISR. RF=3에 min.isr=2 가 표준 조합입니다.
    # 1로 두면 트랜잭션 커밋이 유실될 수 있고, 3으로 두면 1대만 빠져도 트랜잭션이 멈춥니다.
    KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2
    # Share Groups(KIP-932, 4.2 production-ready) 상태 토픽도 같은 원칙을 적용합니다.
    KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 3
    KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 2

    # --- 사용자 토픽 기본값 --------------------------------------------------
    # 자동 생성 토픽은 파티션 1 / RF 1 이 되어 실습을 망칩니다.
    # 오타 토픽이 조용히 만들어지는 것도 막기 위해 자동 생성을 끕니다.
    KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false'
    KAFKA_NUM_PARTITIONS: 3
    KAFKA_DEFAULT_REPLICATION_FACTOR: 3
    # 브로커 기본 min.insync.replicas. 토픽 레벨 설정이 이 값을 덮어씁니다.
    KAFKA_MIN_INSYNC_REPLICAS: 2

    # --- 로컬 실습 편의 ------------------------------------------------------
    # 기본값 3000(3초). 컨슈머가 그룹에 처음 붙을 때 이만큼 리밸런스를 지연시켜
    # 여러 컨슈머를 한 번에 묶습니다. 로컬에서는 즉시 시작하는 편이 편해 0으로 둡니다.
    # 프로덕션에서는 절대 0으로 두지 마세요(배포 중 리밸런스가 폭증합니다).
    KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    # 로그 디렉터리. 아래 named volume 이 이 경로에 마운트됩니다.
    KAFKA_LOG_DIRS: '/var/lib/kafka/data'
    # JVM 힙. 노드 3개가 뜨므로 로컬에서는 512MB 로 묶어 둡니다.
    KAFKA_HEAP_OPTS: '-Xmx512m -Xms512m'
    # 세 노드가 같은 클러스터 ID 를 공유해야 합니다. 다르면 서로를 거부합니다.
    CLUSTER_ID: ${CLUSTER_ID}
  healthcheck:
    # 브로커 API 응답이 오면 준비 완료로 봅니다.
    # depends_on 의 service_healthy 조건이 이 결과를 씁니다.
    test: ['CMD-SHELL', '/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:19092 >/dev/null 2>&1']
    interval: 10s
    timeout: 10s
    retries: 12
    start_period: 30s

services:
  kafka-1:
    <<: *kafka-common
    hostname: kafka-1           # advertised.listeners 의 호스트명과 일치해야 합니다
    container_name: kafka-1
    ports:
      - '29092:9092'            # 호스트 29092 → 컨테이너 9092 (PLAINTEXT_HOST)
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 1
      # 컨테이너 내부용 주소와 호스트용 주소를 함께 광고합니다.
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-1:19092,PLAINTEXT_HOST://localhost:29092'
    volumes:
      - kafka-1-data:/var/lib/kafka/data

  kafka-2:
    <<: *kafka-common
    hostname: kafka-2
    container_name: kafka-2
    ports:
      - '39092:9092'
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 2
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-2:19092,PLAINTEXT_HOST://localhost:39092'
    volumes:
      - kafka-2-data:/var/lib/kafka/data

  kafka-3:
    <<: *kafka-common
    hostname: kafka-3
    container_name: kafka-3
    ports:
      - '49092:9092'
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 3
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-3:19092,PLAINTEXT_HOST://localhost:49092'
    volumes:
      - kafka-3-data:/var/lib/kafka/data

volumes:
  # named volume 을 쓰지 않으면 컨테이너를 지울 때 토픽과 메타데이터가 사라집니다.
  # 클러스터를 완전히 초기화하려면 `docker compose down -v` 로 볼륨까지 지웁니다.
  kafka-1-data:
  kafka-2-data:
  kafka-3-data:

.env

CLUSTER_IDBase64 URL-safe 로 인코딩된 22자 UUID여야 합니다. 아무 문자열이나 넣으면 포맷 단계에서 실패합니다. 아래 값은 Apache Kafka 공식 Docker 예제가 쓰는 값이며, 직접 만들려면 kafka-storage.sh random-uuid를 씁니다(다음 절에 명령이 있습니다).

kafka-lab/.env
# 이미지 태그를 한 곳에서 관리합니다. 4.3.1 은 이 예제를 검증한 버전입니다.
# GraalVM 네이티브 이미지(apache/kafka-native)는 실험적이며 프로덕션 용도가 아닙니다.
KAFKA_IMAGE=apache/kafka:4.3.1

# 클러스터 ID — 세 노드가 반드시 같은 값을 공유해야 합니다.
# 새로 만들려면: docker run --rm apache/kafka:4.3.1 /opt/kafka/bin/kafka-storage.sh random-uuid
CLUSTER_ID=4L6g3nShT-eMCtK--X86sw

create-topics.sh

이후 예제에서 쓰는 토픽을 한 번에 만듭니다. --if-not-exists를 붙였으므로 여러 번 실행해도 안전합니다.

kafka-lab/create-topics.sh
#!/usr/bin/env bash
# 실습용 토픽 생성. 여러 번 실행해도 안전합니다(--if-not-exists).
# 사용법: ./create-topics.sh
set -euo pipefail

# 컨테이너 안에서 실행하므로 컨테이너 네트워크 주소를 씁니다.
BROKER_INTERNAL='kafka-1:19092,kafka-2:19092,kafka-3:19092'
KAFKA_BIN='/opt/kafka/bin'

# 토픽 정의: 이름:파티션수:복제계수:추가설정(쉼표구분, 없으면 빈 문자열)
TOPICS=(
  # 예제 3(무손실 프로듀서) · 예제 4(오프셋 전략)
  "orders:6:3:min.insync.replicas=2"
  # 예제 5(EOS 파이프라인) 입력/출력
  "orders.raw:6:3:min.insync.replicas=2"
  "orders.enriched:6:3:min.insync.replicas=2"
  # 예제 6(DLQ) — DLQ 는 유실이 곧 사고 원인 소실이므로 보관을 길게 둡니다(30일)
  "payments:3:3:min.insync.replicas=2"
  "payments.DLT:3:3:min.insync.replicas=2,retention.ms=2592000000"
  # 예제 9(Streams 집계) 입력/출력
  "clickstream:6:3:min.insync.replicas=2"
  "clicks-per-minute:6:3:min.insync.replicas=2"
  # 예제 4에서 컴팩션 토픽과 비교하기 위한 상태 토픽
  "product-catalog:3:3:cleanup.policy=compact,min.insync.replicas=2"
)

for spec in "${TOPICS[@]}"; do
  IFS=':' read -r name parts rf extra <<< "$spec"

  args=(--bootstrap-server "$BROKER_INTERNAL"
        --create --if-not-exists
        --topic "$name"
        --partitions "$parts"
        --replication-factor "$rf")

  # 추가 설정은 --config 를 항목마다 하나씩 붙여야 합니다.
  if [[ -n "${extra:-}" ]]; then
    IFS=',' read -ra kvs <<< "$extra"
    for kv in "${kvs[@]}"; do
      args+=(--config "$kv")
    done
  fi

  echo "==> create ${name} (partitions=${parts}, rf=${rf}, config=${extra:-none})"
  docker exec kafka-1 "${KAFKA_BIN}/kafka-topics.sh" "${args[@]}"
done

echo
echo '==> 최종 토픽 목록'
docker exec kafka-1 "${KAFKA_BIN}/kafka-topics.sh" \
  --bootstrap-server "$BROKER_INTERNAL" --list

kcli 래퍼

Kafka CLI는 이미지 안 /opt/kafka/bin에 있습니다. 호스트에 tarball을 따로 풀지 않고 컨테이너의 스크립트를 쓰는 래퍼를 두면 버전 불일치를 원천적으로 막을 수 있습니다.

kafka-lab/kcli
#!/usr/bin/env bash
# 컨테이너 안의 Kafka CLI 를 호스트에서 실행하는 래퍼.
#
# 사용법:
#   ./kcli kafka-topics.sh --list
#   ./kcli kafka-consumer-groups.sh --describe --group order-service
#   ./kcli kafka-metadata-quorum.sh describe --status
#
# --bootstrap-server 를 생략하면 컨테이너 네트워크 주소를 자동으로 넣습니다.
set -euo pipefail

CONTAINER="${KCLI_CONTAINER:-kafka-1}"
BOOTSTRAP="${KCLI_BOOTSTRAP:-kafka-1:19092,kafka-2:19092,kafka-3:19092}"

if [[ $# -lt 1 ]]; then
  echo "usage: $0 <kafka-*.sh> [args...]" >&2
  exit 2
fi

script="$1"; shift

# 이미 --bootstrap-server 또는 --bootstrap-controller 가 있으면 그대로 존중합니다.
needs_bootstrap=1
for a in "$@"; do
  case "$a" in
    --bootstrap-server|--bootstrap-controller) needs_bootstrap=0 ;;
  esac
done

if [[ $needs_bootstrap -eq 1 ]]; then
  set -- --bootstrap-server "$BOOTSTRAP" "$@"
fi

# -i 만 주고 -t 는 주지 않습니다. 파이프로 넘길 때 TTY 가 있으면 깨집니다.
exec docker exec -i "$CONTAINER" "/opt/kafka/bin/${script}" "$@"

실행 방법

순서대로 실행
# 0. 작업 디렉터리 준비
mkdir -p kafka-lab && cd kafka-lab
# (위의 4개 파일을 이 디렉터리에 저장합니다)
chmod +x create-topics.sh kcli

# 1. (선택) 나만의 클러스터 ID 생성 → .env 의 CLUSTER_ID 에 붙여넣기
docker run --rm apache/kafka:4.3.1 /opt/kafka/bin/kafka-storage.sh random-uuid

# 2. 클러스터 기동. 로그를 보려면 -d 를 빼세요.
docker compose up -d

# 3. 세 노드가 healthy 가 될 때까지 대기 (보통 30~45초)
docker compose ps

# 4. 실습용 토픽 생성
./create-topics.sh
정리 (필요할 때)
# 컨테이너만 정지 — 데이터(볼륨)는 남습니다. 다시 up 하면 토픽이 그대로 있습니다.
docker compose down

# 데이터까지 완전 초기화 — 볼륨을 지우므로 다음 기동 시 새 클러스터로 포맷됩니다.
docker compose down -v

검증 방법

네 가지를 순서대로 확인합니다. 하나라도 어긋나면 다음 예제가 동작하지 않습니다.

1. 컨트롤러 쿼럼이 정상인가

쿼럼 상태 확인
./kcli kafka-metadata-quorum.sh describe --status
기대 출력 (값은 환경마다 다릅니다)
ClusterId:              4L6g3nShT-eMCtK--X86sw
LeaderId:               2
LeaderEpoch:            1
HighWatermark:          312
MaxFollowerLag:         0
MaxFollowerLagTimeMs:   0
CurrentVoters:          [{"id": 1, ...}, {"id": 2, ...}, {"id": 3, ...}]
CurrentObservers:       []

확인 포인트는 CurrentVoters에 3개가 모두 있는지MaxFollowerLag가 0에 가까운지입니다. 투표자가 2개만 보이면 한 노드가 쿼럼에 참여하지 못한 것이고, MaxFollowerLag가 계속 커지면 메타데이터 복제가 밀리는 것입니다. combined 모드에서 브로커 3대는 컨트롤러 쿼럼의 투표자이므로 CurrentObservers는 비어 있는 것이 정상입니다.

2. 브로커 3대가 모두 등록되었는가

브로커 목록과 광고 주소 확인
# 각 브로커가 어떤 주소로 자신을 광고하는지 그대로 보여 줍니다.
./kcli kafka-broker-api-versions.sh | grep -E '^kafka-[0-9]+:'
기대 출력
kafka-1:19092 (id: 1 rack: null isFenced: false) -> (
kafka-2:19092 (id: 2 rack: null isFenced: false) -> (
kafka-3:19092 (id: 3 rack: null isFenced: false) -> (

isFenced: false가 셋 다 나와야 합니다. isFenced: true는 브로커가 아직 메타데이터를 따라잡지 못해 파티션 리더가 될 수 없는 상태라는 뜻입니다. 기동 직후 몇 초간은 정상적으로 나타날 수 있습니다.

3. 토픽의 리더와 ISR이 3대에 분산되었는가

토픽 상태 확인
./kcli kafka-topics.sh --describe --topic orders
기대 출력 (Replicas 순서는 환경마다 다릅니다)
Topic: orders  TopicId: kkxLGvB1RCel...  PartitionCount: 6  ReplicationFactor: 3
	Configs: min.insync.replicas=2
	Topic: orders	Partition: 0	Leader: 1	Replicas: 1,2,3	Isr: 1,2,3
	Topic: orders	Partition: 1	Leader: 2	Replicas: 2,3,1	Isr: 2,3,1
	Topic: orders	Partition: 2	Leader: 3	Replicas: 3,1,2	Isr: 3,1,2
	Topic: orders	Partition: 3	Leader: 1	Replicas: 1,3,2	Isr: 1,3,2
	Topic: orders	Partition: 4	Leader: 2	Replicas: 2,1,3	Isr: 2,1,3
	Topic: orders	Partition: 5	Leader: 3	Replicas: 3,2,1	Isr: 3,2,1

Isr의 원소가 3개이고 리더가 세 브로커에 골고루 퍼져 있으면 정상입니다. Isr: 1처럼 하나만 남아 있으면 복제가 따라가지 못하는 중입니다. Configs:min.insync.replicas=2가 보이는지도 확인하세요 — 이 값이 없으면 예제 3의 무손실 설정이 의도대로 동작하지 않습니다.

4. 호스트에서, 컨테이너에서 각각 읽고 쓸 수 있는가

왕복 테스트
# (A) 컨테이너 네트워크 경로로 3건 생산
printf 'a\nb\nc\n' | ./kcli kafka-console-producer.sh --topic orders

# (B) 컨테이너 네트워크 경로로 소비 — 3건이 보이면 성공
./kcli kafka-console-consumer.sh --topic orders --from-beginning \
  --timeout-ms 5000 --max-messages 3

# (C) 호스트 경로 확인이 이 예제의 진짜 관문입니다.
#     호스트에 Kafka tarball 이 없다면 컨테이너를 호스트 네트워크로 붙여 확인합니다.
docker run --rm --network host apache/kafka:4.3.1 \
  /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:29092 --list

(C)가 성공하면 advertised.listenersPLAINTEXT_HOST://localhost:29092가 제대로 광고되고 있다는 뜻입니다. 여기서 Connection to node -1 could not be established 또는 무한 재시도가 나오면 광고 주소 설정을 다시 보세요. 이후 예제의 Java 코드는 모두 bootstrap.servers=localhost:29092,localhost:39092,localhost:49092를 씁니다.

프로덕션 고려사항

이 compose는 학습·개발용입니다. 실제 운영 클러스터와 다른 점을 정리합니다. 운영 관점의 상세는 3장 KRaft와 클러스터 메타데이터11장 운영 기초를 보세요.

로컬 예제와 프로덕션의 차이
항목 이 예제 프로덕션 이유
노드 롤 broker,controller combined 3대 컨트롤러 3대 + 브로커 N대로 분리(isolated) combined는 브로커의 GC 정지·디스크 포화가 컨트롤러 쿼럼까지 흔듭니다. 메타데이터 장애가 데이터 장애와 함께 오면 복구가 훨씬 어려워집니다
보안 전 리스너 PLAINTEXT SASL_SSL + ACL, 컨트롤러 리스너도 별도 인증 평문은 인증도 암호화도 없습니다. 설정 예시는 보안 치트시트에 있습니다
장애 도메인 한 호스트의 Docker 3컨테이너 서로 다른 랙·AZ에 분산 + broker.rack 설정 지금 구성은 호스트가 죽으면 3대가 동시에 죽습니다. RF=3의 의미가 없습니다
스토리지 Docker named volume 전용 디스크, log.dirs를 여러 디바이스로 분리, RAID 대신 JBOD 페이지 캐시와 순차 I/O가 성능을 좌우합니다. 컨테이너 오버레이 FS는 지연 특성이 다릅니다
group.initial.rebalance.delay.ms 0 기본값 3000 유지 또는 더 크게 0이면 컨슈머가 하나씩 붙을 때마다 리밸런스가 일어납니다. 배포 시 리밸런스 스톰의 원인이 됩니다
JVM 힙 -Xmx512m 보통 6GB 내외로 고정하고 남은 메모리는 페이지 캐시에 양보 Kafka는 힙보다 페이지 캐시로 성능을 냅니다. 힙을 키우는 것이 항상 유리하지 않습니다
모니터링 없음 JMX → Prometheus → Grafana, UnderReplicatedPartitions·OfflinePartitionsCount·ActiveControllerCount 알림 URP가 0이 아닌 상태를 모르고 지나가면 다음 장애에서 유실이 발생합니다. 예제 10에서 구성합니다
쿼럼 방식 정적(controller.quorum.voters) 동적(controller.quorum.bootstrap.servers, KIP-853) 정적 쿼럼은 컨트롤러를 무중단으로 교체할 수 없습니다

자주 하는 실수

이어서 볼 곳

공식 문서 출처

이 페이지의 설정명·기본값·명령 형식은 Apache Kafka 4.3.1 문서와 공식 Docker 예제에서 확인했습니다.