
들어가며
현대는 수많은 디바이스에서 매일 다양한 형태의 방대한 이벤트 데이터가 생성되는 데이터 세상이다.
사용자 활동에 대한 클릭/임프레션 로그부터 데이터베이스 데이터, 시스템 로그까지 다양한 형태의 방대한 데이터가 하루에도 생성된다.
이러한 데이터를 효율적으로 통합하고 가공하면서 유통하기위해 많은 이벤트 브로커가 탄생했으며, 많은 사용자는 이벤트 브로커에 보통 높은 처리량과 낮은 지연시간을 요구한다.
이러한 요구사항은 10년도 훨씬 전부터 있었으며, 실제 링크드인에서 이러한 요구사항을 해결하는 카프카라는 시스템을 만들어 2011년에 오픈 소스화하였고, 현재는 Apache 인큐베이터를 거쳐 공식 프로젝트로 수많은 회사에서 사용하고있다.
카프카가 왜 탄생하게되었는지에 대해선 카프카를 만든 엔지니어 Jay Kreps의 발표에서 자세히 설명하고있다 - Data Ingestion
현재 내가 만들고 관리하는 프로젝트에서도 하루에 다양한 형태의 방대한 데이터가 생성되고있으며, 이를 수집하는 가장 앞단의 Data Ingestion 역할로 카프카를 십분 활용하고있다.
물론 회사내 많은 분업화로 내가 카프카 클러스터를 직접 구축하고 관리하는 팀은 아니지만, 카프카에 수많은 이벤트를 Produce하고 Consume한다.
그리고 카프카에 수많은 이벤트를 Produce하고 Consume하면서 느낀 점은 카프카를 관리하는 팀뿐 아니라 사용하는 관점에서도 카프카를 제대로 이해해야 효율적으로 사용할 수 있다는 것이다.
실제로 사내 카프카 팀으로부터 Producer와 Consumer의 설정과 사용 형태를 수정해달라는 권고를 몇 차례 받았다..
카프카에 대한 책과 자료는 굉장히 많지만, 사실.. 많은 자료를 읽었음에도 카프카가 왜 이렇게 설계되었고 구조가 왜 이렇게 되어있는지 설명해주는 자료는 별로 찾지 못했다.
그중에서 Jay Kreps의 발표가 많은 도움이 되어, 이를 통해 내가 카프카를 공부하면서 이해한 내용을 사례를 통해 정리해보고자한다.
각 구성요소별로 가지고있는 자세한 스펙은 다루지않는다. 카프카를 이해하기위한 전반적인 큰 그림만을 다룬다.
축구 경기 중계 사례
카프카를 이해하기위해 내가 즐겨보는 프리미어 리그 (축구경기) 문자 중계하는 시스템을 이벤트 드리븐으로 구축되어 카프카를 사용한다고 가정해본다.
축구 경기는 시작부터 종료까지 시간 순으로 축구 선수의 행동에 따라 이벤트가 생성되며, 이벤트는 몇 분, 누가, 어떤 행동을 했는지로 구성할 수 있다.
이벤트 브로커를 시용하는 이벤트 드리븐 아키텍처를 차용한 서버 구조를 간단히 그려보면 아래와 같다.
이벤트 데이터가 유통되는 과정에서 컴포넌트는 크게 3가지로 구성된다고 볼 수 있다.
- Producer 서버 (1대 이상)
- Consumer 서버 (1대 이상)
- 이벤트 브로커 (카프카)
이제 세계의 수많은 축구 경기를 실시간으로 문자 중계해야하는 시스템이라고 가정해보고 높은 처리량과 낮은 지연시간 특성을 가진 시스템을 설계해본다.
그리고 그 과정에서 카프카에서 나오는 다양한 개념들을 이해해본다.
1 카프카 클러스터
가장 먼저 Producer와 Consumer가 빠르고 효율적으로 이벤트를 발행하고 소비할 수 있도록 이벤트 브로커 역할을 수행하는 카프카 클러스터 및 브로커 구조에 대해서 이해해본다.
1-1 파티션
일반적으로 컴퓨터에서의 파티션은 디스크 파티션을 많이 떠올린다. 디스크 파티션은 하나의 물리적인 디스크 (HDD, SSD)를 논리적으로 여러 부분으로 분할하는 것을 의미한다.
윈도우를 사용해본 사람이라면 물리적 하드디스크는 하나인데, 논리적으로 C드라이브, D드라이브로 나뉘고 C드라이브는 운영체제 파티션으로 D드라이브는 파일시스템 파티션으로 자주 활용했을 것이다.
이렇게 논리적으로 분리해서 관리하는 이유는 시스템 운용과 디스크 사용면에서 이득이 많기 때문이다. 그리고 디스크 뿐만 아니라 많은 시스템에서 이러한 논리적 분리를 활용함으로써 시스템의 성능과 관리비용을 줄인다.
대표적으로 DB에서 동일한 스키마를 가진 테이블을 파티션으로 나누어, 각 테이블별로의 인덱스 크기를 줄여 쓰기와 읽기의 성능을 개선한다.
참고 - DB 파티셔닝과 샤딩
카프카도 이러한 파티셔닝을 활용하여 쓰기와 읽기의 성능을 높였다.
💁♂️ 파티셔닝
위에서 살펴본 축구 중계 이벤트 다이어그램을 자세히 살펴보면 아래와 같다.
하나의 Queue에 다양한 축구 경기들의 이벤트가 적재되며, 여러 대의 Producer와 Consumer가 하나의 Queue에 각각 이벤트를 발행 및 소비하는 모습이다.
이렇게 하나의 Queue만을 사용하면 모든 축구 경기 이벤트를 하나의 Queue에 넣고 빼고해야한다. 당연히 여러 대의 컨슈머에서 동시 consume 할 때 동시성 문제가 발생한다.
자연스레 순서 보장하면서 중복 소비를 안할려면 synchronize를 이용해야하며, 병렬 처리를 할 수 없기때문에, 읽기/쓰기 성능을 높일려면 Scale-Up 밖에 대안이 없게된다. (Scale-Up은 성능 향상에 한계가 존재한다.)
또한, 한 축구 경기에 이벤트가 많이 발행되면, 다른 축구 경기의 이벤트 처리 속도까지 영향을 끼치게된다.
이러한 동시성 문제, 읽기/쓰기 성능 향상등 문제를 해결하려면 Queue를 분리하고 병렬적 처리를 해야한다.
병렬적으로 축구 경기에 대한 이벤트를 처리하기위해선 사실상 논리적으로 Queue가 여러 개로 분산하여 처리되어야하며, 이렇게 분할하여 처리하는 기법을 파티셔닝이라한다.
바로 아래와 같이 같은 관심사 (아래 예시에선 축구 경기별)끼리 분할하여 처리하는 것이다.
이렇게하면 아래와 같이 데이터 생성과 소비가 파티션(축구 경기)별로 이뤄지기때문에 Write & Read에 대해 성능상 큰 이점이 생긴다. 또한 특정 축구 경기의 이벤트가 폭발적으로 증가해도 다른 축구 경기 이벤트 생산/소비에 영향을 끼치지 않게 된다.
자연스레 컨슈머의 동시성 문제가 해결되며, 프로듀서와 컨슈머 모두 병렬적인 처리로 성능을 높일 수 있다.
동시성 문제가 해결되었다는 의미는 컨슈머당 하나의 파티션만을 컨슘한다는 가정하이며, 실제 카프카는 컨슈머 그룹내 컨슈머마다 하나의 파티션만을 컨슘할 수 있다. 이와 관련해서는 아래 컨슈머 부분에서 더 자세히 다룰 예정이다.
만약 동시간대에 진행되는 축구 경기가 많아지면서 실시간으로 중계해야함에도 성능이 따라주지 못한다면, 이벤트를 저장하는 브로커입장에서 파티션만을 증설해주면 된다.
즉, Scale-Out만으로 병렬 작업 수를 늘려 쓰기/읽기 처리 성능을 높일 수 있게된다.
💁♂️ 브로커 샤딩
실제 카프카내에서 브로커 샤딩이란 용어는 사용하는지는 모르겠다.. 다만 내가 그냥 이해하기 편하기위해 지어본 용어다. 그저 파티션을 서로 다른 브로커에 저장하여 처리하는게 샤딩과 유사하여 이렇게 불러본다.
하나의 토픽을 파티션으로 나눠 병렬 처리하면 처리 성능을 높일 수 있지만, 여전히 한 대의 물리적 서버(브로커)에서 파티셔닝하여 처리하면 특정 처리량 이상은 여전히 브로커 서버를 Scale-Up 해야되는 문제가 발생하게된다.
이때 해결 방안은 DB 샤딩과 동일하게 파티션을 샤딩하는 것이다. 즉, 아래와 같이 클러스터내 물리적으로 다른 브로커(이벤트 브로커 서버)에 파티션을 나눠 저장하고 처리하도록 하는 것이다.
쉽게 말해 논리적으로 분리된 파티션을 물리적으로 분리한 브로커에 분산하여 저장하는 것.
이제 물리적으로 서로 다른 서버에 파티션이 나눠져 처리되기때문에 물리적인 리소스도 분할하여 병렬 처리가 가능해진다.
그리고 처리 지연이 발생하여 높은 성능이 필요하면 브로커 서버 대수와 파티션을 늘림으로써 Scale-Out 형태로 성능의 한계를 해결할 수 있다.
뒤에서 다룰 내용이긴하지만 카프카는 고가용성을 보장하기위해 파티션을 복제(Replication)하기도 한다.
즉, 동일한 메시지를 가진 파티션이 각 브로커별로 복제되어 리더 파티션과 팔로워 파티션으로 나뉘게된다.
다만 이벤트 순서보장과 여러 상황을 보장하기위해 카프카는 이벤트 데이터에 대한 Write과 Read는 모두 Leader 파티션을 통해서만 이뤄진다.
주의할 점은 Kafka 2.4부터 설정에 따라 Follower Fetching(Read)도 가능하기에, 카프카 버전에 조금 다르게 동작할 수 있기에 사용하는 버전과 설정에 따라 이해하면 좋을 것 같다.
아래는 실제 카프카 토픽내 파티션 상황을 알려주는 정보(CMAK)인데, 각 파티션별로 Leader 파티션이 어떤 브로커에서 담당하는지 명시되어있다.
💁♂️ 파티셔닝시 주의할 점
파티션은 데이터에 대해 특정 관심사에 따라 분할하여 처리하는 방식이다.
이때 순서가 중요한 이벤트라면 꼭 같은 파티션에 이벤트를 저장해야한다.
즉, 데이터에 대해서 파티셔닝할 때 어떤 key를 기반으로 분할할지 잘 설정해줘야한다. 그래야 동일한 파티션으로 이벤트가 저장되기에 순서 보장이되어 제대로 이벤트가 발행 및 소비될 수 있다.
실제로 카프카에 데이터 Produce시 사용되는 ProduceRecord는 토픽, 파티션 번호, 키, 밸류로 구성된다.
이때 파티션 번호를 지정할 수도 있으며, 파티션 번호를 지정하지 않으면 키에 따라 파티셔닝된다. 다시말해, 같은 파티션에 적재되어야하는 이벤트라면 동일한 키를 사용하도록해야 순서가 보장된다.
파티셔닝 방식은 다양하며 설정을 통해 변경할 수 있다. 이와 관련해서는 아래 프로듀서 부분에서 자세히 얘기한다.
1-2 세그먼트
어떤 이벤트 시스템이든 프로듀서의 이벤트 메시지 생성 속도를 컨슈머가 따라가지 못하는 경우가 발생한다.
프로듀서의 메시지 생성 속도가 컨슈머의 소비 속도보다 빠른 경우 이벤트의 손실을 막기위해 많은 이벤트 브로커는 이벤트 자체를 저장한다.
카프카도 이러한 상황으로인해 이벤트 자체를 Local Disk (HDD/SSD)에 저장하며, 토픽으로 들어오는 메시지는 세그먼트 (Segment)라는 파일에 저장된다.
💁♂️ 세그먼트란?
세그먼트란 데이터가 저장되는 저장소인 파티션을 구성하고 있는 실제 물리 파일 (file)이다.
설정한 세그먼트 크기보다 커지거나 지정한 시간이 지나게되면 해당 세그먼트는 더 이상 데이터를 쌓지않고, 다음 세그먼트 파일을 생성하여 저장하게된다.
많은 시스템에서 로그 쌓는 롤링 전략과 동일하며, 크기와 시간 단위로 세그먼트 파일을 나눌 수 있다.
💁♂️ Active 세그먼트
Partition은 여러 개의 Segment를 가지는데, 데이터를 쓰기위해선 하나의 Partition을 대상으로 해야한다.
즉, Partition당 오직 하나의 Segment만 Active한 상태이며, Active Segment에 데이터가 계속 쓰인다.
💁♂️ 세그먼트 사용 이유
카프카는 프로듀서에 의해 전송된 이벤트를 들어온 순서대로 순차적(Sequential)으로 저장한다. 그리고 이러한 순서는 offset이라는 숫자로 기록된다.
순차적으로 들어오는 메시지마다 파티션은 offset을 지정하고, 큰 파일로 저장하는 대신 대략 같은 크기의 세그먼트 파일로 구성된 논리적 .log 파일로 구성한다.
새 메시지를 추가해야 할 때는 큰 파일을 수정하지 않고 최신 세그먼트 파일에 추가함으로써 효율적인 저장을 보장한다.
그리고 각 세그먼트의 .log 파일의 이름은 해당 세그먼트에 가장 먼저 저장된 이벤트의 파티션내 offset으로 기록된다.
세그먼트가 저장되는 구조를 그림으로 보면 아래와 같다.
이렇게함으로써 얻을 수 있는 장점은 아래와 같다.
첫번째. 파일 관리 용이함
프로듀서에 의해 브로커로 전송된 메시지는 평생 카프카 브로커에 저장될 순 없다.
일정 시간마다 자동으로 메시지를 제거해야하는데, 카프카내 메시지를 세그먼트 단위로 작은 파일로 나누어 저장하기때문에 훨씬 빠르고 쉽게 데이터를 제거하고 관리할 수 있다.
두번째. 인덱싱과 I/O 비용 절감
앞서 그림으로 살펴보았듯이 각 세그먼트는 .log 파일단위로 저장되며, 각 파일의 이름은 가장 먼저 저장된 이벤트의 파티션내 offset으로 기록된다.
이렇게하면 특정 offset 단위로 메시지를 조회할 때 인덱스의 레인지 스캔처럼 훨씬 적은 I/O 요청만으로 원하는 메시지를 찾을 수 있게된다.
즉, .log 파일의 이름을 첫번째 offset으로 기록함으로써 파일 이름이 인덱싱 역할을 수행하는 것이며, 이로인해 I/O 비용을 절감할 수 있다.
세번째. 순차적 소비
카프카 컨슈머는 브로커에 메시지 Pull 요청을 보내면 메시지는 특정 파티션에서 순차적으로 소비된다.
당연히 디스크에 순차적(Sequantial)으로 메세지가 저장되므로, 데이터를 소비할 때도 Sequantial I/O로 읽어 갈 수 있다.
디스크 구조상 Random I/O보다 Sequantial I/O가 훨씬 효율적이므로, 이러한 방식은 카프카의 높은 처리 속도와 낮은 지연 시간에 많은 영향을 주며, 디스크 I/O 작업을 비교적 작은 리소스로 해결하므로 리소스 측면에서도 이득이 많다.
HDD에서 Random I/O는 Sequantial I/O보다 훨씬 느리다. 구조상 디스크로 저장되므로, 디스크 헤드가 매 데이터마다 물리적으로 돌려서 필요한 데이터를 찾아야하므로 당연한 결과이기도하다.
비유를 들자면 Sequantial I/O는 창고에 있는 순서대로 저장된 데이터를 한번에 찾을 수 있는 반면, Random I/O는 수많은 창고에서 자료를 하나씩 찾아 보내는 것과 같다. (한번의 창고 탐색과 여러 번의 창고 탐색)
반면 SSD는 컨트롤러가 여러 개의 메모리 셀을 모두 관리한다. 즉, 여러 창고에 동시에 문자를 보내 병렬적으로 처리가 가능하다는 의미가된다.
이로인해 SSD에선 Random I/O의 성능을 굉장히 높여 Sequantial I/O와의 차이를 많이 없앴지만, 여전히 Sequantial I/O가 더 효율적이라고한다.
실제로 SSD를 구매할 때보면, 제조사들은 Sequantial I/O와 Random I/O의 쓰기/읽기 속도를 표시한다.
그리고 카프카 브로커는 메시지 Pull 요청을 받으면, 브로커에 소비를 위한 데이터 버퍼를 유지한다. 즉, 하나의 .log 파일을 인메모리 버퍼에 임시 저장하여 순차적으로 서빙하므로 소비할 데이터를 굉장히 빠르게 서빙한다.
카프카의 빠른 속도는 이러한 디스크를 효율적으로 사용하는 것 외에도 네트워크 zero-copy가 큰 역할을 한다.
1-3 토픽, 파티션, 오프셋
앞서 살펴보았던 내용을 정리하면 카프카는 토픽, 파티션, 오프셋 기준으로 이벤트를 저장한다.
쉽게 말해 카프카는 토픽이라는 곳에 데이터를 저장하는데, 동일한 스키마를 가진 이벤트가 하나의 토픽이 된다고 보면 된다.
그리고 앞서 살펴보았듯이, 토픽은 병렬 처리를 위해 여러 개의 파티션 (partition)이라는 단위로 나뉘게된다.
이를 통해 높은 처리량을 수행할 수 있게되었으며, 이 파티션의 메시지가 저장되는 위치를 offset이라고 부른다. offset은 순차적으로 증가하며 숫자 (64비트 정수)형태로 되어있다.
각 파티션에서의 offset은 고유한 숫자로, 카프카에서는 offset을 통해 메시지의 순서를 보장하고 컨슈머에서 마지막까지 읽은 위치를 알 수 있다.
1-4 리플리케이션
💁♂️ 파티션 복제
카프카는 성능을위해 토픽을 여러 파티션으로 나눠 병렬 처리를 한다. 그리고 분산된 브로커(서버)에서의 병렬 처리를위해 리더 파티션 기준으로 파티션은 브로커로 나뉘어져 병렬 처리된다.
그렇다면 아래와 같이 특정 축구 경기의 이벤트를 저장하는 파티션을 처리하는 브로커-01이 높은 처리량으로인해 SHUTDOWN되거나 네트워크상 연결이 끊어진다면 어떻게 될까?
당연히 브로커-01에서 관리하는 파티션에 담긴 이벤트는 모두 중단될 것이며, 이는 특정 축구 중계 중단으로 이어진다.
카프카는 이러한 가용성 문제를 보장하기 위해 리플리케이션 기능을 제공한다.
리플리케이션은 복제를 의미이며, 여기서 복제하는 단위는 토픽의 파티션을 가리킨다. 즉, 토픽 자체를 복제하는 것이 아닌 토픽의 파티션을 복제한다.
그리고 앞서 파티셔닝에서 언급했듯이 파티션은 리더와 팔로워로 나뉘며, 실제 이벤트에 대한 Write과 Read는 모두 리더 파티션을 통해서만 이뤄진다. 팔로워는 리더에서 생성된 데이터를 복제만 진행한다.
다시 한번 강조하지만, Kafka 2.4부터 설정에 따라 Follower Fetching(Read)도 가능하기에, 카프카 버전에 조금 다르게 동작할 수 있다. 사용하는 버전과 설정을 잘 살펴보고 이해하길 추천한다.
실제 축구 중계 예시에 리플리케이션을 적용하면 아래와 같다.
위와 같이 각 파티션별로 토픽 생성시 설정한 Replication Factor 수에 따라 파티션을 다른 브로커에 복제한다.
그리고 만약 특정 브로커가 다운되어 리더 파티션이 더이상 사용할 수 없게될 경우, 카프카는 주키퍼에 연결된 여러 브로커들 중 팔로워 파티션을 가진 브로커를 해당 파티션의 리더 브로커로 승격시켜 고가용성을 보장한다.
이번 글은 사례를 통해 카프카의 큰그림을 이해하기위함이므로, 어떻게 팔로워 브로커가 리더 브로커로 승격되는지등은 책이나 공식 문서등을 참고하길 바란다.
💁♂️ Replication Factor 수가 높다고 좋은 것만은 아니다
일반적으로 팔로워 수가 많을수록 안정적이고 좋을 거라 생각하지만, 팔로워의 수만큼 결국 브로커의 리소스가 필요하므로 적절한 수의 리플리케이션 팩터 수를 정하는 것이 좋다.
카프카에서는 일반적으로 Factor 수를 3으로 구성하도록 권장하고있다.
2 프로듀서
프로듀서는 카프카에 메시지를 전송하는 역할을 수행한다.
앞서 카프카의 분산 시스템 구조와 대규모의 데이터 처리를 위해 파티션을 두고 고가용성을 복제하여 분산 처리된다는 점까지 살펴보았다.
파티션을 나누어 저장하는만큼 토픽에 새로운 메시지를 전송하기 위해선 어디에선가 파티셔닝을 수행해야한다.
카프카 브로커내 파티션 단위로 나뉘어져 저장되지만, 메시지 Key에 따라 파티션 분배를 수행하는 파티셔너를 누군가 처리해줘야하는데, 카프카에선 이러한 파티션 분배 로직을 브로커가 아닌 프로듀서가 수행한다.
2-1 파티셔너
카프카의 토픽은 여러 개의 파티션으로 나뉘어지며, 프로듀서에서 파티셔너를 구현하고있다.
이때 프로듀서는 ProducerRecord를 하나의 단위로 카프카에 메시지를 전송한다. 그리고 이 ProducerRecord 기준으로 파티셔너는 어떤 파티션으로 보낼지도 정한다.
ProducerRecord의 속성 값에서 알 수 있듯이, 어떤 파티션으로 보낼지에 대한 partition값을 받는다. 다만 이 값은 필수 값이 아니며, 이 값을 지정하지 않으면 설정된 파티셔너 구현체에 따라 파티션이 결정된다.
그리고 따로 Partitioner 인터페이스의 구현체를 Sender에 설정하지 않으면, org.apache.kafka.clients.producer.internals.DefaultPartitioner가 사용되며, 이 파티셔너 구현체는 아래와 같이 동작한다.
- Key 값이 있는 경우 Key의 값을 Hash로 변환하여 Partition을 할당한다.
- Key 값이 없는 경우 Round-Robin 방식으로 Partition을 할당한다.
다시 축구 경기 사례로 살펴보면 축구 경기의 흐름에 따라 이벤트를 생성하면 프로듀서는 파티셔너를 거치며, 파티션을 각 경기별로 결정하여 카프카 브로커로 전송하게된다.
2-2 배치 전송
카프카 프로듀서는 처리량을 높이기 위해 배치 전송을 권장한다. 즉, 아래와 같이 배치 전송을 위해 토픽의 파티션별로 ProducerRecord (메시지)를 잠시 보관하고있는다.
프로듀서내 파티션별로 잠시 보관했다가 한번에 카프카에 배치 전송하는 이유는 네트워크 I/O를 최대한 줄이고, 카프카의 요청 수를 줄이기 위함이다.
이렇게하면 단건의 메시지를 매번 전송하는 것이 아니라 한 번에 다량의 메시지를 묶어서 전송하기 때문에, 프로듀서내에서 네트워크 I/O 횟수를 줄이고, 카프카의 요청 수 자체도 줄일 수 있다.
이외에 메시지를 버퍼에 쌓는 작업과 메시지를 실제 전송하는 작업을 비동기로 처리하기위함도 있다.
물론 배치 전송하게되면 전송에 지연이 발생할 수 있기 때문에, 처리량과 지연 사이의 적절한 설정을 해줘야한다. 프로듀서는 이를 위해 아래와 같은 설정을 제공한다.
buffer.memory: 메시지를 카프카로 전송하기 위해 담아두는 프로듀서내 버퍼 메모리 옵션. (Default는 32MB)- 전체 파티션에 대한 프로듀서내 버퍼 메모리 크기이다.
batch.size: 배치 전송을 위해 메시지 (ProducerRecord)를 묶는 단위를 설정하는 배치 크기 옵션.- 프로듀서내 파티션별 버퍼로 전송하기 위한 버퍼 메모리 크기이다. (Default는 16KB)
linger.ms: 배치 전송을 위해 버퍼 메모리에서 대기하는 메시지들의 최대 대기시간을 설정하는 옵션.
그 외 다양한 Producer의 설정은 공식 문서를 참고하길 추천한다.
2-3 파티션 메타 데이터
앞서 프로듀서는 파티셔너와 배치 전송을 통해 메시지를 전송한다고 살펴보았는데, 문득 “각 파티션별로 어떤 브로커가 리더 역할을 수행하는지 어떻게 알까?”라는 의문이 생긴다.
파티션은 여러 브로커에 샤딩되고 복제되기때문에 리더 파티션과 팔로워 파티션으로 나뉜다. 그리고 리더 파티션만이 Write을 할 수 있으므로, 프로듀서는 각 파티션별로 어떤 브로커가 리더인지 알아야한다.
결론부터 말하면 카프카는 각 파티션별 어떤 브로커가 리더 파티션을 담당하고있는지 알기위해 각 브로커마다 파티션 메타 데이터를 요청한다.
즉, 각 브로커마다 지정된 시간 (metadata.max.age.ms)마다 메타 데이터 요청을 보내 브로커별 관리하고있는 파티션을 조회한다.
2-4 프로듀서 메시지 전송 과정
프로듀서내 중요한 특징은 파티셔너와 배치 전송등을 정리했으니 마지막으로 전체적인 프로듀서의 메시지 전송 과정을 정리해본다.
그림을 통해 알 수 있겠지만, 전송 과정에서 서로 협력하며 전송을 수행하는 컴포넌트는 아래와 같다.
- KafkaProducer
send()를 호출함으로써 메시지(Record)를 전송한다.
- Serializer
- 메시지(
Record)내 Key와 Value를 직렬화하는 역할을 수행한다.
- 메시지(
- Partitioner
- 메시지(
Record)에 기록된 내용을 바탕으로 어떤 파티션에 전송할지 결정한다.
- 메시지(
- RecordAccumulator (Buffer)
- 앞서 배치 전송에서 언급했듯이, 사용자가
send()호출한다고 바로 메시지(Record)가 Broker로 전송되지 않고 파티션별 버퍼에 저장되는데, 이때 실제 저장되는 곳이RecordAccumulator다. - 그림에서 알 수 있듯이, 파티션별로
batch.size단위로 버퍼에 메시지들을 저장하고,batch.size별로 전송한다.
- 앞서 배치 전송에서 언급했듯이, 사용자가
- Sender (I/O Thread)
Sender는 실제 Broker에 메시지를 배치 단위로 전송하는 역할이다.- RecordAccumulator는 버퍼에 메시지를 쌓는 역할만 수행할 뿐, 실제 broker에 메시지를 전송하는 것은 이후에
Sender에 의해 비동기적으로 이뤄진다. 즉, Sender Thread를 따로 구성하고, Sender Thread는RecordAccumulator에 저장된 메시지(Record)들을 drain (pull)하여 Broker에 전송한다. - 그리고 Broker의 응답을 받아서 사용자가 메시지(
Record) 전송 시 설정한 콜백이 있으면 실행하고, Broker로부터 받은 응답 결과를Future를 통해서 사용자에게 전달한다.
프로듀서는 이외에도 브로커내 트랜잭션 코디네이터를 통해 “중복 없는 전송”, “정확히 한 번 전송” 등의 기능을 제공한다. 이와 관련해서는 실제 사용시 공식 문서를 참고하길 추천한다.
그리고 Producer 내부적으로 어떻게 카프카 브로커에 메시지를 전송하는지에 대해서 잘 정리된 글이 있어 남겨둔다. - KafkaProducer Client Internals
2-5 동시성 문제
카프카는 기본적으로 파티션단위로 순서를 보장한다. 프로듀서에서 파티셔너를 잘 설정하거나, 기본 파티셔너는 Key 값을 기준으로 Hash하여 파티션을 할당하기때문에 순서 보장하고싶은 메시지의 Key 설정만 동일하게하면 된다.
다만, 동일한 Key를 가진 여러 메시지가 두 개의 프로듀서 서버에 나눠 처리된다하면, 동일한 파티션에 두 개의 프로듀서가 여러 메시지를 동시에 전송하게된다.
이런경우 순서는 어떻게 보장될까? -> 결론부터 말하자면 카프카 브로커는 서로 다른 프로듀서로부터 받는 메시지를 동일한 파티션에 append할 때 도착한 순서대로 저장한다고 한다.
다시 말해 A 프로듀서가 더 순서가 빠른 메시지를 가지고있는데 네트워크 오류나 배치 처리로 인한 지연으로 B 프로듀서가 더 순서가 늦는 메시지를 먼저 전송하여 브로커가 받게된다면 파티션내 저장된 순서는 틀어질 수 있다.
즉, 프로듀서의 네트워크 지연, 재시도, 배치와 같은 요소로인해 여러 프로듀서가 관련될 땐 카프카 브로커는 파티션에 들어오는 순서에 따라 저장된다.
순서가 굉장히 중요하다면 해당 이벤트에 대해서 프로듀서 하나에서만 발행되도록하거나, 프로듀서 설정들을 조절하면 좋을 듯하다.
3 컨슈머
마지막으로 살펴볼 컨슈머는 카프카 브로커에 저장되어 있는 메시지를 가져와 소비하는 역할을 담당한다.
컨슈머가 단순하게 카프라 브로커로부터 메시지만 가져오는 것 같지만, 내부적으로는 컨슈머 그룹, 리밸런싱 등 여러 동작이 수반된다.
이러한 동작을 제대로 이해하고 사용해야 효율적으로 최소한의 리소스도 메시지를 효율적으로 소비할 수 있다.
3-1 컨슈머 그룹
💁♂️ 토픽, 파티션, 오프셋
앞서 얘기했듯.. 카프카는 토픽, 파티션, 오프셋 기준으로 이벤트를 저장한다.
실제 데이터는 파티션별로 세그먼트에 저장되는데, 메시지가 저장되는 위치를 가리키는 인덱스 역할의 숫자를 offset이라고 부른다. offset은 순차적으로 증가하며 숫자 (64비트 정수)형태로 되어있다.
각 파티션에서의 offset은 고유한 숫자로, 카프카에서는 offset을 통해 메시지의 순서를 보장하고 컨슈머에서 마지막까지 읽은 위치를 알 수 있다.
💁♂️ 컨슈머와 컨슈머 그룹
카프카 컨슈머는 컨슈머 그룹 안에 속하며, 하나의 컨슈머 그룹 안에 여러 개의 컨슈머가 구성된다.
그리고 각 컨슈머는 컨슘하는 토픽내 1개 이상의 파티션과 매핑되어 메시지를 polling하게된다.
축구 사례를 다시 살펴보면, 아래와 같이 하나의 파티션당 컨슈머 그룹내 하나의 컨슈머가 할당되어 데이터를 꺼내간다.
위와 같이 컨슈머는 파티션을 구독하여 데이터는 Polling한다. 그리고 이런 컨슈머들이 1개 이상이 모여 논리적인 컨슈머 그룹이 된다.
💁♂️ 컨슈머 그룹은 다른 컨슈머 그룹과 격리되어 서로 영향을 주고 받지 않는다
이때 만약 축구 경기관련하여 웹이나 앱에 노출하는 것 말고, 따로 어딘가에 저장하여 데이터 분석에 사용하고자한다면 어떻게 될까?
바로 위와 같이 새로운 컨슈머 그룹을 만들고 Group 1은 Front에 노출하기위한 relay 역할을, Group 2는 데이터 분석을 위해 이벤트를 컨슘해 데이터 플랫폼에 넘기게된다.
위와 같이 컨슈머 그룹별로 1대의 컨슈머당 1대 혹은 n개의 파티션을 할당하여 소비함으로써 파티션내 이벤트의 순서 보장과 동시성 문제도 같이 해결한다.
그리고 가장 중요한 점은 컨슈머 그룹은 다른 컨슈머 그룹과 격리되어 서로 영향을 주고 받지 않는다는 것이다. 다르게 말하면 컨슈머 그룹별로 현재까지 읽은 각 파티션 offset에 대한 context를 가지고 서로 다른 생명주기로 이벤트를 컨슘한다. 아래와 같이 말이다.
위 이미지에서 중요한 점은 Group 1의 컨슈머들은 모든 파티션에 대해서 가장 최신 offset까지 컨슘한 반면, Group 2의 컨슈머 1, 2는 최신 offset 이전까지만 컨슘한 상태인 것을 볼 수 있다.
이는 컨슈머 그룹마다 각 토픽|파티션별로 어느 offset까지 컨슘했는지를 따로 가지고있기에 서로 다르게 컨슘하는 것을 의미한다.
즉, 동일한 파티션이라도 컨슈머 그룹내 컨슈머마다 처리 속도가 상이하기에 각 컨슈머의 성능에 따라 메시지를 컨슘한다. (Back-Pressure)
💁♂️ 파티션은 각 컨슈머 그룹별 최대 1개의 컨슈머에만 할당 가능하다. 그리고 파티션 개수보다 컨슈머 개수가 같거나 적은게 좋다
파티션은 각 컨슈머 그룹별 최대 1개의 컨슈머에만 할당 가능하다. 반면 컨슈머 그룹내 컨슈머는 여러 개의 파티션을 구독할 수 있다.
이때 중요한 점은 아래와 같이 컨슈머는 파티션의 개수와 같거나 적을 때 가장 효율적이라는 것이다.
예를 들어, 파티션이 3개인데 컨슈머 그룹내 컨슈머가 4개라면 아래와 같이 그려진다.
당연히 컨슈머 4는 유휴 상태로 리소스 낭비가 발생하게 된다. 즉, 쓰레드가 낭비로 이뤄지는 것이다.
즉, 파티션 개수는 항상 컨슈머의 개수와 같거나 많게 설정하는 것이 좋다.
그리고 이러한 파티션 구조가 앞서 얘기했듯이 카프카 병렬 처리의 핵심이 된다.
특정 토픽의 대한 처리량을 한정되어 있는 자원내 늘리고 싶다면, 파티션과 컨슈머 그룹내 컨슈머의 개수를 늘리는 것이 하나의 방법이 될 수 있다.
💁♂️ offset와 commit
컨슈머 그룹내 컨슈머는 commit을 통해 레코드를 얼마나 컨슘했는지를 카프카 브로커에 기록한다. 정확히는 해당 컨슈머 그룹에 대한 정보를 가지고 관리하는 1대의 컨슈머 그룹 코디네이터 브로커에 저장된다.
자세히 말하자면 파티션내 레코드마다 {key, value, header, offset, timestamp}로 이뤄져있는데, 각 컨슈머 그룹마다 아래와 같이 파티션 어디까지 컨슘했는지는 저장하고있다.
(group=Group1, topic=topicA, partition=0, offset=152)
(group=Group1, topic=topicA, partition=1, offset=89)
(group=Group2, topic=topicA, partition=0, offset=140)
(group=Group2, topic=topicA, partition=1, offset=88)이 offset을 활용하여 각 컨슈머 그룹내 컨슈머들이 파티션의 데이터를 어디까지 가져갔는지 판단한다. (각 컨슈머 그룹마다 offset을 관리)
그리고 컨슈머 그룹내 어디까지 컨슘했는지를 의미하는 offset은 브로커내 __consumer_offsets 라는 특별한 토픽에 저장된다.
💁♂️ commit 방식
마지막으로 컨슈머의 commit은 크게 명시적 방식과 비명시적 방식으로 나뉜다.
- 비명시적 (
enable.auto.commit=true)- 비명시적 offset commit은
poll()메서드가 실행된 이후 일정 시간(auto.commit.interval.ms)이 지나면 그 시점까지 읽은 offset을 자동으로 commit하는 방식이다. - 이 방식은
poll()호출이후 리밸런싱이 발생하거나, 컨슈머가 비정상적으로 종료된다면 메시지가 중복처리되거나 유실될 가능성이 존재하므로, 중복 처리나 유실에 예민한 서비스라면 사용하지 않는 것이 좋다.
- 비명시적 offset commit은
- 명시적 (
enable.auto.commit=false)- 명시적 offset commit은
poll()메서드 호출 이후 애플리케이션에서commitSync()를 호출해서 commit을 직접 해주어야한다. commitSync()는 호출되면poll()메서드를 통해 반환된 Record의 가장 마지막 offset을 기준으로 commit한다.
- 명시적 offset commit은
3-2 컨슈머 그룹 Coordinator
앞서 살펴보았듯이 컨슈머 그룹마다 저장하고 유지해야하는 데이터가 존재한다. 그리고 이러한 데이터는 카프카 브로커 중 한 대가 담당한다.
즉, 카프카 브로커 중 한 대는 컨슈머 그룹 코디네이터(Consumer Group Coordinator)라는 역할을 수행한다. 코디네이터는 특정 컨슈머 그룹을 관리한다.
컨슈머 그룹마다 코디네이터 역할의 브로커는 다를 수 있다. 즉, 브로커가 여러개인 클러스터에서 컨슈머 그룹마다 코디네이터 브로커는 서로 같거나 다를 수 있다.
쉽게 말해 Consumer Group Coordinator = 그룹의 "관리자" 브로커다.
💁♂️ Coordinator의 역할
컨슈머 그룹의 Coordinator 역할을 수행하는 브로커는 아래와 같은 역할을 수행한다.
- 컨슈머 그룹내 컨슈머 관리
- 어떤 컨슈머들이 그룹에 속해 있는지 추적.
- 컨슈머가 새로 들어오거나 나가면 이를 감지하여 그룹 멤버십을 갱신한다.
- 각 Consumer는 Coordinator와 하트비트(heartbeat)를 주고받으며 살아있음을 알린다. 하트비트가 끊기면 Coordinator는 Consumer가 죽었다고 판단하고 리밸런싱을 시작한다.
- 파티션 할당
- 특정 Group이 구독하는 토픽의 파티션들을 Consumer 인스턴스에 분배한다.
- 할당 방식은 range, round-robin, sticky 등의 전략을 사용할 수 있다.
- offset 관리
- 각 Consumer Group이 마지막으로 처리한 오프셋을 추적 할 수 있게 저장 및 관리해준다.
commitSync()/commitAsync()호출 시 Coordinator 브로커가 이를 받아서 기록한다.- 장애 복구 시 이 오프셋을 기준으로 어디서부터 다시 읽을지 결정한다.
- 리밸런싱 트리거
- Consumer 추가, 제거, 장애 발생 시 Coordinator는 리밸런스를 실행한다.
- 리밸런스 시 모든 Consumer가 잠시 데이터를 읽지 못하고 파티션이 재분배된다.
- Coordinator는 누가 어떤 파티션을 맡을지 최종적으로 확정하여 각 Consumer에 통지한다.
3-3 리밸런싱
Kafka의 리밸런싱(Rebalancing) 은 Consumer Group 내 파티션 할당을 다시 조정하는 과정을 말한다. 즉, 누가 어떤 파티션을 읽을지 재분배하는 과정이다.
💁♂️ 리밸런싱이 필요한 이유
- Failover (고가용성)
- 컨슈머중 하나가 장애로 죽거나 네트워크가 끊기면, 해당 컨슈머가 맡고 있던 파티션은 더 이상 읽는 주체가 없어진다.
- 이 상태가 방치되면 그 파티션에 쌓이는 메시지를 아무도 소비하지 못하게된다.
- 따라서 Coordinator는 리밸런싱을 통해 죽은 컨슈머의 파티션을 살아있는 컨슈머들에게 재할당 (리밸런싱)하는 것이다.
- Load Balance (균등 분배)
- 컨슈머 그룹에 새로운 컨슈머가 들어오면, 기존 컨슈머 몇 개만 많은 파티션을 가져가는 불균형이 생길 수 있다.
- 이때 리밸런싱을 통해 파티션을 다시 공평하게 나눔으로써, 처리 속도를 높이고 병렬성을 극대화할 수 있다.
💁♂️ Coordinator는 컨슈머에게 주기적으로 Heartbeat을 전송하여 컨슈머를 관리한다.
컨슈머는 Polling이나 Commit을 수행할 때, Coordinator 브로커에게 Heartbeat 메시지를 전송한다. Coordinator 브로커는 특정 컨슈머가 Heartbeat을 일정시간 동안 전송하지 않으면 해당 컨슈머는 비정상이라고 판단하게 된다.
그리고 이 시간 간격은 session.timeout.ms에 설정하며, 기본값으론 10초로 설정되어있다.
💁♂️ 리밸런싱 발동 조건들
- 새로운 컨슈머가 그룹에 참여 (Load Balance)
- 더 많은 컨슈머가 그룹에 들어오면 균등 분배를 위해 리밸런싱이 발동된다.
- 컨슈머가 그룹에서 이탈 (Failover)
- → 프로세스 종료, 장애, heartbeat timeout 등으로 빠지면 남은 Consumer가 파티션을 다시 가져가야하기에 Failover로써 리밸런싱이 발동된다.
- 구독하는 토픽의 파티션 수가 변경된 경우 (Load Balance)
- 파티션이 늘어마녀 기존 컨슈머에게 새 파티션을 다시 분배해야하기에 리밸런싱이 발동된다.
- 리더 컨슈머가 변경될 경우
- 컨슈머 그룹내부에서 파티션 할당 계획을 주도하는 리더 컨슈머가 바뀔 때도 리밸런싱이 발동된다.
💁♂️ 리밸런싱 과정
Kafka는 Group Coordinator와 Consumer Group 프로토콜을 통해 리밸런싱을 수행한다.
자세한 내용은 Confluence 자료를 참고.
💁♂️ 리밸런싱이 자주 일어나는 것은 바람직하지 않다.
리밸런싱이 발동되면 해당 컨슈머 그룹내 컨슈머들은 리밸런싱 과정동안 토픽의 데이터를 읽을 수 없게 된다. (stop-the-world)
리밸런싱이 데이터 처리의 가용성과 균등 분배를 지키기위해 수행되지만, 너무 자주 발생해도 처리 효율과 가용성이 떨어지기에 자주 발생하지 않는 것이 좋다.
여러 설정을 통해 가능한 불필요한 리밸런싱이 발생하지 않게 하는것이 좋다.
- 세션 타임아웃 조정 (session.timeout.ms, heartbeat.interval.ms)
- 네트워크 지연이나 GC 등으로 false positive 리밸런싱을 막음.
- 스티키 할당 (Sticky Assignor)
- 가능한 한 기존 파티션 할당을 유지 → 불필요한 데이터 로컬리티 손실 방지.
- Cooperative Rebalancing (Incremental Rebalancing)
- Kafka 2.4+ 부터 도입.
- 기존 “전부 정지 → 전부 재할당” 대신, 일부만 천천히 교체하도록 개선. 큰 Consumer Group에서 리밸런싱 부담이 크게 줄어듦.