Posts ISR (In-Sync Replicas) 1편 — Replica, fetch, acks
Post
Cancel

ISR (In-Sync Replicas) 1편 — Replica, fetch, acks

분산 메시지 시스템에서 데이터 유실 방지 + 고가용성을 동시에 달성하려면 복제가 필수.
“몇 개에 복제됐을 때 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 일 수 있음 (장애 상황)

이름 그대로 ReplicaIn-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. → §acksmin.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 configDefault의미
replica.fetch.wait.max.ms500msfollower fetch 의 broker 쪽 최대 대기
replica.fetch.min.bytes1응답까지 모을 최소 바이트
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 사이클이 제때 돌고 있는지를 시간으로 본다.

항목 
DescriptionFollower가 이 시간 내에 리더의 LEO 까지 fetch 요청을 보내지 못하면 ISR 에서 제거.
또는 fetch 요청은 보내지만 리더 LEO 까지 못 따라잡으면 제거.
 
Default30000 (30s) 
Typelong · Importancehigh

정확한 제거 조건 (한 번 못 따라잡았다고 즉시 제거 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 로 조정

acksmin.insync.replicas


ISR 이 producer 의 쓰기 보장으로 드러나는 지점. 여기가 ISR 을 신경 써야 하는 이유다.

acks동작
0ack 안 기다림
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=23중 복제 후 ackNotEnoughReplicasException쓰기 실패일관성 우선
min.insync.replicas=13중 복제 후 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_mslong polling 최대 대기
min_bytes응답까지 모을 최소 바이트
max_bytes전체 응답 최대 바이트
session_id, session_epochIncremental Fetch Session (KIP-227) — 매번 전체 topic/partition 리스트 보내지 않고 변경분만
isolation_levelconsumer 용 — 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_msquota 위반 시 throttle 시간
session_id같은 session 유지
responses[].partitions[].error_codeNOT_LEADER, OFFSET_OUT_OF_RANGE 등
responses[].partitions[].high_watermark리더가 알려주는 현재 HW (follower/consumer 가 자기 HW 갱신)
responses[].partitions[].last_stable_offsettransactional read 시 committed 경계
responses[].partitions[].log_start_offset가장 오래된 보존 중인 offset
responses[].partitions[].aborted_transactions[]transactional, isolation_level=read_committed 시
responses[].partitions[].preferred_read_replicaKIP-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

참고 자료


This post is licensed under CC BY 4.0 by the author.

카프카 파티션 로그의 오프셋들 — LEO, HW, LSO

ISR (In-Sync Replicas) 2편 — HW, Leader Epoch, 리더 교체