Kafka는 파티션 하나에 수억 건의 메시지를 append-only로 쌓아두고도, 컨슈머가 "offset 8,512,393번부터 읽어줘"라고 하면 거의 즉시 그 지점부터 스트리밍을 시작한다. 파일을 처음부터 스캔하면 O(n)일 텐데 어떻게 이게 가능한가? 나는 막연히 "offset이 곧 파일의 byte 위치겠지"라고 생각했는데, 레코드가 가변 길이라는 사실과 곧바로 모순됐다. 그래서 Kafka가 offset을 물리적 파일 위치로 옮기는 경로를 소스 레벨까지 따라가 봤다.
먼저 결론을 한 문장으로 잡고 들어가자.
Kafka 파티션은 append-only 세그먼트 파일들의 나열이고, 각 세그먼트는 일정 byte마다 한 번씩만 offset→파일 위치를 기록한 희소(sparse) 인덱스를 mmap으로 들고 있다. 그래서 offset 조회는 "인덱스 이진 탐색 + 짧은 선형 스캔"으로 끝난다.
여기서 두 가지가 핵심이다. (1) 거대한 로그를 통째로 다루지 않고 세그먼트 단위로 쪼개 탐색 범위를 먼저 좁힌다. (2) 모든 레코드를 색인하는 dense 인덱스가 아니라, 듬성듬성 색인하는 sparse 인덱스라서 인덱스 전체가 메모리에 올라간다. 이 둘이 합쳐져 O(log n) 조회가 된다.
파티션 디렉터리(<topic>-<partition>/) 안에는 세그먼트 3종 파일이 base offset 이름으로 묶여 산다.
my-topic-0/
00000000000000000000.log # 실제 레코드 (append-only)
00000000000000000000.index # offset → 물리 위치 (sparse)
00000000000000000000.timeindex # timestamp → offset (sparse)
00000000000000368122.log # 다음 세그먼트, base offset=368122
00000000000000368122.index
...
파일명의 20자리 숫자는 그 세그먼트 첫 레코드의 절대 offset, 즉 base offset이다. 쓰기는 가장 최근 세그먼트(active segment) 하나에만 일어나고, log.segment.bytes(기본 1GB)를 넘거나 log.roll.ms(기본 7일)가 지나면 롤(roll) 해서 새 세그먼트를 연다. base offset을 파일명에 박아두기 때문에, 어떤 offset이 어느 세그먼트에 있는지는 파일을 열지 않고 이름만 보고도 좁힐 수 있다. 파일명 자체가 가장 바깥쪽 인덱스인 셈이다.
.index 한 엔트리는 정확히 8바이트 고정이다.
[ relative offset : 4B (int) ][ physical position : 4B (int) ]
relative offset = 절대 offset − base offset. base offset을 파일명으로 빼두기 때문에 4바이트로 충분하다.physical position = 그 레코드가 .log 안에서 시작하는 byte 위치. 이 값이 4바이트라서 세그먼트 하나의 상한이 ~4GB가 되고, 그래서 log.segment.bytes 기본값이 1GB대인 것으로 알려져 있다.가장 중요한 점은 모든 레코드마다 엔트리를 만들지 않는다는 것이다. log.index.interval.bytes(기본 4096)만큼 .log가 쌓일 때마다 한 번씩만 엔트리를 추가한다 — 이래서 sparse다. 100만 건이 쌓여도 인덱스 엔트리는 수천 개 수준이라, 인덱스를 통째로 메모리에 올려둘 수 있다.
.index는 MappedByteBuffer로 메모리 매핑(mmap)된다. 세그먼트가 활성일 때는 log.index.size.max.bytes(기본 10MB)로 미리 할당해 두고, 롤되며 닫힐 때 실제 크기로 truncate한다. 덕분에 인덱스 탐색은 디스크 I/O가 아니라 OS 페이지 캐시 위의 메모리 접근이 된다. 8바이트 고정 엔트리라는 점도 여기서 빛을 발한다 — i번째 엔트리의 위치가 i * 8로 바로 계산되니 mmap 버퍼 위에서 이진 탐색이 가능하다.
컨슈머가 offset X를 요청하면 다음 순서로 물리 위치를 찾는다.
1) 세그먼트 선택:
세그먼트 base offset들을 정렬해 둔 자료구조(SkipList)에서
base ≤ X 인 가장 큰 세그먼트(floor)를 고른다. O(log S)
2) 세그먼트 내부:
해당 .index에서 relativeOffset ≤ (X − base) 인
가장 큰 엔트리를 이진 탐색 → 시작 physical position p. O(log E)
3) 선형 스캔:
.log 의 p 부터 레코드를 앞으로 읽으며
offset == X 인 레코드를 만날 때까지 스캔. (최대 ~4KB 분량)
sparse 인덱스가 가리키는 위치는 항상 X보다 "조금 앞"(또는 같음)이다. 이진 탐색이 relOff ≤ 목표인 하한을 고르기 때문에, 스캔은 절대 X를 지나쳐서 시작하지 않는다. 그리고 그 간극은 최대 index.interval.bytes(≈4KB)로 묶이므로 선형 스캔 비용이 상수가 된다. 전체적으로 O(log n) + 상수 스캔이다.
그림으로 보면 X=37을 찾을 때 인덱스의 relOff=33 엔트리(pos 4096)로 점프한 뒤, 4096부터 짧게 스캔해 off=37에 도달한다.
.index (sparse) .log
relOff pos pos
0 -> 0 ─────────────► 0: off=0 ...
33 -> 4096 ─────────────► 4096: off=33 ... ← X=37 요청 시
70 -> 8210 4096부터 스캔해 off=37 도달
조회 구조만큼이나 Kafka의 처리량을 떠받치는 건 디스크를 다루는 방식이다.
FileChannel.transferTo(→ 리눅스 sendfile)로 페이지 캐시에서 소켓으로 바로 보낸다. user space로의 복사가 없다(Kafka Documentation, "Persistence/Efficiency").kafka-dump-log.sh로 인덱스를 떠보면 sparse 구조가 눈에 보인다.
$ kafka-dump-log.sh --files 00000000000000000000.index
offset: 0 position: 0
offset: 33 position: 4096 # 33건마다가 아니라 ~4096B마다
offset: 70 position: 8210
...
offset이 0,1,2…로 촘촘하지 않고 약 4096바이트가 쌓일 때마다 한 칸씩 건너뛴다 — 엔트리 수가 레코드 수보다 훨씬 적다는 걸 직접 볼 수 있다. .index 파일 크기를 8로 나누면 엔트리 개수가 떨어진다는 산술도 8바이트 고정 엔트리를 확인해 준다.
핵심을 한 줄로 남기면: Kafka의 빠른 offset 조회는 "파일명(base offset) → sparse 인덱스 이진 탐색 → 최대 4KB 선형 스캔"이라는 3단 좁히기다. offset은 byte 위치가 아니라 논리적 일련번호이고, 그 둘의 매핑을 듬성듬성한 인덱스가 메모리 위에서 책임진다.
내가 잘못 알고 있던 것 두 가지를 정정하며 마친다. (1) "offset = 파일의 byte 위치"가 아니다 — 가변 길이 레코드라 성립하지 않는다. (2) "인덱스는 레코드마다 한 줄 있는 dense 구조"가 아니다 — 4KB 간격의 sparse 인덱스라서 작고, 대신 짧은 선형 스캔이 따라붙는다.
다음에 더 파고들 만한 주제는 두 가지다. 키별 최신 값만 남기며 세그먼트를 재작성하는 Log compaction, 그리고 log.retention.*에 따라 세그먼트 단위로 통째 삭제할 때 활성 세그먼트가 보호되는 규칙이다.
OffsetIndex, TimeIndex, LogSegment, LogSegments(세그먼트 SkipList)