Apache Kafka 동작 원리 – 대용량 메시지 처리 구조


Apache Kafka 동작 원리를 제대로 이해하지 못한 채 도입하면, 설정 한 줄 차이로 메시지가 유실되거나 브로커가 과부하 상태에 빠지는 상황을 맞이하게 됩니다. 하루에 수천억 건의 이벤트를 처리하는 LinkedIn, Netflix, Uber가 공통으로 선택한 이 플랫폼은 단순한 메시지 큐가 아닙니다. 디스크 기반 순차 I/O, Zero-Copy 전송, 분산 복제 구조가 결합된 분산 이벤트 스트리밍 플랫폼입니다. 이 글에서는 Kafka가 어떻게 디스크를 사용하면서도 인메모리 시스템에 버금가는 속도를 내는지, 브로커·토픽·파티션이 어떻게 맞물려 대용량을 처리하는지를 내부 구조부터 차근차근 해설합니다.


목차

  1. Kafka의 탄생 배경과 핵심 설계 철학
  2. Kafka 핵심 구성 요소 – 브로커·토픽·파티션 구조
  3. 메시지가 저장되는 방식 – 로그 세그먼트와 오프셋
  4. 대용량 처리의 비밀 – Zero-Copy와 배치 전송
  5. 고가용성 설계 – 복제(Replication)와 리더·팔로워 구조
  6. ZooKeeper에서 KRaft로 – Kafka 메타데이터 관리의 진화

1. Kafka의 탄생 배경과 핵심 설계 철학

LinkedIn이 Kafka를 만든 이유

2010년대 초 LinkedIn은 하루 수십억 건의 사용자 활동 데이터(페이지 뷰, 클릭, 연결 요청)를 실시간으로 수집해야 하는 문제에 직면했습니다. 당시 사용하던 ActiveMQ 같은 전통적인 메시지 브로커는 메시지를 메모리에 올려두고 처리하는 방식이었기 때문에, 대용량 트래픽 앞에서 메모리 고갈과 성능 저하를 피할 수 없었습니다. LinkedIn 엔지니어링 팀은 기존 메시지 브로커의 한계를 극복하기 위해 완전히 새로운 설계 원칙으로 Kafka를 개발했고, 2011년 오픈소스로 공개했습니다.

Kafka가 기존 메시지 브로커와 근본적으로 다른 점은 메시지를 처리 후 삭제하지 않고, 디스크에 로그처럼 영속적으로 보관한다는 점입니다. 이 단순해 보이는 설계 결정 하나가 재소비, 고성능, 내결함성이라는 세 가지 특성을 동시에 가능하게 합니다.

Kafka의 세 가지 핵심 설계 철학

① 디스크를 두려워하지 마라 (Disk is not the enemy)

많은 개발자가 “디스크는 느리다”고 생각하지만, Kafka는 이 통념을 정면으로 반박합니다. 디스크의 랜덤 I/O는 느리지만, **순차 I/O(Sequential I/O)**는 메모리 랜덤 접근보다 빠를 수 있습니다. Kafka는 메시지를 항상 로그의 끝에 추가(append-only)하는 순차 쓰기 방식을 채택해, 디스크를 사용하면서도 극한의 쓰기 성능을 달성합니다.

[랜덤 I/O vs 순차 I/O 성능 비교]
HDD 랜덤 읽기:   ~100 IOPS      (느림)
HDD 순차 읽기:   ~200 MB/s      (빠름)
RAM 랜덤 접근:   ~100,000 IOPS  (매우 빠름)
RAM 순차 접근:   ~10,000 MB/s   (극한)

→ Kafka의 순차 디스크 I/O는 RAM 랜덤 접근보다 빠른 경우도 존재

② 브로커를 단순하게, 클라이언트를 똑똑하게

전통적인 메시지 브로커는 어떤 컨슈머가 어디까지 읽었는지, 메시지를 다시 보내야 하는지를 브로커가 직접 추적하고 관리합니다. Kafka는 이 복잡성을 컨슈머 쪽으로 이전했습니다. 컨슈머가 자신의 오프셋(읽은 위치)을 직접 관리하므로 브로커는 단순히 로그를 저장하고 전달하는 역할만 합니다. 브로커가 단순해질수록 속도는 빨라집니다.

③ Pull 방식으로 역압(Backpressure)을 자연스럽게 해결

Kafka 컨슈머는 브로커가 메시지를 밀어주는(Push) 방식이 아니라 컨슈머가 직접 가져가는(Pull) 방식으로 동작합니다. 컨슈머가 처리할 수 있을 때만 메시지를 가져오므로, 처리 속도가 느린 컨슈머가 있어도 브로커가 과부하 상태에 빠지지 않습니다. 또한 네트워크 상황에 따라 배치 크기를 유연하게 조절할 수 있습니다.


2. Kafka 핵심 구성 요소 – 브로커·토픽·파티션 구조

전체 아키텍처 조감도

Kafka 클러스터는 여러 구성 요소가 유기적으로 맞물려 동작합니다. 전체 구조를 먼저 파악한 뒤 각 요소를 세부적으로 살펴보겠습니다.

┌─────────────────────────────────────────────────────────┐
│                    Kafka Cluster                        │
│                                                         │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐            │
│  │ Broker 1 │   │ Broker 2 │   │ Broker 3 │            │
│  │          │   │          │   │          │            │
│  │ Topic A  │   │ Topic A  │   │ Topic A  │            │
│  │  P0(L)   │   │  P1(L)   │   │  P2(L)   │ ← 리더     │
│  │  P1(F)   │   │  P2(F)   │   │  P0(F)   │ ← 팔로워   │
│  └──────────┘   └──────────┘   └──────────┘            │
│       ↑               ↑               ↑                │
│  ZooKeeper / KRaft (메타데이터 관리)                     │
└─────────────────────────────────────────────────────────┘
         ↑                              ↓
  [Producers]                    [Consumer Groups]
  APP1, APP2, APP3              Group A, Group B

브로커(Broker)

브로커는 Kafka 클러스터를 구성하는 개별 서버 인스턴스입니다. 브로커는 프로듀서로부터 메시지를 받아 디스크에 저장하고, 컨슈머의 요청에 따라 메시지를 전달하는 역할을 합니다. 고가용성을 위해 최소 3개 이상의 브로커로 클러스터를 구성하는 것이 권장됩니다.

각 브로커는 broker.id로 식별되며, 토픽의 파티션 중 일부를 자신이 담당(리더)하고 나머지는 다른 브로커의 복제본(팔로워)을 보관합니다. 이 분산 구조 덕분에 특정 브로커가 장애를 겪어도 클러스터 전체는 계속 동작합니다.

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

토픽은 메시지를 분류하는 논리적 채널입니다. 이메일 폴더처럼 “주문 이벤트”, “결제 이벤트”, “로그 데이터” 같이 목적에 따라 구분합니다. 토픽은 하나 이상의 파티션으로 나뉘며, 파티션이 Kafka 병렬 처리의 물리적 단위입니다.

[토픽과 파티션 구조]

Topic: order-events (파티션 3개)
┌─────────────────────────────────────────────┐
│ Partition 0: [msg0][msg1][msg4][msg7]...    │  → Broker 1
│ Partition 1: [msg2][msg5][msg8][msg11]...   │  → Broker 2
│ Partition 2: [msg3][msg6][msg9][msg12]...   │  → Broker 3
└─────────────────────────────────────────────┘
       ↑                              ↑
   오프셋 0부터 순차 증가        파티션 내 순서 보장
  (파티션 간 순서는 미보장)

파티션 내 메시지는 **오프셋(Offset)**이라는 순번이 붙어 영구히 저장됩니다. 오프셋은 단조 증가하며 절대 변경되지 않습니다. 컨슈머는 이 오프셋 번호를 기억해뒀다가 다음 번에 해당 번호부터 이어서 읽습니다.

프로듀서의 파티션 결정 방식

프로듀서가 메시지를 전송할 때 어느 파티션으로 보낼지는 다음 규칙에 따라 결정됩니다.

상황파티션 결정 방식특징
키 없음라운드로빈 또는 스티키 배치균등 분산, 순서 미보장
키 있음hash(key) % 파티션 수동일 키 → 동일 파티션 (순서 보장)
커스텀 파티셔너사용자 정의 로직비즈니스 규칙 적용 가능

java

// 파티션 키를 지정한 메시지 전송
// orderId를 키로 사용 → 같은 주문 이벤트는 항상 같은 파티션
kafkaTemplate.send("order-events", order.getId(), orderEvent);
// hash("ORD-001") % 3 = 1 → 항상 Partition 1로 전달

3. 메시지가 저장되는 방식 – 로그 세그먼트와 오프셋

Kafka 로그의 물리적 구조

Kafka는 파티션 데이터를 디스크의 로그 디렉터리에 저장합니다. 각 파티션은 독립된 디렉터리를 가지며, 내부는 일정 크기 또는 시간 단위로 잘린 로그 세그먼트(Log Segment) 파일들로 구성됩니다.

[Kafka 디스크 디렉터리 구조]

/kafka-logs/
└── order-events-0/          ← 토픽명-파티션번호
    ├── 00000000000000000000.log    ← 실제 메시지 데이터
    ├── 00000000000000000000.index  ← 오프셋 → 파일 위치 인덱스
    ├── 00000000000000000000.timeindex ← 타임스탬프 인덱스
    ├── 00000000000001048576.log    ← 두 번째 세그먼트 (1MB 이후)
    ├── 00000000000001048576.index
    └── leader-epoch-checkpoint

파일명의 숫자는 해당 세그먼트의 시작 오프셋입니다. 00000000000001048576.log는 오프셋 1,048,576번 메시지부터 시작하는 세그먼트임을 의미합니다.

.log / .index / .timeindex 파일의 역할

① .log 파일 – 실제 메시지 저장

메시지가 순차적으로 append되는 바이너리 파일입니다. 각 메시지 레코드는 다음 필드로 구성됩니다.

[메시지 레코드 구조]
┌──────────────────────────────────────────┐
│ Offset          : 8 bytes (오프셋 번호)   │
│ Message Size    : 4 bytes                │
│ CRC             : 4 bytes (무결성 체크)   │
│ Magic Byte      : 1 byte  (포맷 버전)     │
│ Attributes      : 1 byte  (압축 방식 등)  │
│ Timestamp       : 8 bytes                │
│ Key Length      : 4 bytes                │
│ Key             : N bytes                │
│ Value Length    : 4 bytes                │
│ Value           : N bytes (실제 메시지)   │
└──────────────────────────────────────────┘

② .index 파일 – 빠른 오프셋 탐색

특정 오프셋의 메시지를 찾으려면 .log 파일 전체를 처음부터 스캔해야 한다면 매우 비효율적입니다. .index 파일은 오프셋 → 파일 내 바이트 위치 매핑을 저장하여, 원하는 오프셋으로 즉시 점프할 수 있게 합니다. 이진 탐색(Binary Search)으로 O(log n) 시간에 원하는 메시지 위치를 찾습니다.

[.index 파일 구조 (희소 인덱스)]
오프셋 0     → 파일 위치 0 byte
오프셋 100   → 파일 위치 4,823 byte
오프셋 200   → 파일 위치 9,611 byte
...
(매 메시지가 아닌 일정 간격으로만 기록 → 희소 인덱스)

③ 리텐션(Retention) 정책 – 언제 삭제하는가

Kafka는 메시지를 소비 여부와 무관하게 설정된 정책에 따라 보관합니다.

yaml

# server.properties / 토픽 설정
# 시간 기반 보존: 7일 (기본값)
log.retention.hours=168

# 크기 기반 보존: 파티션당 최대 1GB
log.retention.bytes=1073741824

# 두 조건 중 먼저 만족하는 쪽 적용
# 오래된 세그먼트부터 순서대로 삭제

# 압축(Compaction) 정책: 같은 키의 최신 메시지만 유지
log.cleanup.policy=compact
# 예: 사용자 프로필 업데이트 → 최신 상태만 보존 (이벤트 소싱에 활용)

4. 대용량 처리의 비밀 – Zero-Copy와 배치 전송

일반적인 데이터 전송의 문제

일반적인 서버 애플리케이션이 디스크의 파일을 네트워크로 전송할 때는 데이터가 4번의 복사와 2번의 컨텍스트 스위칭을 거칩니다.

[일반 파일 전송 경로 (4-copy)]

① 디스크 → 커널 페이지 캐시      (DMA 복사)
② 커널 페이지 캐시 → 사용자 버퍼  (CPU 복사) ← 컨텍스트 전환 1
③ 사용자 버퍼 → 소켓 버퍼        (CPU 복사) ← 컨텍스트 전환 2
④ 소켓 버퍼 → NIC 버퍼           (DMA 복사)

총 CPU 복사: 2회 / 컨텍스트 스위칭: 2회
→ 대용량 처리 시 CPU 병목 발생

Kafka의 Zero-Copy 전송

Kafka는 Linux의 sendfile() 시스템 콜을 활용한 Zero-Copy 기법으로 이 문제를 해결합니다. 데이터가 사용자 공간(User Space)을 거치지 않고 커널 안에서 직접 전달되므로 CPU 복사 횟수가 0이 됩니다.

[Zero-Copy 전송 경로 (2-copy)]

① 디스크 → 커널 페이지 캐시      (DMA 복사)
② 커널 페이지 캐시 → NIC 버퍼    (DMA 복사) ← sendfile() 시스템 콜

총 CPU 복사: 0회 / 컨텍스트 스위칭: 최소화
→ CPU 사용량 대폭 절감, 처리량 2~4배 향상

Zero-Copy는 Kafka가 디스크 기반임에도 불구하고 네트워크 전송 병목 없이 높은 처리량을 유지하는 핵심 기술입니다.

배치(Batch) 전송과 압축

Kafka 프로듀서는 메시지를 하나씩 보내지 않고 묶어서(배치) 전송합니다. 배치 전송은 네트워크 왕복 횟수(RTT)를 줄여 처리량을 극적으로 높입니다.

java

// application.yml – 프로듀서 배치 설정
spring:
  kafka:
    producer:
      # 배치 최대 크기: 16KB (기본값, 높일수록 처리량 ↑ 지연 ↑)
      batch-size: 16384
      # 배치 대기 시간: 최대 10ms 기다렸다가 묶어서 전송
      # (0이면 즉시 전송, 지연 최소화 but 처리량 낮음)
      properties:
        linger.ms: 10
        # 프로듀서 메모리 버퍼 크기: 32MB
        buffer.memory: 33554432
        # 메시지 압축 방식: snappy (속도/압축률 균형)
        # none, gzip, snappy, lz4, zstd 선택 가능
        compression.type: snappy
[배치 + 압축 효과]

배치 없이 1,000개 메시지 전송:
  → 네트워크 요청 1,000회 × RTT = 높은 지연

배치로 1,000개 메시지 전송 (배치 크기 100):
  → 네트워크 요청 10회 × RTT = 지연 1/100

+ snappy 압축 적용:
  → 전송 데이터 크기 약 30~50% 감소
  → 네트워크 대역폭 절감 + 디스크 저장 공간 절감

페이지 캐시(Page Cache) 활용

Kafka가 높은 성능을 내는 또 다른 비결은 OS 페이지 캐시를 적극 활용한다는 것입니다. Kafka는 JVM 힙 메모리를 최소화하고, 데이터를 읽고 쓸 때 OS가 자동으로 관리하는 페이지 캐시를 통합니다. 최근에 쓴 데이터는 거의 항상 페이지 캐시에 남아 있으므로, 컨슈머가 최신 메시지를 읽을 때는 실제로 디스크에 접근하지 않고 메모리에서 직접 전달됩니다.

[페이지 캐시 활용 흐름]

Producer 쓰기:
메시지 → 페이지 캐시 (즉시 반환) → 비동기 디스크 플러시

Consumer 읽기 (최신 메시지):
페이지 캐시 HIT → 디스크 접근 없이 메모리에서 직접 전달 ✅

Consumer 읽기 (오래된 메시지):
페이지 캐시 MISS → 디스크에서 로드 → 페이지 캐시 갱신

5. 고가용성 설계 – 복제(Replication)와 리더·팔로워 구조

파티션 복제 메커니즘

Kafka는 파티션 단위로 데이터를 복제해 브로커 장애를 대비합니다. replication.factor를 3으로 설정하면 파티션 하나가 3개의 브로커에 복제됩니다. 이 중 하나가 리더(Leader), 나머지 둘이 **팔로워(Follower)**입니다.

[복제 구조 – replication.factor=3]

Topic: order-events, Partition 0

Broker 1 (리더)  ← 프로듀서/컨슈머가 직접 통신
  [msg0][msg1][msg2][msg3]...

Broker 2 (팔로워)  ← 리더에서 복제
  [msg0][msg1][msg2][msg3]...

Broker 3 (팔로워)  ← 리더에서 복제
  [msg0][msg1][msg2][msg3]...

→ Broker 1 장애 발생 시:
  Broker 2 또는 3이 새 리더로 자동 선출
  → 서비스 중단 없이 처리 지속

ISR(In-Sync Replicas) – 신뢰할 수 있는 복제본 목록

ISR은 리더와 동기화 상태를 유지하고 있는 팔로워 목록입니다. 리더는 ISR에 속한 팔로워들이 메시지를 복제 완료했을 때만 **커밋(Commit)**했다고 간주합니다. acks=all 설정 시 ISR 전체가 복제를 완료해야 프로듀서에게 성공 응답을 보냅니다.

[ISR과 acks 설정 비교]

acks=0: 브로커 응답 기다리지 않음
  → 처리량 최고 / 데이터 유실 위험 최고
  → 로그성 데이터 수집에 적합

acks=1: 리더만 저장 확인
  → 균형 (기본값)
  → 리더 장애 시 미복제 데이터 유실 가능

acks=all (또는 -1): ISR 전체 저장 확인
  → 처리량 낮음 / 데이터 유실 위험 없음
  → 금융·결제 등 중요 데이터에 필수

[ISR 이탈 기준]
replica.lag.time.max.ms=30000 (기본 30초)
→ 30초 동안 리더를 따라오지 못하면 ISR에서 제외
→ ISR에서 제외된 팔로워는 리더 선출 대상에서도 제외

리더 선출 과정

브로커 장애로 리더가 내려가면 Kafka 컨트롤러(Controller)가 ISR 목록에서 새 리더를 선출합니다. 이 과정은 통상 수 초 이내에 완료되며, 이 시간 동안 해당 파티션으로의 쓰기/읽기가 일시 중단됩니다.

[리더 선출 타임라인]

t=0s  : Broker 1(리더) 장애 감지
t=2s  : ZooKeeper/KRaft가 컨트롤러에 통보
t=3s  : 컨트롤러가 ISR에서 Broker 2를 새 리더로 선출
t=3s  : 메타데이터 업데이트 전파 (모든 브로커·클라이언트)
t=4s  : Broker 2가 새 리더로 읽기/쓰기 재개

→ 약 3~5초의 일시적 불가용 후 자동 복구 ✅

6. ZooKeeper에서 KRaft로 – Kafka 메타데이터 관리의 진화

ZooKeeper 의존성의 한계

Kafka 초기 버전부터 Kafka 3.3 이전까지, Kafka 클러스터의 메타데이터(브로커 목록, 토픽 구성, 리더 정보, 컨트롤러 선출)는 별도의 Apache ZooKeeper 클러스터가 관리했습니다. 이 구조는 실제 운영에서 여러 문제를 야기했습니다.

[ZooKeeper 의존 구조의 문제점]

운영 복잡성:
  → Kafka 클러스터 3대 + ZooKeeper 클러스터 3대 = 최소 6대 서버 필요
  → 두 시스템의 버전·설정을 각각 관리해야 함

확장성 한계:
  → ZooKeeper에 저장 가능한 메타데이터 크기 한계 존재
  → 파티션 수가 수십만 개를 넘으면 컨트롤러 재시작 시간이 수분~수십분

단일 장애점:
  → ZooKeeper 클러스터 장애 = Kafka 클러스터 전체 메타데이터 손실 위험

KRaft 모드 – Kafka가 자체 합의 엔진을 갖다

Kafka 2.8(2021년)에서 KIP-500으로 도입되고 Kafka 3.3(2022년)에서 프로덕션 준비 완료된 KRaft(Kafka Raft) 모드는 ZooKeeper 없이 Kafka 자체가 메타데이터를 관리합니다. Raft 합의 알고리즘을 Kafka 내부에 구현하여, 브로커 중 일부가 컨트롤러 역할을 겸임합니다.

[KRaft 아키텍처]

KRaft 모드 클러스터 (3대로 구성)

┌──────────────────────────────────────────┐
│  Broker 1 (Controller + Broker 겸임)     │ ← Active Controller
│  Broker 2 (Controller + Broker 겸임)     │ ← Standby Controller
│  Broker 3 (Controller + Broker 겸임)     │ ← Standby Controller
└──────────────────────────────────────────┘
           ↑
  ZooKeeper 없이 Raft 프로토콜로
  내부 메타데이터 합의 처리

[KRaft의 개선 효과]
파티션 수 100만 개 시 컨트롤러 재시작:
  ZooKeeper 모드: 수십 분 소요
  KRaft 모드: 수 초 이내 완료 (약 10,000배 향상)

KRaft 모드 설정 예시

properties

# server.properties (KRaft 모드)

# 노드 역할: broker + controller 겸임
process.roles=broker,controller

# 클러스터 고유 ID (kafka-storage.sh random-uuid 로 생성)
node.id=1

# 컨트롤러 쿼럼 구성 (node.id@host:port)
controller.quorum.voters=1@broker1:9093,2@broker2:9093,3@broker3:9093

# 리스너 설정
listeners=PLAINTEXT://:9092,CONTROLLER://:9093
inter.broker.listener.name=PLAINTEXT
controller.listener.names=CONTROLLER

# 로그 디렉터리
log.dirs=/var/kafka/data

bash

# KRaft 클러스터 초기화 (최초 1회만)
# 클러스터 고유 UUID 생성
KAFKA_CLUSTER_ID=$(kafka-storage.sh random-uuid)

# 각 브로커 스토리지 포맷
kafka-storage.sh format \
  --config /etc/kafka/server.properties \
  --cluster-id $KAFKA_CLUSTER_ID

# Kafka 브로커 시작 (ZooKeeper 없이 바로 실행)
kafka-server-start.sh /etc/kafka/server.properties

결론

Apache Kafka 동작 원리의 핵심은 세 가지 구조적 결정에 있습니다. 첫째, append-only 순차 쓰기와 페이지 캐시·Zero-Copy 활용으로 디스크 기반이면서도 극한의 처리량을 달성합니다. 둘째, 토픽을 파티션으로 분할하고 컨슈머 그룹이 파티션을 나눠 처리함으로써 처리량을 수평 확장합니다. 셋째, ISR 기반 복제와 자동 리더 선출로 브로커 장애에도 데이터 유실 없이 운영을 지속합니다. Kafka를 단순한 메시지 큐가 아닌 영속적 이벤트 로그 플랫폼으로 이해할 때, 비로소 올바른 설계 결정을 내릴 수 있습니다. 이 글에서 배운 원리를 바탕으로 컨슈머 그룹 파티션 전략과 Spring Boot 연동 실습으로 학습을 이어가 보세요.


⚠️ 면책 고지: 본 글은 기술 학습 및 참고 목적으로 작성된 가이드입니다. 설명된 내용은 Apache Kafka 공식 문서와 일반적인 운영 환경을 기준으로 작성되었으며, Kafka 버전 및 클러스터 구성에 따라 동작 방식이 다를 수 있습니다. 실제 운영 환경 적용 전 공식 문서 확인과 충분한 검증을 권장합니다. 설정 변경으로 인한 장애·데이터 손실에 대해 본 블로그는 책임을 지지 않습니다.

Comments

답글 남기기