Apache Kafka 입문 정리 2편. 카프카의 모든 동작이 시작되는 핵심 아키텍처를 우체국 비유로 풀어 — Topic(주제별 게시판), Partition(여러 칸), Offset(번호표), Broker(보관소), Replication(복제), Leader/Follower/ISR, Zookeeper에서 KRaft로의 전환, 메시지 키와 순서 보장까지 친절하게 정리.
이 글은 Apache Kafka 입문 정리 시리즈의 두 번째 편입니다. 1편에서 "카프카가 도대체 뭘 해 주는 시스템인지"를 회사 중앙 우체국 비유로 잡아 봤다면, 이번 2편은 그 우체국 안으로 한 걸음 들어가는 단계예요. 카프카 아키텍처의 모든 동작 원리가 Partition에서 시작되니, 이 글만큼은 다른 편보다 한 번 더 곱씹어 두는 게 좋습니다.
다루는 주제가 좀 많아 보일 수 있어요. Topic·Partition·Offset·Broker·Replication·Leader/Follower·ISR·Zookeeper·KRaft·메시지 키·bootstrap server. 그런데 다행히 이것들이 Partition을 중심으로 한 줄로 연결되는 그림이 있습니다. 그 그림 하나만 머리에 박아 두면 나머지는 그 위에 자연스럽게 얹혀요. 이번 글이 그 한 줄 그림을 잡아 드리는 게 목표예요.
왜 이 단원이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, 단어가 너무 많이 한꺼번에 쏟아져요. Topic만 알면 되는 게 아니라 그 안에 Partition이 있고, Partition 안에 Offset이 있고, Partition은 Broker에 분산되고, 그 Broker들은 Cluster를 이루고, 같은 Partition은 여러 Broker에 복제되고, 그중 하나가 Leader고 나머지는 Follower고, 그 Follower 중 따라잡은 애들만 ISR이고… 한 문장 안에 단어 8개가 등장하니 머리가 핑 돌죠.
둘째, "왜 이렇게 쪼개 놓는 거지?"라는 의문이 안 풀려요. 처음 보면 "그냥 하나의 큰 게시판에 다 모으면 되는 거 아냐?" 싶어집니다. 카프카가 토픽을 굳이 파티션으로 쪼개고, 같은 데이터를 굳이 여러 군데 복사해 두는 데는 다 이유가 있는데, 그 이유가 코드를 본격적으로 짜 보기 전엔 잘 와 닿지 않아요.
셋째, 분산 시스템 특유의 "한 명이 죽으면?" 시나리오가 머리에 안 그려져요. 카프카는 브로커 한두 대가 죽어도 동작해야 하는 시스템이에요. 그래서 모든 설계가 "한 명이 죽었을 때 누가 무엇을 이어받는가"라는 질문에 답하는 형태로 짜여 있습니다. 이 관점이 잡히기 전에는 ISR이니 Leader 선출이니 하는 단어가 그냥 외워야 할 단어로만 느껴져요.
해결법은 한 가지예요. "우체국 본부 = Cluster, 보관소 = Broker, 게시판 = Topic, 게시판의 칸 = Partition, 칸 안의 게시글 번호표 = Offset" — 이 비유 사슬을 머리에 박고 시작하는 겁니다. 그 위에 "같은 칸의 사본을 여러 보관소에 두고, 그중 한 보관소가 책임자(Leader)예요"라는 한 줄을 얹으면 갑자기 그림이 맞춰져요. 이번 글은 정확히 이 순서로 풀어 갑니다.
토픽(Topic) — 주제별 게시판
먼저 가장 바깥 단위인 토픽부터 봅시다.
토픽은 카프카에서 특정 데이터 스트림에 이름을 붙인 것이에요. 우체국 비유로 풀면 주제별 게시판입니다. "주문 게시판", "결제 게시판", "트럭 GPS 게시판"처럼 주제마다 하나씩 만들어 두는 거예요.
데이터베이스의 테이블과 비슷해 보이지만 결정적인 차이가 있습니다. 쿼리를 던질 수 없어요. 토픽은 그냥 메시지가 시간 순서대로 쌓이는 자리이지, 거기에 SQL을 던져 "어제 주문 중 1만원 이상" 같은 걸 골라낼 수는 없습니다. 그건 데이터베이스나 ksqlDB의 자리예요(1편에서 짚었죠).
토픽의 특성을 정리하면:
- 토픽은 원하는 만큼 여러 개 만들 수 있어요
- 토픽은 이름으로 식별됩니다 (예:
logs,purchases,twitter_tweets,trucks_gps) - 토픽은 모든 메시지 포맷을 지원해요 — Avro·CSV·JSON·Binary 다 됩니다
- 토픽 안에 메시지가 줄지어 쌓인 흐름을 데이터 스트림(Data Stream) 이라고 불러요
토픽은 불변(Immutable)이다
여기서 시험 함정이 하나 있어요. 카프카 토픽에 한 번 저장된 데이터는 변경할 수 없습니다. 일단 파티션에 기록되면 수정도 삭제도 안 돼요(기본 설정 기준). "어, 그러면 잘못된 데이터를 넣으면 어떡하죠?" 답은 — 새 메시지를 추가로 넣어 "이전 데이터 취소"라는 사실을 별도 이벤트로 기록하는 식입니다. 이게 1편에서 말한 append-only 로그 철학이에요.
토픽: trucks_gps
│
├─ Partition 0: [오프셋 0][오프셋 1][오프셋 2][오프셋 3] ...
├─ Partition 1: [오프셋 0][오프셋 1][오프셋 2] ...
└─ Partition 2: [오프셋 0][오프셋 1][오프셋 2][오프셋 3][오프셋 4] ...
게시판에 한 번 붙인 글은 떼지 못한다고 생각하면 됩니다. 다만 보존 기간이 지나면 자동으로 만료돼요. 기본값은 1주일(168시간) 이고 설정으로 바꿀 수 있습니다.
> 한 줄 정리 — 토픽은 주제별 게시판이고, 메시지는 시간 순서대로 영구히(보존 기간 안에서) 쌓이며, 한 번 적은 글은 못 고친다.
파티션(Partition) — 게시판을 여러 칸으로 쪼갠 것
이제 한 단계 안으로 들어갑니다. 토픽 자체는 그냥 이름이에요. 실제로 데이터가 들어가는 자리는 파티션입니다.
파티션은 토픽을 여러 조각으로 나눈 것이에요. 하나의 토픽은 1개 이상의 파티션으로 구성됩니다. 우체국 비유로 풀면 한 게시판을 여러 칸으로 쪼갠 것이에요. "주문 게시판"을 0번 칸, 1번 칸, 2번 칸으로 나눠 두고 메시지를 분산해서 받습니다.
잠깐, 여기서 헷갈리는 부분이 하나 있어요. "왜 굳이 쪼개죠?" 이유는 병렬 처리 때문입니다. 한 게시판에 모든 메시지를 줄 세워 받으면 처리 속도가 게시판 한 자리 속도에 묶여요. 그런데 칸을 3개로 쪼개면 3명이 동시에 글을 받을 수 있고, 3명이 동시에 글을 가져갈 수 있어요. 처리량이 곱하기로 늘어납니다.
파티션의 특성을 보면:
- 파티션 내부에서는 메시지가 정해진 순서를 가져요
- 파티션 사이에서는 순서가 보장되지 않아요(이건 곧 다시 옵니다)
- 각 파티션은 독립적으로 운영됩니다
- 파티션 번호는 0부터 시작해요
파티션 수는 어떻게 정하나
파티션 수는 카프카의 성능과 처리 능력에 직접적인 영향을 줍니다.
| 파티션 수 | 영향 |
|---|---|
| 많을수록 | 병렬 처리 가능, 더 많은 컨슈머가 동시에 읽을 수 있음 |
| 적을수록 | 순서 보장이 쉬움, 관리 오버헤드 낮음 |
실무 가이드라인을 한 번에 정리하면:
- 소규모는 파티션 3~6개 로 시작
- 처리량이 높으면 파티션을 늘려서 병렬도 향상
- 파티션은 늘릴 수는 있지만 줄이기는 어렵다 (토픽 삭제 후 재생성이 거의 유일한 길)
여기서 시험 함정이 하나 있어요. "파티션 수는 많을수록 좋다"는 단순화는 부정확해요. 너무 많으면 리더 선출 비용이 커지고, 파일 핸들이 낭비되고, 메타데이터 부담이 커집니다. 적당히 많이가 답이에요.
> 한 줄 정리 — 파티션은 게시판을 여러 칸으로 쪼갠 것. 쪼갠 만큼 병렬 처리가 늘지만, 한 번 늘리면 되돌리기 어렵다.
오프셋(Offset) — Partition 안의 번호표
이제 한 단계 더 안으로 들어갑니다. 파티션 안에서 각 메시지는 오프셋(Offset) 이라는 고유한 번호를 받아요. 우체국 비유로 풀면 칸 안에 들어온 글마다 붙는 번호표예요.
Partition 0: [0][1][2][3][4][5][6] ...
Partition 1: [0][1][2][3] ...
Partition 2: [0][1][2][3][4][5][6][7][8][9] ...
오프셋은 컨슈머가 "내가 어디까지 읽었지?"를 추적하는 핵심 도구예요. 컨슈머는 자기가 마지막으로 처리한 오프셋을 기억해 뒀다가 거기서부터 다시 이어 읽습니다.
파티션 내 오프셋 구조:
┌────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ...
└────┴────┴────┴────┴────┴────┘
↑
컨슈머가 현재 여기까지 읽음
(committed offset = 1)
오프셋의 함정 세 가지
여기서 시험 함정이 한꺼번에 세 개 있어요. 잠깐 멈춰서 봅시다.
첫째, 오프셋은 파티션 안에서만 고유합니다. 파티션 0의 오프셋 3과 파티션 1의 오프셋 3은 완전히 다른 메시지예요. 같은 토픽이라도 파티션이 다르면 같은 번호가 따로 매겨집니다. 토픽 전체에 걸쳐 유일한 ID 같은 게 아니에요.
둘째, 오프셋은 재사용되지 않아요. 메시지가 보존 기간 만료로 삭제돼도 오프셋 번호는 계속 증가합니다. 파티션 0에서 오프셋 0~99가 만료돼 사라져도 다음 메시지는 100, 101, 102… 이렇게 이어져요. 0번부터 다시 시작하지 않습니다.
셋째, 오프셋은 단조 증가만 합니다. 한 번 증가한 오프셋은 절대 거꾸로 가지 않아요. 이게 카프카의 순서 보장의 토대입니다.
파티션 내 순서는 보장, 파티션 사이는 보장 안 됨
여기가 카프카 시험에서 가장 자주 묻는 자리예요.
- 파티션 내부에서는 메시지 순서가 완전히 보장됩니다
- 파티션 사이에서는 순서가 보장되지 않아요
예: 트럭 GPS 토픽 (3개 파티션)
- 트럭 1번 데이터는 항상 파티션 0으로 → 순서 보장
- 트럭 2번 데이터는 항상 파티션 1으로 → 순서 보장
- 파티션 0과 파티션 1 간의 순서는 보장되지 않음
이게 이해가 안 가면 우체국 비유로 다시 풀어 봅시다. 한 칸에 줄 세워 받은 글은 그 칸 안에서는 1번 → 2번 → 3번 순서가 보장돼요. 그런데 0번 칸의 1번 글과 1번 칸의 1번 글 중 어느 게 더 먼저 들어왔는지는 카프카가 보장하지 않습니다. 두 칸은 독립적으로 돌아가니까요.
이게 왜 중요하냐면, "같은 트럭의 GPS 좌표는 시간순으로 정확히 처리돼야 한다" 같은 요구가 있을 때 그 트럭의 모든 메시지를 같은 파티션으로 모아야 한다는 결론으로 이어지거든요. 이건 곧 메시지 키 절에서 다시 등장해요.
오프셋 초기화 설정
컨슈머가 처음 시작할 때 "어디서부터 읽지?"를 정하는 설정이 있어요.
# 컨슈머가 오프셋 초기화 위치 선택
auto.offset.reset=earliest # 처음부터 읽기 (--from-beginning)
auto.offset.reset=latest # 최신 메시지부터 읽기 (기본값)
auto.offset.reset=none # 오프셋이 없으면 예외 발생
여기서도 함정이 하나 있어요. 기본값이 latest 라는 점입니다. 새로 만든 컨슈머를 띄우면 그 시점부터의 메시지만 읽어요. "어, 옛날 데이터를 못 받네요?"는 거의 다 이 설정 때문이에요.
> 한 줄 정리 — 오프셋은 칸 안의 번호표. 파티션 안에서만 고유하고, 재사용되지 않으며, 파티션 내 순서만 보장한다.
브로커(Broker)와 클러스터(Cluster) — 보관소와 우체국 본부
이제 시야를 한 번 넓혀 봅니다. 지금까지는 토픽 안의 구조를 봤는데, 이번엔 데이터가 실제로 어디에 있는지 볼 차례예요.
브로커는 카프카 클러스터의 구성 서버 한 대예요. 우체국 비유로 풀면 데이터 보관소 한 곳입니다. 각 브로커는 서버 역할을 하며, 고유한 정수 ID로 식별돼요.
여러 브로커가 묶여 클러스터를 이룹니다. 클러스터는 여러 보관소가 묶인 우체국 본부예요.
Kafka Cluster
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Broker 101 │ │ Broker 102 │ │ Broker 103 │
│ │ │ │ │ │
│ Topic A P0 │ │ Topic A P1 │ │ Topic A P2 │
│ Topic B P1 │ │ Topic B P0 │ │ Topic B P2 │
└─────────────┘ └─────────────┘ └─────────────┘
여기서 처음 보면 헷갈리는 그림이 있어요. 한 브로커에 한 토픽이 통째로 들어 있는 게 아닙니다. 토픽 A의 파티션 0은 Broker 101에, 파티션 1은 Broker 102에, 파티션 2는 Broker 103에 흩어져 있어요. 카프카가 자동으로 분산해 줍니다. 이게 바로 1편에서 말한 수평 확장성의 토대예요.
브로커는 몇 개가 적당한가
최소 권장 브로커 수는 3개예요. 일부 대형 클러스터는 100개 이상의 브로커를 운영하기도 합니다(LinkedIn은 수천 개를 운영해요).
왜 하필 3개가 최소냐면, 곧 등장할 복제 팩터 3 과 짝이 맞기 때문이에요. 보관소가 3개여야 같은 자료를 3곳에 복사 보관할 수 있습니다.
모든 브로커가 모든 데이터를 가지진 않는다
이 부분이 처음 보면 의외로 헷갈려요. 예시로 풀면:
토픽 A (3개 파티션, 복제 팩터 1):
- Partition 0 → Broker 101
- Partition 1 → Broker 102
- Partition 2 → Broker 103
토픽 B (2개 파티션, 복제 팩터 1):
- Partition 0 → Broker 102
- Partition 1 → Broker 103
(브로커 101은 토픽 B의 데이터 없음 → 모든 브로커가 모든 데이터를 가질 필요는 없음)
복제 팩터가 1인 단순한 예시예요. 토픽 B의 데이터는 Broker 102와 103에만 있고, Broker 101에는 없습니다. 카프카는 필요한 만큼만 분산해요.
부트스트랩 서버(Bootstrap Server) — 안내 데스크
여기서 처음 보면 가장 신기한 동작이 등장해요.
클라이언트(Producer/Consumer)가 카프카에 연결할 때 모든 브로커의 주소를 알 필요가 없습니다. 아무 브로커 하나에만 연결하면 돼요.
작동 원리는 이렇습니다.
- 클라이언트가 Broker 101에 연결
- Broker 101이 클러스터 전체 메타데이터 (모든 브로커 목록, 파티션-브로커 매핑)를 반환
- 클라이언트는 그 메타데이터를 이용해 올바른 브로커에 직접 연결
// 어느 브로커 하나만 지정해도 됨 (실제로는 여러 개 지정 권장)
props.put("bootstrap.servers", "broker1:9092,broker2:9092,broker3:9092");
이 동작 덕분에 카프카는 클러스터 구성이 바뀌어도 클라이언트 코드를 안 고쳐도 돼요. 보관소가 늘거나 줄어도 안내 데스크에 한 번 물으면 다 알려 주니까요.
우체국 비유로 풀면 — 안내 데스크에 가서 "주문 게시판 1번 칸은 어느 보관소에 있나요?"라고 물으면 "Broker 102에 있습니다"라고 알려 주는 거예요. 그러면 클라이언트는 그 보관소로 직접 가서 일을 봅니다.
실무 권장사항 — 고가용성을 위해 최소 2개의 브로커 주소를 지정해 두세요. 한 브로커가 죽어 있어도 다른 주소로 안내 데스크 역할을 받을 수 있어요.
> 한 줄 정리 — 브로커는 보관소, 클러스터는 본부, 안내 데스크(bootstrap server)는 클러스터 메타데이터를 알려 주는 진입점.
복제(Replication) — Partition 사본을 여러 보관소에
여기서부터 카프카가 분산 시스템답게 동작하기 시작해요.
복제 팩터(Replication Factor)는 각 Partition이 몇 개의 복제본을 가질지 결정합니다. 우체국 비유로 풀면 같은 자료를 몇 개의 보관소에 복사 보관할지예요. 복제 동작의 정확한 정의·옵션은 Kafka 공식 문서의 Replication 섹션에 정리돼 있으니 한 번 살펴 두면 좋아요.
권장값 — 운영 환경에서는 복제 팩터 3 (최소 2).
토픽 A (2개 파티션, 복제 팩터 2):
Broker 101: Partition 0 [Leader], Partition 1 [Replica]
Broker 102: Partition 1 [Leader], Partition 0 [Replica]
Broker 103: (여기서는 없음, 브로커가 2개뿐)
여기서 잠깐, 새 단어가 두 개 등장했어요. Leader와 Replica. 이게 다음 절의 주제예요.
리더(Leader)와 팔로워(Follower)
각 파티션에는 리더(Leader) 하나와 여러 팔로워(Follower/Replica) 가 있어요.
Partition 0:
Leader → Broker 101 (프로듀서는 여기에 씀, 컨슈머는 기본적으로 여기서 읽음)
Replica → Broker 102 (리더 데이터를 복제, 리더 장애 시 승격)
Replica → Broker 103 (동일)
리더의 역할은 명확해요.
- 모든 프로듀서 쓰기를 받습니다
- 컨슈머의 읽기 요청을 처리해요(기본 설정 기준)
- 팔로워들은 리더의 데이터를 비동기적으로 복제합니다
우체국 비유로 풀면 — 같은 자료를 보관하는 보관소가 3곳 있을 때, 그중 한 곳이 책임자(Leader) 예요. 모든 글쓰기는 책임자가 받고, 다른 두 곳은 사본만 받아 보관(Follower)합니다. 책임자가 사고로 문 닫으면 사본을 보관하던 곳 중 하나가 새로 책임자로 승격돼요.
여기서 처음 보면 헷갈리는 게 한 가지 있어요. "왜 사본 보관소에서는 안 읽어 가요? 부하 분산되면 좋잖아요?" 답은 — 기본 설정에서는 그렇게 동작합니다. 다만 Kafka 2.4부터 가장 가까운 복제본에서 읽는 기능(rack-aware fetching)이 추가됐어요. 1편에서 짚었듯 카프카는 버전마다 동작이 발전합니다.
ISR(In-Sync Replicas) — 따라잡은 백업 그룹
이제 가장 까다로운 단어가 등장해요. ISR.
ISR은 리더와 동기화된 복제본의 집합이에요. 우체국 비유로 풀면 최신 상태를 따라잡은 백업 보관소 그룹입니다.
ISR = {Leader, 최신 복제를 유지 중인 Follower들}
핵심 규칙은 두 가지예요.
- 팔로워가 리더를 따라가지 못하면 ISR에서 제외됩니다
- 리더 장애 시 ISR 목록 안에서만 새 리더가 선출돼요
정상 상태: ISR = {Broker 101, Broker 102, Broker 103}
Broker 102 느려짐: ISR = {Broker 101, Broker 103}
Broker 101 장애: ISR에서 새 리더 선출 → Broker 103이 리더
잠깐, 여기 시험 함정이 하나 있어요. "왜 ISR 안에서만 새 리더를 뽑죠?" 답은 — ISR 밖의 복제본은 데이터가 뒤처져 있을 수 있어서, 그걸 리더로 승격하면 이미 쓰여진 메시지가 사라지는 사고가 날 수 있기 때문이에요. ISR은 "최신 데이터를 보장하는 안전한 후보군"이라고 생각하면 됩니다.
이걸 안전하게 운영하려면 1편에서 짚은 acks=all 설정과 짝지어야 해요. acks=all이면 프로듀서가 ISR의 모든 복제본에 데이터가 도착했음을 확인받고 나서야 다음 메시지를 보냅니다. Kafka 3.0부터 이게 기본값이 됐다는 건 1편에서 봤죠.
> 한 줄 정리 — Leader는 책임자, Follower는 사본 보관소, ISR은 책임자를 따라잡은 안전한 후보군. 리더가 죽으면 ISR 안에서 새 리더가 나온다.
프로듀서·컨슈머·메시지 키 — 데이터를 보내고 받는 쪽
지금까지가 카프카 내부 구조였다면, 이번 절은 카프카에 데이터를 보내고 받는 쪽이에요. 이 둘은 시리즈 4편·5편에서 본격적으로 다루지만, 아키텍처를 이해하려면 기본 동작은 짚어야 해요.
프로듀서(Producer) — 데이터를 쓰는 쪽
프로듀서는 토픽에 데이터를 쓰는 클라이언트예요.
Producer ──▶ [Kafka Topic] ──▶ Partition 0
──▶ Partition 1
──▶ Partition 2
프로듀서의 주요 특성:
- 데이터를 어떤 파티션으로 보낼지 자동으로 결정
- 브로커 장애 시 자동 재연결 및 재시도
- 메시지에 키(Key) 를 설정해 특정 파티션으로 유도 가능
메시지 키(Message Key) — 같은 키는 같은 칸으로
여기가 아키텍처 단원에서 정말 중요한 자리예요.
프로듀서는 메시지에 키를 포함시킬 수 있어요. 키가 있느냐 없느냐에 따라 동작이 완전히 달라집니다.
- 키가 null인 경우 — 라운드 로빈으로 파티션에 분산돼요
- 키가 있는 경우 — 동일한 키는 항상 동일한 파티션으로 전송됩니다 (murmur2 해시 함수 사용)
키 = null:
메시지 → Partition 0 → Partition 1 → Partition 2 → Partition 0 ...
키 = "truck_id_123":
모든 메시지 → 항상 Partition 1 (예시)
우체국 비유로 풀면 — 메시지 키는 같은 키는 같은 칸으로 가게 하는 분류표예요. "truck_id_123"이라는 분류표가 붙은 글은 항상 1번 칸으로 들어갑니다.
여기서 잠깐, 이게 왜 중요한지 한 번 더 짚어 볼게요. 앞에서 "파티션 내 순서는 보장, 파티션 사이는 보장 안 됨"이라고 했죠. 그럼 "같은 트럭의 GPS 좌표를 시간순으로 정확히 처리하고 싶다" 는 요구는 어떻게 풀까요?
답은 — 트럭 ID를 메시지 키로 쓴다입니다. 그러면 트럭 1번의 모든 GPS 메시지는 항상 같은 파티션으로 들어가고, 그 파티션 안에서는 순서가 보장되니까요. 트럭 2번의 메시지는 다른 파티션으로 갈 수 있지만, 트럭 2번 안에서는 또 순서가 보장돼요.
여기서 시험 함정이 하나 있어요. "메시지 키가 곧 파티션 번호인가요?" 아닙니다. 키를 murmur2로 해시한 값이 파티션 번호를 결정해요. 같은 키는 항상 같은 파티션으로 가지만, 키 자체와 파티션 번호 사이에 직접적인 의미 연결은 없습니다.
컨슈머(Consumer) — 데이터를 읽는 쪽
컨슈머는 토픽에서 데이터를 읽는 클라이언트예요.
[Kafka Topic]
Partition 0 ──▶
Partition 1 ──▶ Consumer
Partition 2 ──▶
컨슈머의 주요 특성:
- Pull 방식 — 카프카가 밀어주는 게 아니라 컨슈머가 가져와요
- 오프셋 추적 — 어디까지 읽었는지 기록
- 파티션 내 순서 보장
여기서도 시험 함정이 하나 있어요. 카프카는 Push가 아니라 Pull입니다.
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Push | 서버가 클라이언트에게 밀어줌 | 즉시 전달 | 컨슈머 속도 제어 어려움 |
| Pull | 클라이언트가 서버에서 가져옴 | 속도 자율 조절, 배치 가능 | 빈 폴링 가능성 |
카프카가 Pull을 선택한 이유는 — 컨슈머가 자기 처리 속도에 맞게 가져올 수 있어 과부하 방지가 용이하기 때문이에요. 빠른 카프카가 느린 컨슈머에게 메시지를 들이부어 컨슈머가 터지는 사고를 막을 수 있습니다.
컨슈머 그룹(Consumer Group) — 협력적 소비
컨슈머들은 그룹으로 묶여 협력적으로 토픽을 소비해요.
토픽 (3개 파티션):
Partition 0 ──▶ Consumer A ─┐
Partition 1 ──▶ Consumer B ─┤ Consumer Group "my-app"
Partition 2 ──▶ Consumer C ─┘
핵심 규칙은 한 줄이에요. 하나의 파티션은 같은 컨슈머 그룹 내에서 하나의 컨슈머에게만 할당된다.
컨슈머 수에 따른 동작을 시나리오로 풀면:
파티션 3개, 컨슈머 그룹 내 컨슈머 수에 따라:
컨슈머 1개: 모든 파티션 처리 (P0, P1, P2)
컨슈머 2개: Consumer 1 → P0, P1 / Consumer 2 → P2
컨슈머 3개: 각 1개씩 담당 (최적)
컨슈머 4개 이상: 1명은 idle (파티션보다 많으면 유휴 컨슈머 발생)
여기서 시험 함정이 하나 있어요. "컨슈머를 파티션보다 많이 두면 처리량이 더 늘어나는 거 아닌가요?" 아닙니다. 파티션 수가 컨슈머 그룹 내 병렬도의 상한이에요. 파티션 3개에 컨슈머 4개를 띄우면 1명은 그냥 놀게 됩니다. 컨슈머 그룹은 5편에서 본격적으로 다루니, 여기서는 "파티션이 병렬도의 상한"이라는 한 줄만 가져가면 됩니다.
> 한 줄 정리 — Producer는 메시지 키로 어느 파티션에 갈지 결정, Consumer는 Pull 방식으로 읽고, Consumer Group의 병렬도 상한은 파티션 수.
Zookeeper — 옛날 행정 사무국
이제 카프카의 역사적 동반자였던 Zookeeper를 봅시다.
Zookeeper는 카프카 브로커들을 관리하는 별도 서비스예요. Kafka 4.0 이전까지 필수였습니다. 우체국 비유로 풀면 옛날에 따로 있던 행정 사무국이에요. 보관소(Broker)들이 뭘 하든 행정 사무국이 따로 옆에 있어서 누가 살아 있는지, 누가 책임자인지 같은 걸 관리해 줬습니다.
Zookeeper의 역할:
- 브로커 생존 여부 추적 (heartbeat 기반)
- 파티션 리더 선출 지원
- 카프카 설정 및 메타데이터 저장
- 토픽 변경사항(생성/삭제) 알림
Zookeeper 클러스터 구성
Zookeeper는 홀수 개로 구성해야 해요(과반수 투표 방식).
Zookeeper 클러스터 (3개 또는 5개):
┌──────────┐ ┌──────────┐ ┌──────────┐
│ ZK Leader │◄──▶│ ZK Follower│◄──▶│ ZK Follower│
└──────────┘ └──────────┘ └──────────┘
▲ ▲ ▲
│ │ │
┌─────────────────────────────────────────────┐
│ Kafka Cluster │
│ Broker 1 Broker 2 Broker 3 │
└─────────────────────────────────────────────┘
허용 장애 수는 이렇게 돼요.
- 3개 노드 → 1개 장애 허용
- 5개 노드 → 2개 장애 허용
여기서 시험 함정이 하나 있어요. "왜 홀수인가요?" 답은 과반수 투표 때문이에요. 4개 중 2개가 죽으면 남은 2개로는 과반수가 안 됩니다. 5개 중 2개가 죽으면 남은 3개로 과반수가 돼요. 그래서 짝수보다 홀수가 효율적입니다.
Zookeeper의 한계
Zookeeper는 오래 잘 돌아갔지만 다음 문제가 누적됐어요.
- 파티션이 10만 개를 넘으면 성능 저하
- 별도의 Zookeeper 클러스터 관리 필요(운영 부담)
- 카프카와 별도의 보안 설정 필요(보안 모델 이중화)
- 컨트롤러 장애 복구 시간이 오래 걸림
이게 KRaft 등장의 배경이에요.
클라이언트는 더 이상 Zookeeper와 통신하지 않는다
여기 정말 중요한 시험 함정이 있어요.
Kafka 0.10 이후 클라이언트(Producer/Consumer)는 Zookeeper와 직접 통신하지 않습니다.
이전 방식:
Consumer ──▶ Zookeeper (오프셋 저장)
현재 방식:
Consumer ──▶ Kafka Broker (오프셋을 __consumer_offsets 토픽에 저장)
따라서 클라이언트 코드에 Zookeeper 주소를 사용하면 안 됩니다. 항상 bootstrap.servers만 사용해요. 옛날 자료에 --zookeeper localhost:2181 같은 게 보이면 그건 Kafka 0.10 이전 자료예요. 무시해도 됩니다.
> 한 줄 정리 — Zookeeper는 옛날 행정 사무국. 브로커 생존·리더 선출·메타데이터 관리를 맡았고, 클라이언트는 Kafka 0.10 이후로 Zookeeper와 직접 통신하지 않는다.
KRaft 모드 — 통합 행정 (외부 사무국 없이)
이제 카프카의 새 시대로 넘어갑니다.
KRaft(Kafka Raft)는 Zookeeper를 제거하고 카프카 자체가 메타데이터 관리를 담당하는 모드예요. 우체국 비유로 풀면 행정 사무국을 따로 두지 않고 본부 안에서 직접 행정을 보는 통합 행정 체계입니다.
Zookeeper 시대 아키텍처:
Kafka Cluster + Zookeeper Cluster (2개 시스템)
KRaft 시대 아키텍처:
Kafka Cluster (단일 시스템, 내부에 Quorum Controller 포함)
KRaft 도입 배경 — KIP-500
LinkedIn이 제안한 KIP-500 (KIP: Kafka Improvement Proposal)이 KRaft의 기원이에요. 카프카 내부에 Raft 합의 알고리즘을 직접 박아서 외부 의존성을 없애자는 제안이었습니다. 자세한 동작 모델은 Kafka 공식 문서의 KRaft 섹션에 정리돼 있어요.
KRaft의 장점을 표로 보면 한 번에 와 닿아요.
| 항목 | Zookeeper 모드 | KRaft 모드 |
|---|---|---|
| 최대 파티션 수 | ~200,000 | 수백만 개 |
| 아키텍처 복잡도 | 높음 (2개 시스템) | 낮음 (1개 시스템) |
| 컨트롤러 페일오버 | 수십 초 | 몇 초 |
| 모니터링 | 복잡 | 단순 |
| 보안 모델 | 이중 (Kafka + ZK) | 단일 |
KRaft 버전 히스토리
| 버전 | KRaft 상태 |
|---|---|
| Kafka 2.8 | KRaft 미리보기 (실험적) |
| Kafka 3.3.1 | KRaft 프로덕션 사용 가능 (KIP-833 승인) |
| Kafka 3.5 | Zookeeper 모드 deprecated |
| Kafka 4.0 | Zookeeper 완전 제거, KRaft 전용 |
여기서 시험 함정이 하나 있어요. 새로 시작하는 프로젝트는 무조건 KRaft예요. Kafka 4.0부터는 Zookeeper 자체가 사라졌습니다. 옛날 자료에서 "Zookeeper 설정"이 나오면 그건 4.0 이전 자료입니다.
KRaft 내부 아키텍처
KRaft 모드에서는 특정 브로커들이 Quorum Controller 역할을 겸임해요.
KRaft Cluster (3개 브로커, 모두 Controller + Broker 겸임):
┌──────────────────────────────────────────┐
│ Kafka KRaft Cluster │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │Broker 1 │ │Broker 2 │ │Broker 3 ││
│ │+Controller│ │+Controller│ │+Controller││
│ └──────────┘ └──────────┘ └──────────┘│
│ ↕ Raft Consensus Protocol │
└──────────────────────────────────────────┘
Raft는 분산 시스템에서 합의(consensus) 를 이루는 알고리즘이에요. 어느 노드가 리더인지, 어떤 데이터가 최신인지 같은 걸 노드들끼리 투표로 결정합니다. 카프카가 이걸 내부에 박아서 Zookeeper의 역할을 대신하게 한 거예요.
KRaft 모드로 카프카 시작
실무 명령어로 보면 이렇게 돌립니다.
# 1. 클러스터 ID 생성
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
# 2. 스토리지 포맷
bin/kafka-storage.sh format \
-t $KAFKA_CLUSTER_ID \
-c config/kraft/server.properties
# 3. 서버 시작
bin/kafka-server-start.sh config/kraft/server.properties
> 한 줄 정리 — KRaft는 Zookeeper를 카프카 내부의 Raft 합의 알고리즘으로 대체한 통합 행정. Kafka 4.0부터는 KRaft 전용.
메시지 구조(Message/Record) — 카프카가 다루는 단위
카프카에 흘러가는 한 건의 메시지(레코드)는 어떻게 생겼을까요? 구성 요소를 한 표에 정리하면:
| 요소 | 설명 | 기본값 |
|---|---|---|
| Key | 파티션 결정에 사용 (선택적) | null |
| Value | 실제 데이터 | - |
| Compression Type | 압축 방식 (none, gzip, snappy, lz4, zstd) | none |
| Headers | 메타데이터 키-값 쌍 (선택적) | - |
| Partition + Offset | 저장 위치 (카프카가 할당) | - |
| Timestamp | 메시지 시간 (프로듀서 또는 브로커 할당) | - |
카프카는 바이트만 본다
이게 카프카의 본질을 가장 잘 보여 주는 한 줄이에요.
카프카는 메시지 내용을 절대 알지 못합니다. 단순히 바이트 배열을 저장하고 전달해요. 의미 해석은 Producer와 Consumer가 담당합니다.
Producer → [직렬화] → 바이트 → [Kafka 저장] → 바이트 → [역직렬화] → Consumer
이 구조 덕분에 카프카는 어떤 데이터 포맷도 운반할 수 있어요. 1편에서 짚은 "카프카는 운반 메커니즘이지 데이터 처리 시스템이 아니다"가 여기서 다시 등장하는 셈입니다.
메시지 직렬화
카프카가 바이트만 저장하니까, 키와 값은 직렬화(Serialization) 되어야 해요. 자바 예시로 보면:
// 자바 예시: StringSerializer 사용
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
// Integer 키, String 값 예시
ProducerRecord<Integer, String> record =
new ProducerRecord<>("my-topic", 1, "Hello Kafka");
주요 Serializer 종류는 이렇게 돼요.
| 타입 | Serializer 클래스 |
|---|---|
| String | StringSerializer |
| Integer | IntegerSerializer |
| Long | LongSerializer |
| Bytes | ByteArraySerializer |
| JSON/Avro | KafkaAvroSerializer (Schema Registry 활용) |
역직렬화는 Producer와 짝이 맞아야 한다
컨슈머에서는 동일한 타입의 Deserializer를 사용해야 해요.
// StringDeserializer 사용
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
여기서 정말 중요한 시험 함정 — 프로듀서의 Serializer와 컨슈머의 Deserializer 타입이 반드시 일치해야 합니다. 프로듀서는 String으로 보내고 컨슈머는 Integer로 받으려 하면 그대로 깨져요. 카프카는 바이트만 보니까 형 불일치를 검증해 주지 못합니다.
이런 사고를 막으려고 1편에서 짚은 Schema Registry 가 등장한 거예요. 모든 부서가 같은 양식을 쓰게 강제하는 표준 양식 관리실 말이죠.
> 한 줄 정리 — 메시지 = Key + Value + Headers + Timestamp + (할당된 Partition·Offset). 카프카는 바이트만 본다. Producer의 Serializer와 Consumer의 Deserializer는 반드시 짝이 맞아야 한다.
Kafka 아키텍처 전체 그림 — Topic·Partition·Broker
여기까지의 모든 단어를 한 그림에 모아 봅시다. 클라이언트가 카프카와 통신하는 흐름은 이래요.
[Producer]
│
│ 1. bootstrap.servers로 연결 (아무 브로커나)
▼
[Broker 101] ← → [Broker 102] ← → [Broker 103]
│ │ │
│ 2. 메타데이터 반환 (토픽-파티션-리더 브로커 매핑)
▼
[Producer]
│
│ 3. 올바른 파티션의 리더 브로커에 직접 쓰기
▼
[Broker 102: Partition 1 Leader]
│
│ 4. 팔로워들이 복제
▼
[Broker 103: Partition 1 Replica] [Broker 101: Partition 1 Replica]
전체 클러스터를 한 번 보면 이래요.
┌─────────────────────────────────────────────────────────┐
│ Apache Kafka 클러스터 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Broker 1 │ │ Broker 2 │ │ Broker 3 │ │
│ │ │ │ │ │ │ │
│ │ T-A P0 [L] │ │ T-A P1 [L] │ │ T-A P2 [L] │ │
│ │ T-A P1 [R] │ │ T-A P2 [R] │ │ T-A P0 [R] │ │
│ │ T-B P0 [L] │ │ T-B P1 [L] │ │ T-B P0 [R] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ [L] = Leader [R] = Replica │
└─────────────────────────────────────────────────────────┘
▲ ▲
│ │
[Producer] [Consumer Group]
(Topic A에 쓰기) (Topic A에서 읽기)
이 그림 한 장에 지금까지 풀어 온 모든 단어가 다 들어 있어요. 토픽 A의 파티션 0은 Broker 1이 리더고, 그 사본을 Broker 3이 갖고 있어요. 파티션 1은 Broker 2가 리더고, 사본은 Broker 1에 있어요. 모든 파티션이 다른 브로커에 분산돼 있고, 각 파티션마다 한 명의 리더와 한 명 이상의 사본이 있는 구조입니다.
CLI로 직접 보기
카프카는 명령줄 도구로 토픽·컨슈머 그룹을 직접 들여다볼 수 있어요. 처음 실습할 때는 이 명령들로 동작을 눈으로 보는 게 가장 빨라요.
토픽 생성과 정보 확인
# 토픽 생성 (3개 파티션, 복제 팩터 1)
kafka-topics.sh \
--bootstrap-server localhost:9092 \
--create \
--topic first_topic \
--partitions 3 \
--replication-factor 1
# 토픽 목록 조회
kafka-topics.sh \
--bootstrap-server localhost:9092 \
--list
# 토픽 상세 정보 (파티션-브로커 매핑 확인)
kafka-topics.sh \
--bootstrap-server localhost:9092 \
--describe \
--topic first_topic
describe 결과 예시는 이렇게 떠요.
Topic: first_topic Partitions: 3 ReplicationFactor: 1 Configs: ...
Topic: first_topic Partition: 0 Leader: 1 Replicas: 1 Isr: 1
Topic: first_topic Partition: 1 Leader: 1 Replicas: 1 Isr: 1
Topic: first_topic Partition: 2 Leader: 1 Replicas: 1 Isr: 1
여기서 Leader, Replicas, Isr 칼럼이 지금까지 풀어 온 단어들이에요. 복제 팩터 1짜리 단순 토픽이라 모든 파티션의 리더·복제·ISR이 다 Broker 1로 똑같이 잡혀 있어요.
컨슈머 그룹 정보 확인
# 컨슈머 그룹 목록
kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--list
# 컨슈머 그룹 상세 (오프셋 및 렉 확인)
kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe \
--group my-consumer-group
describe 결과는 이런 모양이에요.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID
my-group my-topic 0 10 12 2 consumer-1
my-group my-topic 1 8 8 0 consumer-1
my-group my-topic 2 5 7 2 consumer-2
용어 설명 — CURRENT-OFFSET은 컨슈머가 마지막으로 커밋한 오프셋, LOG-END-OFFSET은 파티션의 최신 오프셋, LAG는 아직 처리하지 못한 메시지 수예요(LOG-END - CURRENT). 운영에서 LAG가 계속 커지면 컨슈머가 처리 속도를 못 따라간다는 뜻이에요.
> 한 줄 정리 — kafka-topics.sh --describe로 토픽의 Leader/Replicas/ISR을, kafka-consumer-groups.sh --describe로 컨슈머의 LAG를 확인.
실무 아키텍처 설계 가이드 — Partition 수와 복제 팩터
이론을 풀었으니 실무에서 어떻게 정해야 하는지를 한 번 더 정리할게요.
파티션 수 결정 기준
목표 처리량 = 파티션 수 × 단일 파티션 처리량
예시:
- 목표: 초당 1GB 처리
- 단일 파티션 쓰기 속도: ~100MB/s
- 필요 파티션 수: 10개
가이드라인을 한 번에 정리하면:
- 너무 적으면 — 처리 병렬도가 제한됨
- 너무 많으면 — 리더 선출 비용 증가, 파일 핸들 낭비
- 경험적 권장 — 브로커당 2,000~4,000 파티션, 클러스터 전체 최대 200,000 파티션 (Zookeeper 기준)
KRaft 시대에는 이 상한이 수백만 개까지 풀렸어요. 다만 그렇다고 파티션을 마구 늘릴 일은 거의 없습니다.
복제 팩터 결정 기준
| 복제 팩터 | 내결함성 | 스토리지 비용 | 권장 환경 |
|---|---|---|---|
| 1 | 없음 | 1× | 개발/테스트 |
| 2 | 브로커 1개 장애 허용 | 2× | - |
| 3 | 브로커 2개 장애 허용 | 3× | 운영 환경 표준 |
황금 규칙 — 운영 환경에서는 항상 복제 팩터 3을 사용해요.
토픽 네이밍 컨벤션
카프카는 공식 네이밍 컨벤션이 없지만 실무에서 자주 쓰이는 패턴이 있어요.
{환경}.{팀}.{서비스}.{이벤트타입}
예시:
prod.logistics.trucks.gps
dev.payments.transactions.created
staging.users.accounts.updated
또는 더 간단하게:
{서비스}.{엔티티}.{이벤트}
예시:
order-service.orders.created
user-service.users.registered
여기서 시험 함정이 하나 있어요. 토픽 이름에 점(.)과 언더스코어(_)는 둘 다 허용되지만, 카프카 내부 메트릭에서 두 문자가 충돌할 수 있어요. 가능하면 한 가지로 통일하는 게 좋습니다. 운영에 들어가면 토픽 이름은 거의 안 바뀌니, 처음 정할 때 신중하게 정해 두는 게 좋아요.
> 한 줄 정리 — 파티션 수는 처리량 기준으로, 복제 팩터는 운영에서 항상 3, 토픽 네이밍은 환경·팀·서비스·이벤트 순으로 패턴을 잡아 두자.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 카프카 2편의 핵심입니다. 면접·시험·실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 토픽 = 주제별 게시판, 데이터베이스 테이블과 비슷하지만 쿼리는 안 됨
- 토픽은 불변(append-only) — 한 번 적은 메시지는 못 고친다
- 기본 보존 기간은 1주일(168시간)
- 파티션 = 토픽을 여러 칸으로 쪼갠 것 — 병렬 처리의 단위
- 파티션 번호는 0부터 시작
- 파티션은 늘릴 수는 있어도 줄이기는 어렵다
- 오프셋 = 파티션 안의 번호표 — 파티션 안에서만 고유, 재사용되지 않음, 단조 증가
- 파티션 안에서는 순서 보장, 파티션 사이는 보장 안 됨
auto.offset.reset기본값은latest— 새 컨슈머는 새 메시지부터 읽음- 브로커 = 보관소, 클러스터 = 본부, 최소 권장 브로커 수 3개
- 모든 브로커가 모든 데이터를 가지진 않는다 — 카프카가 자동 분산
- bootstrap.servers 는 안내 데스크 — 아무 브로커 하나만 알면 클러스터 전체 메타데이터를 받음
- 실무에서는 bootstrap에 최소 2개 이상의 브로커 주소 지정
- 복제 팩터 운영 표준은 3 (최소 2)
- 각 파티션에 Leader 1개 + Follower(Replica) 여러 개
- 모든 쓰기는 Leader가 받는다, 컨슈머도 기본적으로 Leader에서 읽음
- ISR = 리더를 따라잡은 안전한 후보군 — 리더 장애 시 ISR 안에서만 새 리더 선출
- 메시지 키가 같으면 같은 파티션 (murmur2 해시) — 같은 키의 순서 보장에 활용
- 메시지 키가 null이면 라운드 로빈 분산
- 카프카는 Pull 방식 — 컨슈머가 자기 속도로 가져감
- 컨슈머 그룹의 병렬도 상한은 파티션 수 — 컨슈머가 더 많으면 유휴 발생
- 하나의 파티션은 같은 컨슈머 그룹 안에서 하나의 컨슈머에게만 할당
- Zookeeper는 옛날 행정 사무국 — Kafka 4.0부터는 완전히 제거됨
- Kafka 0.10부터 클라이언트는 Zookeeper와 직접 통신하지 않는다 —
bootstrap.servers만 사용 - Zookeeper는 홀수 개 (3개 또는 5개)로 구성
- KRaft = 카프카 내부 Raft 합의로 메타데이터 관리 — Zookeeper 대체
- KRaft 정식 지원 = Kafka 3.3.1, Zookeeper 완전 제거 = Kafka 4.0
- 카프카는 바이트만 본다 — Producer의 Serializer와 Consumer의 Deserializer는 반드시 짝이 맞아야 함
CURRENT-OFFSET/LOG-END-OFFSET/LAG— LAG가 계속 커지면 컨슈머가 처리 속도를 못 따라간다는 뜻
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.