Kafka & MyKafka 정리

CRC 무결성 · 충돌 · 실제 Kafka와 MyKafka의 차이 · 동작 과정 흐름도

1. CRC란 무엇인가

CRC = Cyclic Redundancy Check (순환 중복 검사). 데이터가 전송·저장 과정에서 비트 단위로 깨졌는지 확인하는 체크섬이다.

핵심 아이디어

데이터 바이트들을 정해진 다항식으로 나눈 나머지를 4바이트로 붙여둔다.

[ 실제 데이터 ............ ][ CRC 4바이트 ]
                              ↑ 데이터로부터 계산한 나머지

쓸 때 : 데이터 → 계산 → CRC 같이 저장
읽을 때: 데이터 → 다시 계산 → 저장된 값과 비교
         같으면 ✅ 멀쩡 / 다르면 ❌ 오염(corruption)

CRC32는 결과가 32비트(4바이트)인 변종이라는 뜻이다.

"부분 쓰기"와 "오염"은 다른 위협
두 가지 보호막이 서로 다른 문제를 막는다.
위협무엇어떻게 잡힘
부분 쓰기 (truncation)전원 손실로 record가 중간까지만 기록됨decode가 null 반환 (길이 불일치)
오염 (corruption)다 써졌는데 시간이 지나 일부 비트가 변질CRC mismatch
CRC의 한계 — ① 보안용이 아니다(악의적 변조는 CRC도 다시 계산해 붙이면 통과 → 해시·서명의 영역). ② 검출만 하고 복구는 못 한다(그건 ECC/RAID). ③ 검출 확률은 매우 높지만 100%는 아니다(→ §3 충돌).

2. Kafka에서 CRC가 쓰이는 방식

① record가 아니라 batch(RecordBatch) 단위

Kafka는 여러 record를 묶은 batch를 한 단위로 저장한다. CRC는 그 batch 헤더에 딱 하나 들어간다(메시지 포맷 v2, 0.11+).

RecordBatch
├── baseOffset            (int64)   ┐
├── batchLength           (int32)   │  ← CRC 범위에서 제외
├── partitionLeaderEpoch  (int32)   │
├── magic                 (int8 = 2)│
├── crc                   (uint32)  ┘  ← CRC32C, 여기 하나
├── attributes            (int16)   ┐
├── lastOffsetDelta                 │
├── firstTimestamp                  │  ← CRC가 덮는 범위
├── ...producerId/epoch/seq         │   (attributes 부터 batch 끝까지)
└── records[]  (압축됐으면 압축 상태) ┘

② 일부러 안 덮는 필드가 있다 (가장 중요한 설계)

CRC는 attributes부터 끝까지만 덮고, 앞의 baseOffset·partitionLeaderEpoch제외한다. 이유는:

이 둘을 CRC 범위에서 빼놓았기에, broker가 offset/epoch를 수정해도 CRC를 다시 계산할 필요가 없다. producer가 만든 CRC가 그대로 살아남는다 → 이게 zero-copy를 가능하게 한다.

③ 언제, 누가 검증하나 — 3+1 지점

[Producer]            [Broker]                 [Consumer]
 batch 만들며   ──→  수신 시 검증     ──→ disk    ──→  fetch 후 검증
 CRC 계산·기록       (transit 오류)    (그대로 저장)    (전달 직전)
                          │
                     recovery / log cleaner scan 시에도 검증
  1. Producer: batch 조립하며 CRC32C 계산해 헤더에 기록
  2. Broker 수신: 전송 중 깨짐 검출 → 불일치면 CorruptRecordException → producer 재전송
  3. Broker 저장/복구: CRC 포함 그대로 디스크 append. recovery·log cleaner scan 때도 검증
  4. Consumer 수신: fetch한 batch를 application에 넘기기 직전 재계산·비교

④ 왜 CRC32C인가

3. CRC 충돌과 silent corruption

"일치"와 "충돌"은 다른 말

CRC 검증이 "일치 ✅"로 나올 때, 그 뒤에는 두 가지 진실이 숨어 있다. CRC는 이 둘을 구분 못 한다.

겉보기실제빈도
일치 ✅데이터가 진짜 멀쩡함 (정상)거의 전부 (99.999…%)
일치 ✅깨졌는데 우연히 CRC가 똑같음충돌극히 드묾 (~1/2³²)

충돌이 나면? → 깨진 데이터가 멀쩡한 척 통과

깨진 데이터 → CRC 다시 계산 → 하필 저장된 CRC와 같음
           → Kafka: "일치! 멀쩡하네 ✅"
           → consumer에게 그대로 전달  (에러 안 남)
silent data corruption — 아무 에러도 안 뜨고, 깨진 record를 정상 데이터로 착각해 전달한다. 시스템이 "나 멀쩡해"라고 거짓말하는 가장 위험한 상태.

그래도 겁먹을 필요 없는 이유 — CRC의 수학적 보장

CRC32는 단순 해시보다 흔한 오류 패턴을 100% 보장해서 잡는다.

충돌이 나려면 이 보장 범위를 벗어나는 패턴이어야 하고, 그 잔여 확률은 약 1/2³² ≈ 43억분의 1.

충돌까지 막으려면 — 다른 계층으로 보완

Kafka는 "CRC32C면 속도 대비 충분"이라는 트레이드오프를 택했다. 더 강한 보장이 필요하면 다른 층을 겹친다.

계층막는 것
ECC RAM메모리 bit flip을 하드웨어가 정정
파일시스템 체크섬 (ZFS, Btrfs)디스크 레벨 강한 검증
앱 레벨 해시/서명 (SHA-256)강한 무결성 + 악의적 변조 방어
오염의 정체 — CRC mismatch는 "저장 시점의 그 데이터가 아니다"만 알려준다. 원인(디스크 bit rot · bad sector · RAM 오류 · 버스 오류)은 알려주지 않는다. 주 무대는 디스크에 멀쩡히 저장됐다 믿은 데이터가 시간이 지나 썩는 bit rot이다. append-only라 "이미 쓴 위치를 코드가 덮어쓰는" 일은 정상 동작상 일어나지 않는다.

4. CRC vs Offset — 역할 구분

"이 record를 처음 읽는 건가 / 이미 읽었나"는 CRC가 아니라 다른 메커니즘의 일이다.

궁금한 것담당설명
데이터가 안 깨졌나CRC무결성. 몇 번째 조회인지는 안 봄
어디까지 읽었나Consumer Offset"여기까지 읽었다"를 기억 → 다음엔 거기부터
같은 메시지를 두 번 처리해도 안전한가Idempotent 처리 / exactly-once앱이 책임
partition: [m0][m1][m2][m3][m4] ...
                     ↑
           committed offset = 3
           → "다음엔 m3부터 읽으면 됨" (m0~m2는 이미 읽음)
같은 record를 100번 다시 읽어도 CRC는 매번 "안 깨졌네 ✅"만 답한다. 조회 횟수·순서·중복과는 완전히 별개다.

5. 실제 Kafka vs MyKafka

차이는 세 양동이로 나뉜다.

① 본질은 같음 — 뼈대는 진짜 Kafka와 동일

개념MyKafka도 실 Kafka처럼
append-only 로그partition = 디스크에 줄줄이 쌓는 로그
length-prefix framing4바이트 길이 접두
segment 파일명 = baseOffset%020d.log → 디렉토리가 곧 인덱스
sparse + mmap 인덱스4KB 간격 sparse, mmap
broker가 offset 부여클라이언트가 못 정함
partition = 병렬성 단위partition당 락, 늘리면 throughput 비례
꼬리 자르기 복구부분 쓰기된 trailing record truncate
batch로 syscall 분할상환1×1000 batch가 61배 빠름
"모든 게 로그"offset도 그냥 또 다른 로그(__consumer_offsets)

② 구현은 했지만 단순화 — 본질 동일, 디테일 다름

항목MyKafka실제 Kafka
CRCrecord별 CRC32batch별 CRC32C (offset/epoch 제외 → zero-copy 가능)
partitioner 해시contentHashCode (JVM 의존)murmur2 (언어 독립적)
__consumer_offsets1 partition, compaction 미구현50 partition + log-compacted
FETCH없으면 즉시 빈 응답 (busy-poll)long-poll (fetch.max.wait.ms)
segment 크기1MB (학습용)1GB 기본
ApiKey5개수십 개 (JoinGroup, Heartbeat…)

③ 아예 안 넣음 — 구조적 부재 (전부 분산·신뢰성)

영역MyKafka실 Kafka부재의 한계
Replication단일 노드Leader/Follower + ISR + acks=all노드 죽으면 데이터 손실·중단
Controller단순 메타KRaft (Zookeeper 제거)클러스터 메타 조율 불가
Group coordinationgroup = 문자열rebalance, heartbeat, sticky assignmentconsumer 자동 재분배 없음
Exactly-onceat-least-onceidempotence + transactions중복/유실 가능
Zero-copybyte copy 재직렬화sendfile() / FileRegionfetch마다 user-space 복사
Log compactionsparse만같은 key 옛 record 삭제offsets 토픽 무한 증가
Durability tuningOS flush 의존channel.force()(fsync) 정책전원 손실 시 OS 버퍼 유실
Client SDK서버만 있음Producer/Consumer 라이브러리표준 클라이언트 없음
가장 큰 차이 3개 — ① 분산 통째 부재(Replication + Controller): "distributed commit log"에서 distributed가 빠짐. ② Group coordination 부재: consumer 자동 partition 재분배(rebalance) 없음. ③ Zero-copy 미구현: 성능 천장. ②의 CRC가 offset 제외하는 설계가 곧 이 zero-copy를 가능케 하므로 CRC 차이 ↔ zero-copy 부재가 한 묶음.

6. 동작 과정 흐름도

실 Kafka 파란 단계 MyKafka 초록 단계
실 Kafka PRODUCE (발행) 과정
1
Producer가 batch 조립
같은 (topic, partition)끼리 record를 모음. partitioner: key 있으면 murmur2(key) % N, 없으면 round-robin. linger.ms / batch.size 동안 모아 압축 후 CRC32C 계산.
2
네트워크 전송 → Leader broker
해당 partition의 leader replica에게만 보냄.
3
Broker 검증 + offset 부여
CRC 검증(transit 오류) → baseOffset 할당 → append-only 로그에 그대로 기록(순차 디스크 쓰기).
4
Follower로 복제 (ISR)
ISR(In-Sync Replica) follower들이 leader에서 fetch해 복제.
5
ack 반환
acks=all이면 모든 ISR이 받은 뒤 ack → producer는 baseOffset 받음.
실 Kafka FETCH (소비) 과정
1
Consumer가 자기 offset부터 요청
"partition P, offset 100부터 maxBytes 만큼". 데이터 없으면 long-poll로 대기.
2
Broker가 HW까지 읽음
High-Watermark(복제 완료 지점)까지만 노출. offset → segment 위치는 sparse index로 이진 탐색.
3
Zero-copy 전송 (sendfile)
디스크 page cache → 소켓으로 커널이 직접 전송. user-space 복사·재직렬화 생략. CRC가 offset을 안 덮으므로 데이터 손 안 대도 됨.
4
Consumer가 CRC 검증 후 처리
batch CRC 재계산·비교 → 처리.
5
Offset commit
처리 완료 후 __consumer_offsets(50 partition, compacted)에 "여기까지 읽음" 기록. 재시작 시 거기서 이어 읽음.

MyKafka PRODUCE 과정 (코드 흐름)
1
Frame 도착 → FrameDecoder
Netty ByteToMessageDecoder가 length-prefix([4 len][1 apiKey][payload]) 파싱. 부분 도착 자동 누적 → 다 모이면 Frame(apiKey, payload).
2
RequestRouter → handleProduce
apiKey == PRODUCE 분기. payload에서 topic·partition·recordCount, record N개(keyLen/key/valLen/value) 디코드.
3
LogManager.pickPartition
partition == -1이면 결정: key 있으면 floorMod(contentHashCode, N), 없으면 round-robin.
4
Log.appendBatch (partition당 락)
lock.withLockmaybeRoll()(segment 크기 검사) → offset 부여 → 각 record RecordCodec.encode(끝에 CRC32).
5
Segment에 한 번의 write
N record를 메모리에서 합쳐 channel.write() 1 syscall. sparse index 갱신(4KB 간격). → 61배 speedup의 정체.
6
응답
[errCode:1][partition:4][baseOffset:8][count:4]. 개별 offset = baseOffset … baseOffset+count-1.
MyKafka FETCH 과정
1
handleFetch
payload에서 topic·partition·offset·maxBytes 읽기.
2
Log.read → segment 결정
segmentIndexFor(offset) 이진 탐색(파일명=baseOffset). 시작 segment 결정.
3
OffsetIndex.lookup + sequential scan
sparse index 이진 탐색으로 "근처" 위치 → 짧은 순차 scan으로 정확한 offset 도달. 첫 record는 maxBytes 초과해도 무조건 포함(진행 보장).
4
Cross-segment 자동 처리
offset이 segment 경계를 넘어가도 다음 segment로 자동 이어 읽음. consumer는 경계를 의식 안 함.
5
재직렬화 후 응답 (zero-copy 미구현)
읽은 record를 RecordCodec.encode로 다시 직렬화해 합침 → [errCode][recordCount][record bytes]…. 실 Kafka라면 sendfile로 생략될 byte copy.
MyKafka OFFSET COMMIT 과정 — "오프셋도 그냥 또 다른 로그"
1
handleCommitOffset
payload에서 group·topic·partition·offset 읽기.
2
OffsetStore.commit
key = encode(group, topic, partition), value = offset(8B).
3
내부 토픽에 append
__consumer_offsets(1 partition)에 그냥 또 다른 append. 새 저장소 없이 Log 메커니즘 재활용.
4
cache 갱신
ConcurrentHashMap에 최신값. FETCH_OFFSET은 cache lookup(디스크 접근 0).
5
재시작 시 복구
내부 토픽을 처음부터 끝까지 scan → cache 재구축. 같은 key 반복 commit → 옛 record는 garbage(compaction 도입 시 정리될 의미).
두 흐름의 공통 뼈대 — ① broker가 offset 부여, ② append-only 순차 쓰기, ③ batch로 syscall 분할상환, ④ sparse index로 위치 탐색, ⑤ 클라이언트가 offset을 들고 다님(broker는 consumer 상태를 거의 안 가짐). 가장 큰 갈림길은 FETCH 마지막 단계: 실 Kafka는 zero-copy(sendfile), MyKafka는 재직렬화(byte copy) — 그리고 그걸 가능케 하는 게 §2의 "CRC가 offset을 안 덮는" 설계다.