들어가기 전
우리 팀은 Reactor Kafka 기반으로 직접 구성한 컨슈머 구현체에서 classic 프로토콜을 사용해 왔다. 배포로 pod가 교체되는 몇 분 동안 컨슈머 랙이 쌓였다가 줄어드는 일이 반복됐고, 컨슈머 수가 늘면서 소비가 지연되는 구간도 길어졌다. 확인해 보니 원인은 크게 두 가지였다.
- eager 리밸런스에서 기존 멤버가 보유 파티션을 모두 반납했다.
- pod 이름으로 만든 정적 멤버 ID가 배포마다 달라져, 종료된 멤버의 세션 타임아웃을 기다려야 했다.
두 문제를 해결하려고 Spring Boot와 Kafka를 4.x로 올린 뒤 KIP-848의 consumer 프로토콜을 적용하고, 컨슈머 구현도 Spring Kafka 컨테이너로 교체했다. 이 글에는 consumer 프로토콜로 바꾸면서 달라진 동작과 전환 후 관측한 수치를 정리해 두고자 한다.
프로토콜의 상세한 동작 원리는 이전 글 Kafka 리밸런스 프로토콜 비교: Classic과 KIP-848 Consumer에 정리했다. classic의 eager/cooperative와 consumer 프로토콜이 파티션을 반납하고 새 할당을 적용하는 과정이 궁금하다면 해당 글을 함께 참고하면 된다.
1. 배포 중 소비가 멈추던 이유
배포 중 그룹의 소비가 멈추는 과정은 두 단계로 나뉜다. 첫째는 리밸런스에 참여하는 멤버들이 파티션을 반납하는 과정이고, 둘째는 종료된 멤버가 그룹에서 빠지기까지의 대기다. 두 원인을 순서대로 정리해 두고자 한다.
1-1. eager 리밸런스와 파티션 반납
classic 프로토콜에서는 그룹 코디네이터가 조인 요청을 모으고, 그룹 리더로 선택된 컨슈머가 파티션 할당을 계산한다. 그룹 리더는 별도 서버가 아니라 컨슈머 중 한 멤버이며, 이 예시에서는 A가 리더를 맡는다. assignor가 RangeAssignor 같은 eager 방식이면 기존 멤버는 새 할당을 받기 전에 보유한 파티션을 모두 반납한다. 실제로 B에게 이동하는 파티션은 P2와 P3 둘뿐이지만, A는 P0과 P1까지 내려놓고 새 할당을 기다린다.
<svg viewBox="0 0 640 656" width="100%" role="img" aria-labelledby="classic-seq-title">
<title id="classic-seq-title">classic 프로토콜에서는 그룹 전원이 조인과 동기화를 마칠 때까지 기존 멤버가 보유 파티션을 모두 반납한 채 기다린다</title>
<g stroke="currentColor" stroke-width="1.75" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.85">
<g transform="translate(118 8) scale(0.8333)"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></g>
<g transform="translate(310 8) scale(0.8333)"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/></g>
<g transform="translate(502 8) scale(0.8333)"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></g>
</g>
<g fill="currentColor" fill-opacity="0.85" font-size="12" text-anchor="middle"><text x="128" y="44">기존 컨슈머 A</text><text x="320" y="44">그룹 코디네이터</text><text x="512" y="44">신규 컨슈머 B</text></g>
<g stroke="currentColor" stroke-opacity="0.28" stroke-width="1" fill="none">
<path d="M128 56V194"/><path d="M128 238V354"/><path d="M128 398V554"/>
<path d="M320 56V194"/><path d="M320 238V554"/>
<path d="M512 56V194"/><path d="M512 238V554"/>
</g>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M512 96H320"/><path d="M327 92L320 96L327 100"/></g>
<text x="416" y="88" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">JoinGroup</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M128 136H320"/><path d="M313 132L320 136L313 140"/></g>
<text x="224" y="128" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">Heartbeat</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 176H128" stroke-dasharray="4 4"/><path d="M135 172L128 176L135 180"/></g>
<text x="224" y="168" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">응답: REBALANCE_IN_PROGRESS</text>
<rect x="40" y="202" width="560" height="28" rx="4" fill="#ea580c" fill-opacity="0.08" stroke="#ea580c" stroke-width="1.5"/>
<text x="320" y="220" text-anchor="middle" fill="currentColor" fill-opacity="0.9" font-size="11.5">기존 멤버의 보유 파티션 전체 반납 (A는 P0~P3 모두 내려놓음)</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M128 256H320"/><path d="M313 252L320 256L313 260"/></g>
<text x="224" y="248" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">JoinGroup (재조인)</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 296H128" stroke-dasharray="4 4"/><path d="M135 292L128 296L135 300"/></g>
<text x="224" y="288" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">JoinGroup 응답과 멤버 목록</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 336H512" stroke-dasharray="4 4"/><path d="M505 332L512 336L505 340"/></g>
<text x="416" y="328" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">JoinGroup 응답</text>
<rect x="40" y="362" width="176" height="28" rx="4" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="128" y="380" text-anchor="middle" fill="currentColor" fill-opacity="0.85" font-size="11.5">그룹 리더로서 할당 계산</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M128 416H320"/><path d="M313 412L320 416L313 420"/></g>
<text x="224" y="408" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">SyncGroup과 전체 할당</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M512 456H320"/><path d="M327 452L320 456L327 460"/></g>
<text x="416" y="448" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">SyncGroup</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 496H128" stroke-dasharray="4 4"/><path d="M135 492L128 496L135 500"/></g>
<text x="224" y="488" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">응답: A의 할당</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 536H512" stroke-dasharray="4 4"/><path d="M505 532L512 536L505 540"/></g>
<text x="416" y="528" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">응답: B의 할당</text>
<rect x="40" y="562" width="560" height="28" rx="4" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="320" y="580" text-anchor="middle" fill="currentColor" fill-opacity="0.85" font-size="11.5">할당된 파티션의 소비 재개</text>
<text x="128" y="616" fill="currentColor" font-size="12" font-weight="600">조인과 동기화가 모두 끝날 때까지 반납한 파티션은 소비되지 않는다</text>
</svg>
멤버들은 조인과 할당 완료를 함께 기다린다. 회의 참석자가 모두 모일 때까지 시작을 기다리는 것과 비슷하다. 같은 과정을 파티션 관점에서 보면 그 대기 구간 동안 소비가 멈춘다.
<svg viewBox="0 0 720 264" width="100%" role="img" aria-labelledby="eager-span-title">
<title id="eager-span-title">eager 리밸런스에서는 이동하지 않는 P0, P1까지 리밸런스가 끝날 때까지 소비가 멈춘다</title>
<g fill="currentColor" fill-opacity="0.62" font-size="12" text-anchor="middle"><text x="208" y="32">1. B 조인 전</text><text x="400" y="32">2. 리밸런스 진행</text><text x="592" y="32">3. 새 할당 이후</text></g>
<g stroke="currentColor" stroke-opacity="0.12" stroke-width="1"><path d="M304 48V208"/><path d="M496 48V208"/></g>
<g fill="currentColor" fill-opacity="0.62" font-size="12" text-anchor="end"><text x="96" y="72">P0</text><text x="96" y="112">P1</text><text x="96" y="152">P2</text><text x="96" y="192">P3</text></g>
<g fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1">
<rect x="120" y="56" width="176" height="24" rx="3"/><rect x="120" y="96" width="176" height="24" rx="3"/><rect x="120" y="136" width="176" height="24" rx="3"/><rect x="120" y="176" width="176" height="24" rx="3"/>
<rect x="504" y="56" width="176" height="24" rx="3"/><rect x="504" y="96" width="176" height="24" rx="3"/><rect x="504" y="136" width="176" height="24" rx="3"/><rect x="504" y="176" width="176" height="24" rx="3"/>
</g>
<g fill="currentColor" fill-opacity="0.85" font-size="12" text-anchor="middle">
<text x="208" y="72">A 소비</text><text x="208" y="112">A 소비</text><text x="208" y="152">A 소비</text><text x="208" y="192">A 소비</text>
<text x="592" y="72">A 소비</text><text x="592" y="112">A 소비</text><text x="592" y="152">B 소비</text><text x="592" y="192">B 소비</text>
</g>
<rect x="312" y="48" width="176" height="160" rx="4" fill="#ea580c" fill-opacity="0.08" stroke="#ea580c" stroke-width="1.5"/>
<g fill="currentColor" fill-opacity="0.9" font-size="11.5" text-anchor="middle"><text x="400" y="124">A가 P0~P3 전부 반납</text><text x="400" y="140">전 파티션 소비 중단</text></g>
<path d="M112 208H688" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="112" y="240" fill="currentColor" font-size="12" font-weight="600">이동하는 파티션은 P2, P3 둘인데 네 파티션 모두 소비가 멈춘다</text>
</svg>
이동 대상이 아닌 P0과 P1도 리밸런스가 끝나기 전까지는 소비되지 않는다. 그룹 단위 동기화 자체는 classic의 cooperative 방식에도 남아 있지만, eager 방식에서는 반납 범위까지 전체 파티션으로 넓어진다. KIP-848 설계 배경에서도 이 그룹 단위 동기화를 확장성의 제약으로 설명한다.
운영 로그에서는 이 대기를 멤버 한 명의 전환 시간으로 측정했다.
[!TIP]
classic의 멤버 전환 한 건은 컨슈머 로그의 최초Request joining group부터Successfully synced group까지로 묶는다. consumer 프로토콜에서 대응하는 구간은JOINING또는RECONCILING진입부터ACKNOWLEDGING을 거쳐STABLE이 되기까지다. [[4-1]]의 전후 비교는 이 두 구간을 같은 종료점 기준으로 놓고 본 것이다.
| 지표 | classic 멤버 전환 시간 |
|---|---|
| 완료 표본 | 26,457건 |
| p50 | 0.218초 |
| p95 | 63.006초 |
| p99 | 84.027초 |
| 최대 | 117.126초 |
| 10초 초과 | 10,266건, 38.8% |
| 60초 초과 | 2,774건, 10.5% |
1-2. pod 이름 기반의 정적 멤버십
정적 멤버십은 group.instance.id로 인스턴스를 식별한다. 같은 ID의 멤버가 세션 타임아웃 안에 돌아오면 코디네이터는 기존 할당을 그대로 돌려주고 리밸런스를 생략한다. 재시작이 잦은 환경에서 리밸런스를 줄이기 위한 기능이며, 기존 구현체에서는 이 값을 모든 컨슈머에 부여했다.
1-2-1. 배포마다 달라지는 인스턴스 ID
기존 구현체에서 수신 옵션의 프로퍼티를 조립하는 부분은 다음과 같았다.
// 기존 Reactor Kafka 기반 구현체의 수신 옵션 설정
val groupInstanceId = groupInstanceIdFor(podOrHostName)
val props = (commonKafkaProps() + mapOf<String, Any>(
ConsumerConfig.GROUP_ID_CONFIG to consumerGroupIdOf(channel, customGroupSuffix),
ConsumerConfig.GROUP_INSTANCE_ID_CONFIG to groupInstanceId,
// ...
)).toMutableMap()
groupInstanceIdFor(podOrHostName)은 "$clientIdPrefix-$baseId" 형식으로 ID를 만들었다. Deployment의 pod 이름은 교체할 때마다 달라진다. A가 실행되던 pod가 내려가고 새 pod에서 B가 올라오면, B는 A의 ID로 돌아오는 재합류가 아니라 처음 보는 멤버의 조인으로 처리된다. 정적 멤버십으로 재시작 시 리밸런스를 생략하려면 같은 ID로 돌아와야 한다. 기존 설정에서는 배포마다 ID가 달라졌기 때문에, 정적 멤버십을 사용하고 있어도 배포 시 리밸런스를 줄이는 효과를 기대하기 어려웠다.
1-2-2. 종료된 멤버의 세션 만료 대기
종료된 정적 멤버는 재합류를 기다리는 동안 그룹에 남는다. classic 경로에서 사용한 session.timeout.ms의 기본값은 45초이고, 그 안에 같은 ID가 돌아오지 않으면 코디네이터가 세션 만료 시점에 해당 멤버를 제외한다. Kafka 컨슈머 설정 문서에 정적 멤버십과 타임아웃 조건이 나와 있다.
배포 중에는 다음 두 상황이 겹쳤다.
- 새 pod의 B는 다른 ID로 조인하므로 A의 재합류로 처리되지 않았다.
- A의 세션이 남아 있는 동안에는 A에 할당된 파티션의 소비가 지연될 수 있었다.
아래 그림은 A의 pod가 종료된 시점을 0초로 놓고 45초 세션이 만료될 때까지의 구간을 나타낸다. B의 조인 시각은 예시이고, 45초만 설정 기본값이다.
<svg viewBox="0 0 720 280" width="100%" role="img" aria-labelledby="session-wait-title">
<title id="session-wait-title">새 ID로 조인한 B는 A의 재합류로 인정되지 않고, A의 파티션은 세션이 만료되는 45초까지 이동하지 않는다</title>
<g fill="currentColor" fill-opacity="0.62" font-size="11" text-anchor="middle"><text x="168" y="40">A의 pod 종료</text><text x="528" y="40">세션 만료 (45초)</text></g>
<path d="M248 48V176" stroke="currentColor" stroke-opacity="0.28" stroke-width="1" stroke-dasharray="4 4"/>
<path d="M528 48V176" stroke="#ea580c" stroke-width="1" stroke-dasharray="4 4"/>
<g fill="currentColor" fill-opacity="0.62" font-size="12" text-anchor="end"><text x="152" y="72">멤버 A</text><text x="152" y="112">멤버 B</text><text x="152" y="152">A의 파티션</text></g>
<rect x="168" y="56" width="360" height="24" rx="3" fill="#ea580c" fill-opacity="0.08" stroke="#ea580c" stroke-width="1.5"/>
<text x="348" y="72" text-anchor="middle" fill="currentColor" fill-opacity="0.9" font-size="11.5">pod는 없지만 세션 만료까지 그룹 멤버로 남음</text>
<rect x="248" y="96" width="400" height="24" rx="3" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="448" y="112" text-anchor="middle" fill="currentColor" fill-opacity="0.85" font-size="11.5">새 ID로 조인 요청 (A의 재합류로 처리되지 않음)</text>
<text x="348" y="152" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">A에 할당된 채 소비 지연</text>
<rect x="528" y="136" width="120" height="24" rx="3" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="588" y="152" text-anchor="middle" fill="currentColor" fill-opacity="0.85" font-size="11.5">재할당 후 소비</text>
<path d="M168 176H648" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<g fill="currentColor" fill-opacity="0.40" font-size="11" text-anchor="middle" style="font-variant-numeric: tabular-nums"><text x="168" y="200">0</text><text x="288" y="200">15</text><text x="408" y="200">30</text><text x="528" y="200">45</text><text x="648" y="200">60</text><text x="408" y="224">A의 pod가 종료된 뒤 경과 시간 (초)</text></g>
<text x="168" y="256" fill="currentColor" font-size="12" font-weight="600">B가 조인해도 A의 파티션은 세션이 만료되는 45초까지 이동하지 않는다</text>
</svg>
[[1-1]]의 반납 대기가 리밸런스 한 번에 걸리는 시간이라면, 이 대기는 리밸런스가 끝나기 위해 사라져야 할 멤버를 기다리는 시간이다. 그룹별 수치에서도 이 길이의 대기가 관측됐다. 세 그룹의 p95는 각각 54.012초, 59.169초, 60.004초였고, 마지막 그룹의 최대값은 61.073초였다. 세션 만료 대기가 포함됐을 가능성은 있지만, 백분위만으로 각 전환의 대기 원인을 확정할 수는 없다.
[!IMPORTANT]
정적 멤버십의 재시작 이점은 같은 ID로 돌아온다는 전제에 달려 있다. 이 환경에서는 pod 교체 때 ID가 달라졌으므로 그 이점을 기대하기 어려웠다. 세션 대기는 멤버별로 겹칠 수 있어 전체 지연을 멤버 수 × 45초로 계산할 수도 없다.
2. 프로토콜과 컨슈머 구현의 변경
[[1-1]]의 전체 반납은 프로토콜의 동작에서, [[1-2]]의 세션 대기는 멤버 식별 설정에서 나온 문제다. 프로토콜을 바꾸는 작업과 함께 컨슈머를 실행하는 라이브러리도 교체했다. 사용처의 API 사용 방식은 유지하되, 파티션 할당과 멤버 식별에 관련된 설정은 따로 정리했다.
2-1. Spring Kafka 컨테이너와 consumer 프로토콜
기존 구현은 Reactor Kafka 1.3.25를 사용했다. Reactor Kafka는 1.3 계열을 마지막으로 유지보수 종료가 공지된 상태였고, 기존 모듈에서는 KIP-848과의 조합을 검증하지 않았다. Reactor Kafka 유지보수 종료 공지에서도 마지막 마이너 버전을 1.3으로 명시한다.
컨슈머는 Spring Kafka의 ConcurrentMessageListenerContainer 기반으로 다시 구현했다. 그 위에서 group.protocol=consumer를 적용하고, 필요한 컨슈머는 classic을 선택할 수 있게 했다.
consumer 프로토콜에서는 할당을 계산하는 주체와 교환 방식이 함께 바뀐다. 그룹 코디네이터가 서버 측 assignor로 할당을 계산하고, 컨슈머 리더의 할당 계산과 SyncGroup 교환 대신 ConsumerGroupHeartbeat 하나로 상태와 할당을 주고받는다. 같은 예시를 이 프로토콜로 그리면 다음과 같다. 그림의 모든 화살표는 ConsumerGroupHeartbeat 요청과 그 응답이다.
<svg viewBox="0 0 640 416" width="100%" role="img" aria-labelledby="consumer-seq-title">
<title id="consumer-seq-title">consumer 프로토콜에서는 A가 P2, P3만 반납하고 그 확인 뒤에 B가 할당을 받는다</title>
<g stroke="currentColor" stroke-width="1.75" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.85">
<g transform="translate(118 8) scale(0.8333)"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></g>
<g transform="translate(310 8) scale(0.8333)"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/></g>
<g transform="translate(502 8) scale(0.8333)"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></g>
</g>
<g fill="currentColor" fill-opacity="0.85" font-size="12" text-anchor="middle"><text x="128" y="44">컨슈머 A</text><text x="320" y="44">그룹 코디네이터</text><text x="512" y="44">컨슈머 B</text></g>
<g stroke="currentColor" stroke-opacity="0.28" stroke-width="1" fill="none">
<path d="M128 56V314"/>
<path d="M320 56V114"/><path d="M320 158V314"/>
<path d="M512 56V314"/>
</g>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M512 96H320"/><path d="M327 92L320 96L327 100"/></g>
<text x="416" y="88" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">Heartbeat: 조인 요청</text>
<rect x="192" y="122" width="256" height="28" rx="4" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="320" y="140" text-anchor="middle" fill="currentColor" fill-opacity="0.85" font-size="11.5">target 할당 계산: A{P0, P1}, B{P2, P3}</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M128 176H320"/><path d="M313 172L320 176L313 180"/></g>
<text x="224" y="168" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">Heartbeat: 주기 전송</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 216H128" stroke-dasharray="4 4"/><path d="M135 212L128 216L135 220"/></g>
<text x="224" y="208" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">응답: P2, P3 반납 요청</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M128 256H320"/><path d="M313 252L320 256L313 260"/></g>
<text x="224" y="248" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">Heartbeat: P2, P3 반납 완료</text>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M320 296H512" stroke-dasharray="4 4"/><path d="M505 292L512 296L505 300"/></g>
<text x="416" y="288" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">응답: P2, P3 할당</text>
<rect x="40" y="322" width="560" height="28" rx="4" fill="#ea580c" fill-opacity="0.08" stroke="#ea580c" stroke-width="1.5"/>
<text x="320" y="340" text-anchor="middle" fill="currentColor" fill-opacity="0.9" font-size="11.5">A는 P0, P1 소비를 유지하고, 멈추는 파티션은 P2, P3만이다</text>
<text x="128" y="376" fill="currentColor" font-size="12" font-weight="600">그룹 전원의 조인 완료를 기다리는 단계가 없다</text>
</svg>
B가 조인하면 코디네이터는 target 할당을 계산하고, A의 다음 하트비트 응답에 P2와 P3의 반납을 요청한다. A는 P0과 P1을 계속 소비하면서 P2와 P3만 내려놓고, 반납이 끝났다는 사실을 다음 하트비트로 알린다. 코디네이터는 이 확인을 받은 뒤에야 B에게 P2와 P3을 할당한다. [[1-1]]과 비교하면 그룹 전원의 조인을 기다리는 단계가 없고, 반납 범위도 이동 대상으로 한정된다. 그렇다고 다른 멤버의 진행 상태와 무관해지는 것은 아니다. 옮겨 받을 파티션은 기존 소유자의 반납이 확인돼야 할당되며, KIP-848의 할당 조정 절차에 이 의존 관계가 설명돼 있다.
설정이 적용되는 위치도 다르다. 클라이언트의 partition.assignment.strategy는 consumer 경로에서 사용하지 않고, 대신 브로커의 서버 측 assignor가 할당을 맡는다. Kafka consumer 프로토콜 문서를 기준으로 프로토콜별 설정을 나눴다.
<svg viewBox="0 0 640 176" width="100%" role="img" aria-labelledby="config-scope-title">
<title id="config-scope-title">consumer 프로토콜에서는 클라이언트 설정과 브로커 설정이 나뉘고 할당 계산은 브로커에서 수행한다</title>
<g fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"><rect x="24" y="16" width="272" height="112" rx="4"/><rect x="344" y="16" width="272" height="112" rx="4"/></g>
<g stroke="currentColor" stroke-width="1.75" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.85">
<g transform="translate(36 26) scale(0.8333)"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></g>
<g transform="translate(356 26) scale(0.8333)"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/></g>
</g>
<g fill="currentColor" fill-opacity="0.85" font-size="12" font-weight="600"><text x="64" y="40">컨슈머 (클라이언트 설정)</text><text x="384" y="40">브로커 (서버 설정)</text></g>
<g fill="currentColor" fill-opacity="0.62" font-size="11"><text x="40" y="64">group.protocol = consumer</text><text x="40" y="88">partition.assignment.strategy: classic만</text><text x="40" y="112">group.instance.id (정적 멤버십)</text><text x="360" y="64">group.version 기능 플래그</text><text x="360" y="88">서버 측 assignor의 할당 계산</text><text x="360" y="112">group.consumer.heartbeat.interval.ms</text></g>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M296 72H344"/><path d="M337 68L344 72L337 76"/></g>
<text x="24" y="160" fill="currentColor" font-size="12" font-weight="600">두 묶음은 ConsumerGroupHeartbeat로 연결되고, 할당 계산은 브로커 쪽에서 일어난다</text>
</svg>
모듈의 프로퍼티 조립에서는 프로토콜에 따라 왼쪽 묶음의 설정만 갈아 끼운다.
// 컨슈머 프로퍼티 조립 부분
val resolvedProtocol = groupProtocol ?: GroupProtocol.fromConfig(
environment.getProperty("kafka.group-protocol", "consumer"),
)
when (resolvedProtocol) {
GroupProtocol.CLASSIC ->
props[ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG] = listOf(
CooperativeStickyAssignor::class.java.name,
RangeAssignor::class.java.name,
)
GroupProtocol.CONSUMER ->
props[ConsumerConfig.GROUP_PROTOCOL_CONFIG] = "consumer"
}
모듈의 전역 기본값은 kafka.group-protocol=consumer로 정했다. 컨슈머별 재정의가 없으면 이 값을 사용한다. Kafka 4.0 클라이언트 자체의 기본값은 classic이므로, 여기서 말하는 기본값은 사내 모듈의 설정이다.
classic 경로에서는 CooperativeStickyAssignor를 먼저 두고 RangeAssignor도 함께 광고했다. 기존 멤버가 Range만 지원하는 동안 새 멤버가 CooperativeSticky만 광고하면 공통 assignor가 없어 INCONSISTENT_GROUP_PROTOCOL로 조인이 거부될 수 있다. 기존 멤버가 모두 교체되고 전원이 위 목록을 사용하면 그때 CooperativeSticky가 선택된다. 이 설정은 classic 그룹의 assignor 호환성을 위한 것이며, 그림 오른쪽의 서버 측 assignor와는 구분된다.
consumer 프로토콜을 사용하려면 그림 오른쪽의 브로커 기능도 활성화돼 있어야 한다. Kafka 4.0부터는 서버에서 기본 활성화되며, 활성화 여부는 group.version 기능 플래그로 관리한다. 업그레이드한 클러스터에서는 이 플래그의 상태도 영향을 준다.
2-2. 컨슈머 API와 멤버 관리
사용처에서는 상속 대상을 바꾸는 방식으로 옮길 수 있도록 기존 파라미터 이름과 의미를 맞췄다. 모듈 내부에서는 다음과 같이 연결했다.
| 항목 | 신규 구현의 처리 |
|---|---|
| 인스턴스 수 | consumerInstanceCount를 컨테이너의 concurrency에 연결했다. |
| 커밋 시점 | AckMode.BATCH로 poll 배치 처리 후 커밋하는 방식을 유지했다. |
| 실행 스레드 | VirtualThreadTaskExecutor를 사용했다. |
| 프로토콜 파싱 | 알 수 없는 값은 모듈에서 CLASSIC으로 해석하도록 했다. |
| 정적 멤버십 | 기본값을 false로 바꾸고 필요한 컨슈머에서만 활성화하도록 했다. |
| 시작 | 컨슈머 시작 제어 로직에서 그룹 ID 단위로 시작 요청을 묶고, 실패하면 지수 백오프로 재시도하도록 했다. |
| 종료 | preStop drain에서 pause, 진행 중인 처리 소진, 컨슈머 닫기를 순서대로 수행하도록 했다. |
알 수 없는 설정값을 classic으로 해석하는 것은 모듈의 동작이다. 잘못된 값을 넣어도 원하는 프로토콜로 실행된다는 뜻은 아니며, 그룹 조인의 다른 조건도 그대로 적용된다.
프로토콜을 컨슈머 단위로 지정하는 코드도 유지했다.
@Component
// ConsumerBase는 직접 구성한 컨슈머 기반 클래스를 나타낸다.
class LegacyGroupConsumer : ConsumerBase<SomeMessage>(channel = SomeMessage) {
override val groupProtocol = GroupProtocol.CLASSIC
override suspend fun handle(payload: SomeMessage) {
// 메시지 처리
}
}
이 컨슈머는 전역 기본값과 관계없이 classic을 사용한다.
멤버 관리에서 가장 크게 달라진 부분은 정적 멤버 ID를 누가 만드느냐다. 기존 구현체에서는 인스턴스마다 별도의 KafkaReceiver와 정적 멤버 ID를 직접 만들었다.
// 인스턴스별 설정 부분
repeat(instanceCount) { idx ->
val baseGroupInstanceId = "${podOrHostName}-$idx"
val groupInstanceId = groupInstanceIdFor(baseGroupInstanceId)
// ...
.consumerProperty(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, groupInstanceId)
}
이 경로에서는 정적 멤버 수가 pod 수 × 인스턴스 수만큼 늘어났다. 인스턴스 수를 늘리면 리밸런스에 참여하는 멤버도 많아졌고, 배포 때 [[1-2-1]]처럼 교체되는 정적 ID도 그만큼 늘었다. 다만 [[1-2-2]]에서 본 세션 대기 시간이 멤버 수에 정비례하는 것은 아니다.
신규 모듈에서는 인스턴스 수를 concurrency에 전달하고, 직접 인덱스를 붙여 ID를 조립하던 코드는 사용하지 않는다. 정적 멤버십을 켜면 모듈은 pod 단위의 기본 ID만 전달하고, Spring Kafka가 concurrency에 맞춰 인스턴스 인덱스를 -n 형태의 접미사로 붙인다. 따라서 실제 정적 멤버는 여전히 컨슈머 인스턴스별로 생긴다. Spring Kafka 컨테이너 문서에 이 동작이 명시돼 있다.
정적 멤버십을 기본으로 사용하지 않으면 종료된 멤버가 세션 만료를 기다리며 그룹에 남는 상황 자체가 줄어든다. 대신 정상 종료 때 그룹 이탈을 알리는데, 이탈 통지는 classic에서는 LeaveGroup 요청을, consumer 프로토콜에서는 이탈을 나타내는 ConsumerGroupHeartbeat를 사용한다. 예시로 돌아가면, A의 pod가 정상 종료되며 이탈을 알리면 코디네이터는 45초를 기다리지 않고 바로 A의 파티션을 재할당 대상으로 삼는다.
3. 구현 교체 후 확인한 동작 차이
컨테이너로 옮긴 뒤에는 기존 구현체의 오류 처리와 백프레셔 동작을 다시 맞춰야 했다. 상속 대상과 파라미터가 같아도, 처리 예외가 발생하거나 다운스트림 적재가 밀릴 때의 동작은 라이브러리마다 다르기 때문이다.
3-1. 처리 실패 후 재시도와 커밋
초기 구현에서는 처리 실패를 컨테이너의 기본 오류 핸들러에 맡겼다. 이 상태에서 실패한 레코드의 재처리가 반복되고 오프셋 전진이 지연되면서 랙이 쌓이는 상황이 있었다. 기존 구현체에서는 onErrorContinue로 오류를 기록하고 다음 처리를 계속했으므로, 같은 업무 핸들러를 사용해도 실패 이후 동작이 달라졌다.
Spring Kafka의 DefaultErrorHandler는 실패한 레코드나 배치를 재시도하고, 재시도 소진 후에는 복구를 시도한다. 범위와 횟수는 리스너 종류, 예외 유형, 설정에 따라 달라진다. 따라서 기본 핸들러가 모든 오류를 무한 재시도한다고 설명할 수는 없다. Spring Kafka 오류 처리 문서에 재시도와 복구 조건이 나뉘어 있다.
모듈에서는 기존의 처리 실패 정책에 맞춰 오류 핸들러를 명시했다.
// 컨테이너 설정 부분
it.setCommonErrorHandler(createConsumerErrorHandler())
internal fun createConsumerErrorHandler(): CommonErrorHandler =
CommonLoggingErrorHandler()
CommonLoggingErrorHandler를 사용하면 처리 예외를 로그로 남기고 컨테이너 재시도 없이 진행한다. isAckAfterHandle이 true이면 오류 핸들러가 정상 반환한 뒤 해당 오프셋을 커밋 대상으로 처리한다. 커밋 성공 자체를 보장하는 설정은 아니다. CommonErrorHandler API 문서에 이 플래그의 의미가 정의돼 있다.
모듈의 별도 실패 처리가 필요하면 handleFailover(error, failoverKey)에서 수행한다. 이 경로와 컨테이너 오류 핸들러를 구분하고, 컨테이너까지 전달된 처리 예외는 재시도하지 않도록 했다.
[!WARNING]
로그를 남기고 커밋 대상으로 처리한 메시지는 같은 그룹에서 자동으로 다시 처리되지 않을 수 있다. 특히 배치 리스너의 예외를 이 방식으로 처리하면 영향 범위가 실패 레코드 하나보다 넓을 수 있다. 레코드별 실패 처리와 배치 전체의 오류 처리를 같은 동작으로 보면 안 된다.
핸들러 종류와 커밋 대상 여부는 테스트로 고정했다.
@Test
fun `컨테이너 오류 처리기는 실패 배치를 재시도하지 않고 커밋 대상으로 처리한다`() {
val errorHandler = RecordingConsumer().createConsumerErrorHandler()
assertThat(errorHandler).isInstanceOf(CommonLoggingErrorHandler::class.java)
assertThat(errorHandler.isAckAfterHandle).isTrue()
}
이 테스트는 선택한 오류 핸들러와 플래그를 확인한다. 브로커에 오프셋이 실제로 커밋되는 과정까지 검증하는 테스트는 아니다.
3-2. 다운스트림 적재량에 따른 pause/resume
기존 구현체에는 워터마크 기반 pause/resume이 있었다. 색인 큐처럼 컨슈머 외부에 쌓이는 적재량이 상한을 넘으면 파티션 소비를 멈추고, 하한 아래로 줄면 다시 소비하는 방식이다.
상속 대상만 Spring Kafka 컨테이너 기반 구현체로 바꿨을 때는 이 동작이 유지되지 않았다. 핸들러가 다운스트림 큐에 작업을 넣고 반환하면 컨슈머는 다음 처리를 계속할 수 있다. 큐를 비우는 속도가 유입 속도보다 느리면 핸들러 오류 없이도 적재량이 계속 늘어난다.
모듈에는 주기적인 판정 로직을 두고, 임계값과 현재 적재량은 개별 컨슈머에서 제공하도록 했다. backpressureConfig가 없으면 별도 확인 작업도 실행하지 않는다.
// BackpressureConfig는 구현체에서 정의한 백프레셔 설정을 나타낸다.
override val backpressureConfig = BackpressureConfig(
highWatermark = 10_000,
lowWatermark = 2_000,
)
override fun backpressureCurrent(): Int = backlogGauge.get()
이 예시에서는 적재량이 10,000 이상일 때 pause를 요청하고, 2,000 이하로 줄었을 때 resume을 요청한다. 두 값 사이에서는 기존 상태를 유지한다. 적재량 축 위에 세 구간을 놓으면 다음과 같다.
<svg viewBox="0 0 640 232" width="100%" role="img" aria-labelledby="watermark-title">
<title id="watermark-title">적재량이 highWatermark 이상이면 pause를, pause 상태에서 lowWatermark 이하면 resume을 요청하고 그 사이에서는 상태를 유지한다</title>
<g fill="currentColor" fill-opacity="0.62" font-size="12" text-anchor="middle"><text x="136" y="40">resume 요청</text><text x="336" y="40">기존 상태 유지</text><text x="536" y="40">pause 요청</text></g>
<g fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"><rect x="96" y="56" width="80" height="32" rx="3"/><rect x="176" y="56" width="320" height="32" rx="3"/></g>
<rect x="496" y="56" width="80" height="32" rx="3" fill="#ea580c" fill-opacity="0.08" stroke="#ea580c" stroke-width="1.5"/>
<g stroke="currentColor" stroke-opacity="0.28" stroke-width="1" stroke-dasharray="4 4"><path d="M176 48V104"/><path d="M496 48V104"/></g>
<g fill="currentColor" fill-opacity="0.62" font-size="11" text-anchor="middle" style="font-variant-numeric: tabular-nums"><text x="176" y="120">lowWatermark = 2,000</text><text x="496" y="120">highWatermark = 10,000</text></g>
<path d="M96 136H576" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<g fill="currentColor" fill-opacity="0.40" font-size="11" text-anchor="middle" style="font-variant-numeric: tabular-nums"><text x="96" y="152">0</text><text x="256" y="152">4,000</text><text x="416" y="152">8,000</text><text x="576" y="152">12,000</text><text x="336" y="176">backpressureCurrent() 값 (적재량)</text></g>
<text x="96" y="208" fill="currentColor" font-size="12" font-weight="600">resume 요청은 백프레셔로 pause한 상태이고 drain 중이 아닐 때만 나간다</text>
</svg>
가운데 구간을 두는 이유는 경계 한 곳에서 pause와 resume이 번갈아 나가는 진동을 막기 위해서다. 설정이 있으면 가상 스레드에서 checkInterval마다 적재량을 확인하며, 기본 간격은 1초다. 판정 부분은 다음과 같다.
internal fun runBackpressureCheck() {
val config = backpressureConfig ?: return
val current = backpressureCurrent()
when {
current >= config.highWatermark ->
if (pauseForBackpressure(config)) pausedByBackpressure.set(true)
pausedByBackpressure.get() && current <= config.lowWatermark -> {
if (draining.get()) return
if (resumeForBackpressure(config)) pausedByBackpressure.set(false)
}
}
}
pausedByBackpressure는 백프레셔로 pause를 요청한 상태를 기록하고, draining은 종료 중 resume을 막는 데 사용한다. 그림의 세 구간을 코드의 조건과 대응시키면 다음과 같다.
| 조건 | 동작과 이유 |
|---|---|
| 적재량이 high 이상이다. | 확인할 때마다 pause를 요청한다. 이미 플래그가 켜져 있어도 같은 요청을 반복한다. |
| 적재량이 low와 high 사이에 있다. | 기존 상태를 유지한다. 한 경계에서 pause와 resume이 반복되는 것을 피한다. |
| pause 요청에 실패했다. | 플래그를 바꾸지 않고 다음 확인 때 다시 시도한다. |
| 백프레셔로 pause한 상태에서 low 이하가 됐다. | drain 중이 아닐 때만 resume을 요청한다. |
| drain이 진행 중이다. | 백프레셔 조건이 풀려도 resume하지 않는다. |
pauseForBackpressure와 resumeForBackpressure는 재정의할 수 있게 했다. 기본 구현은 컨테이너 전체를 대상으로 하고, 파티션 단위 제어가 필요한 컨슈머는 두 메서드를 재정의한다.
다만 컨테이너의 pause 요청은 진행 중인 핸들러를 즉시 중단하지 않는다. 기본적으로 이전 poll의 처리가 끝난 뒤 적용되며, pause 상태에서도 그룹 멤버십 유지를 위한 poll은 계속된다. Spring Kafka pause/resume 문서에 요청 시점과 실제 정지 시점의 차이가 설명돼 있다.
워터마크 판정은 적재량을 직접 바꾸는 테스트로 확인했다.
@Test
fun `highWatermark 이상이면 pause 하고 lowWatermark 이하면 resume 한다`() {
val consumer = CountingConsumer()
consumer.current = 11
consumer.runBackpressureCheck()
assertThat(consumer.pauseCalls).isEqualTo(1)
assertThat(consumer.resumeCalls).isZero()
consumer.current = 4
consumer.runBackpressureCheck()
assertThat(consumer.resumeCalls).isEqualTo(1)
}
이 테스트는 테스트용 임계값에서 pause와 resume 호출 여부를 확인한다. 실제 Kafka의 파티션 할당과 정지 시점은 별도 동작이다.
draining 플래그가 가리키는 종료 과정은 [[2-2]]의 preStop drain이다. pause, 처리 소진, 컨슈머 닫기가 순서대로 이어지며, 이 전체가 Kubernetes의 종료 유예 시간 안에 끝나야 한다.
<svg viewBox="0 0 640 192" width="100%" role="img" aria-labelledby="drain-order-title">
<title id="drain-order-title">preStop drain의 네 단계는 terminationGracePeriodSeconds 안에 끝나야 하며, 시간이 부족하면 이탈 통지 전에 SIGKILL로 종료된다</title>
<text x="324" y="32" text-anchor="middle" fill="currentColor" fill-opacity="0.62" font-size="11.5">terminationGracePeriodSeconds</text>
<g stroke="currentColor" stroke-opacity="0.28" stroke-width="1"><path d="M32 44H616"/><path d="M32 40V48"/></g>
<path d="M616 40V128" stroke="#ea580c" stroke-width="1" stroke-dasharray="4 4"/>
<g fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"><rect x="32" y="64" width="128" height="40" rx="4"/><rect x="184" y="64" width="128" height="40" rx="4"/><rect x="336" y="64" width="128" height="40" rx="4"/><rect x="488" y="64" width="128" height="40" rx="4"/></g>
<g fill="currentColor" fill-opacity="0.85" font-size="11.5" text-anchor="middle">
<text x="96" y="80" font-weight="600">1. preStop</text><text x="96" y="96">pause 요청</text>
<text x="248" y="80" font-weight="600">2. drain</text><text x="248" y="96">진행 중 처리 소진</text>
<text x="400" y="80" font-weight="600">3. 컨슈머 닫기</text><text x="400" y="96">그룹 이탈 통지</text>
<text x="552" y="80" font-weight="600">4. 종료</text><text x="552" y="96">프로세스 exit</text>
</g>
<g stroke="currentColor" stroke-opacity="0.62" stroke-width="1" fill="none"><path d="M160 84H184"/><path d="M177 80L184 84L177 88"/><path d="M312 84H336"/><path d="M329 80L336 84L329 88"/><path d="M464 84H488"/><path d="M481 80L488 84L481 88"/></g>
<text x="616" y="144" text-anchor="end" fill="currentColor" fill-opacity="0.9" font-size="11.5">유예 시간 만료 시 SIGKILL</text>
<text x="32" y="176" fill="currentColor" font-size="12" font-weight="600">이탈 통지 전에 SIGKILL로 끝나면 세션 만료까지 파티션 재할당이 지연된다</text>
</svg>
[!CAUTION]
terminationGracePeriodSeconds는 preStop 실행과 drain을 포함한 종료 과정 전체에 적용된다. 종료 시간이 부족하면 처리 소진이나 그룹 이탈 통지 전에 SIGKILL로 종료될 수 있다. 이 경우 코디네이터는 이탈을 알지 못하므로, [[1-2-2]]와 같은 세션 만료 대기가 다시 생긴다.
4. 전환 전후의 관측값
[[1-1]]에서 측정한 멤버 전환 시간이 전환 후 얼마나 달라졌는지, 그리고 프로토콜 전환에 따라 브로커의 조정 요청이 어떻게 바뀌었는지를 비교했다.
[!NOTE]
수치는 운영 Loki/Prometheus와 브로커 전환 로그를 바탕으로 한 기존 집계값이다.
4-1. 멤버 전환 시간
측정 구간은 [[1-1]]의 기준과 같다. classic은 최초 Request joining group부터 Successfully synced group까지, consumer는 JOINING 또는 RECONCILING 진입부터 STABLE까지다. 두 프로토콜의 로그상 전환 구간을 비교한 값이며, 대상은 전후 모두 완료 표본이 있는 Spring 154개 그룹이다.
전환 후에는 p50뿐 아니라 p95와 p99도 짧아졌다. 막대는 각 프로토콜의 완료 시간 분포를 나타낸다.
<svg viewBox="0 0 720 480" width="100%" role="img" aria-labelledby="member-chart-title">
<title id="member-chart-title">멤버 전환 시간의 p95는 적용 전 63.006초에서 적용 후 0.718초로 줄었다</title>
<g stroke="currentColor" stroke-opacity="0.12" stroke-width="1"><path d="M264 24V328"/><path d="M368 24V328"/><path d="M472 24V328"/><path d="M576 24V328"/></g>
<path d="M160 24V328" stroke="currentColor" stroke-opacity="0.28" stroke-width="1"/>
<text x="144" y="56" text-anchor="end" fill="currentColor" fill-opacity="0.62" font-size="12" style="font-variant-numeric: tabular-nums">p50</text>
<path d="M160.37785 24H160.37785Q160.7557 24 160.7557 24.37785V47.62215Q160.7557 48 160.37785 48H160.37785Q160 48 160 47.62215V24.37785Q160 24 160.37785 24Z" fill="#ea580c"/>
<text x="176" y="40" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">0.218</text>
<path d="M160.104 64H160.10399999999998Q160.208 64 160.208 64.104V87.896Q160.208 88 160.10399999999998 88H160.104Q160 88 160 87.896V64.104Q160 64 160.104 64Z" fill="#3b82f6"/>
<text x="176" y="80" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">0.060</text>
<text x="144" y="136" text-anchor="end" fill="currentColor" fill-opacity="0.62" font-size="12" style="font-variant-numeric: tabular-nums">p95</text>
<path d="M163 104H375.4208Q378.4208 104 378.4208 107V125Q378.4208 128 375.4208 128H163Q160 128 160 125V107Q160 104 163 104Z" fill="#ea580c"/>
<text x="392" y="120" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">63.006</text>
<path d="M161.24455 144H161.24455Q162.4891 144 162.4891 145.24455V166.75545Q162.4891 168 161.24455 168H161.24455Q160 168 160 166.75545V145.24455Q160 144 161.24455 144Z" fill="#3b82f6"/>
<text x="176" y="160" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">0.718</text>
<text x="144" y="216" text-anchor="end" fill="currentColor" fill-opacity="0.62" font-size="12" style="font-variant-numeric: tabular-nums">p99</text>
<path d="M163 184H448.2936Q451.2936 184 451.2936 187V205Q451.2936 208 448.2936 208H163Q160 208 160 205V187Q160 184 163 184Z" fill="#ea580c"/>
<text x="464" y="200" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">84.027</text>
<path d="M163 224H173.9312Q176.9312 224 176.9312 227V245Q176.9312 248 173.9312 248H163Q160 248 160 245V227Q160 224 163 224Z" fill="#3b82f6"/>
<text x="192" y="240" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">4.884</text>
<text x="144" y="296" text-anchor="end" fill="currentColor" fill-opacity="0.62" font-size="12" style="font-variant-numeric: tabular-nums">최대</text>
<path d="M163 264H563.0368000000001Q566.0368000000001 264 566.0368000000001 267V285Q566.0368000000001 288 563.0368000000001 288H163Q160 288 160 285V267Q160 264 163 264Z" fill="#ea580c"/>
<text x="576" y="280" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">117.126</text>
<path d="M163 304H278.7805Q281.7805 304 281.7805 307V325Q281.7805 328 278.7805 328H163Q160 328 160 325V307Q160 304 163 304Z" fill="#3b82f6"/>
<text x="296" y="320" fill="currentColor" font-size="12" font-weight="600" style="font-variant-numeric: tabular-nums">35.129</text>
<g fill="currentColor" fill-opacity="0.40" font-size="11" text-anchor="middle" style="font-variant-numeric: tabular-nums"><text x="160" y="352">0</text><text x="264" y="352">30</text><text x="368" y="352">60</text><text x="472" y="352">90</text><text x="576" y="352">120</text><text x="368" y="376">멤버 전환 완료 시간 (초)</text></g>
<text x="160" y="408" fill="currentColor" font-size="11.5" font-weight="600" style="font-variant-numeric: tabular-nums">p95 감소율은 약 98.9%다</text>
<g fill="currentColor" fill-opacity="0.62" font-size="12"><path d="M160 432h16" stroke="#ea580c" stroke-width="2"/><text x="184" y="436">적용 전 / classic</text><path d="M360 432h16" stroke="#3b82f6" stroke-width="2"/><text x="384" y="436">적용 후 / consumer</text></g>
</svg>
| 지표 | classic / 적용 전 72시간 | consumer / 적용 후 96시간 |
|---|---|---|
| 완료 표본 | 26,457건 | 33,666건 |
| p50 | 0.218초 | 0.060초 |
| p95 | 63.006초 | 0.718초 |
| p99 | 84.027초 | 4.884초 |
| 최대 | 117.126초 | 35.129초 |
| 10초 초과 | 10,266건 | 17건 |
| 60초 초과 | 2,774건 | 0건 |
p95는 기존의 약 1/87.8로 줄었다. 다만 완료 표본의 멤버 전환 시간을 비교한 값이므로, 그룹 전체의 소비 중단 시간이 같은 비율로 줄었다고 볼 수는 없다. 관측 기간도 72시간과 96시간으로 달라 단순 건수는 같은 시간당 발생률을 나타내지 않는다.
감소 방향은 [[1-1]]과 [[2-1]]에서 본 프로토콜 차이와 맞는다. classic의 조인 단계에는 그룹 단위 대기가 있고, 종료된 정적 멤버의 세션이 남아 있으면 그 대기가 길어진다. consumer 프로토콜에서는 각 멤버가 자신의 할당만 조정하며, 그룹 전원이 조인을 마칠 때까지 기다리는 절차를 사용하지 않는다. 그렇다고 대기가 사라지는 것은 아니다. 예시의 B가 P2와 P3을 받으려면 A의 반납 확인이 먼저 있어야 한다. 이번에는 프로토콜과 함께 정적 멤버십, 시작과 종료 처리도 바뀌었으므로 p95 감소분을 각각의 효과로 나누지는 않았다.
consumer 쪽에도 10초를 넘긴 표본이 17건 남았다. 최대 35.129초는 한 Spring 서비스의 동의 변경 그룹에서 나왔다. 이 그룹은 maxPollRecords = 50이고 레코드마다 항목 목록을 순회하며 조회와 업서트를 수행한다. 진행 중인 배치 처리와 poll 스레드의 콜백 실행이 길어지면 반납 확인도 늦어지므로 할당 조정 전체가 밀릴 수 있다. 다만 최대값만으로 대기 시간을 모두 업무 핸들러의 처리 시간으로 설명할 수는 없다.
4-2. 브로커 조정 요청
같은 요일의 48시간을 비교해 브로커에서 관측한 하트비트 요청률과 JoinGroup, SyncGroup 요청 건수가 얼마나 달라졌는지 확인했다.
| 지표 | 적용 전 → 적용 후 | 변화 |
|---|---|---|
| classic + consumer 하트비트 요청률 | 454.45 → 337.90 req/s | -25.6% |
| JoinGroup 요청 | 22,926 → 4,998건 | 약 -78% |
| SyncGroup 요청 | 20,510 → 4,508건 | 약 -78% |
[[2-1]]에서 본 대로 consumer 프로토콜은 JoinGroup과 SyncGroup 대신 ConsumerGroupHeartbeat로 조정하므로, 전환 뒤 두 요청이 줄어든 방향은 프로토콜 동작과 맞는다. 전환 후에도 classic 요청이 남아 있는 것은 [[2-2]]처럼 classic을 유지한 컨슈머가 있기 때문이다.
하트비트의 기본 간격도 다르다. Kafka 4.0 기준으로 classic의 heartbeat.interval.ms는 3초이고, consumer의 group.consumer.heartbeat.interval.ms는 5초다. consumer의 간격은 브로커에서 관리하며 응답에 담아 전달한다. 기본값은 컨슈머 설정 문서와 브로커 설정 문서를 기준으로 했다.
이 간격 차이는 요청률이 줄어들 수 있는 조건이다. 실제 요청률에는 멤버 수와 조정 과정도 영향을 주므로, 관측된 25.6% 감소를 간격 차이만으로 계산할 수는 없다.
5. 정리
배포 중 소비 지연에는 두 원인이 겹쳐 있었다. eager 리밸런스에서 이동 대상이 아닌 파티션까지 반납했고, pod 이름 기반 정적 멤버 ID가 배포마다 바뀌어 종료된 멤버의 세션 만료 45초를 기다려야 했다. 예시의 A와 B로 보면, P2와 P3만 옮기면 되는 상황에서 P0과 P1까지 멈추고, 그 멈춤이 A의 세션이 사라질 때까지 이어졌다.
Spring Kafka 컨테이너로 옮기면서 consumer 프로토콜을 적용하고, 정적 멤버십 기본값과 시작/종료 처리를 바꿨다. consumer 프로토콜에서는 코디네이터가 할당을 계산하고 A는 P2와 P3만 반납하며, 정상 종료 때 이탈을 알리므로 세션 만료를 기다리는 상황이 줄어든다. 기존의 오류 처리와 백프레셔 동작도 컨테이너 위에서 다시 맞췄다.
완료된 멤버 전환의 p95는 63.006초에서 0.718초로 줄었다. 프로토콜과 컨슈머 구현, 멤버십 설정, 시작과 종료 처리를 함께 바꿨으므로 단축 효과 중 프로토콜만의 기여분을 나누지는 못했다. 전환 후에도 긴 처리 시간, poll timeout, 커밋 실패가 관측됐으므로 짧아진 전환 시간이 곧 모든 컨슈머의 안정 실행을 뜻하는 것은 아니다.
6. 출처
프로토콜과 라이브러리 동작은 아래 공식 문서를 기준으로 했다.
6-1. Kafka 프로토콜과 설정
- KIP-848 설계 문서: 그룹 단위 동기화, 할당 조정, 멤버 epoch, 그룹 이탈과 OffsetCommit의 동작을 참고했다.
- Kafka 4.0 consumer 프로토콜 문서: 서버 측 기능 활성화와 클라이언트 설정 차이를 참고했다.
- Kafka 4.0 컨슈머 설정: 정적 멤버십, classic의 하트비트/세션 설정,
max.poll.interval.ms, assignor 설정을 참고했다. - Kafka 4.0 브로커 설정: consumer 프로토콜의 하트비트 간격 기본값을 참고했다.
6-2. Spring Kafka와 Kubernetes
- Reactor Kafka 유지보수 종료 공지: 1.3 계열을 마지막 마이너 버전으로 정한 공지를 참고했다.
- Spring Kafka 리스너 컨테이너: concurrency, 정적 멤버 ID 접미사, 배치 커밋 동작을 참고했다.
- Spring Kafka 오류 처리: 기본 오류 핸들러의 재시도/복구와
CommonLoggingErrorHandler의 동작을 참고했다. - CommonErrorHandler API:
isAckAfterHandle의 의미를 참고했다. - Spring Kafka 컨테이너 pause/resume: pause 요청이 적용되는 시점과 정지 중 poll 동작을 참고했다.
- Kubernetes pod 종료 과정: preStop과 종료 유예 시간의 관계를 참고했다.
'Infra > Kafka' 카테고리의 다른 글
| Kafka 리밸런스 프로토콜 비교: Classic과 KIP-848 Consumer (1) | 2026.08.31 |
|---|---|
| Kafka 파티션 증가 없이 처리량 늘리기 (feat. Parallel Consumer, Share Group) (0) | 2026.08.29 |