[Kafka] 아키텍처(브로커, 파티션, KRaft, Zero-Copy)

개요

Kafka는 메시지를 큐에서 꺼내는 것이 아니라 분산 커밋 로그(Distributed Commit Log)에 append하고 오프셋으로 읽는 시스템에 가깝다. 이 차이가 Kafka가 다른 메시지 브로커들과 구분되는 거의 모든 특성을 만들어낸다.

전통적인 메시지 큐(RabbitMQ, ActiveMQ 등)는 소비된 메시지를 삭제한다. 따라서 한 메시지를 여러 시스템이 독립적으로 소비하려면 복제가 필요하고, 재처리는 별도의 데드 레터 큐(Dead Letter Queue)나 백업 메커니즘으로 풀어야 한다. 반면 Kafka는 메시지를 보관 기간(retention) 동안 디스크에 그대로 두고, 어떤 컨슈머가 어디까지 읽었는지를 컨슈머 자신의 오프셋으로 관리한다. 같은 토픽을 주문 처리 서비스가 읽든, 분석 서비스가 읽든, 감사 로그 서비스가 읽든 서로 간섭하지 않는다.

또 하나의 핵심은 수평 확장(scale-out)이다. 하나의 토픽을 여러 파티션으로 쪼개서 서로 다른 브로커에 분산 배치하고, 컨슈머 그룹이 파티션을 나눠 가지면서 병렬 처리가 가능하다. 단일 브로커 기준 초당 수십만에서 수백만 건의 메시지를 처리하고도 엔드투엔드 지연을 수 밀리초 수준에서 유지할 수 있는 이유는 이 파티셔닝과 함께 순차 입출력(I/O), Zero-Copy, 운영체제 페이지 캐시 활용, 배치 처리 같은 저수준 최적화가 받쳐주기 때문이다.

키워드의미
분산 커밋 로그append-only 로그에 오프셋으로 메시지를 쌓고 읽는 저장 모델
파티션(Partition)병렬성과 순서 보장의 기본 단위
ISR(In-Sync Replicas)리더와 동기화 상태인 레플리카 집합
KRaft(Kafka Raft Metadata)ZooKeeper 없이 Raft 합의로 메타데이터를 관리하는 모드
Zero-Copy유저 영역을 거치지 않고 디스크에서 네트워크로 바로 전송하는 최적화

왜 Kafka인가

전통적 메시지 큐의 한계

RabbitMQ나 ActiveMQ 같은 전통적 메시지 큐는 다음과 같은 가정 위에 설계되어 있다. 메시지는 큐에 들어와서 컨슈머가 꺼내가면 사라진다. 브로커가 메시지의 상태(소비됨, 미소비됨)를 추적한다. 따라서 컨슈머가 늘어날수록 브로커의 부담도 함께 늘어난다.

이 모델은 하나의 메시지를 단일 컨슈머가 소비하는 워크큐 패턴에는 잘 맞지만, 하나의 이벤트가 발생하면 여러 시스템이 각자 다른 방식으로 처리해야 하는 이벤트 기반 아키텍처에서는 한계가 명확하다. 같은 메시지를 여러 시스템에 전달하려면 그 수만큼 복제해야 하고, 컨슈머가 한 번 받은 메시지를 다시 처리하고 싶어도 보존되지 않으므로 불가능하다.

Kafka의 분산 커밋 로그 모델

Kafka에서 메시지는 큐가 아니라 로그(log)에 쌓인다. 로그는 끝에만 추가(append-only)되는 자료구조이며, 메시지 각자에 순차 번호인 오프셋(offset)이 붙는다. 컨슈머는 자신이 어느 오프셋까지 읽었는지를 직접 관리한다.

이 구조에서 얻어지는 이점은 크다.

  • 메시지는 소비 후에도 보존 기간 동안 살아 있으므로, 새로운 컨슈머 그룹이 과거 데이터를 처음부터 다시 읽을 수 있다(replay, backfill).
  • 컨슈머가 자기 오프셋을 관리하므로 브로커는 컨슈머 수에 비례하는 부담이 없다.
  • 여러 컨슈머 그룹이 동일한 데이터를 독립적으로 소비할 수 있다. 주문 처리, 통계 집계, 감사 로그가 모두 같은 토픽을 본다.
  • 파티션 단위로 병렬 처리가 가능해 수평 확장이 자연스럽다.

Kafka와 전통적 메시지 큐 비교

특성전통적 메시지 큐 (RabbitMQ 등)Kafka
메시지 보존소비 후 삭제보존 기간까지 유지
소비 추적브로커가 관리컨슈머가 오프셋 관리
다중 소비자메시지 복제 필요컨슈머 그룹으로 독립 소비
순서 보장단일 큐에서만파티션 내 보장
처리량초당 수만 건초당 수백만 건
재처리불가(별도 데드 레터 큐 필요)오프셋 리셋으로 가능
저장소메모리 위주디스크 기반(순차 입출력)
확장성수직 확장 위주수평 확장(파티셔닝)

핵심 구성 요소

클러스터 아키텍처 개요

브로커(Broker)

브로커는 Kafka 클러스터를 구성하는 개별 서버 프로세스다. 각 브로커는 일부 파티션의 로그를 디스크에 저장하고, 프로듀서의 쓰기 요청과 컨슈머의 읽기 요청을 처리한다. 클러스터 안에서는 broker.id라는 고유 식별자로 구분되며, 주요 설정은 다음과 같다.

  • broker.id: 클러스터 내 브로커 식별자.
  • log.dirs: 파티션 로그를 저장할 디스크 경로(여러 개 지정 가능).
  • advertised.listeners: 외부 클라이언트가 접속할 주소.
  • process.roles: KRaft 모드에서 이 노드가 브로커인지 컨트롤러인지 또는 둘 다인지를 지정.

브로커들은 평상시 모두 메시지를 저장하고 전달하는 일을 하지만, 그중 하나는 컨트롤러(Controller)를 겸한다. 컨트롤러는 클러스터 전체의 메타데이터(브로커 목록, 파티션 리더, ISR 등)를 관리하고, 브로커 장애가 발생하면 새로운 리더를 선출한다. KRaft 모드에서는 컨트롤러가 별도의 쿼럼으로 분리되어 Raft 합의로 운영된다.

토픽(Topic)과 파티션(Partition)

토픽은 메시지의 논리적 분류 단위다. 주문 이벤트는 orders 토픽, 결제 이벤트는 payments 토픽 같은 식으로 도메인별로 나눈다.

각 토픽은 다시 파티션이라는 물리적 단위로 나뉜다. 파티션은 Kafka의 병렬성과 순서 보장의 기본 단위다.

파티션의 특성을 정리하면 다음과 같다.

  • 파티션 내에서만 순서가 보장된다. 파티션 간 순서는 보장되지 않는다.
  • 파티션 수가 컨슈머 그룹 내 최대 병렬 처리 수를 결정한다.
  • 파티션 수는 늘릴 수 있지만 줄일 수 없다. 늘리면 키와 파티션 매핑이 깨지므로 초기 설계가 중요하다.
  • 같은 키를 가진 메시지는 같은 파티션에 들어가므로, 같은 키로 들어온 메시지의 순서는 보장된다.

키가 있을 때의 파티션 결정 식은 hash(key) % partition_count이며, Kafka는 기본적으로 murmur2 해시를 사용한다. 키가 null이면 Kafka 2.4 이후로는 Sticky Partitioner가 적용되어 배치가 찰 때까지 한 파티션에 몰아 보내고, 가득 차면 다음 파티션으로 옮긴다. 이는 라운드로빈 대비 배치 효율을 높이기 위한 전략이다.

파티션과 리플리카 구조

가용성을 위해 파티션은 여러 브로커에 복제된다. 토픽 단위로 replication-factor를 지정하며, 각 파티션은 하나의 리더(Leader)와 여러 개의 팔로워(Follower) 레플리카를 가진다. 모든 읽기와 쓰기는 리더가 처리하고, 팔로워는 리더의 로그를 따라가며 복제한다.

브로커가 3대인 클러스터에 파티션이 9개인 토픽을 운영하면, 각 브로커가 약 3개의 파티션 리더를 담당한다. 파티션 수는 브로커 수와 무관하게 정할 수 있고, 일반적으로 브로커 수의 배수로 두면 부하가 고르게 분산된다.

프로듀서, 컨슈머, 컨슈머 그룹

프로듀서는 토픽에 메시지를 발행한다. 키 기반 파티셔닝, 배치 전송, 압축, 멱등성, 트랜잭션 같은 책임을 모두 클라이언트 쪽에서 수행한다.

컨슈머는 파티션에서 메시지를 읽는다. 여러 컨슈머가 하나의 컨슈머 그룹으로 묶이며, 그룹 안에서 파티션을 나눠 가진다. 같은 그룹 내에서는 한 파티션을 하나의 컨슈머만 소비한다는 규칙이 있고, 따라서 컨슈머 수가 파티션 수를 초과하면 남는 컨슈머는 유휴 상태가 된다.

서로 다른 컨슈머 그룹은 독립적으로 같은 데이터를 소비할 수 있다. 같은 orders 토픽을 주문 처리 그룹과 분석 그룹이 동시에 소비한다.

메시지 저장 구조

세그먼트와 인덱스

각 파티션은 디스크에 세그먼트 파일들로 저장된다. 세그먼트는 일정 크기(log.segment.bytes, 기본 1GB)나 시간이 지나면 새 파일로 롤링된다. 파티션 orders-0의 디스크 구성은 다음과 같은 파일들로 이루어진다.

  • /kafka-logs/orders-0/00000000000000000000.log (메시지 데이터)
  • /kafka-logs/orders-0/00000000000000000000.index (오프셋과 물리 위치 매핑)
  • /kafka-logs/orders-0/00000000000000000000.timeindex (타임스탬프와 오프셋 매핑)
  • /kafka-logs/orders-0/00000000000000049152.log (다음 세그먼트)
  • /kafka-logs/orders-0/00000000000000049152.index
  • /kafka-logs/orders-0/00000000000000049152.timeindex
  • /kafka-logs/orders-0/leader-epoch-checkpoint

파일명은 해당 세그먼트의 시작 오프셋이다. .index 파일은 희소 인덱스(sparse index)여서 모든 오프셋을 인덱싱하지 않고 일정 간격으로만 기록한다. 컨슈머가 특정 오프셋의 메시지를 찾을 때 이 인덱스에서 이분 탐색으로 가까운 위치를 찾고, 거기서부터 순차 스캔으로 정확한 위치를 찾는다.

배치(RecordBatch) 단위로 저장하는 점도 중요하다. Kafka 0.11 이후 도입된 RecordBatch 포맷은 헤더에 오프셋, 타임스탬프, producer ID, sequence 등을 담고, 그 안의 개별 레코드는 헤더 대비 delta로 표현해 공간을 절약한다. 압축도 배치 단위로 수행하므로 배치가 클수록 압축률이 좋아진다.

순차 입출력과 페이지 캐시

Kafka는 디스크 기반인데도 빠른 속도로 입출력이 가능하다. 그 이유는 순차 읽기이다.

입출력 패턴하드 디스크(HDD)솔리드 스테이트 드라이브(SSD)
순차 읽기와 쓰기약 100 MB/s약 500 MB/s에서 수 GB/s
랜덤 읽기와 쓰기약 0.1 MB/s약 50 MB/s

순차 디스크 입출력은 랜덤 입출력 대비 수십에서 수백 배 빠르고, 메모리의 랜덤 액세스와 비슷한 수준의 처리량을 보인다. Kafka는 모든 쓰기가 파티션 로그의 끝에 append되고 모든 읽기가 오프셋 기반 순차 읽기이므로, 디스크 헤드를 거의 움직이지 않는다.

거기에 운영체제의 페이지 캐시(page cache)를 적극 활용한다. Kafka는 자체 캐시를 거의 구현하지 않는다. 대신 운영체제가 최근 쓰여진 페이지를 메모리에 캐싱하도록 두고, 컨슈머가 최신 데이터를 읽으면 캐시에서 바로 응답한다.

이 설계가 주는 이점은 다음과 같다.

  • JVM 힙 외부에 캐시가 있으므로 가비지 컬렉션(GC)에 영향이 없다.
  • 프로세스 재시작 시에도 캐시는 살아 있다(운영체제가 관리).
  • LRU와 read-ahead 같은 운영체제의 검증된 정책을 그대로 활용한다.
  • Kafka 브로커 자체의 힙은 작게 유지할 수 있다.

Zero-Copy

컨슈머가 데이터를 fetch할 때 Kafka가 적용하는 또 다른 핵심 최적화가 Zero-Copy다. 전통적인 방식은 디스크에서 커널 버퍼로, 커널 버퍼에서 유저 영역 버퍼로, 다시 소켓 버퍼를 거쳐 네트워크 카드까지 데이터가 4번 복사되며 컨텍스트 스위칭도 4번 일어난다.

Java의 FileChannel.transferTo()는 내부적으로 운영체제의 sendfile() 시스템 콜을 호출한다. 데이터를 애플리케이션이 가공할 필요가 없으므로 유저 영역을 거치지 않는다.

// FileChannel.java 내부 (개념)
private native long transferTo0(FileDescriptor src, long position,
                                long count, FileDescriptor dst);

다만 보안 소켓 계층(SSL/TLS)을 사용해 애플리케이션 영역에서 암호화가 필요하면 Zero-Copy를 적용할 수 없다. 보안과 성능 사이의 트레이드오프다.

메타데이터 관리: ZooKeeper에서 KRaft로

ZooKeeper 모드 (레거시)

Kafka 초기에는 클러스터 메타데이터(브로커 목록, 토픽과 파티션 정보, 리더 위치, ISR, 접근 제어 목록 등)를 외부의 ZooKeeper 앙상블이 관리했다.

이 구조의 문제는 다음과 같다.

  • ZooKeeper를 별도로 운영해야 했으므로 운영 부담이 늘었다.
  • 대규모 클러스터에서 파티션 수가 늘면 메타데이터 갱신이 병목이 됐다.
  • 컨트롤러 장애 시 메타데이터 전체를 다시 로딩해야 해서 복구가 느렸다.
  • 파티션 수 증가에 따라 ZooKeeper 부하가 급증했다.

KRaft 모드

KRaft(Kafka Raft Metadata) 모드는 ZooKeeper 의존성을 완전히 제거한다. 메타데이터는 Kafka 내부의 __cluster_metadata 토픽으로 관리되며, 컨트롤러들끼리 Raft 합의로 동기화한다.

process.roles 설정으로 노드의 역할을 지정한다.

# 컨트롤러와 브로커 겸용 (소규모, 개발)
process.roles=broker,controller
 
# 전용 컨트롤러 (대규모)
process.roles=controller
 
# 전용 브로커 (대규모)
process.roles=broker

KRaft가 주는 이점은 다음과 같다.

  • ZooKeeper 운영이 불필요해 운영 복잡도가 준다.
  • 메타데이터를 Kafka 자신의 로그로 관리하므로 일관된 도구로 다룬다.
  • 컨트롤러 장애 복구가 수 초 이내로 단축된다.
  • 수백만 파티션까지 확장 가능하다.
  • 단일 프로세스로도 동작하므로 로컬 개발이 쉽다.

복제와 ISR

High Watermark, Log End Offset, ISR

Kafka의 복제 메커니즘을 이해하는 데 필요한 세 가지 용어가 있다.

  • Log End Offset: 각 레플리카의 로그 끝 오프셋. 마지막 메시지의 다음 위치를 가리킨다.
  • High Watermark: 모든 ISR이 복제 완료한 가장 높은 오프셋. 컨슈머는 이 지점까지만 읽을 수 있다.
  • ISR(In-Sync Replicas): 리더와 동기화 상태인 레플리카 집합. 리더 자신도 ISR에 포함된다.

Follower 2가 따라잡지 못해 로그 끝 오프셋이 6에 머물러 있으면 High Watermark도 6이다. 컨슈머는 7, 8을 읽을 수 없다. 만약 Follower 2가 replica.lag.time.max.ms(기본 30초) 안에 따라잡지 못하면 ISR에서 제외되고, High Watermark는 따라잡고 있는 레플리카 기준으로 다시 계산된다.

리더 선출

리더 브로커가 죽으면 컨트롤러가 ISR 중 하나를 새 리더로 선출한다.

sequenceDiagram
    participant CTRL as Controller
    participant B0 as Broker 0 (Leader, 장애)
    participant B1 as Broker 1 (ISR)
    participant B2 as Broker 2 (ISR)
    participant CLIENT as Producer/Consumer

    Note over B0: 장애 발생
    CTRL->>CTRL: 하트비트 미수신 감지
    CTRL->>B1: 새 리더로 승격
    B1-->>CTRL: 확인
    CTRL->>CLIENT: 메타데이터 갱신 알림
    CLIENT->>B1: 이후 요청은 새 리더로

전체 데이터 흐름

프로듀서가 메시지를 보내면 파티셔너가 키를 보고 어느 파티션으로 갈지 결정한다. 메시지는 해당 파티션 리더에게 도착하고, 리더는 로컬 로그에 append한 뒤 팔로워의 fetch 요청을 처리한다. ISR이 모두 복제하면 High Watermark가 이동하고, 프로듀서에게 응답이 돌아간다.

컨슈머 그룹은 자기 그룹에 속한 컨슈머들에게 파티션을 나눠주고, 각 컨슈머는 자기 파티션의 메시지를 순차로 읽는다. 처리한 위치는 __consumer_offsets라는 내부 토픽에 기록되고, 이게 다음 재시작 시 어디부터 읽을지를 결정한다.