분산 메시지 시스템에서 데이터 유실 방지 + 고가용성을 동시에 달성하려면 복제가 필수.
“몇 개에 복제됐을 때 ack 할지”, “리더 죽으면 누가 새 리더?” 라는 두 결정을 Kafka 는 ISR (In-Sync Replicas) 이라는 동적 집합 으로 추상화.
정적 RF (replication factor) 와 별도로, 런타임에 실제 sync 된 멤버만 ISR 로 추적
ISR은 Kafka의 핵심 복제 메커니즘. 즉, 가용성과 일관성 사이의 trade off를 따지는 핵심.
선행 개념: 이 글은 LEO·HW·LSO 같은 파티션 로그의 오프셋 개념을 전제로 한다. 각각이 무엇이고 무엇이 그것을 전진시키는지는 카프카 파티션 로그의 오프셋들 — LEO, HW, LSO에서 정리했다.
ISR 정의
ISR (In-Sync Replicas): 리더와 충분히 sync 된 follower replica 집합 (+ 리더 자신)
- 정적 RF 와 다름. RF=3 이라도 ISR=1 일 수 있음 (장애 상황)
이름 그대로 Replica 와 In-Sync 두 단어가 각각 다른 걸 정한다. Replica 는 “복제본이 몇 개, 어디에 있나” (정적, 토픽 설정), In-Sync 는 “그중 지금 믿을 수 있는 게 어느 것인가” (동적, 런타임 판정).
Replica
복제 단위는 브로커가 아니라 partition. topic 의 partition 마다 RF (replication factor) 개의 복제본이 서로 다른 브로커에 배치된다.
- partition 당 replica 하나가 리더, 나머지가 follower. 리더는 partition 단위로 정해지므로 브로커 A 가
P0의 리더이면서 동시에P1의 follower 일 수 있다 — 그래서 부하가 브로커 전체에 흩어진다. - 읽기·쓰기는 리더만 처리한다. follower 는 클라이언트 트래픽을 받지 않고 fetch 로 리더의 로그를 그대로 복제할 뿐이다. (예외: Kafka 2.4+ 의 fetch-from-follower(KIP-392) —
client.rack을 맞춰 가까운 follower 에서 읽기만 받을 수 있다. 쓰기는 여전히 리더) - follower 의 로그는 리더 로그의 순서까지 동일한 복사본이다. 그래서 리더가 죽어도 follower 가 그 자리를 이어받을 수 있다.
- RF 는 정적이다. 토픽 생성 시
--replication-factor로 정하고, 이후 바꾸려면 partition reassignment 가 필요하다. “복제본을 몇 개 둘 것인가” 까지만 정하고, 그것들이 실제로 따라오고 있는지는 관여하지 않는다.
In-Sync
“sync 됐다” = 리더와 지금 완전히 똑같다가 아니라, 정해진 시간 안에 리더 LEO 를 따라잡은 적이 있다는 뜻이다.
순간 비교로 판정하면 in-sync 인 follower 는 사실상 존재할 수 없다. 리더에는 producer 쓰기가 계속 들어오고 follower 는 그걸 fetch 로 가져오므로, 어느 시점을 찍어도 follower 는 리더보다 뒤에 있다. 그래서 기준은 “현재 일치” 가 아니라 “최근에 따라잡았는가” 다.
- 기준값은
replica.lag.time.max.ms(default 30s). 이 시간 안에 리더 LEO 까지 따라온 적이 있으면 in-sync 로 본다. → 판정 로직 상세는 §sync 의 기준 - 리더 자신은 항상 ISR 에 포함된다. 그래서 ISR 의 최솟값은 1 이다.
- 예전에는 메시지 개수 기준(
replica.lag.max.messages)도 함께 썼지만 0.9.0.0 에서 제거됐다(KIP-16 / KAFKA-1546). 트래픽이 순간적으로 튀면 정상 동작 중인 follower 까지 한꺼번에 ISR 에서 빠지는 문제 때문이다. 지금은 시간 기준 하나만 남았다.
in-sync 판정이 실제로 결정하는 건 두 가지다.
| in-sync 여부가 정하는 것 | 내용 |
|---|---|
acks=all 의 대상 | ack 을 기다릴 replica 집합 = 현재 ISR. → §acks 와 min.insync.replicas |
| 리더 후보 자격 | 리더가 죽으면 원칙적으로 ISR 안에서만 새 리더를 뽑는다. → 2편 §Unclean Leader Election |
Fetch 와 Pull 모델
복제가 실제로 어떻게 일어나는지 — 이후의 sync 판정·HW·acks 가 전부 이 fetch 사이클 위에 얹힌다.
Fetch 란
consumer / follower 가 리더 파티션에게 “내 현재 offset 이후의 레코드를 (최대 N바이트까지) 달라” 고 보내는 read(pull) 요청.
- 단일 메시지가 아니라 메시지 시퀀스(batch) 단위.
- consumer 의 메시지 읽기와 follower 의 복제가 동일한 Fetch RPC (
replica_id로만 구분 — consumer=-1, follower=broker ID).
공식 (Design): “data is pushed to the broker from the producer and pulled from the broker by the consumer.” / “A pull-based system has the nicer property that the consumer simply falls behind and catches up when it can.”
공식 (Design): “Both our API to send messages and our API to fetch messages always work with a sequence of messages not a single message to encourage [efficiency].”
Pull 모델 (consumer 와 동일 패턴)
- Follower 가 리더에게
FetchRequest보냄 (push 가 아님) - 별도 replication 프로토콜 없이 consumer fetch 재사용
- Pull → backpressure 자동 처리: broker 가 전송 속도를 강제하지 못함 → 느린 consumer/follower 가 자기 속도로 따라옴
이 글에서 계속 나오는 LEO (Log End Offset) = 각 replica 의 “다음에 받을 offset” (= 마지막으로 받은 offset + 1). replica 마다 값이 다름 — 리더 LEO 는 producer 쓰기로, follower LEO 는 fetch 응답 append 로 전진. (상세: 오프셋 글 §LEO)
Fetch 요청 주기 (long polling)
Follower 의 fetch 는 고정 주기가 아니라 long polling.
long polling = 줄 데이터가 없을 때 빈 응답을 즉시 돌려주는 대신, 생길 때까지 응답을 붙잡고 있다가 답하는 것.
일반 폴링은 간격 튜닝이 양쪽 다 손해 — 길게 잡으면 데이터 도착 후 다음 폴링까지 지연, 짧게 잡으면 빈 응답 왕복만 늘어남. long polling 은 idle 일 땐 왕복이 생기지 않고, 데이터가 도착하는 즉시 응답이라 이 트레이드오프가 사라짐.
- Follower → Leader: FetchRequest
- Leader:
replica.fetch.min.bytes만큼 데이터 쌓일 때까지 보류- 또는
replica.fetch.wait.max.ms지나면 즉시 response (빈 응답이라도)
- Follower → response 받자마자 즉시 다음 FetchRequest 전송
| Broker config | Default | 의미 |
|---|---|---|
replica.fetch.wait.max.ms | 500ms | follower fetch 의 broker 쪽 최대 대기 |
replica.fetch.min.bytes | 1 | 응답까지 모을 최소 바이트 |
replica.fetch.max.bytes | ~1MB | 한 fetch 최대 크기 |
효과
- 데이터 활발: 거의 연속 streaming (request → 즉시 response → 즉시 다음 request)
- idle: 약 500ms 마다 빈 응답 + 다음 request (= 사실상 그게 “주기”)
- → CPU/네트워크 효율적, latency 낮음
min.bytes 를 올리면 배치를 키워 처리량을 얻고 지연을 내주는 쪽. max.wait.ms 는 그때 지연의 상한. 두 설정이 처리량↔지연 다이얼 한 쌍.
보류 중엔 스레드가 blocking 되지 않는다
요청마다 스레드를 대기시키면 파티션 수천 개 규모에서 스레드가 먼저 고갈. 대신 대기 중인 fetch 를 purgatory (DelayedOperationPurgatory) 에 DelayedFetch 로 등록하고, 네트워크 스레드는 즉시 다음 요청 처리로 넘어감.
- 새 레코드 append → 그 파티션을 기다리던
DelayedFetch를 깨워 응답 - 아무 일 없으면 → 타이밍 휠 타이머가
max.wait.ms에 만료시켜 응답
즉 “붙잡고 있다” = 스레드 blocking 이 아니라 콜백 등록 상태. acks=all produce 가 HW 전진을 기다리는 것도 같은 purgatory 메커니즘 (→ 2편 §리더가 FetchRequest 를 받으면 4단계).
LEO Piggyback
piggyback = 별도 메시지를 따로 안 보내고, 어차피 보내는 메시지에 정보를 얹어 보내는 것 (TCP 가 ACK 를 데이터 세그먼트에 얹어 보내듯). 여기선 follower 의 LEO 를 별도 진행보고 없이 FetchRequest 의
fetch_offset에 얹어 전달.
왜 별도 ACK 를 두지 않나 — 리더는 HW = min(ISR 전원 LEO) 를 계산해야 하니 각 follower 의 LEO 를 알아야 함. 소박하게 설계하면 진행보고 메시지가 따로 필요:
1
2
3
4
5
[ 별도 ACK 방식 — Kafka 는 이렇게 안 함 ]
Follower → Leader : FetchRequest(100) "100 부터 주세요"
Leader → Follower: records[100..200]
Follower → Leader : ReplicaAck(200) "200 까지 저장했음" ← 추가 메시지
Follower → Leader : FetchRequest(200) "200 부터 주세요" ← 같은 사실 반복
ReplicaAck(200) 과 FetchRequest(200) 이 완전히 같은 정보. Kafka 는 뒤엣것만 남김.
1
2
3
4
5
6
7
8
9
[ LEO piggyback — 실제 동작 ]
Follower → Leader : FetchRequest(fetch_offset = 100)
Leader : ├─ "이 follower LEO = 100" 기록 ← 진행보고로 해석
├─ HW = min(ISR 전원 LEO) 재계산
├─ lastCaughtUpTime 갱신 (ISR 판정용)
└─ acks=all produce ack 조건 확인
Leader → Follower: records[100..200] + high_watermark
Follower : append → 자기 LEO = 200
Follower → Leader : FetchRequest(fetch_offset = 200) ← 이게 곧 "200 까지 받았음"
fetch_offset = N 은 문자 그대로는 “N 부터 달라” 지만, 리더 입장에선 “N-1 까지는 이미 다 받았다”. 요청 하나가 직전 데이터 ACK + 다음 데이터 요청 을 겸함.
- 별도 ACK 메시지 없음 — fetch 사이클이 곧 ACK
- 한 round-trip 에 ACK + 다음 데이터 요청 동시 처리
- 복제 전용 프로토콜 불필요 (consumer fetch 그대로 재사용)
이 설계가 만드는 결과
| 결과 | 내용 |
|---|---|
| HW 가 구조적으로 한 라운드 늦음 | 리더는 follower 수신 여부를 직접 확인 못 하고 다음 요청의 fetch_offset 으로 추론. 마지막 배치 확인은 항상 다음 요청에 실려 옴 → acks=all ack 도 “follower 가 받은 순간” 이 아니라 “follower 가 다음 fetch 를 보낸 순간” (→ 2편 §HW 전파의 1 라운드 지연) |
| idle 이어도 LEO 보고가 안 끊김 | 쓸 데이터가 없어도 long polling 이라 replica.fetch.wait.max.ms(500ms) 마다 빈 응답 후 다음 요청이 올라옴 → lastCaughtUpTime 계속 갱신 → ISR 유지. long polling 과 piggyback 이 맞물리는 지점 |
| consumer 요청은 LEO 로 안 씀 | 같은 fetch_offset 필드지만 replica_id=-1(consumer) 이면 LEO 추적·HW 재계산 안 함. consumer 가 어디까지 읽었는지는 복제 내구성과 무관 |
⚠️ 위 예시의
fetch_offset=100은 정상 사이클 예시지 최초 요청이 아님. 최초 요청의 fetch_offset 은 truncation 후 LEO (신규 replica 면 0) → 부록 B §최초 fetch 시점.
sync 의 기준 (replica.lag.time.max.ms)
어떤 follower 를 ISR 에 남길지 판정하는 유일한 기준. 위 fetch 사이클이 제때 돌고 있는지를 시간으로 본다.
| 항목 | 값 | |
|---|---|---|
| Description | Follower가 이 시간 내에 리더의 LEO 까지 fetch 요청을 보내지 못하면 ISR 에서 제거. 또는 fetch 요청은 보내지만 리더 LEO 까지 못 따라잡으면 제거. | |
| Default | 30000 (30s) | |
| Type | long · Importance | high |
정확한 제거 조건 (한 번 못 따라잡았다고 즉시 제거 X)
- 리더는 각 follower 마다 “마지막으로 리더 LEO 까지 도달한 시점” (
lastCaughtUpTime) 추적 - 그 시점 이후
replica.lag.time.max.ms(30s) 동안 한 번도 리더 LEO 에 도달하지 못하면 ISR 제거 - 따라잡으면 (= 그 시점의 리더 LEO 까지 fetch 완료) 카운터 리셋
- 30s 의 grace period 가 핵심 — ISR flapping 을 줄이는 메커니즘
이전 (Kafka 0.9 이전): replica.lag.max.messages (메시지 수 기준) 도 함께 썼지만 0.9.0.0 에서 제거. 현재는 시간 기준 단일.
축소·확장 트리거
| 조건 | |
|---|---|
| 축소 | follower 가 replica.lag.time.max.ms 내에 리더 LEO 까지 못 따라잡음 (네트워크 장애, GC, 디스크 IO 병목 등) |
| 확장 | 제거된 follower 가 리더의 현재 LEO 까지 다시 따라잡으면 ISR 재진입 |
- 컨슈머와 무관 — ISR 은 broker 간 replication 영역. consumer rebalancing 은 별개.
ISR flapping
follower 가 ISR 을 짧게 들락이며 반복 이탈·재진입하는 상태. 모니터링 노이즈 + Producer ack 지연이 잦아짐.
- 원인: 주로 follower 의 디스크 IO 병목 / GC pause / 네트워크 jitter
- 감지:
IsrShrinksPerSec/IsrExpandsPerSec메트릭 - 대응: follower 브로커 리소스 진단이 먼저. 설정으로는
replica.lag.time.max.ms를 조금 늘림
replica.lag.time.max.ms 튜닝
- 너무 짧게 (예: 3s): 잠깐 lag 만 생겨도 flapping
- 너무 길게 (예: 5분): 사실상 죽은 follower 도 ISR 에 남아 →
min.insync.replicas보장이 명목상으로만 - 일반적으로 default 유지. 네트워크 불안정 환경에서만 60~120s 로 조정
acks 와 min.insync.replicas
ISR 이 producer 의 쓰기 보장으로 드러나는 지점. 여기가 ISR 을 신경 써야 하는 이유다.
| acks | 동작 |
|---|---|
| 0 | ack 안 기다림 |
| 1 | 리더에만 저장되면 ack |
| all | 현재 ISR 전체 에 복제되면 ack (min.insync.replicas 검증 필수) |
acks=all 의 “all” = 전체 replica 가 아니라 현재 ISR 전체.
ISR 은 앞 절처럼 런타임에 줄었다 늘었다 하므로, 같은 acks=all 이라도 시점에 따라 보장 정도가 다르다.
ISR=1 (리더만) 이면 사실상 acks=1 과 동일해짐.
이 “보장이 조용히 약해지는” 구멍을 막는 게 min.insync.replicas.
min.insync.replicas
- Producer 가
acks=all로 보낼 때 최소 ISR 수 검증. 미달이면 쓰기 실패. - Default:
1 - 미달 시 에러:
NotEnoughReplicasException(append 전 검증) /NotEnoughReplicasAfterAppendException(append 후 검증, 더 자주 보임) acks=all일 때만 적용.acks=0/acks=1에서는 무관
가용성과 일관성 트레이드오프
min.insync.replicas 값을 어디에 두느냐가 곧 “장애 때 쓰기를 막을 것인가, 보장을 낮춰서라도 받을 것인가”의 선택이다. RF=3, acks=all 기준으로 양 극단은 이렇다.
| 설정 | 정상 (ISR=3) | 브로커 2대 다운 (ISR=1) | 성격 |
|---|---|---|---|
min.insync.replicas=2 | 3중 복제 후 ack | NotEnoughReplicasException → 쓰기 실패 | 일관성 우선 |
min.insync.replicas=1 | 3중 복제 후 ack | 리더만 저장하고 ack (= acks=1) | 가용성 우선 |
min.insync.replicas=2 에서 쓰기가 막히는 건 버그가 아니라 의도된 동작이다. 유실 위험을 감수하고 쓰기를 받느니 실패시키고 producer 에게 판단을 넘긴다.
min.insync.replicas=1 은 ISR 크기에 따라 보장 강도가 자동으로 조정되는 형태다. 정상일 땐 3중 복제, ISR 이 줄면 그만큼 약한 보장. 쓰기가 멈추지 않는 대신 보장이 약해진 사실이 producer 에게 드러나지 않는다 — 유실이 치명적인 시스템에는 맞지 않는다.
이어서 (2편)
여기까지가 ISR 의 구성과 producer 쪽 계약이다.
커밋 경계인 HW 가 어떻게 전진하는지, 그리고 리더가 바뀌는 순간 그 경계가 어떻게 무너지는지(Leader Epoch · unclean election · ELR)는 ISR (In-Sync Replicas) 2편 — HW, Leader Epoch, 리더 교체에서 이어진다.
아래 부록 두 개는 1·2편 공통 레퍼런스다.
부록 A — FetchRequest / FetchResponse 필드
본문 흐름에는
replica_id·fetch_offset·high_watermark셋이면 충분하다. 나머지 필드는 레퍼런스용.
Kafka wire protocol 의 핵심 RPC. consumer / follower 모두 같은 RPC 사용 (replica_id 로 구분). 한 사이클이 어떻게 도는지는 §LEO Piggyback 참조.
FetchRequest (요청)
| 필드 | 의미 |
|---|---|
replica_id | 누가 보냈는지 → 리더가 이걸로 동작 분기. consumer=-1 (HW 까지만 응답) / follower=자기 broker ID (broker.id·KRaft 면 node.id, 클러스터 내 고유 정수 / LEO 갱신·ISR 추적·LEO 까지 응답) |
max_wait_ms | long polling 최대 대기 |
min_bytes | 응답까지 모을 최소 바이트 |
max_bytes | 전체 응답 최대 바이트 |
session_id, session_epoch | Incremental Fetch Session (KIP-227) — 매번 전체 topic/partition 리스트 보내지 않고 변경분만 |
isolation_level | consumer 용 — read_committed / read_uncommitted |
topics[].partitions[].fetch_offset | 핵심. consumer·follower 모두 보내지만 의미가 다름. consumer = 내 읽기 시작 위치(단순 읽기). follower = 자기 LEO. 리더가 이걸 ISR 진행도 추적용 LEO 로 쓰는 건 follower(replica_id≥0) 요청일 때만 — consumer(-1) 의 fetch_offset 으로는 LEO piggyback·HW 재계산 안 함 |
topics[].partitions[].current_leader_epoch | 자기가 알고 있는 leader epoch (KIP-320 — stale leader fencing) |
topics[].partitions[].last_fetched_epoch | 마지막 fetched epoch (KIP-595 — truncation 협상) |
topics[].partitions[].partition_max_bytes | 한 partition 의 최대 응답 크기 |
FetchResponse (응답)
| 필드 | 의미 |
|---|---|
throttle_time_ms | quota 위반 시 throttle 시간 |
session_id | 같은 session 유지 |
responses[].partitions[].error_code | NOT_LEADER, OFFSET_OUT_OF_RANGE 등 |
responses[].partitions[].high_watermark | 리더가 알려주는 현재 HW (follower/consumer 가 자기 HW 갱신) |
responses[].partitions[].last_stable_offset | transactional read 시 committed 경계 |
responses[].partitions[].log_start_offset | 가장 오래된 보존 중인 offset |
responses[].partitions[].aborted_transactions[] | transactional, isolation_level=read_committed 시 |
responses[].partitions[].preferred_read_replica | KIP-392 (rack-aware read) |
responses[].partitions[].records | 실제 메시지 RecordBatch |
부록 B — 최초 fetch 시점
본문의 fetch 사이클은 이미 follower 로 동작 중인 상태의 정상(steady-state) 사이클. 그럼 맨 처음 fetch 는 언제 나가나.
백그라운드에서 계속 돌고 있는 게 아니라, 브로커가 그 파티션의 follower 로 지정되는 순간 시작. 트리거는 컨트롤러의 LeaderAndIsr 요청.
| 상황 | 설명 |
|---|---|
| 브로커 기동 | 재시작한 브로커가 자기가 호스팅하던 replica 복구 |
| 토픽/파티션 생성 | 새 파티션의 follower 로 이 브로커가 배정 |
| 파티션 재배치 (reassignment) | replica 목록에 이 브로커가 새로 추가 |
| 리더십 변경 | 리더였던 브로커가 follower 로 강등 / follower 의 리더가 바뀜 |
1
2
3
4
5
6
7
1. Controller → Broker: LeaderAndIsr ("이 파티션 follower, 리더는 B0")
2. ReplicaManager.becomeLeaderOrFollower → makeFollowers()
3. 로그 truncation ← fetch 전에 리더 기준으로 자기 로그를 잘라 정합성 맞춤
4. ReplicaFetcherThread 에 파티션 등록 → 첫 FetchRequest 전송
fetch_offset = (truncation 후) 자기 LEO
- 신규 replica: LEO=0 → fetch_offset=0
- 재기동 브로커: truncation 후 LEO
왜 truncation 이 첫 fetch 전에 필요한가
이 follower 가 과거에 리더였을 수 있음. 그때 자기만 갖고 있던 offset 100 메시지가, 새 리더에는 없거나 다른 메시지일 수 있음 (unclean 상황). 그대로 fetch 하면 두 로그가 영구히 어긋남(divergence). 그래서 fetch 시작 전에 자기 로그를 새 리더 기준으로 잘라 맞춤.
- 과거: HW 기준 truncation → split-brain 시 유실/divergence 버그
- 현재: Leader Epoch 기준 (KIP-101). follower 가
OffsetsForLeaderEpoch로 “epoch N 에서 너 LEO 어디였어?” 물어 정확한 분기점을 찾아 거기까지만 truncate → 2편 §HW 의 한계와 Leader Epoch
참고 자료
- Apache Kafka — Design: Replication (kafka.apache.org/42/design) — ISR 정의, committed 메시지의 조건
- Apache Kafka — Design: Push vs. pull (kafka.apache.org/42/design) — 컨슈머·팔로워가 pull 하는 이유, backpressure
- Apache Kafka — Design: Efficiency (kafka.apache.org/42/design) — fetch 가 단일 메시지가 아니라 배치 단위인 이유
- Apache Kafka — Broker Configs (kafka.apache.org/42/configuration) —
replica.lag.time.max.ms,replica.fetch.wait.max.ms,replica.fetch.min.bytes,replica.fetch.max.bytes기본값 - Apache Kafka — Topic Configs (kafka.apache.org/42/configuration) —
min.insync.replicas - Apache Kafka — Producer Configs (kafka.apache.org/42/configuration) —
acks의 0/1/all 정의 - Apache Kafka — A Guide To The Kafka Protocol: Fetch API (kafka.apache.org/protocol) —
replica_id,fetch_offset,high_watermark등 FetchRequest/Response 필드 - KIP-16: Automated Replica Lag Tuning (cwiki.apache.org) — 메시지 개수 기준 lag 을 없애고 시간 기준만 남긴 배경
- KAFKA-1546: Automate replica lag tuning (issues.apache.org) —
replica.lag.max.messages제거가 반영된 버전 (fixVersion 0.9.0.0) - KIP-227: Introduce Incremental FetchRequests (cwiki.apache.org) —
session_id/session_epoch - KIP-320: Allow fetchers to detect and handle log truncation (cwiki.apache.org) —
current_leader_epoch를 통한 stale leader fencing - KIP-392: Allow consumers to fetch from closest replica (cwiki.apache.org) — follower 에서 읽기,
preferred_read_replica - KIP-595: A Raft Protocol for the Metadata Quorum (cwiki.apache.org) —
last_fetched_epoch - KIP-101: Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation (cwiki.apache.org) — 첫 fetch 전 truncation 기준