Apache Kafka 입문 정리 4편. Consumer와 Consumer Group을 공동 읽기 팀 비유로 풀어 — 역직렬화, Group Coordinator, Partition Assignment 4가지(Range/RoundRobin/Sticky/Cooperative Sticky), 리밸런싱(Eager vs Cooperative), Offset 커밋 전략, auto.offset.reset, Heartbeat·Session Timeout, at-most/at-least/exactly-once 처리, Static Membership까지 친절하게 정리.
이 글은 Apache Kafka 입문 정리 시리즈의 네 번째 편입니다. 3편에서 Producer가 게시판(Topic)에 글을 어떻게 안전하게 부치는지를 봤다면, 이번 편은 그 반대편 — 누가 어떻게 그 글을 읽어 가는지를 정리합니다.
Consumer는 처음에는 "그냥 메시지 가져오는 쪽"으로 단순하게 보이는데, 막상 들어가 보면 Consumer Group·Group Coordinator·Partition Assignment·리밸런싱·Offset 커밋·Heartbeat·Static Membership 같은 단어가 줄줄이 나와서 갑자기 머리가 무거워집니다. 게다가 "메시지를 한 번만 정확히 처리한다"는 말 한 줄 안에 at-most-once / at-least-once / exactly-once 같은 표현 세 개가 숨어 있어요.
다행히 이번에도 비유가 잘 먹힙니다. Consumer는 게시판에 가서 글을 읽는 사람, Consumer Group은 한 게시판을 팀으로 나눠 읽는 공동 읽기 팀이라고 잡고 가면 디테일이 자연스럽게 자기 자리를 찾습니다.
왜 이 단원이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, 단어가 비슷비슷해서 헷갈려요. Consumer·Consumer Group·Group Coordinator·Group Instance — 다 "Group"이 들어가는데 가리키는 게 다 달라요. 한 번 정리해 두지 않으면 글을 읽다가 자꾸 길을 잃습니다.
둘째, "한 번만 처리"가 진짜 어려운 문제예요. 분산 시스템에서 메시지를 정확히 한 번만 처리한다는 건 굉장히 까다로운 보장이라, 보통은 "최소 한 번(at-least-once)"으로 받고 컨슈머 쪽에서 중복을 막는 식으로 풀어요. 이 그림이 처음에는 잘 안 잡힙니다.
셋째, 리밸런싱이 보이지 않는 곳에서 일어나요. 컨슈머가 들어오거나 나갈 때마다 카프카는 내부에서 파티션 할당을 다시 짜는데, 이게 잘못 설계되면 운영 중에 지연이 튀어오릅니다. "왜 갑자기 처리가 멈췄지?"의 범인이 이 친구일 때가 많아요.
해결법은 한 가지예요. Consumer = 글 읽는 사람, Consumer Group = 공동 읽기 팀, Group Coordinator = 팀장이라고 잡고, 거기에 "팀원이 들어오거나 나갈 때 팀장이 분배를 다시 짠다 = 리밸런싱"을 얹어 풀면 깔끔합니다. 이 글은 그 비유를 따라 차근차근 풀어 갑니다.
Consumer가 하는 일 — 게시판에 가서 글 읽어 오기
Producer가 게시판(Topic)에 글을 부쳤다면, Consumer는 그 게시판을 구독해서 글을 읽어 오는 클라이언트입니다. 회사 비유로 — 사내 게시판에 새 공지가 올라오면 그걸 가져가서 자기 일에 쓰는 직원이에요.
가장 먼저 꼭 알아 둘 점 하나 — 카프카는 Push 방식이 아니라 Pull 방식입니다. 게시판이 직원에게 "야 새 글 올라왔어, 가져가!" 하고 밀어 주는 게 아니라, 직원이 주기적으로 게시판에 들러서 "새 글 있나요?" 하고 가져갑니다.
여기서 시험 함정이 하나 있어요. Pull 방식이라고 해서 카프카가 느린 게 아닙니다. 오히려 Pull이라서 좋은 게 — 컨슈머가 자기 처리 속도에 맞춰 가져갈 수 있어요. Push 방식이라면 게시판이 직원 처리 속도보다 빠르게 밀어 넣을 때 직원이 무너지는데, Pull은 직원이 천천히 가져가면 그만입니다.
Consumer가 데이터를 가져오는 방법은 단순해요.
while (계속) {
records = consumer.poll(100ms); ← 100ms 동안 새 글 가져오기
for (record in records) {
처리(record);
}
}
이걸 폴링 루프(polling loop) 라고 부르고, 이게 Consumer의 심장 박동이에요. 100ms 안에 새 글이 있으면 즉시 가져오고, 없으면 100ms 기다렸다 빈 결과를 받습니다.
폴링 루프가 한 번 돌 때마다 카프카는 단순히 메시지만 주는 게 아니라 백그라운드로 세 가지를 같이 처리해 줘요.
- 새 메시지 가져오기
- 리밸런싱이 필요하면 처리
- Heartbeat(생존 신호) 전송
그래서 폴링 루프를 자주 돌려야 컨슈머가 "나 살아있어요" 신호를 잘 보낼 수 있어요. 한 번 가져온 메시지를 너무 오래 처리하면 카프카가 "이 친구 죽었나?" 하고 의심하기 시작합니다. 이 부분은 뒤에서 다시 풀어요.
Consumer가 메시지를 읽는 순서
한 가지 또렷이 알아 둘 게 있어요. 한 파티션 안에서는 오프셋 낮은 순 → 높은 순으로 읽습니다. 즉 한 파티션 안에서 순서는 보장돼요. 0번 메시지를 읽고, 1번을 읽고, 2번을 읽고… 절대 거꾸로 가지 않습니다.
하지만 여러 파티션 사이의 순서는 보장되지 않습니다. P0의 5번 메시지와 P1의 3번 메시지 중 누가 먼저 읽힐지는 카프카가 보장 안 해요. 글로벌 순서가 필요하면 토픽을 파티션 1개로 만들어야 하는데, 그러면 처리량을 못 살리는 트레이드오프가 생기죠. 2편에서 다룬 "키 → 같은 파티션" 규칙을 다시 떠올리면, 같은 키는 같은 파티션이라 그 안에서는 순서가 보장된다는 결과로 연결됩니다.
역직렬화(Deserializer) — Producer 반대 작업
3편에서 Producer가 객체를 바이트로 바꿔서 보냈다고 했죠. 카프카 안에서는 데이터가 바이트 상태로만 있어요. 그래서 Consumer는 받은 바이트를 다시 자바 객체로 되돌려 놔야 하는데, 그 일을 하는 게 Deserializer(역직렬화기) 입니다.
props.setProperty(KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
props.setProperty(VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
여기서 시험 함정이 하나 있어요. Producer의 Serializer와 Consumer의 Deserializer는 반드시 짝이 맞아야 합니다. Producer가 StringSerializer로 보냈는데 Consumer가 IntegerDeserializer로 받으려 하면 그대로 깨져요. 회사 비유로 — 한쪽이 한국어로 쓴 편지를 다른 쪽이 영어로 읽으려 하는 격이에요.
지원되는 기본 Deserializer는 다음과 같아요.
| 종류 | 용도 |
|---|---|
StringDeserializer | 문자열 |
IntegerDeserializer, LongDeserializer, FloatDeserializer | 숫자형 |
ByteArrayDeserializer | 가장 원시적인 바이트 배열 |
KafkaAvroDeserializer | Avro 포맷 (Schema Registry 필요) |
운영 환경에서 본격 데이터 파이프라인을 짜면 거의 다 Avro + Schema Registry 조합으로 갑니다. 1편에서 봤던 그 Schema Registry예요. 짝 맞추기 규칙·진화(evolution) 정책은 Confluent Schema Registry 공식 문서에 자세히 정리돼 있어요.
Consumer Group — 공동 읽기 팀
이제 핵심 개념입니다. Consumer 한 명이 게시판 전체를 다 읽어도 되긴 하지만, 게시판이 커지면 한 명이 다 읽기엔 너무 무거워요. 그래서 여러 Consumer를 하나의 팀으로 묶고, 그 팀이 게시판을 나눠서 읽는 구조가 필요해집니다. 이 팀이 Consumer Group 이에요.
회사 비유로 풀면 — 알림 부서가 주문 게시판을 읽어야 하는데, 주문이 너무 많아서 한 사람으로는 못 따라가요. 그래서 알림 부서 안에 공동 읽기 팀을 짜고, 팀원 셋이 게시판을 나눠서 읽기로 해요. 0번 칸은 A, 1번 칸은 B, 2번 칸은 C가 맡는 식이죠.
핵심 규칙 두 개만 외우면 됩니다.
- 한 파티션은 같은 Consumer Group 안에서 한 명에게만 할당돼요. A가 P0를 맡으면 B는 P0를 못 가져가요. 같은 글을 두 사람이 동시에 처리하면 일이 두 번 돌아가니까요.
- 여러 Consumer Group은 같은 토픽을 독립적으로 읽을 수 있어요. 알림팀이 주문 게시판을 읽고 있어도, 분석팀이 같은 게시판을 따로 처음부터 다시 읽을 수 있습니다.
주문 게시판: P0, P1, P2
알림팀(Consumer Group A):
P0 → 팀원 A1
P1 → 팀원 A2
P2 → 팀원 A3
분석팀(Consumer Group B):
P0, P1, P2 → 팀원 B1 (혼자 다 읽음)
감사팀(Consumer Group C):
P0, P1 → 팀원 C1
P2 → 팀원 C2
세 팀이 같은 게시판을 보고 있는데도 서로 간섭이 없어요. 각 팀은 자기 팀 책갈피(Offset)를 따로 들고 있거든요. 1편에서 카프카가 RabbitMQ와 다른 결정적 차이로 든 게 바로 이 구조입니다 — 여러 소비자가 같은 데이터를 각자 속도로 다회 읽을 수 있다.
Consumer 수와 Partition 수의 관계
여기는 시험에도 자주 나오고 실무에서도 첫 번째로 만나는 함정 자리예요.
파티션 3개일 때:
Consumer 1명: 한 명이 P0, P1, P2 다 처리
Consumer 2명: 한 명이 P0+P1, 다른 한 명이 P2
Consumer 3명: 1:1로 깔끔하게 분배 (최적)
Consumer 4명: 한 명이 놀게 됨 (idle)
여기서 시험 함정이 하나 있어요. 컨슈머 수를 파티션 수보다 많이 늘려도 처리량은 늘지 않습니다. 한 파티션은 그룹 내 한 명만 가져갈 수 있다는 규칙 때문에, 남는 컨슈머는 그냥 놀아요. 회사 비유로 — 게시판이 3개 칸으로 나뉘어 있는데 5명이 와 있으면 2명은 할 일이 없는 거죠.
처리량을 늘리고 싶다면 파티션 수부터 먼저 늘려야 합니다. 그게 카프카에서 처리량의 상한을 정하는 가장 큰 손잡이예요. 단, 2편에서 본 대로 파티션 수는 한 번 늘리면 줄이기 힘드니까 처음 설계할 때 신중해야 합니다.
Group Coordinator — 팀을 굴리는 팀장
Consumer Group이 잘 굴러가려면 누가 누구를 어디에 배치할지 정해 줄 사람이 필요해요. 그게 Group Coordinator 입니다. 회사 비유로 — 알림팀의 팀장이에요. 팀원이 새로 들어오거나 누가 나가면 칸 분배를 다시 짜고, 팀원들이 살아 있는지 정기적으로 확인하는 사람.
Group Coordinator는 카프카 클러스터 안에 있는 어떤 브로커가 됩니다. 컨슈머가 처음 그룹에 들어올 때 — __consumer_offsets 토픽의 어느 파티션이 그 그룹을 담당하는지 보고, 그 파티션의 리더 브로커가 그 그룹의 코디네이터가 돼요.
코디네이터가 하는 일은 세 가지예요.
- 그룹 멤버십 관리 — 누가 들어왔고 누가 나갔는지 추적
- 파티션 할당 조율 — 누가 어떤 파티션을 맡을지 결정 (정확히는 그룹 리더가 정하고 코디네이터가 전달)
- Heartbeat 수신 — 팀원들의 생존 신호 받기
여기서 시험 함정이 하나 있어요. 파티션 할당 알고리즘은 그룹 리더(Group Leader, 그룹에서 가장 먼저 들어온 컨슈머) 가 직접 돌리고, 코디네이터는 그 결과를 모두에게 뿌려 주기만 합니다. 즉 코디네이터 = 브로커, 그룹 리더 = 컨슈머. 둘은 다른 친구예요.
Partition Assignment Strategy — 칸을 어떻게 나눌까
팀장이 칸을 분배할 때, 그 분배 규칙을 정하는 게 Partition Assignment Strategy(파티션 할당 전략) 입니다. 카프카에는 네 가지가 있어요. 처음에는 다 외우려 하지 말고, 이름과 한 줄 특징만 잡고 가도 충분합니다.
1. RangeAssignor — 토픽별로 범위로 나누기
각 토픽 단위로 파티션을 컨슈머 수만큼 범위로 나눠 분배하는 방식이에요.
토픽 T1 (P0, P1, P2, P3) + Consumer A, B 두 명
→ A: P0, P1 / B: P2, P3
문제가 하나 — 여러 토픽을 같이 구독하면 한 컨슈머에 부담이 쏠려요. 토픽 두 개가 다 4파티션이면 A가 두 토픽의 앞 절반(P0, P1)을 다 들고 가서 일이 몰립니다. 카프카 기본값이 이거였지만, 점점 다른 전략으로 옮겨 가는 분위기예요.
2. RoundRobinAssignor — 순환 분배
여러 토픽의 파티션을 한 줄로 늘어놓고 컨슈머에게 한 칸씩 돌려가며 분배해요.
T1-P0 → A, T1-P1 → B, T2-P0 → A, T2-P1 → B...
부담이 더 고르게 퍼지는 장점이 있어요. 다만 다음 단점이 있어요 — 리밸런싱 시 모든 할당이 새로 짜져요. 잘 일하던 컨슈머도 칸이 다 바뀝니다.
3. StickyAssignor — 가능하면 자기 자리 유지
기본은 RoundRobin과 비슷하게 균등 분배인데, 리밸런싱 시 가능하면 기존 할당을 유지해 줘요. 회사 비유로 — 새 팀원이 들어와도 기존 팀원은 자기 칸 그대로 두고, 필요한 만큼만 새 팀원에게 떼어 줍니다.
리밸런싱 비용이 줄어드는 게 장점인데, 다만 여전히 Eager 방식이라 리밸런싱이 시작되면 잠시 모두 멈춰요(이 부분은 다음 절에서 풀어요).
4. CooperativeStickyAssignor — 멈추지 않는 점진적 재배치 (권장)
Sticky의 장점을 가져가면서 Cooperative(점진적) 리밸런싱까지 적용한 친구. 카프카 2.4부터 들어왔고, 지금 거의 표준으로 쓰입니다.
특징을 한 줄로 — 변경이 필요한 파티션만 살짝 옮기고, 나머지는 계속 일해요.
여기서 시험 함정이 하나 있어요. 이름에 "Cooperative"가 들어가는 건 CooperativeStickyAssignor 하나뿐입니다. RoundRobin과 Sticky는 이름에 그게 없어요. 전략 이름을 헷갈리지 않게 외워 두세요.
props.setProperty(PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
CooperativeStickyAssignor.class.getName());
리밸런싱 — Consumer가 들어오거나 나갈 때
리밸런싱(Rebalancing) 은 컨슈머 그룹의 파티션 할당을 다시 짜는 일 이에요. 회사 비유로 — 팀원 한 명이 휴가 가거나 새 인원이 들어오면 팀장이 칸 분배를 다시 짜는 거죠.
언제 리밸런싱이 일어날까요?
- 새 컨슈머가 그룹에 들어올 때
- 기존 컨슈머가 그룹을 떠날 때(정상 종료든 비정상 종료든)
- 구독 중인 토픽의 파티션 수가 늘 때
- 컨슈머가 Heartbeat를 못 보내서 죽었다고 판정될 때
문제는 리밸런싱이 운영 중에 일어나면 처리가 잠시 멈춘다는 거예요. 이걸 어떻게 줄이느냐가 카프카 운영의 큰 주제 중 하나입니다.
Eager Rebalance — 모두 정지(Stop-the-World)
전통 방식이에요. 리밸런싱이 시작되면 모든 컨슈머가 자기가 들고 있던 파티션을 한 번 다 반납해요. 그리고 새로 분배가 끝날 때까지 아무도 메시지를 처리 안 합니다.
Before: A ← P0, P1 / B ← P2
새 팀원 C 참여
↓
모두 정지: 메시지 처리 안 됨 (STW)
↓
재할당 끝
↓
After: A ← P0 / B ← P1 / C ← P2
회사 비유로 — 새 팀원 한 명 들어왔다고 모든 팀원이 일을 다 멈추고 책상을 비운 다음, 팀장이 자리 다시 정해 주면 그제야 일을 시작하는 격이에요. 사람이 셋이면 별일 아닌데, 수십~수백 명짜리 컨슈머 그룹이라면 매번 멈추는 시간이 만만찮습니다.
Cooperative Rebalance — 멈추지 않고 점진적 재배치
카프카 2.4부터 들어온 방식이에요. 변경이 필요한 파티션만 살짝 옮기고 나머지는 계속 일해요.
Before: A ← P0, P1 / B ← P2
새 팀원 C 참여
↓
A가 P1만 반납 (P0는 계속 처리)
B는 그대로
↓
C에게 P1 할당
↓
After: A ← P0 / B ← P2 / C ← P1
A는 P1만 잠깐 멈추고 P0는 끊김 없이 처리합니다. B는 아예 영향이 없어요. STW가 사라지는 거죠.
여기서 시험 함정이 하나 있어요. Cooperative Rebalance를 쓰려면 할당 전략을 CooperativeStickyAssignor로 바꿔 줘야 합니다. 그냥 RangeAssignor 기본값으로는 Eager 방식이 그대로 굴러가요. 카프카 신규 프로젝트라면 거의 무조건 CooperativeSticky로 시작하는 게 정답입니다.
Offset 커밋 — Consumer가 어디까지 읽었는지 책갈피
Consumer가 메시지를 읽고 처리한 다음, "여기까지 읽었어요" 라고 표시를 남겨야 다음에 재시작했을 때 그 자리에서 이어서 읽을 수 있어요. 이 표시가 Offset 커밋 이고, 카프카는 이걸 __consumer_offsets 라는 내부 토픽에 저장합니다.
회사 비유로 — 두꺼운 책을 읽다가 책갈피를 끼워 두는 거예요. 다음에 책을 펼치면 책갈피 자리부터 이어서 읽으면 되니까요.
저장되는 건 단순한 키-값 구조예요.
(Group ID, Topic, Partition) → Offset
("notification-team", "orders", 0) → 42
("notification-team", "orders", 1) → 37
("notification-team", "orders", 2) → 55
notification-team 그룹의 orders 토픽 0번 파티션은 42까지 읽었다는 뜻이에요. 그룹별로 따로 저장되니까, 같은 토픽을 여러 그룹이 읽고 있어도 책갈피가 안 섞입니다.
auto.offset.reset — 책갈피가 없을 때 어디부터?
새 컨슈머 그룹이 처음 시작하면 책갈피가 없어요. 또는 책갈피가 너무 오래되어 보존 기간이 지나면 카프카에서 사라질 수도 있어요. 이때 어디부터 읽기 시작할지 정하는 게 auto.offset.reset 설정입니다. 세 값을 외우면 됩니다.
| 값 | 의미 |
|---|---|
earliest | 처음부터 다시 읽기 (가장 오래된 메시지부터) |
latest | 지금부터만 읽기 (기본값, 새로 들어오는 메시지만) |
none | 책갈피가 없으면 예외 발생 |
여기서 시험 함정이 하나 있어요. 이 설정은 책갈피가 없을 때만 적용됩니다. 이미 책갈피가 있다면 항상 그 자리부터 읽어요. "earliest로 바꾸면 다시 처음부터 읽겠지?" 하고 기대해도 안 됩니다. 처음부터 다시 읽고 싶으면 책갈피를 먼저 지워야 해요(컨슈머 그룹 리셋 명령으로). 이건 운영 중 자주 헷갈리는 자리니까 한 번 더 적어 둘게요.
회사 비유로 — earliest는 "처음 책 펼친 사람은 1쪽부터 읽기", latest는 "처음 책 펼친 사람은 지금 펼친 자리부터 읽기"예요. 기존에 다른 사람이 책갈피를 끼워 둔 자리가 있으면 그 자리부터 읽지, 1쪽부터 다시 시작하지 않는다는 게 핵심이에요.
자동 커밋 vs 수동 커밋
카프카는 두 가지 커밋 방식을 제공합니다.
자동 커밋(Auto Commit) 은 기본값이고, 5초마다 폴링 루프 안에서 자동으로 책갈피를 저장해요.
props.setProperty(ENABLE_AUTO_COMMIT_CONFIG, "true");
props.setProperty(AUTO_COMMIT_INTERVAL_MS_CONFIG, "5000");
편하긴 한데 위험이 있어요 — 메시지를 읽고 아직 처리도 끝나기 전에 책갈피가 먼저 저장될 수도 있어요. 그 사이에 컨슈머가 죽으면, 처리 안 된 메시지의 책갈피만 저장돼서 그 메시지는 영영 처리되지 않을 수 있습니다(데이터 손실, at-most-once).
수동 커밋(Manual Commit) 은 이 위험을 풀려고 자동 커밋을 끄고 코드에서 직접 책갈피를 찍는 방식이에요.
props.setProperty(ENABLE_AUTO_COMMIT_CONFIG, "false");
while (true) {
records = consumer.poll(100ms);
for (record in records) {
processRecord(record); // 처리 먼저
}
consumer.commitSync(); // 다 처리한 다음 책갈피
}
처리 → 커밋 순서를 코드가 보장해 주니까, 처리 완료 후에만 책갈피가 찍혀요. 도중에 죽으면 책갈피가 옛날 자리 그대로라, 다시 시작하면 그 메시지부터 다시 처리합니다(중복 가능, at-least-once). 이게 운영 환경 표준 패턴이에요.
여기서 시험 함정이 하나 있어요. commitSync() 는 동기, commitAsync() 는 비동기입니다.
| 종류 | 특징 | 언제 쓸까 |
|---|---|---|
commitSync() | 커밋 응답이 올 때까지 대기, 실패 시 재시도 자동 | 안정성 우선, 종료 시점 |
commitAsync() | 응답 안 기다리고 즉시 다음 폴링, 콜백으로 결과 받음 | 처리량 우선, 일반 루프 |
운영 패턴 한 가지 — 루프 안에서는 commitAsync() 로 처리량 살리고, 컨슈머 종료 직전에는 commitSync() 로 마지막 책갈피만큼은 확실히 찍는 조합이 자주 쓰여요.
At-Most-Once / At-Least-Once / Exactly-Once
오프셋 커밋 시점에 따라 메시지 전달 보장(Delivery Semantics) 이 달라집니다. 시험에 정말 자주 나오는 자리니까 한 번에 정리할게요.
At-Most-Once — 최대 한 번 (잃을 수 있음)
메시지를 읽자마자 책갈피부터 찍고, 그 다음에 처리.
poll()
↓
commitSync() ← 책갈피 먼저
↓
processRecord() ← 여기서 죽으면 그 메시지 영원히 손실
회사 비유로 — 책갈피 먼저 끼우고 그 자리 글을 읽기 시작했는데 도중에 일이 멈췄어요. 다시 켜면 책갈피가 이미 다음 자리에 있으니까 그 글은 다시 안 읽혀요. 메시지는 0번 또는 1번 처리됩니다 — 절대 두 번은 안 되지만, 잃을 수 있어요. 손실이 허용되는 자리(예: 단순 메트릭)에서나 쓰지, 일반적으로는 권장하지 않아요.
At-Least-Once — 최소 한 번 (중복될 수 있음)
처리 먼저 끝낸 다음에 책갈피를 찍어요. 카프카 운영의 표준이에요.
poll()
↓
processRecord() ← 처리 먼저
↓
commitSync() ← 다 했으면 책갈피
처리 도중 죽으면 책갈피가 옛 자리 그대로라, 다시 시작하면 같은 메시지를 또 가져옵니다. 메시지는 1번 이상 처리됩니다 — 잃지는 않지만 중복될 수 있어요. 손실은 막을 수 있어 좋지만, 중복이 신경 쓰이면 컨슈머 측에서 중복 제거 로직(멱등성)을 짜야 해요.
회사 비유로 — 처리할 때마다 영수증을 남기고, 다시 시작하면 영수증 본 적 있는 작업은 건너뛰는 식이에요.
Exactly-Once — 정확히 한 번
진짜 정확히 한 번만 처리. 가장 어려운 보장이고, 쉽게 풀려고 하면 함정에 빠집니다.
여기서 시험 함정이 하나 있어요. 카프카 → 카프카 사이의 Exactly-Once는 가능합니다. Kafka Streams 같은 도구나 Producer의 트랜잭션 API를 쓰면 카프카에서 읽고 가공해서 다시 카프카에 쓸 때 정확히 한 번이 보장돼요.
하지만 카프카 → 외부 시스템(데이터베이스·검색 엔진 등) 까지의 Exactly-Once는 외부 시스템에서 멱등성(같은 키로 두 번 들어와도 한 번 들어온 것과 같은 결과) 을 보장하지 않으면 어렵습니다. 일반적으로는 At-Least-Once + 외부 시스템의 멱등성 처리 조합으로 풉니다.
회사 비유로 — 컨슈머가 같은 주문 ID로 두 번 보내도, 데이터베이스가 "이 ID로 이미 들어왔네?" 하고 한 번만 처리하게 만드는 거예요. 카프카에서 본 OpenSearch 파이프라인 예제도 정확히 이 패턴을 씁니다 — 메시지에서 고유 ID를 뽑아서 OpenSearch 문서 ID로 넣어 두면, 같은 메시지가 두 번 와도 같은 문서를 덮어쓰기만 해서 결과가 한 번 처리한 것과 같아져요.
Heartbeat와 Session Timeout — Consumer 생존 신호
팀장(Group Coordinator)은 팀원이 살아있는지 어떻게 알까요? 컨슈머는 백그라운드 스레드에서 주기적으로 코디네이터에게 "나 살아있어요" 신호를 보냅니다. 이게 Heartbeat 예요.
신호가 일정 시간 동안 안 오면 코디네이터는 그 컨슈머가 죽었다고 판단하고, 리밸런싱을 트리거해서 그 친구가 들고 있던 파티션을 다른 컨슈머에게 넘깁니다.
이걸 다루는 설정이 네 개 있어요. 처음에는 다 외우지 말고 session.timeout.ms 와 max.poll.interval.ms 둘만 또렷이 잡고 가면 충분해요. 옵션별 정확한 기본값은 Kafka 공식 문서의 Consumer 설정에서 확인할 수 있어요.
| 설정 | 기본값 | 역할 |
|---|---|---|
heartbeat.interval.ms | 3초 | Heartbeat 보내는 주기 |
session.timeout.ms | 45초 | 이 시간 안에 Heartbeat 없으면 죽었다고 판정 |
max.poll.interval.ms | 5분 | 두 번의 poll() 호출 사이 최대 허용 시간 |
max.poll.records | 500 | 한 번 poll() 에서 가져오는 최대 레코드 수 |
여기서 시험 함정이 하나 있어요. session.timeout.ms는 Heartbeat 기준이고, max.poll.interval.ms는 폴링 루프 기준이에요. 둘은 다릅니다.
- Heartbeat가 안 오면 →
session.timeout.ms초과 → 죽은 걸로 판정 → 리밸런싱 - 폴링 루프가 너무 오래 안 돌아가면 →
max.poll.interval.ms초과 → 죽은 걸로 판정 → 리밸런싱
전자는 컨슈머 프로세스 자체가 죽거나 네트워크가 끊긴 경우, 후자는 프로세스는 살아있는데 메시지 처리가 너무 오래 걸려서 폴링을 못 돌리는 경우예요. 이 둘이 분리돼 있는 이유는, 처리가 오래 걸린다고 해서 컨슈머가 죽은 건 아니지만 카프카 입장에서는 "이 친구는 일을 못 따라가고 있으니 다른 사람에게 넘기자"라는 판단이 필요하기 때문이에요.
운영 팁을 두 개 적어 두면 — 메시지 처리 시간이 길면 max.poll.interval.ms를 늘리거나 max.poll.records를 줄여서 한 번에 가져오는 양을 줄이면 됩니다. 5분 안에 500건 처리가 빠듯하다면, 100건씩만 가져오게 줄이면 폴링 간격이 짧아져요.
Static Membership — 자기 자리 그대로 유지하기
쿠버네티스 같은 환경에서는 컨슈머 Pod이 잠깐 재시작했다가 곧 돌아오는 경우가 흔해요. 기본 동작이라면 컨슈머가 사라지자마자 코디네이터가 리밸런싱을 시작하고, 컨슈머가 돌아오면 다시 한 번 리밸런싱이 일어나요. 이게 매번 일어나면 운영이 꽤 시끄러워집니다.
이걸 풀려고 만든 게 Static Group Membership 이에요. 회사 비유로 — 팀원이 잠깐 자리 비워도 자기 자리 그대로 유지하고, 일정 시간 안에 돌아오면 칸을 다시 짜지 않고 그냥 일을 이어서 하게 해 줘요.
props.setProperty(GROUP_INSTANCE_ID_CONFIG, "consumer-instance-1");
props.setProperty(SESSION_TIMEOUT_MS_CONFIG, "60000");
핵심은 group.instance.id 라는 고정 ID를 컨슈머에게 부여하는 거예요. 이 ID가 같으면 카프카는 "이 친구는 그 자리 사람이네" 하고 인식해요. 그리고 session.timeout.ms를 좀 길게 잡아서, 그 시간 안에 같은 ID로 돌아오면 리밸런싱을 건너뜁니다.
여기서 시험 함정이 하나 있어요. Static Membership을 쓰려면 컨슈머마다 group.instance.id가 유일해야 합니다. 두 컨슈머가 같은 ID를 들고 들어오면 카프카가 혼란을 일으켜요. 보통은 호스트네임이나 Pod 이름으로 채웁니다.
언제 쓰면 좋을까요?
- 쿠버네티스 Pod 롤링 업데이트
- 컨슈머가 자주 재시작되는 환경
- 리밸런싱 비용이 큰 대규모 컨슈머 그룹
반대로 컨슈머 수가 많이 안 변하고 안정적이면 굳이 안 써도 됩니다.
가까운 복제본에서 읽기 (Kafka 2.4+)
3편에서 봤듯이 카프카 파티션은 리더 + 팔로워 복제본 구조예요. 카프카 2.4 이전에는 컨슈머가 항상 리더에서만 읽었어요. 한 데이터센터에 리더가 있고 다른 데이터센터에 컨슈머가 있다면, 그 거리만큼 매번 네트워크 지연이 깔리는 거죠.
DC1 (US-East) DC2 (US-West)
Broker 1: P0 [Leader] ← Consumer (US-West)에서 매번 호출
네트워크 지연 발생
카프카 2.4부터는 컨슈머가 자기와 같은 rack에 있는 가까운 복제본(Preferred Read Replica) 에서 읽을 수 있게 됐어요.
DC1 (US-East) DC2 (US-West)
Broker 1: P0 [Leader] Broker 2: P0 [Replica]
↑
Consumer (US-West)
가까운 Broker 2에서 읽기 → 지연 감소
설정은 두 군데 손봐야 해요.
# 브로커 (server.properties)
broker.rack=us-east-1a
# 컨슈머
client.rack=us-east-1a
여기서 시험 함정이 하나 있어요. 쓰기(Producer)는 여전히 리더에게만 갑니다. 가까운 복제본 읽기는 읽기 경로에만 적용돼요. 쓰기 경로는 데이터 일관성 때문에 리더가 단일 진입점이라는 규칙이 그대로 유지됩니다.
이 기능은 여러 데이터센터·가용 영역에 걸친 클러스터에서 빛나요. 컨슈머가 한 리전 안에 있다면 차이가 크지 않습니다.
그레이스풀 셧다운과 특정 파티션 읽기
마지막으로 운영에서 자주 만나는 두 가지 패턴을 짧게 정리할게요.
그레이스풀 셧다운 — wakeup() 패턴
컨슈머를 끌 때 마지막 책갈피를 찍고 그룹에서 깔끔히 빠져나가게 만들어야 해요. 자바에서는 Runtime.getRuntime().addShutdownHook() 으로 종료 신호를 받고, 메인 루프 안의 poll() 을 강제로 깨우는 consumer.wakeup() 을 호출합니다.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
consumer.wakeup(); // poll()이 WakeupException 발생
}));
try {
while (true) { consumer.poll(...); }
} catch (WakeupException e) {
// 정상 종료 신호
} finally {
consumer.close(); // 책갈피 커밋 + 그룹 탈퇴 → 즉시 리밸런싱
}
consumer.close() 가 핵심이에요. 자동으로 마지막 책갈피를 커밋하고 그룹에서 즉시 빠져나가서, Session Timeout 기다릴 필요 없이 다른 컨슈머가 그 파티션을 바로 가져갈 수 있어요. 그레이스풀 셧다운이 안 되어 있으면 컨슈머가 죽었는지 카프카가 알아챌 때까지 45초를 기다려야 합니다.
assign() + seek() — 그룹 없이 직접 읽기
일반적으로는 subscribe() 로 토픽을 구독하고 카프카에게 파티션 분배를 맡기지만, 특정 파티션의 특정 오프셋부터 직접 읽고 싶을 때가 있어요. 데이터 재처리·디버깅·마이그레이션 같은 자리예요.
TopicPartition tp = new TopicPartition("orders", 0);
consumer.assign(Arrays.asList(tp));
consumer.seek(tp, 15); // 0번 파티션 오프셋 15부터 읽기
assign() 으로 직접 파티션을 잡으면 Consumer Group과 무관하게 동작해요. 책갈피도 안 찍고, 리밸런싱도 안 일어납니다. "이번 한 번만 특정 자리 데이터 다시 봐야 해" 라는 일회성 작업에 잘 어울려요.
여기서 시험 함정이 하나 있어요. subscribe() 와 assign() 은 같은 컨슈머에서 같이 쓸 수 없어요. 하나를 골라야 합니다. 그룹 단위로 협업해야 하면 subscribe(), 일회성 직접 접근이면 assign() — 자기 자리가 명확해요.
시험 직전 한 번 더 — Consumer 압축 노트
여기까지가 카프카 4편의 핵심입니다. 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Consumer는 Pull 방식 — 카프카가 밀어주는 게 아니라 컨슈머가 폴링 루프로 가져옴
- 한 파티션 안에서는 오프셋 낮은 순 → 높은 순, 파티션 사이 순서는 보장 안 됨
- Deserializer는 Producer의 Serializer와 짝이 맞아야 함 (StringSerializer ↔ StringDeserializer)
- Consumer Group = 같은 토픽을 공동으로 읽는 팀 (Group ID로 식별)
- 한 파티션은 같은 그룹 안에서 한 컨슈머에게만 할당
- 여러 그룹은 같은 토픽을 독립적으로 읽음 (각자 책갈피)
- 컨슈머 수 > 파티션 수면 남는 컨슈머는 idle — 처리량 늘리려면 파티션부터 늘려야
- Group Coordinator = 브로커, 그룹 멤버십·할당 조율·Heartbeat 관리
- Group Leader = 컨슈머, 실제 파티션 할당 알고리즘을 돌림
- Partition Assignment 4종 — Range / RoundRobin / Sticky / CooperativeSticky
- 카프카 2.4+ 권장은 CooperativeStickyAssignor (Cooperative + Sticky)
- 리밸런싱 발생 — 컨슈머 합류·탈퇴, 파티션 추가, Heartbeat 끊김
- Eager Rebalance = 모두 정지 후 재할당 (STW), Cooperative Rebalance = 점진적, 멈춤 없음
- 오프셋은
__consumer_offsets내부 토픽에 저장, (Group, Topic, Partition) → Offset auto.offset.reset= 책갈피 없을 때만 적용 —earliest(처음부터) /latest(지금부터, 기본) /none(예외)- 자동 커밋: 5초마다 자동, 편하지만 데이터 손실 위험 (At-Most-Once 가능성)
- 수동 커밋: 처리 후
commitSync()/commitAsync()— 운영 표준 (At-Least-Once) - At-Most-Once = 잃을 수 있음, At-Least-Once = 중복 가능, Exactly-Once = 정확히 한 번
- 카프카 → 카프카 Exactly-Once는 가능 (Kafka Streams), 카프카 → 외부는 보통 At-Least-Once + 멱등성으로 풀음
- Heartbeat = 살아있어요 신호, 백그라운드 스레드에서 3초마다
session.timeout.ms(45초, Heartbeat 기준) ≠max.poll.interval.ms(5분, 폴링 기준) — 둘은 다름- 처리 시간이 길면
max.poll.interval.ms늘리거나max.poll.records줄이기 - Static Membership =
group.instance.id부여로 짧은 재시작 시 리밸런싱 회피 - 쿠버네티스 Pod 롤링 업데이트에 잘 어울림, ID는 컨슈머마다 유일해야
- 카프카 2.4+ 가까운 복제본 읽기 —
client.rack+broker.rack설정, 읽기만 적용 (쓰기는 여전히 리더) - 그레이스풀 셧다운: ShutdownHook에서
consumer.wakeup(), finally에서consumer.close() subscribe()= 그룹으로 협업,assign()+seek()= 그룹 없이 직접 접근, 둘은 같이 못 씀
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.