Table of Contents
Kafka의 주요 구성요소
Kafka는 크게 3가지로 이루어 있습니다.
- Producer: Kafka로 메시지를 보내는 모든 클라이언트
- Broker: 메시지를 분산 저장 및 관리하는 Kafka 애플리케이션이 설치된 서버
- Consumer: Kafka에서 메시지를 꺼내서 사용하는 모든 클라이언트
Topic, Partition, Segment
Kafka의 구성요소에 대해 알아보기 전에 메시지가 어떤 식으로 구성, 저장되는지에 대해 짚고 넘어가려고 합니다.
- Topic: 메시지가 저장될 카테고리 이름 (논리적인 저장소)
- Partition: 병렬 처리를 위해 Topic을 여러 개로 나눈 것 (Server 디스크에 저장된 디렉토리)
- Segment: 메시지가 실제로 저장되는 파일. 기본적으로 1GB를 넘을 때마다 파일이 새로 생성
카프카를 실행하게 되면 보통 토픽을 가장 먼저 생성합니다. 그리고 토픽은 병렬 처리를 통한 성능 향상을 위해 파티션으로 나뉘어 구성됩니다. 그리고 프로듀서가 카프카로 전송한 메시지는 해당 토픽 내 각 파티션의 로그 세그먼트에 저장됩니다. 따라서 프로듀서는 토픽으로 메시지를 보낼 때 해당 토픽의 어느 파티션으로 메시지를 보낼지를 결정해야 합니다.
Producer
프로듀서는 카프카의 토픽으로 메시지를 전송하는 역할을 합니다. 프로듀서가 동작하는 방식은 다음과 같습니다.
레코드 전송과정
프로듀서가 카프카의 브로커로 데이터를 전송할 때에는 ProducerRecord라고 하는 형태로 전송되며, Topic과 Value는 필수값이며, Partition과 Key는 선택값입니다. 프로듀서는 카프카로 레코드를 전송할 때, 카프카의 특정 토픽으로 메세지를 전송합니다. 전송 과정은
- 프로듀서에서 send() 메소드 호출
- Serializer는 JSON, String, Avro 등의 object를 bytes로 변환
- ProducerRecord에 target Partition이 있으면 해당 파티션으로 레코드 전달
- Partition이 지정되지 않았을 때, Key값이 지정되었다면 Partitioner가 Key값을 바탕으로 해당 파티션에 전달
- Partition, Key값이 모두 없으면 라운드 로빈(Round-Robbin)방식 또는 스티키 파티셔닝(Sticky Partitioning) 방식으로 메세지를 파티션에 할당
- 파티션에 세그먼트 파일 형태로 저장된 레코드는 바로 전송할 수도 있고, 프로듀서의 버퍼 메모리 영역에 잠시 저장해두고 배치로 전송할 수도 있음
레코드 파티셔닝 전략
라운드 로빈(Round-Robbin) 방식
프로듀서의 메시지에서 키값은 필수값이 아니므로, 값이 null일 수도 있습니다. 그럴 경우 기본적인 메세지 할당 방식은 라운드 로빈 방식 입니다.
메시지를 위 그림과 같이 순차적으로 파티션에 할당합니다. 하지만 이 방법은 배치 전송을 할 경우 배치 사이즈가 3일 때, 메시지를 5개 보내는 동안에도 카프카로 전송되지 못한채 프로듀서의 버퍼 메모리 영역에서 대기하고 있습니다. 이러한 비효율적인 전송을 보완하기 위해 카프카에서는 스티키 파티셔닝 방식을 공개했습니다.
스티키 파티셔닝(Sticky Partitioning) 방식
라운드 로빈 방식의 비효율적인 전송을 개선하기 위해 아파치 카프카 2.4버전부터는 스티키 파티셔닝 방식을 사용하고 있습니다. 스키티 파티셔닝이란 하나의 파티션에 레코드를 먼저 채워 카프카로 빠르게 배치 전송하는 방식을 말합니다.
이렇게 파티셔너는 배치를 위한 레코드 수에 도달할 때까지 파티션 한 곳에만 메시지를 담아놓습니다. 이러한 미묘한 변화가 프로듀서 성능을 높일 수 있는지 의구심이 들지만 컨플루언트에서는 블로그에서 약 30% 이상 지연시간이 감소되었다고 합니다.
(Confluent 블로그 참고, linger.ms는 배치 전송을 위해 버퍼 메모리에서 메시지가 대기하는 최대시간입니다.)
적어도 한 번 전송
카프카에서 적어도 한 번 전송은 프로듀서와 브로커간 주고 받는 ACK로 구현됩니다. 프로듀서는 메시지를 브로커에게 전송하고 브로커로부터 메시지를 잘 받았다는 ACK를 받으면 다음 메시지를 전송합니다.
만약 브로커가 메시지를 못 받았다면 프로듀서는 ACK를 못 받게 되고 프로듀서는 다시 메시지를 전송합니다. 또한 브로커가 메시지를 받았다고 하더라도 네트워크 장애로 ACK를 브로커에게 돌려 주지 못하면 프로듀서는 브로커가 메시지를 못 받은 것으로 간주하고 다시 같은 메시지를 보내게 됩니다. 이러한 특징으로 인해 이를 적어도 한 번 전송이라고 합니다.
카프카는 기본적으로 이와 같은 적어도 한 번 전송 방식을 기반으로 동작합니다.
정확히 한 번 전송
데이터 처리나 가공 작업을 하는 대부분의 사람들은 데이터 파이프라인에서 메세지 손실 뿐만 아니라 중복도 발생하지 않기를 원할겁니다. 이러한 방식을 정확히 한 번 전송 방식이라고 합니다.
하지만 카프카에서 정확히 한 번 전송 방식은 기준이 조금 더 엄격합니다. 카프카에서 정확히 한 번 전송은 트랜잭션과 같은 전체적인 프로세스 처리를 의미하며, 중복 없는 전송은 정확히 한 번 전송의 일부 기능이라 할 수 있습니다.
중복 없는 전송
중복 없는 전송은 브로커가 중복된 메세지를 받을 경우 중복 저장하지 않고, 메세지를 받았다는 ACK만 돌려보내는 방식입니다. 프로듀서는 메세지를 보낼 때 누가(Producer ID), 어떤 메세지(시퀀스 번호)를 보내는지 정보를 추가해서 브로커에게 보내고, 브로커는 이 정보를 메모리와 리플리케이션 로그에 저장해 놓습니다.(kafka-logs/토픽-파티션 폴더/세그먼트 시작 오프셋.snapshot
) 그리고 프로듀서가 보낸 메세지의 시퀀스 번호와 비교해 브로커가 자신이 저장해 놓은 시퀀스 번호보다 정확하게 하나 큰 경우에만 메세지를 저장합니다.
트랜잭션 API
프로듀서가 카프카로 정확히 한 번 방식으로 메세지를 전송할 때, 프로듀서가 보내는 메세지들은 원자적으로 처리되어 전송에 성공하거나 실패하게 됩니다. 이렇게 메세지를 관리하며 커밋 또는 중단 등을 표시하는 것은 트랜잭션 코디네이터에 의해 수행됩니다.
정확히 한 번 전송을 위해서는 트랜잭션 API를 이용해 다음과 같은 동작을 단계별로 수행합니다.
-
트랜잭션 코디네이터 찿기
트랜잭션 코디네이터는
__transaction_state
토픽의 리더 파티션을 가지고 있는 브로커입니다. 트랜잭션 코디네이터의 주 역할은PID와
transactional.id
를 매핑하고 해당 트랜잭션 전체를 관리하는 것입니다. -
프로듀서 초기화
프로듀서가
InitPidRequest
(프로듀서 초기화 요청)를 트랜잭션 코디네이터로 보내면 트랜잭션 코디네이터는TID
와PID
를 매핑하고 해당 정보를 트랜잭션 로그에 기록합니다. -
메시지 전송
프로듀서는 토픽의 파티션으로 메세지를 전송합니다.
-
트랜잭션 종료 요청
메세지 전송을 완료한 프로듀서는
commitTransaction()
메소드 또는abortTransaction()
메소드 중 하나를 호출해 트랜잭션이 완료되었음을 트랜잭션 코디네이터에게 알립니다. -
사용자 토픽에 표시
메세지를 전송한 토픽의 파티션에 트랜잭션 커밋 표시를 기록합니다. 이렇게 파티션에 기록한 커밋 표시를 ‘컨트롤 메세지’라고 합니다. 이 메세지는 해당 메세지가 제대로 전송됐는지 여부를 컨슈머에게 나타내는 용도로, 커밋되지 않은 트랜잭션에 포함된 메세지는 컨슈머에게 반환하지 않게 됩니다.
-
트랜잭션 완료
마지막으로 트랜잭션 코디네이터는 트랜잭션 로그에 완료됨(Committed)이라고 기록합니다.
Broker
브로커는 Topic내의 Partition들을 분산 저장, 관리해줍니다. 하나의 브로커에는 Topic의 모든 데이터를 가지고 있지 않고, 일부분(Partition)만 가지게 됩니다. 보통 Broker를 최소 3대 이상으로 구성해 Kafka cluster를 형성합니다.
컨트롤러
클러스터의 다수 브로커중 한 대가 컨트롤러의 역할을 합니다. 컨트롤러는 다른 브로커들의 상태를 체크하고 브로커가 장애로 클러스터에서 빠지는 경우 해당 브로커에 존재하던 리더 파티션을 다른 브로커로 재분배합니다. 컨트롤러 역할을 하는 브로커에 장애가 생기면 다른 브로커가 컨트롤러 역할을 합니다.
데이터의 저장
카프카의 데이터는 config/server.properties
의 log.dir
옵션에 정의한 디렉토리(보통 /tmp/kafka-logs
)에 저장됩니다. 데이터는 토픽 이름과 파티션 번호의 조합으로 디렉토리(토픽명-파티션 번호
)를 생성하여 데이터를 저장합니다.
디렉토리(토픽명-파티션 번호
) 아래를 확인하면 .index
, .log
, .timeindex
파일이 존재합니다.
000000000.index: 메시지의 오프셋을 인덱싱한 정보를 담은 파일
000000000.log: 메시지와 메타데이터를 저장한 파일(대표적으로 offset, key, value 저장)
000000000.timeindex: 메시지에 포함된 타임스탬프 값을 기준으로 인덱싱한 정보를 담은 파일(생성 시간 또는 적재된 시간)
(000000000은 각 세그먼트가 가지는 레코드중 시작 오프셋)
데이터의 삭제
카프카는 다른 메세징 플랫폼과 다르게 컨슈머가 데이터를 가져가더라도 토픽의 데이터는 삭제되지 않습니다. 또한 컨슈머나 프로듀서가 데이터 삭제를 요청할 수 없으며 오직 브로커만이 데이터를 삭제할 수 있습니다. 하지만 카프카에 저장한 데이터는 다른 일반적인 데이터베이스처럼 특정 데이터를 선별해서 삭제할 수 없습니다. 카프카에서 데이터를 삭제하는 단위는 로그 세그먼트 파일 단위입니다. 참고로 수정 또한 불가능하기 때문에 프로듀서는 데이터를 브로커에 전송하기 전에 검증하는 것이 좋습니다.
데이터 복제
Consumer
컨슈머는 카프카에 저장되어 있는 메시지를 가져오는 역할을 합니다. 그러나 단순히 가져오는 역할만 하지는 않고, 조금 더 자세히 들여다 보면 컨슈머 그룹을 만들고, 그룹 내 모든 컨슈머가 파티션을 골고루 가져오도록 하는 리밸런싱과 같은 역할도 합니다. 컨슈머 수는 파티션 수보다 작거나 같도록 하는 것이 바람직합니다.
컨슈머 그룹 내에 있는 컨슈머들은 서로 협력하여 메시지를 처리합니다. 이 때 Partition은 같은 그룹에 있는 컨슈머 중 한 개의 컨슈머에 의해서만 소비됩니다. (같은 그룹에 있는 여러 컨슈머가 한 개의 Partition을 소비하면 메시지 중복 문제를 해결하는데 또 비용이 든다) 컨슈머에서 고려해야 할 사항에는 다음과 같은 것들이 있습니다.
- 파티션 할당 전략
- 프로듀서가 카프카에 메세지를 저장하는 속도와 컨슈머가 읽어가는 속도가 비슷한가
- 컨슈머의 개수가 파티션보다 많지는 않은가
- 컨슈머 그룹 내에 장애가 발생한 컨슈머가 생기면 어떻게 처리할 것인가
컨슈머 오프셋 관리
컨슈머의 동작 중 가장 핵심은 바로 오프셋 관리입니다. 이를 통해 마지막 고려사항인 컨슈머 장애 발생에 대응할 수 있습니다. 오프셋 관리는 컨슈머가 메시지를 어디까지 가져왔는지를 표시하는 것이라고 할 수 있습니다. 예를 들어 컨슈머가 일시적으로 동작을 멈추고 재시작하거나, 컨슈머 서버에 문제가 발생해 새로운 컨슈머가 생성된 경우 새로운 컨슈머는 기존 컨슈머의 마지막 위치에서 메시지를 가져올 수 있어야 장애를 복구할 수 있습니다. 카프카에서는 메시지의 위치를 나타내는 숫자를 오프셋이라고 하고 이러한 오프셋 정보는 __consumer_offsets
라는 별도의 토픽에 저장합니다. 이러한 정보는 컨슈머 그룹별로 기록됩니다.
이렇게 __consumer_offsets 토픽에 정보를 기록해 두면 컨슈머의 변경이 발생했을 때 해당 컨슈머가 어디까지 읽었는지 추적할 수 있습니다. 여기서 주의할 점은 저장되는 오프셋값은 컨슈머가 마지막으로 읽은 위치가 아니라, 컨슈머가 다음으로 읽어야 할 위치를 말합니다.
참고로 __consumer_offsets 또한 하나의 토픽이기 때문에 파티션 수와 리플리케이션 팩터 수를 설정할 수 있습니다.
그룹 코디네이터
컨슈머 그룹 내의 각 컨슈머들은 서로 정보를 공유하며 하나의 공동체로 동작합니다. 컨슈머 그룹에는 컨슈머가 떠나거나 새로 합류하는 등 변화가 일어나기 때문에 이러한 변화가 일어날 때마다 컨슈머 리밸런싱을 통해 작업을 새로 균등하게 분배해야 합니다.
이렇게 컨슈머 그룹내의 변화를 감지하기 위해 트래킹하는 것이 바로 그룹 코디네이터입니다. 그룹 코디네이터는 컨슈머 그룹 내의 컨슈머 리더와 통신을 하고, 실제로 파티션 할당 전략에 따라 컨슈머들에게 파티션을 할당하는 것은 컨슈머 리더입니다. 리더 컨슈머가 작업을 마친 뒤 그룹 코디네이터에게 전달하면 그룹 코디네이터는 해당 정보를 캐시하고 그룹 내의 컨슈머들에게 성공을 알립니다. 할당을 마치고 나면 각 컨슈머들은 각자 할당받은 파티션으로부터 메시지를 가져옵니다.
그룹 코디네이터는 그룹 별로 하나씩 존재하며 브로커 중 하나에 위치합니다.
그룹 코디네이터는 컨슈머와 주기적으로 하트비트를 주고받으며 컨슈머가 잘 동작하는지 확인합니다. 컨슈머는 그룹에서 빠져나가거나 새로 합류하게 되면 그룹 코디네이터에게 join, leave 요청을 보내고 그룹 코디네이터는 이러한 정보를 컨슈머 리더에게 전달해 새로 파티션을 할당하도록 합니다. 이 밖에도 컨슈머가 일정 시간(session.timeout.ms)이 지나도록 하트비트를 보내지 않으면 컨슈머에 문제가 발생한 것으로 간주하고 다시 컨슈머 리더에게 이러한 정보를 알려줍니다.
파티션 리밸런싱
이렇게 컨슈머에 변화가 생길 때마다 파티션 리밸런싱이 일어나게 되는데 파티션 리밸런싱은 파티션을 골고루 분배해 성능을 향상시키기도 하지만 너무 자주 일어나게 되면 오히려 배보다 배꼽이 더 커지는 상황이 발생할 수 있습니다. 이러한 문제를 해결하기 위해 아파치 카프카에서는 몇가지의 파티션 할당 전략을 제공하고 있습니다.
라운드 로빈 파티션 할당 전략
라운드 로빈 방식은 파티션 할당 방법 중 가장 간단한 방법입니다. 할당해야할 모든 파티션과 컨슈머들을 나열한 후 하나씩 파티션과 컨슈머를 할당하는 방식입니다.
이렇게 하면 파티션을 균등하게 분배할 수 있지만 컨슈머 리밸런싱이 일어날 때 마다 컨슈머가 작업하던 파티션이 계속 바뀌게 되는 문제점이 생깁니다. 예를 들어 컨슈머 1이 처음에는 파티션 0을 작업하고 있었으나 컨슈머 리밸런싱이 일어난 후 파티션 0은 컨슈머 2에게 가고 컨슈머 1은 다른 파티션을 작업해야 합니다. 이런 현상을 최대한 줄이고자 나오게 된 것이 바로 스티키 파티션 할당 전략입니다.
스티키 파티션 할당 전략
스티키 파티션 할당 전략의 첫 번째 목적은 파티션을 균등하게 분배하는 것이고, 두 번째 목적은 재할당이 일어날 때 최대한 파티션의 이동이 적게 발생하도록 하는 것입니다. 우선순위는 첫 번째가 더 높습니다.
동작 방식은 먼저 문제가 없는 컨슈머에 연결된 파티션은 그대로 둡니다. 그리고 문제가 생긴 컨슈머에 할당된 파티션들만 다시 라운드 로빈 방식으로 재할당합니다.
마지막 할당 전략으로 넘어가기 전에 짚고 넘어갈 점이 있습니다. 위에서 배웠던 재할당 방식은 모두 EAGER라는 리밸런스 프로토콜을 사용했고, EAGER 프로토콜은 리밸런싱할 때 컨슈머에게 할당되었던 모든 파티션들을 할당 취소합니다. 스티키 파티션 할당 전략은 문제가 없는 컨슈머의 파티션은 그렇지 않을 것 같지만 스티키 파티션 할당 전략도 마찬가지로 모든 파티션을 할당 취소합니다. 이렇게 구현한 이유는 먼저 파티션은 그룹 내의 컨슈머에게 중복 할당 되어서는 안되기 때문에 이러한 로직을 쉽게 구현하고자 하였던 것입니다. 그러나 이렇게 모든 파티션을 할당 취소하게 되면 일시적으로 컨슈머가 일을 할 수 없게 됩니다. 이 때 소요되는 시간을 다운타임이라고 합니다. 즉 컨슈머의 다운타임 동안 LAG가 급격하게 증가합니다.
협력적 스티키 파티션 할당 전략
이러한 이슈를 개선하고자 아파치 카프카 2.3 버전부터는 새로운 리밸런싱 프로토콜인 COOPERATIVE 프로토콜을 적용하기 시작했고, 이 프로토콜은 리밸런싱이 동작하기 전의 컨슈머 상태를 유지할 수 있게 했습니다.
이 방식은 컨슈머 리밸런싱이 트리거 될 때(컨슈머의 이탈 또는 합류) 모든 컨슈머들은 자신의 정보를 그룹 코디네이터에게 전송하고 그룹 코디네이터는 이를 조합해 컨슈머 리더에게 전달합니다. 리더는 이를 바탕으로 새로 파티션 할당 전략을 세우고 이를 컨슈머들에게 전달합니다. 컨슈머들은 이를 통해 기존의 할당 전략과 차이를 비교해보고 차이가 생긴 파티션만 따로 제외시킵니다. 그리고 제외된 파티션만을 이용해 다시 리밸런싱을 진행합니다.
이런식으로 스티키 파티션 할당 전략은 리밸런싱이 여러번 일어나게 됩니다. 이 협력적 스티키 파티션 할당 전략은 아파치 카프카 2.5 버전에서 서비스가 안정화되어 본격적으로 이용되기 시작하면서 컨슈머 리밸런싱으로 인한 다운타임을 최소화 할 수 있게 되었습니다.
컨플루언트 블로그에서는 기존의 EAGER 방식과 COOPERATIVE 프로토콜 방식의 성능을 비교한 결과를 공개하였는데 COOPERATIE 방식이 더 빠른 시간 안에 짧은 다운타임을 가지고 리밸런싱을 할 수 있었습니다.
마치며
이번 포스트에서는 카프카에서 중요한 개념들에 대해 간단히 살펴보았습니다. 프로듀서는 메세지의 전송, 브로커는 저장, 컨슈머는 읽어가는 역할을 담당합니다. 또한 카프카에서 주고 받는 데이터는 토픽, 파티션, 세그먼트라는 단위로 나뉘어 처리, 저장됩니다.
카프카는 데이터 파이프라인의 중심에 위치하는 허브 역할을 합니다. 그렇기 때문에 카프카는 장애 발생에 대처 가능한 안정적인 서비스를 제공해 줄 수 있어야 하고, 각 서비스들의 원활한 이용을 위한 높은 처리량, 데이터 유실, 중복을 해결함으로써 각 서비스에서의 이용을 원활하게 해주는 것이 좋습니다.