Skip to main content

Command Palette

Search for a command to run...

카프카 입문 시리즈 2편: 토픽, 파티션, 오프셋

Published
12 min read

이 글은 Apache Kafka 입문 시리즈의 두 번째 글입니다. 1편에서 살펴본 구성 요소들 위에서, 메시지가 실제로 어떤 구조로 저장되고 관리되는지 알아보겠습니다.

1편을 마치며 세 가지 질문을 남겼습니다.

  • 메시지는 브로커 안에서 어떤 구조로 저장될까?
  • 토픽과 파티션은 정확히 무엇이고, 왜 필요할까?
  • 컨슈머의 오프셋은 어떻게 동작할까?

이번 편에서 이 질문들에 하나씩 답하겠습니다.


Topic: 메시지의 논리적 분류

토픽(Topic)은 메시지를 분류하는 논리적 단위입니다. 1편에서 "메시지가 저장되는 카테고리"라고 소개한 바로 그것입니다.

쇼핑몰 시스템이라면 orders, payments, notifications처럼 용도에 따라 토픽을 나눕니다. 프로듀서는 토픽을 지정하여 메시지를 보내고, 컨슈머는 관심 있는 토픽을 구독하여 메시지를 읽습니다.

토픽은 로그다

토픽의 본질은 추가 전용 로그(append-only log)입니다. 새로운 메시지는 항상 로그의 끝에만 추가됩니다. 한번 기록된 메시지는 수정하거나 삭제할 수 없습니다. 이 불변성(immutability)은 카프카의 핵심 설계 원칙입니다.

그렇다면 오래된 메시지는 영원히 남아 있을까요? 아닙니다. 카프카는 보존 정책(retention policy)에 따라 오래된 메시지를 자동으로 정리합니다.

보존 정책

정책설정기본값동작
시간 기반 삭제retention.ms7일 (604,800,000ms)지정된 시간이 지난 메시지를 삭제
크기 기반 삭제retention.bytes-1 (무제한)파티션 크기가 한도를 초과하면 오래된 메시지부터 삭제
로그 컴팩션cleanup.policy=compact같은 키의 메시지 중 최종 값만 남기는 것을 목표로 정리

기본 설정에서는 7일이 지난 메시지가 삭제됩니다. 로그 컴팩션은 조금 다른 개념인데, 키별로 가장 마지막 값만 남기는 것을 목표로 백그라운드에서 점진적으로 정리하는 방식입니다. 컴팩션이 아직 실행되지 않은 시점에는 같은 키의 메시지가 여러 개 존재할 수 있습니다. 예를 들어 사용자 프로필을 저장하는 토픽이라면, 같은 사용자 ID를 키로 가진 메시지 중 최종 상태만 보존합니다.


Partition: 토픽을 나누는 단위

토픽 하나에 모든 메시지를 순서대로 쌓으면 간단하겠지만, 한 대의 브로커에 부하가 집중됩니다. 이 문제를 해결하기 위해 카프카는 토픽을 파티션(Partition)이라는 더 작은 단위로 나눕니다.

왜 파티션이 필요한가?

핵심은 병렬 처리입니다.

파티션이 3개라면:

  • 쓰기 분산: 프로듀서가 3개의 브로커에 동시에 메시지를 보낼 수 있습니다
  • 읽기 분산: 컨슈머 3대가 각각 하나의 파티션을 담당하여 동시에 읽을 수 있습니다
  • 저장 분산: 데이터가 3개의 브로커에 나뉘어 저장됩니다

파티션 수가 곧 병렬 처리의 상한선입니다. 컨슈머 그룹 내에서 하나의 파티션은 하나의 컨슈머만 읽을 수 있으므로, 파티션이 3개면 동시에 읽는 컨슈머도 최대 3대입니다.

파티션 내부의 순서 보장

1편 Q&A에서 잠깐 언급했던 내용입니다. 카프카는 파티션 단위로 순서를 보장합니다.

하나의 파티션 안에서 메시지는 들어온 순서 그대로 저장되고, 같은 순서로 읽힙니다. 하지만 서로 다른 파티션 간에는 순서가 보장되지 않습니다.

같은 파티션 내에서는 Created → Paid → Shipped이 보장되지만, Partition 0의 Shipped과 Partition 1의 Cancelled 중 어느 것이 먼저 처리될지는 알 수 없습니다.


메시지는 어떤 파티션으로 가는가?

프로듀서가 메시지를 보낼 때, 어떤 파티션에 넣을지를 결정하는 것이 파티셔닝 전략입니다.

키가 있는 경우: 해시 기반 분배

메시지에 키(key)가 있으면, 카프카는 키의 해시값으로 파티션을 결정합니다.

partition = toPositive(murmur2(key)) % numPartitions

murmur2 해시는 음수를 반환할 수 있으므로, toPositive()로 양수 변환 후 나머지 연산을 수행합니다.

같은 키는 항상 같은 파티션으로 갑니다. 따라서 같은 키를 가진 메시지끼리 순서가 보장됩니다.

주문 시스템에서 사용자 ID를 키로 설정하면, 같은 사용자의 주문 이벤트는 항상 같은 파티션에 쌓이므로 이벤트 순서가 보장됩니다.

주의: 파티션 수가 변경되면 같은 키라도 다른 파티션으로 갈 수 있습니다. murmur2(key) % 3murmur2(key) % 5는 다른 결과를 냅니다. 키 기반 순서 보장이 중요하다면 파티션 수를 처음에 잘 정해야 합니다.

키가 없는 경우: 스티키 파티셔너

키가 없는 메시지는 어떻게 분배될까요? 과거에는 라운드 로빈 방식으로 한 건씩 돌아가며 보냈지만, 이 방식은 작은 배치를 많이 만들어 비효율적이었습니다.

현재 카프카(3.3+)는 스티키 파티셔너(Sticky Partitioner)를 기본으로 사용합니다. 하나의 파티션에 배치가 가득 찰 때까지 메시지를 모은 뒤, 다음 파티션으로 전환합니다.

방식동작배치 크기성능
라운드 로빈 (과거)메시지마다 다른 파티션작음네트워크 왕복 많음
스티키 (현재)배치가 찰 때까지 같은 파티션네트워크 왕복 적음

스티키 파티셔너 도입으로 p99 지연 시간이 1017ms에서 204ms로 감소한 벤치마크 결과도 있습니다 (KIP-480).


Segment: 파티션의 물리적 저장 구조

지금까지 토픽과 파티션은 논리적인 개념이었습니다. 이제 파티션이 디스크에 실제로 어떻게 저장되는지 살펴보겠습니다.

파티션 = 디렉토리

각 파티션은 브로커의 디스크에 하나의 디렉토리로 존재합니다. 디렉토리 이름은 {토픽명}-{파티션번호} 형식입니다.

/kafka-logs/
├── orders-0/          ← Topic: orders, Partition: 0
│   ├── 00000000000000000000.log
│   ├── 00000000000000000000.index
│   ├── 00000000000000000000.timeindex
│   ├── 00000000000000001007.log
│   ├── 00000000000000001007.index
│   └── 00000000000000001007.timeindex
├── orders-1/          ← Topic: orders, Partition: 1
└── orders-2/          ← Topic: orders, Partition: 2

세그먼트 파일 구조

파티션 안의 데이터는 세그먼트(Segment)라는 단위로 나뉩니다. 하나의 세그먼트는 세 개의 파일로 구성됩니다.

파일확장자역할
로그 파일.log실제 메시지 데이터가 저장되는 파일
오프셋 인덱스.index오프셋 → 로그 파일 내 바이트 위치 매핑
타임스탬프 인덱스.timeindex타임스탬프 → 오프셋 매핑

파일 이름은 해당 세그먼트의 첫 번째 메시지 오프셋을 20자리로 표현한 것입니다. 00000000000000001007.log는 오프셋 1007부터 시작하는 세그먼트입니다.

활성 세그먼트와 세그먼트 롤링

파티션에서 현재 쓰기가 진행 중인 세그먼트를 활성 세그먼트(Active Segment)라고 합니다. 활성 세그먼트는 파티션당 항상 하나만 존재합니다.

활성 세그먼트가 일정 조건에 도달하면, 해당 세그먼트를 닫고 새로운 세그먼트를 생성합니다. 이것을 세그먼트 롤링(rolling)이라 합니다.

조건설정기본값
크기 초과segment.bytes1GB
시간 초과segment.ms7일

중요한 점은, 보존 정책은 닫힌 세그먼트에만 적용된다는 것입니다. 활성 세그먼트는 삭제되거나 컴팩션되지 않습니다. 따라서 실제 데이터 보존 기간은 retention.ms보다 최대 한 세그먼트 기간만큼 길어질 수 있습니다.

인덱스로 빠르게 찾기

컨슈머가 "오프셋 1500번 메시지를 달라"고 요청하면, 카프카는 매번 로그 파일 전체를 스캔하지 않습니다.

  1. .index 파일에서 오프셋 1500에 가장 가까운 인덱스 항목을 이진 탐색합니다
  2. 해당 바이트 위치로 .log 파일을 열어 약간만 순방향 스캔합니다

.index 파일은 모든 오프셋을 기록하지 않고, 기본적으로 4KB마다 하나의 항목을 기록하는 희소 인덱스(sparse index)입니다. 전체 인덱스를 유지하는 것보다 파일 크기와 메모리 사용을 크게 줄이면서도 충분히 빠른 검색이 가능합니다.


Offset: 메시지의 위치 추적

오프셋(Offset)은 파티션 내에서 각 메시지에 부여되는 순차적 번호입니다. 1편에서 "책갈피"에 비유했던 개념입니다.

오프셋의 종류

하나의 파티션에는 여러 종류의 오프셋이 존재합니다.

오프셋의미
Log-Start Offset파티션에서 읽을 수 있는 가장 오래된 오프셋. 보존 정책에 의해 삭제되면 앞으로 이동합니다
Committed Offset컨슈머가 "여기까지 처리했다"고 저장한 오프셋. 재시작 시 이 지점부터 다시 읽습니다
Consumer Position컨슈머가 현재 읽고 있는 오프셋. poll() 호출마다 앞으로 이동합니다
High Watermark (HW)모든 ISR 복제본에 복제 완료된 가장 높은 오프셋. 컨슈머는 여기까지만 읽을 수 있습니다
Log-End Offset (LEO)파티션에 다음으로 기록될 오프셋. 리더에 가장 마지막으로 쓰인 메시지의 다음 위치입니다

이 중 Committed OffsetConsumer Position이 컨슈머 입장에서 가장 중요합니다.

Committed Offset과 Consumer Position의 차이

두 개념의 차이가 중요한 이유는 장애 시 재처리 범위를 결정하기 때문입니다.

  • Consumer Position: poll()로 메시지를 가져올 때마다 자동으로 앞으로 이동합니다
  • Committed Offset: 명시적으로 커밋해야 이동합니다

컨슈머가 오프셋 4까지 읽었지만 2까지만 커밋한 상태에서 장애가 발생하면, 재시작 시 오프셋 2부터 다시 읽기 시작합니다. 오프셋 2, 3의 메시지는 중복 처리될 수 있습니다. 이 간격이 바로 "위험 구간"입니다.

오프셋은 어디에 저장되는가?

컨슈머가 커밋한 오프셋은 __consumer_offsets라는 카프카 내부 토픽에 저장됩니다.

설정기본값
파티션 수50
복제 계수3
정리 정책로그 컴팩션

이 토픽의 키는 (컨슈머 그룹, 토픽, 파티션) 조합이고, 값은 커밋된 오프셋입니다. 로그 컴팩션이 적용되어 각 키의 최신 값만 유지됩니다.

컨슈머 그룹의 코디네이터 브로커는 이 값을 메모리에 캐시하여 빠르게 응답합니다. 코디네이터는 hash(group.id) % 50으로 결정되는 __consumer_offsets 파티션의 리더 브로커입니다.

오프셋 커밋 방식

오프셋을 커밋하는 방법은 자동 커밋수동 커밋 두 가지입니다.

자동 커밋

설정기본값
enable.auto.committrue
auto.commit.interval.ms5000ms (5초)

기본적으로 카프카 컨슈머는 5초마다 자동으로 오프셋을 커밋합니다. 편리하지만, 메시지를 처리하는 도중 장애가 나면 처리하지 못한 메시지의 오프셋까지 커밋되어 메시지를 놓칠 수 있습니다. 반대로 커밋 직전에 장애가 나면 이미 처리한 메시지를 중복 처리할 수 있습니다.

수동 커밋

// 동기 커밋 — 커밋 완료를 기다림
consumer.commitSync();

// 비동기 커밋 — 커밋 완료를 기다리지 않음
consumer.commitAsync();
  • commitSync(): 커밋이 완료될 때까지 블로킹합니다. 실패하면 자동 재시도합니다
  • commitAsync(): 커밋 요청을 보내고 즉시 반환합니다. 실패해도 재시도하지 않습니다 (순서 역전 방지)

정확한 오프셋 관리가 필요한 시스템에서는 자동 커밋을 끄고 수동 커밋을 사용합니다.

오프셋 리셋 정책

컨슈머가 처음 시작하거나, 커밋된 오프셋이 보존 정책에 의해 삭제된 경우에는 읽기 시작할 위치가 없습니다. 이때 auto.offset.reset 설정이 적용됩니다.

동작
latest (기본값)파티션의 끝부터 읽기 시작 (새로 들어오는 메시지만)
earliest파티션의 처음부터 읽기 시작 (모든 보존 메시지)
none커밋된 오프셋이 없으면 예외 발생

이 설정은 유효한 커밋 오프셋이 없을 때만 적용됩니다. 한번 오프셋이 커밋되면 이후에는 이 설정과 무관하게 커밋 지점부터 읽습니다.


전체 구조 한눈에 보기

토픽, 파티션, 세그먼트, 오프셋의 관계를 하나의 그림으로 정리하면 다음과 같습니다.

개념역할비유
Topic메시지의 논리적 분류도서관의 서가
Partition토픽의 물리적 분할 단위, 병렬 처리의 기본 단위서가 안의 선반
Segment파티션의 디스크 저장 단위선반 위의 파일 바인더
Offset파티션 내 메시지의 순차 번호바인더 안의 페이지 번호

정리

이번 글에서는 카프카의 데이터 저장 구조를 살펴보았습니다.

  • 토픽은 메시지의 논리적 분류이며, 본질은 추가 전용 로그입니다
  • 파티션은 토픽을 물리적으로 나누어 병렬 처리를 가능하게 합니다
  • 세그먼트는 파티션이 디스크에 저장되는 실제 파일 단위입니다
  • 오프셋은 파티션 내 메시지의 순차 번호이며, 컨슈머는 이를 커밋하여 읽기 진행 상태를 관리합니다

하지만 아직 답하지 못한 질문들이 있습니다.

  • 카프카는 어떻게 초당 수백만 건의 메시지를 처리할 수 있을까?
  • 순차적 디스크 I/O, Zero-Copy, 배치 처리 — 이 설계 결정들은 어떻게 연결될까?

이 질문들에 대한 답은 다음 편인 "카프카의 핵심 기능과 설계 철학"에서 이어집니다.


부록: Q&A

Q1. 파티션 수는 어떻게 정하는가?

답변 보기 파티션 수를 결정하는 일반적인 공식은 다음과 같습니다. partitions = max(T/P, T/C) - T = 목표 처리량 - P = 단일 파티션의 프로듀서 처리량 - C = 단일 파티션의 컨슈머 처리량 예를 들어 목표 처리량이 100MB/s이고, 프로듀서는 파티션당 50MB/s, 컨슈머는 파티션당 25MB/s를 처리할 수 있다면: partitions = max(100/50, 100/25) = max(2, 4) = 4 최소 4개의 파티션이 필요합니다. 실무에서의 가이드라인은 다음과 같습니다. - 브로커당 100~200개 파티션이 보수적인 기준 (지연 민감한 워크로드 기준) - 브로커당 최대 4,000개까지 가능 (Confluent 권장 상한) - KRaft 기반 클러스터는 수백만 개 파티션까지 확장 가능 (ZooKeeper 기반은 약 20만 개가 실질적 한계) 중요한 점은 파티션 수를 늘리는 것은 쉽지만 줄이는 것은 불가능하다는 것입니다. 파티션 수를 줄이려면 토픽을 삭제하고 다시 만들어야 합니다. 또한 파티션 수가 변경되면 키 기반 분배가 깨집니다. 처음에 적절한 수를 정하되, 조금 넉넉하게 잡는 것이 좋습니다.

Q2. 파티션 수를 나중에 변경하면 어떻게 되는가?

답변 보기 파티션 수를 늘리는 것은 가능합니다. 하지만 두 가지 영향이 있습니다. 1. 키 기반 라우팅이 깨진다 파티션이 3개에서 5개로 늘어나면, murmur2(key) % 3murmur2(key) % 5의 결과가 달라집니다. 기존에 같은 파티션으로 가던 키가 다른 파티션으로 분산됩니다. 이는 해당 키의 메시지 순서가 보장되지 않는다는 의미입니다. 2. 기존 데이터는 이동하지 않는다 파티션을 늘려도 기존 파티션에 있는 데이터는 그대로 남습니다. 새로운 메시지만 새로운 분배 규칙을 따릅니다. 따라서 일정 기간 동안 같은 키의 데이터가 여러 파티션에 걸쳐 존재할 수 있습니다. 파티션 수를 줄이는 것은 지원되지 않습니다. 토픽을 삭제하고 새로 만드는 것이 유일한 방법입니다.

Q3. High Watermark는 왜 필요한가?

답변 보기 High Watermark(HW)는 모든 ISR(In-Sync Replica)에 복제가 완료된 오프셋입니다. 컨슈머는 HW까지만 읽을 수 있습니다. 이 제한이 없다면 어떤 일이 벌어질까요? 1. 리더 브로커에 오프셋 100번 메시지가 기록됩니다 2. 아직 팔로워에 복제되지 않았습니다 3. 컨슈머가 오프셋 100번을 읽습니다 4. 리더가 죽습니다 5. 새 리더(팔로워)에는 100번이 없습니다 컨슈머는 "존재하지 않는 메시지"를 읽은 것이 됩니다. 이를 팬텀 리드(phantom read)라고 합니다. HW 덕분에 컨슈머는 모든 복제본에 안전하게 저장된 메시지만 읽을 수 있습니다. HW와 LEO 사이의 메시지는 리더에만 존재하며, 복제가 완료되어 HW가 올라가면 비로소 컨슈머가 읽을 수 있게 됩니다. 복제와 ISR에 대한 자세한 내용은 6편에서 다루겠습니다.

Q4. 로그 컴팩션에서 메시지를 삭제하려면?

답변 보기 로그 컴팩션이 적용된 토픽에서 특정 키의 데이터를 완전히 삭제하려면, 톰스톤(tombstone) 레코드를 보냅니다. 톰스톤은 키는 있지만 값이 null인 메시지입니다. java producer.send(new ProducerRecord<>("topic", "user-123", null)); 톰스톤의 동작 순서는 다음과 같습니다. 1. 톰스톤이 기록되면, 컴팩션 시 해당 키의 이전 메시지가 모두 삭제됩니다 2. 톰스톤 자체는 delete.retention.ms (기본 24시간) 동안 유지됩니다 3. 이 기간이 지나면 톰스톤도 삭제됩니다 톰스톤 보존 기간이 필요한 이유는, 이 시간 동안 뒤처진 컨슈머가 해당 키가 삭제되었다는 사실을 인식할 수 있도록 하기 위해서입니다.

Q5. 자동 커밋은 언제 위험한가?

답변 보기 자동 커밋은 다음과 같은 시나리오에서 문제를 일으킬 수 있습니다. 시나리오 1: 메시지 유실 (at-most-once) 1. poll()로 오프셋 10~20번 메시지를 가져옵니다 2. 5초 뒤 자동 커밋이 오프셋 21을 커밋합니다 3. 아직 15~20번을 처리하지 못한 상태에서 애플리케이션이 죽습니다 4. 재시작 시 오프셋 21부터 읽으므로, 15~20번은 영영 처리되지 않습니다 시나리오 2: 메시지 중복 (at-least-once) 1. poll()로 오프셋 10~20번 메시지를 가져옵니다 2. 모두 처리했지만, 자동 커밋 주기(5초)가 아직 도래하지 않았습니다 3. 애플리케이션이 죽습니다 4. 재시작 시 마지막 커밋 지점(오프셋 10)부터 다시 읽으므로, 10~20번이 중복 처리됩니다 메시지 유실이 치명적인 시스템(결제, 주문 등)에서는 수동 커밋을 사용하여 처리 완료 후 커밋하는 패턴이 안전합니다.

Q6. __consumer_offsets 토픽이 손상되면?

답변 보기 __consumer_offsets는 복제 계수 3으로 운영되므로, 브로커 1대 장애로는 손상되지 않습니다. 하지만 만약 이 토픽의 데이터가 유실되면: - 모든 컨슈머 그룹의 커밋 오프셋이 사라집니다 - 컨슈머 재시작 시 auto.offset.reset 설정에 따라 동작합니다 - latest로 설정되어 있으면 유실 기간의 메시지를 건너뜁니다 - earliest로 설정되어 있으면 처음부터 다시 읽으므로 대량 중복이 발생합니다 이 때문에 프로덕션에서는 __consumer_offsets의 복제 계수를 3 이상으로 유지하고, 브로커 장애를 신속히 복구하는 것이 중요합니다.

이 글은 Apache Kafka 4.1 공식 문서를 기반으로 작성되었습니다.

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ