백엔드 데이터 인프라 108편. Kafka Log 파일의 바이트 단위 구조 — partition 디렉토리·segment 파일·index·timeindex 파일·offset 의 의미·binary search lookup·kafka-dump-log.sh 운영 디버깅까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 108편이에요. Part 5-6 고급 인프라 까지 끝났다면, 이번 108편부터는 Part 5-7 — Kafka Internals (5편)로 들어갑니다. 가장 깊은 내부 영역이고, 첫 글은 Log 파일 구조 — 카프카가 디스크에 실제로 어떻게 데이터를 쌓는가입니다.
Internals가 어렵게 느껴지는 이유
운영 도구나 API보다 한 단계 더 깊은 영역이에요. "Kafka가 디스크에 어떻게 데이터를 쓰는가" 같은 바이트 단위 이야기라서 그렇습니다.
첫째, 일상에서 자주 다루지 않습니다. 디버깅이나 성능 분석에만 쓰이거든요. 그래도 알아두면 깊은 운영 사고가 났을 때 직접 풀어낼 수 있어요.
둘째, 공식 문서가 짧습니다. implementation 영역은 설명이 간결한 편이고, 자세한 건 코드·KIP(Kafka Improvement Proposal, 기능 제안 문서)·source 분석으로 보충해야 합니다.
이 글에서 log 파일에 대한 거의 모든 걸 정리할게요.
Partition Directory 구조
각 partition은 별도 디렉토리로 떨어집니다.
/var/kafka-data/
├── my-topic-0/ # partition 0
│ ├── 00000000000000000000.log # segment file
│ ├── 00000000000000000000.index # offset index
│ ├── 00000000000000000000.timeindex # time index
│ ├── 00000000000000123456.log
│ ├── 00000000000000123456.index
│ ├── 00000000000000123456.timeindex
│ └── leader-epoch-checkpoint
├── my-topic-1/ # partition 1
│ └── ...
└── ...
핵심은 세 가지예요. 디렉토리 이름은 <topic>-<partition> 형태로 붙고, 한 partition 안에 segment file(데이터를 일정 크기로 잘라 저장한 청크 파일)이 여러 개 들어 있어요. 그리고 segment 파일 이름은 그 파일에 들어있는 첫 메시지의 offset을 20자리 0-padded로 표현한 값입니다.
Segment File — .log
구조
log entry 1: [4-byte length N][N bytes message]
log entry 2: [4-byte length M][M bytes message]
log entry 3: [4-byte length K][K bytes message]
...
각 메시지는 앞에 4 byte length prefix(뒤따라올 본문의 길이)가 붙고, 그 뒤로 N bytes의 메시지 본문(key·value·timestamp·headers 등)이 이어집니다.
Offset
각 메시지의 unique ID는 64-bit integer offset이에요. partition 안에서 처음부터 0, 1, 2, … 순차로 증가하고, byte position이 아니라 logical position(몇 번째 메시지인가)입니다.
Segment Roll
log.segment.bytes=1073741824 # 1GB 도달 시 새 segment
log.segment.ms=604800000 # 7일 후 강제 roll
조건을 충족하면 현재 segment를 닫고 새 segment를 만들어요. append를 받는 건 언제나 active segment 하나뿐입니다.
Deletion
retention.ms 또는 retention.bytes를 초과하면 segment를 통째로 삭제합니다. 메시지를 하나씩 지우지 않는 이유는 그쪽이 훨씬 효율적이라서예요.
Index File — .index
역할
offset → 파일 내 byte position 매핑이에요. binary search lookup(정렬된 데이터를 절반씩 잘라 찾는 탐색)을 가속하기 위한 보조 파일입니다.
구조 (sparse index)
offset → physical position (bytes from segment start)
12000 → 0
12100 → 4096
12200 → 8192
12300 → 12288
...
모든 메시지를 인덱싱하지는 않아요. index.interval.bytes=4096(기본 4KB)마다 entry를 하나씩 만드는 sparse index(드문드문 표시한 인덱스) 방식이라 디스크와 메모리를 아낄 수 있어요.
Lookup 흐름
consumer 가 offset 12150 요청
↓
binary search in .index
↓
가장 가까운 entry 찾음 = (12100, 4096)
↓
.log 파일의 4096 byte 부터 sequential scan
↓
offset 12150 까지 점프
↓
메시지 반환
sparse index로 대략의 위치를 잡고 그 뒤로 sequential scan으로 남은 거리를 좁히는, 메모리와 속도가 균형 잡힌 설계예요.
TimeIndex File — .timeindex
역할
timestamp → offset 매핑이에요. 시간 기반 lookup에 씁니다.
timestamp → offset
1747475100000 → 12000
1747475200000 → 12500
1747475300000 → 13100
...
활용
consumer.offsetsForTimes(...) 를 호출할 때 한 번 쓰이고, retention.ms 기반 삭제에서도 message.timestamp.type 설정에 따라 참조돼요.
여기서 시험 함정이 하나 있어요 — timeindex도 sparse라는 점입니다. 특정 timestamp를 넣어도 정확히 그 시각의 offset이 나오는 게 아니라 근사값, 정확히는 offset_at_or_after(timestamp) 가 나옵니다.
Leader Epoch Checkpoint
leader-epoch-checkpoint:
0 0 # epoch 0 = offset 0 부터
3 12000 # epoch 3 = offset 12000 부터 (leadership 변경)
5 25000 # epoch 5 = offset 25000 부터
Leader가 바뀔 때마다 epoch(리더십 세대 번호)가 올라가요. unclean leader election(데이터 손실을 감수하고 뒤처진 replica를 leader로 올리는 선출) 직후에 데이터를 truncate하는 시나리오에서 이 checkpoint를 씁니다.
Writes — Sequential Append
84편 persistence에서 다룬 핵심이에요.
Producer → Broker → Active Segment 의 끝에 append (sequential I/O)
→ 동시에 .index, .timeindex 도 sparse 업데이트
모든 write가 sequential I/O(파일 끝에만 이어 붙이는 디스크 패턴)로 처리돼요. 같은 디스크에서도 random보다 6,000배 빠른 패턴입니다.
Reads — Pull 모델
Consumer.fetch(topic, partition, offset, max_bytes)
↓
broker:
1. offset 으로 segment file 찾음 (filename 으로 binary search)
2. .index 로 byte position 찾음
3. .log 의 해당 position 부터 max_bytes 만큼 read
4. consumer 에게 chunk 반환
한 chunk에 여러 메시지가 같이 담겨 돌아와요. consumer는 그 chunk 안을 iterate하면서 메시지를 하나씩 꺼냅니다.
OutOfRangeException
존재하지 않는 offset(너무 작거나 너무 큰 값)을 요청하면 OutOfRangeException(범위 밖 offset 예외)이 떨어져요. consumer 쪽에서 처리해야 하는데, 보통 auto.offset.reset으로 정책을 정합니다. earliest면 처음부터, latest면 끝부터 다시 잡고, none이면 에러를 그대로 throw합니다.
kafka-dump-log.sh — 디버깅 도구
운영에서 segment 파일 내용을 직접 들여다볼 때 쓰는 도구예요.
기본 사용
$ kafka-dump-log.sh \
--files /var/kafka-data/my-topic-0/00000000000000000000.log
Dumping /var/kafka-data/my-topic-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 49 count: 50 baseSequence: 0 ...
| offset: 0 ... key: user42 payload: ...
| offset: 1 ... key: user99 payload: ...
...
각 메시지의 메타데이터가 전부 찍혀 나와요.
Index 파일
$ kafka-dump-log.sh \
--files /var/kafka-data/my-topic-0/00000000000000000000.index
offset: 0 position: 0
offset: 100 position: 4096
offset: 200 position: 8192
자주 쓰는 자리
- 데이터 검증 — 특정 메시지가 실제로 저장됐는지 확인
- Corruption 디버깅 — 손상된 segment 분석
- 포렌식 — 어떤 메시지가 언제 어디에 있었나 추적
- 성능 분석 — index 엔트리 수와 간격 확인
Log 운영 명령
Segment 정보
$ ls -la /var/kafka-data/my-topic-0/
-rw-r--r-- 1 kafka kafka 1073741824 May 17 12:00 00000000000000000000.log
-rw-r--r-- 1 kafka kafka 262136 May 17 12:00 00000000000000000000.index
-rw-r--r-- 1 kafka kafka 393204 May 17 12:00 00000000000000000000.timeindex
각 파일 크기를 확인하는 가장 단순한 방법입니다.
Log Cleaner 모니터링 (compacted topic)
/var/kafka-data/cleaner-offset-checkpoint 가 cleaner thread(중복 key를 정리하는 백그라운드 스레드)의 진행 상황을 저장합니다.
Flush Policy — 다시 짚어보기
84편·104편에서 다룬 내용을 한 번 더 정리할게요.
log.flush.interval.messages=Long.MAX_VALUE # 무한 (기본)
log.flush.interval.ms=Long.MAX_VALUE # 무한 (기본)
기본값은 명시적 fsync(디스크에 강제로 동기화하는 시스템 콜)를 호출하지 않습니다. OS pagecache(커널이 관리하는 메모리 캐시)가 알아서 디스크로 내려주고, 데이터 보장은 replication 쪽이 더 강하게 잡아줍니다.
특정 broker만 fsync를 강제할 수도 있지만 성능이 100~1000배 떨어져서 비권장입니다.
File Format 진화 (Magic Byte)
Magic byte 0: Kafka 0.7.x (very old)
Magic byte 1: Kafka 0.8.x ~ 0.9.x
Magic byte 2: Kafka 0.10.x+ (현재)
v2에서 RecordBatch(여러 메시지를 묶어 하나의 배치로 저장하는 단위) 모델이 도입됐어요. batch 단위로 통째로 저장하면서 compression·CRC·timestamp가 한 배치 안에 다 들어갑니다.
새 클라이언트는 v2 형식을 쓰고, 옛 클라이언트와 호환하려면 broker가 변환을 끼워줘야 해서 그만큼 성능 부담이 생겨요.
한계·실무 함정
1. Segment 너무 작으면 파일 폭증
segment.bytes=100KB 처럼 작게 잡으면 segment file이 수만 개로 불어납니다. file descriptor와 메모리가 같이 부담을 받아요. 최소 100MB ~ 1GB 정도가 무난합니다.
2. Index 파일 크기
segment.index.bytes=10485760 (기본 10MB)이 한 segment가 가질 수 있는 index의 한계예요. 초과하면 그 시점에 segment roll이 일어납니다.
3. Log Corruption
디스크 오류나 갑작스러운 종료로 segment가 손상될 수 있어요. Kafka는 startup 시점에 자동으로 복구를 시도하고, 심한 경우는 수동으로 dump-log 떠서 분석해야 합니다.
4. Disk I/O 분리
log 디렉토리와 OS·application 로그를 같은 디스크에 두면 I/O 경합이 납니다. 디스크 분리는 필수예요 (104편).
5. JBOD partition 불균등
JBOD(Just a Bunch Of Disks, 여러 디스크를 단순 묶음으로 쓰는 구성) 환경에서는 partition이 한 디스크로 쏠릴 수 있어요. 모니터링과 manual rebalance를 챙기거나 Cruise Control 같은 자동화 도구로 분산을 맡깁니다.
시험 직전 한 번 더 — Kafka Log 함정 압축 노트
- 디렉토리 =
<topic>-<partition>per partition - 각 partition 안 = 여러 segment file
- Segment 파일 =
<first-offset>.log+.index+.timeindex - Segment file 구조 =
[4-byte length][N bytes message]연속 - Offset = 64-bit integer, partition 안 unique
- 처음부터 0, 1, 2, ... 순차 증가
- Segment Roll =
log.segment.bytes (1GB)또는log.segment.ms (7일) - Active segment 만 append, 오래된 segment 통째로 삭제
.index=offset → byte positionsparse indexindex.interval.bytes=4096마다 entry- Lookup = binary search + sequential scan
.timeindex=timestamp → offsetsparse,offsetsForTimes용- Sparse 라 정확한 timestamp 의 정확한 offset 보장 X (근사)
leader-epoch-checkpoint= leader 변경 시 epoch 추적- Unclean leader election 후 truncate 시 사용
- Writes = sequential append (6000배 빠른 디스크 패턴)
- Reads = offset → segment → index → log file
- 한 chunk 에 여러 메시지 포함 → consumer iterate
- OutOfRangeException =
auto.offset.reset으로 처리 kafka-dump-log.sh= segment 파일 직접 검사 (디버깅)- 데이터 검증·corruption 디버깅·포렌식·index 분석
- Flush Policy 기본 = 무한 (OS pagecache 의존)
- 명시 fsync = 성능 100~1000배 하강
- Magic byte v2 (Kafka 0.10+) = RecordBatch 모델
- 함정 — segment 너무 작음 = file 폭증
- 함정 — Index 파일 크기 한계
- 함정 — Log corruption (자동 복구·수동 분석)
- 함정 — Disk I/O 분리 (OS log 와 같은 디스크 X)
- 함정 — JBOD partition 불균등 → 모니터링
공식 문서: Kafka Log Implementation 에서 자세한 사양을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 103편 — Kafka Geo-Replication (MirrorMaker 2)
- 104편 — Kafka Hardware · OS (CPU·메모리·디스크·튜닝)
- 105편 — Kafka KRaft (Zookeeper 의 후속 · Quorum 운영)
- 106편 — Kafka Tiered Storage (S3 · 무한 Retention)
- 107편 — Kafka Log Compaction (Key 별 최신만 유지)
다음 글: