Kafka 설계: 파티션·프로듀서·컨슈머의 속살

Kafka 설계: 파티션·프로듀서·컨슈머의 속살

Kafka가 어떻게 해서 그렇게 빠르고 안정적인지, 설계 문서에 들어가면 답이 있어요. 토픽 파티션을 어떻게 나누고, 프로듀서·컨슈머가 어떻게 통신하고, 무슨 보장을 주는지가 이 페이지의 핵심이에요. Kafka는 전통적인 메시징 시스템이라기보다 데이터베이스 로그에 가까운 설계를 택했어요.

출처: Apache Kafka — Design

동기: 왜 이런 설계?

대기업이 가진 모든 실시간 데이터 피드를 하나의 플랫폼에서 다루게 하려는 게 출발점이었어요. 그러려면 고용량 이벤트 스트림(실시간 로그 집계)을 받을 높은 처리량, 오프라인 시스템의 주기적 벌크 로드를 감당할 큰 백로그 처리, 전통적인 메시징 요구를 지원할 저지연 전달이 모두 필요했죠. 그 흐름을 파티션으로 나눠 분산·실시간 처리하고, 다른 데이터 시스템으로 보낼 때도 머신 실패 속에서 내결함성을 보장해야 한다는 결론에 이르렀어요.

파티셔닝과 순서 보장

Kafka의 확장성은 토픽을 파티션으로 나누는 것에서 시작해요. 토픽은 여러 파티션으로 분산되고, 각 파티션은 완전히 순서가 정렬된 로그예요. 같은 키(고객 ID, 차량 ID 같은 것)를 가진 이벤트는 항상 같은 파티션으로 쓰여서, 컨슈머가 그 파티션의 이벤트를 쓴 순서 그대로 읽을 수 있어요.

프로듀서는 어떤 파티션에 메시지를 보낼지 제어해요. 랜덤으로 보내 로드밸런싱을 하거나, 시맨틱 파티셔닝 함수로 보낼 수 있어요. 키로 파티션을 정하는 방식이 대표적이에요 — 사용자 ID를 키로 쓰면 특정 사용자의 모든 데이터가 같은 파티션으로 가고, 컨슈머는 "이 파티션에 이 사용자 관련 데이터가 모여 있겠다"는 지역성(locality) 가정을 할 수 있게 돼요. 이건 컨슈머의 locality-sensitive 처리를 위해 의도된 설계예요.

컨슈머의 위치: 오프셋

메시징 시스템에서 "무엇을 소비했는지"를 추적하는 건 의외로 핵심 성능 포인트예요. 많은 시스템이 브로커 쪽에 소비 상태 메타데이터를 두지만, Kafka는 달라요. 토픽은 완전히 정렬된 파티션들로 나뉘고, 각 파티션은 주어진 소비 그룹 안에서 한 번에 정확히 하나의 컨슈머가 소비해요. 그래서 컨슈머의 각 파티션 위치는 "다음에 소비할 메시지의 오프셋"이라는 정수 하나뿐이에요. 상태가 파티션마다 숫자 하나라 아주 작고, 주기적으로 체크포인트할 수 있어요. 메시지 확인(acknowledgement)의 비용이 그만큼 싸진 거죠.

이 선택의 부수적 장점도 있어요. 컨슈머는 오래된 오프셋으로 의도적으로 **되감기(rewind)**해서 데이터를 다시 소비할 수 있어요. 큐의 일반적인 계약과는 다르지만, 코드 버그를 발견했을 때 이미 소비한 메시지를 다시 처리할 수 있는 건 많은 컨슈머에게 필수 기능이에요.

푸시가 아니라 풀: 컨슈머 설계

Kafka는 브로커가 컨슈머에게 데이터를 미는(push) 대신, 컨슈머가 브로커에서 당겨오는(pull) 설계를 택했어요. 푸시 방식은 브로커가 전송 속도를 제어하므로, 컨슈머의 소비 속도가 생산 속도보다 떨어지면 컨슈머가 압도돼요(사실상 서비스 거부 상태). 풀 방식은 컨슈머가 느리면 그저 뒤처졌다가 가능할 때 따라잡으면 돼요. 또 풀은 컨슈머로 보낼 데이터를 공격적으로 배칭하기 좋아요. 로그에서 자신의 현재 위치 이후의 모든 메시지를(설정한 최대 크기까지) 한 번에 당겨오니까 불필요한 지연 없이 최적의 배칭을 얻는 거죠.

나이브한 풀 방식의 단점은 브로커에 데이터가 없을 때 컨슈머가 빡빡한 루프로 busy-wait 한다는 것인데, Kafka는 풀 요청이 데이터가 올 때까지(long poll) 블록하도록 매개변수를 제공해 해결해요.

프로듀서: 부하 분산과 배칭

프로듀서는 중간 라우팅 계층 없이 파티션의 리더 브로커로 직접 데이터를 보내요. 그렇게 하도록 모든 Kafka 노드가 "어떤 서버가 살아 있고, 어느 시점에 각 파티션의 리더가 누구인지"에 대한 메타데이터 요청에 응답해 주거든요.

배칭은 효율의 큰 축이에요. 프로듀서는 데이터를 메모리에 모아 더 큰 배치를 한 요청으로 보내는데, 메시지 수 상한과 지연 상한(예: 64kB 또는 10ms)을 설정해 조절할 수 있어요. 서버 쪽 I/O 연산을 적게, 크게 만들려는 것이고, 약간의 추가 지연을 감수하고 처리량을 올리는 트레이드오프를 설정으로 조절하는 거예요.

복제: 파티션 단위 내결함성

Kafka는 각 토픽 파티션의 로그를 설정 가능한 수의 서버에 걸쳐 복제해요(토픽별로 복제 팩터 설정 가능). 클러스터의 서버가 실패하면 자동으로 복제본으로 페일오버 돼 메시지가 계속 이용 가능한 상태로 남아요.

복제의 단위는 토픽 파티션이에요. 정상 상태에서 각 파티션은 리더 하나와 0개 이상의 팔로워를 가져요. 리더를 포함한 전체 복제본 수가 복제 팩터예요. 모든 쓰기는 파티션의 리더로 가고, 읽기는 리더나 팔로워로 갈 수 있어요. 팔로워는 일반 Kafka 컨슈머처럼 리더에서 메시지를 소비해 자기 로그에 적용해요.

리더는 "in sync"인 복제본 집합(ISR)을 추적해요. 브로커가 살아 있으려면 (1) 컨트롤러와 활성 세션을 유지하고, (2) 팔로워라면 리더의 쓰기를 복제하며 너무 뒤처지지 않아야 해요. replica.lag.time.max.ms 설정으로 뒤처진 복제본을 판정해 ISR에서 제거해요. min.insync.replicas 설정은 프로듀서가 전체 ISR에 쓰였다는 확인을 요청할 때 검사되는 최소 ISR 수예요.

메시지 전달 시맨틱스

프로듀서와 컨슈머 사이의 전달 보장은 크게 세 가지로 나뉘어요.

  • At most once — 메시지가 유실될 수 있지만 재전달되지는 않음.
  • At least once — 메시지가 유실되지는 않지만 재전달될 수 있음.
  • Exactly once — 각 메시지가 정확히 한 번만 처리됨.

메시지가 파티션의 ISR에 있는 모든 복제본이 로그에 적용했을 때 "커밋되었다(committed)"고 봐요. 커밋된 메시지는 그 파티션을 복제하는 브로커가 하나라도 살아 있으면 유실되지 않아요. Kafka는 기본적으로 at-least-once 보장을 하고, 프로듀서의 멱등(idempotent) 전달 옵션(0.11.0.0부터)으로 재전송 시 로그에 중복이 생기지 않게 하며, 트랜잭션(0.11.0.0부터)으로 여러 토픽 파티션에 원자적으로 쓰기를 지원해요. 컨슈머 쪽에서 정확히-한-번을 얻으려면 컨슈머의 오프셋을 출력 데이터와 같은 트랜잭션으로 저장해 커밋하는 방식을 써요.

더 알아보기

  • 이벤트 스트리밍 개념 자체가 궁금하면 Introduction을 보세요.
  • API 사용법은 API 문서를 보세요.
  • 운영·모니터링은 Operations을 참고하세요.