티스토리 뷰
Saga 패턴 토이 프로젝트로 EDA를 구현하던 중, "Kafka가 메시지를 영속화한다"는 말이 무슨 의미인지 본격적으로 파보게 되었습니다. 이 글은 그 과정에서 정리한 학습 노트입니다.
이 글에서 다룰 것
- Kafka가 왜 "메시지 큐"가 아닌 "분산 로그 저장소" 라고 불리는지
- ISR(In-Sync Replicas)이 어떻게 데이터 안전성을 만들어내는지
- 실제 장애 상황에서 ISR이 어떻게 동작하는지 (인터랙티브 시뮬레이터 포함)
- 운영 시 어떤 설정이 표준이고, 왜 그런지
1. Kafka는 분산 로그 저장소이다
Kafka는 메시지 큐가 아니라 분산 로그(distributed commit log) 시스템이다.
- 메시지를 받으면 즉시 디스크의 append-only 로그 파일에 기록
- 메모리(페이지 캐시)는 성능 향상용 보조, 최종 저장소는 디스크
acks설정과 replication에 따라 데이터 안전성이 달라짐
데이터 영속화 과정
sequenceDiagram
autonumber
participant P as Producer
participant C as Page Cache (RAM)
participant D as Disk
P->>C: write() — 페이지 캐시까지만 기록
C-->>P: ack 반환 (이 시점엔 디스크 도달 X)
Note over C,D: OS가 한가할 때 백그라운드로
C->>D: 비동기 flush
Kafka는 메시지를 바로 영속화 하는게 아니라 페이지 캐시라는 메모리 저장소에 데이터를 저장한 후 비동기로 영속화(flush1)한다.
| 시스템 콜 | 도달 지점 | 비용 | Kafka 사용 |
|---|---|---|---|
write() |
페이지 캐시 (RAM) | 빠름 (μs) | ✓ 매 메시지마다 |
fsync() |
디스크 강제 동기화 | 느림 (ms) | ✗ 거의 안 함 |
성능을 위해 매 메시지마다 fsync 안 함. OS가 백그라운드로 디스크에 저장.
메시지 영속화로 인해 유실 방지가 완전히 보장되는 것은 아니다.
Kafka에 전달된 메시지는 바로 영속화를 하는건 아니기 때문에 완전 메시지 유실 방지가 되는 것은 아니다. Kafka가 아닌 서버(OS) 자체가 죽으면 페이지 캐시가 날아가 메시지 유실이 발생할 수 있다. 이를 replica로 방지하는 구조이다. 모든 서버가 죽을 가능성이 낮기 때문
| 장애 유형 | 페이지 캐시 | 디스크 | 결과 |
|---|---|---|---|
| Kafka 프로세스만 재시작 | 유지 (OS가 보유) | 유지 | 유실 없음 ✓ |
| 컨테이너 재시작 (볼륨 마운트) | 유지 (호스트 OS) | 유지 | 유실 없음 ✓ |
| OS 자체 크래시 (kernel panic) | 휘발 ✗ | 유지 | 미플러시분 유실 ⚠ |
| 디스크 손상 | 휘발 | 손상 ✗ | 유실 ✗ |
→ 단일 머신의 fsync (물리적 보장) 비용을 회피하고, 분산 시스템의 복제 (통계적 보장) 로 안전 모델을 옮긴 것이 Kafka 설계의 핵심.
2. RabbitMQ vs Kafka
핵심 차이
| RabbitMQ | Kafka | |
|---|---|---|
| 패러다임 | 메시지 큐 (전달 후 잊음) | 분산 로그 저장소 |
| 디스크 기록 | 옵션 설정 시 가능 | 항상 (디자인상 강제) |
| 컨슈머가 ack | 메시지 삭제 | offset만 전진, 메시지 유지 |
| 같은 메시지 재처리 | 불가 | 가능 (offset 되감기) |
| 다중 소비 | fanout exchange + 큐 N개 필요 | 토픽 1개 + 컨슈머 그룹 N개 |
| 저장 구조 | 메시지별 random write | append-only sequential write |
큐 vs 토픽 모델
RabbitMQ — 큐 모델
큐 1개 + 컨슈머 N명 = 부하 분산 (각 메시지가 한 명에게만)
큐: [msg1, msg2, msg3, msg4]
├─→ Consumer A: msg1, msg3
└─→ Consumer B: msg2, msg4
같은 메시지를 여러 곳에서 받으려면 fanout exchange로 큐를 여러 개 만들어 사본 복제.
Kafka — 로그 모델
토픽 1개 + 컨슈머 그룹 N개 = 각 그룹이 독립 소비 (사본 X)
Topic: [msg1, msg2, msg3, msg4]
├─→ Consumer Group A: offset 1, 2, 3, 4...
├─→ Consumer Group B: offset 1, 2, 3, 4...
└─→ Consumer Group C: offset 1, 2, 3, 4...
토픽에 메시지는 1개만, 각 그룹이 자기 offset 따로 관리.
비유
- RabbitMQ = 우편함: 편지 도착 → 받는 사람이 가져감 → 우편함 비움
- Kafka = 신문 보관소: 발행되면 보관소에 쌓임 → 누구든 와서 읽을 수 있음 → 7일 후 폐기
왜 Kafka가 즉시 삭제 안 하는가
- 여러 컨슈머 그룹이 독립 소비 가능
- 재처리(replay) — 버그 수정, 새 컨슈머 추가, 장애 복구
- append-only sequential write의 성능 이점 유지
- 브로커가 컨슈머 상태를 모름 → 확장성
- 철학: 메시지 = 사실(fact), 사실은 영원하다 (회계 장부 비유)
메시지 유지(Retention) 정책
log.retention.hours(기본 168 = 7일): 시간 경과 시 삭제log.retention.bytes(기본 -1 = 무제한): 크기 초과 시 오래된 것부터 삭제cleanup.policy=compact: Log Compaction — 같은 key의 최신 메시지만 유지
3. Kafka 클러스터 구조
| 용어 | 의미 |
|---|---|
| Kafka | 소프트웨어/시스템 이름 |
| Kafka 클러스터 | 브로커 여러 대의 묶음 |
| 브로커 (Broker) | 클러스터 안의 한 노드(서버) |
| Topic | 논리적 메시지 분류 (cluster-wide) |
| Partition | 토픽을 물리적으로 나눈 단위 |
| Replica | 한 partition의 사본 (한 브로커가 1개 보관) |

- 같은 토픽이어도 partition마다 leader가 다른 브로커에 존재
- leader는 한 브로커, follower는 다른 브로커들에 위치 (같은 브로커에 다 있으면 의미 없음)
- 한 브로커는 여러 토픽의 partition replica들을 보관
4. ISR (In-Sync Replicas)
정의
현재 leader와 충분히 동기화된 replica들의 동적 집합
"충분히"의 정확한 의미: replica.lag.time.max.ms (기본 30초) 안에 leader의 최신 offset까지 fetch를 완료한 적이 있는가
- Leader 자기 자신은 항상 ISR에 포함
- ISR은 시간에 따라 변하는 집합
- Follower가 30초 안에 한 번이라도 따라잡으면 ISR 유지
시간 기반인 이유
옛날(0.8 이하)은 메시지 개수 기반(replica.lag.max.messages)이었지만:
- 트래픽 급증 시 정상 follower들도 일제히 ISR에서 빠지는 현상 발생
- 0.9부터 시간 기반으로 변경 → "트래픽 얼마든 30초 안에 따라잡으면 OK"
Follower가 메시지를 복제하는 방법
Kafka는 pull 모델이다 — leader가 push해주는 게 아니라, follower가 직접 leader에게 가서 메시지를 가져온다. 이 가져오기 요청을 fetch라고 부른다.
sequenceDiagram
autonumber
participant F as Follower B
participant L as Leader
F->>L: fetch 요청<br/>"나 offset 700까지 받았어. 다음 줘"
Note over L: "B는 700까지 받았구나"<br/>메모리에 기록
L-->>F: offset 701~ 응답
Fetch가 곧 Heartbeat
leader는 follower가 30초 안에 따라잡았는지(replica.lag.time.max.ms) 어떻게 알까?
일반 분산 시스템은 heartbeat(각 노드가 "나 살아있어!"라고 주기적으로 보내는 신호)를 별도 채널로 운영한다.
🫀 정상: 두근... 두근... 두근... (heartbeat 계속 옴)
💀 죽음: 두근... ... (heartbeat 끊김 → 죽었다고 판단)
대표적으로 ZooKeeper, Kubernetes 등이 별도 heartbeat 채널을 운영한다. 그러나 Kafka는 별도 heartbeat 채널 없이, 위의 fetch 요청 자체를 heartbeat로 활용한다.
fetch 요청이 동시에 두 역할을 한다:
- ① 진행 보고 — "나 700까지 받았어" → lag 측정
- ② 생존 신호 (heartbeat) — "fetch 보냈다 = 살아있다"
Leader가 능동적으로 follower를 체크하지 않음. 수동적으로 follower의 fetch를 받으며 메모리에 기록.
5. ISR 추적은 어떻게?
분산 추적 + 중앙 메타데이터
1단계 — 추적 (분산): 각 partition의 leader 브로커가 자기 follower들의 lastCaughtUpTime을 메모리에 보관
Broker 1 (Leader of partition 0):
→ "partition 0의 follower B, C가 따라잡았는지" 추적
Broker 2 (Leader of partition 1):
→ "partition 1의 follower A, C가 따라잡았는지" 추적
→ partition별로 분산되어 한 곳에 부하 안 몰림
2단계 — 메타데이터 (중앙): ISR 변경 시 leader가 Controller에 보고
Leader: "Follower C가 30초 동안 못 따라잡음. ISR에서 제외"
↓ AlterPartition RPC
Controller: 메타데이터 로그에 기록 → 모든 브로커에 브로드캐스트
Controller 위치
| 모드 | 메타데이터 저장 위치 |
|---|---|
| ZooKeeper (deprecated) | 외부 ZooKeeper 클러스터 |
| KRaft (현재 표준, 3.3+ GA) | Controller Quorum (3~5 브로커) + __cluster_metadata 토픽 |
KRaft 모드에선 메타데이터도 결국 Kafka의 로그 메커니즘을 그대로 사용 → ZooKeeper 불필요.
6. ISR이 결정하는 두 가지
1) 쓰기 성공 조건
acks=all + min.insync.replicas 조합:
producer가 acks=all로 메시지 발행
↓
leader가 메시지를 디스크에 씀
↓
ISR 안의 모든 follower가 받을 때까지 대기
↓
모두 받으면 → ack 반환
min.insync.replicas=2일 때:
| ISR | 결과 |
|---|---|
| {L, F1, F2} | 쓰기 OK ✓ |
| {L, F1} | 쓰기 OK ✓ |
| {L} | NotEnoughReplicasException ❌ (쓰기 거부) |
2) 리더 선출
리더 죽으면 Controller가 ISR 중에서만 새 리더 선출. ISR 멤버는 정의상 동기화돼 있어 데이터 손실 없음.
ISR이 비어버리면({}):
unclean.leader.election.enable=false(기본, 권장): 새 리더 못 뽑음 → 파티션 사용 불가, 안전성 우선unclean.leader.election.enable=true: 비-ISR replica가 리더 → 데이터 손실 가능
7. ISR 장애 시나리오
replication.factor=3, acks=all, min.insync.replicas=2 환경에서 시간차로 여러 장애가 발생할 때 ISR이 어떻게 변하는지 시뮬레이션:
단계별 시뮬레이션
위 시뮬레이터의 **"다음 →"** 버튼으로 5단계 시나리오를 따라가보세요. 각 단계마다 브로커 상태, ISR 멤버십, Producer 쓰기 가능 여부가 동시에 변합니다.
핵심 관찰
- ISR은 동적 집합 — 시간에 따라 멤버가 들고 나감 (GC pause/회복으로도 변동)
- min.insync.replicas가 안전선 — ISR 크기가 이 값에 미달하면 즉시 쓰기 거부
- 리더 선출은 ISR 안에서만 — T=90s에 B1 다운 시 B3가 새 리더 (B2는 죽어있어서 선택 불가)
- "브로커 1대 죽어도 무중단, 2대 죽으면 안전 모드" 정책이 시간축에 그대로 드러남
8. 운영 표준 설정
# Producer
acks: all
enable.idempotence: true # 중복 발행 방지
retries: 2147483647 # Integer.MAX_VALUE
# Topic
replication.factor: 3
min.insync.replicas: 2
unclean.leader.election.enable: false
이 조합이 의미하는 바:
- 정상: ISR=3, 1개 빠져도 ISR=2로 정상 동작
- 위험 신호: ISR=1로 떨어지면 즉시 쓰기 거부
- "브로커 1대까지는 죽어도 무중단, 2대 죽으면 안전 모드 전환"
마치며
처음엔 단순히 "Kafka는 메시지를 영속화한다" 정도로 알고 썼지만, 파고 들어보니 그 한 줄 뒤에 꽤 정교한 설계가 있었습니다. 이 글의 핵심을 한 문장으로 압축한다면
"Kafka는 분산 로그 저장소이고, 디스크 fsync(물리적 보장) 비용을 회피하는 대신 ISR 기반 복제(통계적 보장)로 안전성을 만들어내는 시스템"
이 한 줄이 자연스럽게 읽히면, 위의 운영 표준 설정이 왜 그렇게 생겼는지, 장애 상황에서 어떻게 동작하는지 보입니다.
운영 표준 설정 한 줄 (acks=all, min.insync.replicas=2, replication.factor=3) 을 그냥 외우는 것과, 그 설정이 어떤 시나리오에서 어떤 안전성을 만들어내는지 이해하고 쓰는 것은 큰 차이가 있다고 있습니다.
- 메모리(페이지 캐시)에 모아둔 데이터를 한꺼번에 디스크로 쏟아 내보내는 행위 [본문으로]
