Backend/kafka

카프카 기본 개념과 구조

jinmook 2023. 7. 26. 02:42

이번 글은 카프카 관련 책 내용의 일부를 정리한 글입니다. 이후에도 주기적으로 정리해서 업로드할 예정입니다.

 

카프카를 구성하는 주요 요소

  • 주키퍼 : 아파치 프로젝트 어플리케이션 이름입니다. 카프카의 메타데이터 관리 및 브로커의 정상상태 점검을 담당합니다.
  • 카프카 : 아파치 프로젝트 애플리케이션 이름입니다. 여러 대의 브로커를 구성한 클러스터를 의미합니다.
  • 브로커 : 카프카 애플리케이션이 설치된 서버 또는 노드를 의미합니다.
  • 프로듀서 : 카프카로 메시지를 보내는 역할을 하는 클라이언트를 총칭합니다.
  • 컨슈머 : 카프카에서 메시지를 꺼내가는 역할을 하는 클라이언트를 총칭합니다.
  • 토픽 : 카프카는 메시지 피드들을 토픽으로 구분하고, 각 토픽의 이름은 카프카 내에서 고유합니다.
  • 파티션 : 병령 처리 및 고성능을 얻기 위해 하나의 토픽을 여러 개로 나눈 것을 말합니다.
  • 세그먼트 : 프로듀서가 전송한 실제 메시지가 브로커의 로컬 디스크에 저장되는 파일을 말합니다.
  • 메시지 또는 레코드 : 프로듀서가 브로커로 전송하거나 컨슈머가 읽어가는 데이터 조각을 말합니다.

 

 

리플리케이션

리플리케이션이란 각 메시지들을 여러 개로 복제해서 카프카 클러스터 내 브로커들에 분산시키는 동작을 의미합니다.

이러한 리플리케이션 동작 덕분에 하나의 브로커가 종료되더라도 카프카는 안정성을 유지할 수 있습니다.

리플리케이션이란 정확하게 토픽을 복제하는 것이 아닌 토픽의 파티션을 복제하는 것입니다.

리플리케이션 팩터 수가 커지면 안정성은 높아지지만 그만큼 브로커 리소스를 많이 사용하게 됩니다.

 

테스트나 개발 환경 : 리플리케이션 팩터 수를 1로 설정 운영 환경(로그성 메시지로서 약간의 유실 허용) : 리플리케이션 팩터 수를 2로 설정 운영 환경(유실 허용하지 않음) : 리플리케이션 팩터 수를 3으로 설정
  • 안정성을 높이고자 리플리케이션 팩터 수를 더 높일 수 있지만 일반적으로 리플리케이션 팩터 수가 3일 경우에도 충분히 안정적이고 적절한 디스크 공간을 사용할 수 있었다

 

파티션

하나의 토픽이 한 번에 처리할 수 있는 한계를 높이기 위해 토픽 하나를 여러 개로 나눠 병렬 처리가 가능하게 만든 것을 파티션이라고 합니다.

하나를 여러 개로 나누면 분산 처리도 가능합니다. 즉, 이렇게 나뉜 파티션 수만큼 컨슈머를 연결할 수 있습니다.

 

위 그림에서 알 수 있듯이 파티션 번호는 0부터 시작합니다.

파티션 수는 초기 생성 후 언제든지 늘릴 수 있지만, 반대로 한 번 늘린 파티션 수는 절대로 줄일 수 없다는 점을 반드시 명심해야 합니다.

초기에 토픽을 생성할 때 파티션 수를 작게, 즉 2 또는 4 정도로 생성한 후, 메시지 처리량이나 컨슈머의 LAG 등을 모니터링하면서 조금씩 늘려가는 방법이 가장 좋습니다.

 

세그먼트

카프카에서는 각 메시지들을 저장합니다.

프로듀서에 의해 브로커로 전송된 메시지는 토픽의 파티션에 저장되며, 각 메시지들은 세그먼트라는 로그 파일의 형태로 브로커의 로컬 디스크에 저장됩니다.

위 그림에서 각 파티션 별로 각각의 세그먼트가 존재하게 됩니다. 즉, 파티션 수만큼 세그먼트 로그 파일이 존재합니다.

컨슈머는 브로커의 세그먼트 로그 파일에 저장된 메시지를 읽어가는 것입니다.

 


 

카프카의 핵심 개념

[ 분산 시스템 ]

분산 시스템은 네트워크상에서 연결된 컴퓨터들의 그룹을 말하며, 단일 시스템이 갖지 못한 높은 성능을 목표로 합니다.

이러한 분산 시스템은 성능이 높다는 장점 이외에도 하나의 서버 또는 노드 등에 장애가 발생할 때 다른 서버 또는 노드가 대신 처리하므로 장애 대응이 탁월하며, 부하가 높은 경우에는 시스템 확장이 용이하다는 장점도 있습니다.

카프카도 분산 시스템이므로 최초 구성한 클러스터의 리소스가 한계치에 도달해 더욱 높은 메시지 처리량이 필요한 경우, 브로커를 추가하는 방식으로 확장이 가능합니다.

 

 

[ 페이지 캐시 ]

카프카는 높은 처리량을 얻기 위해 몇 가지 기능을 추가했는데, 그중 대표적인 것이 바로 페이지 캐시의 이용입니다.

카프카는 OS의 페이지 캐시를 활용하는 방식으로 설계되어 있습니다.

페이지 캐시는 직접 디스크에 읽고 쓰는 대신 물리 메모리 중 애플리케이션이 사용하지 않는 일부 잔여 메모리를 활용합니다. 이렇게 페이지 캐시를 이용하면 디스크 I/O 에 대한 접근이 줄어들므로 성능을 높일 수 있습니다.

 

 

위 그림에서 볼 수 있듯이, 카프카가 OS의 페이지 캐시를 이용한다는 것은 카프카가 직접 디스크에서 읽고 쓰기를 하지 않고 페이지 캐시를 통해 읽고 쓰기를 한다고 이해하면 됩니다.

 

 

[ 배치 전송 처리 ]

카프카는 프로듀서와 컨슈머들과 서로 통신하며, 이들 사이에서 수많은 메시지를 주고 받습니다.

이때 발생하는 수많은 통신을 묶어서 처리할 수 있다면, 단건으로 통신할 때에 비해 네트워크 오버헤드를 줄일 수 있을 뿐만 아니라 장기적으로는 더욱 빠르고 효율적으로 처리할 수 있습니다.

 

 

[ 압축 전송 ]

카프카는 메시지 정송 시 좀 더 성능이 높은 압축 전송을 사용하는 것을 권장합니다.

카프카에서 지원하는 압축 타입은 gzip, snappy, lz4, zstd 등입니다.

압축만으로도 네트워크 대역폭이나 회선 비용 등을 줄일 수 있는데, 위에서 설명한 배치 전송과 결합해 사용한다면 더욱 높은 효과를 얻게 됩니다.

 

 

[ 토픽, 파티션 오프셋 ]

카프카는 토픽이라는 곳에 데이터를 저장합니다.

토픽은 병렬 처리를 위해 여러 개의 파티션이라는 단위로 다시 나뉩니다. 카프카에서는 이와 같은 파티셔닝을 통해 단 하나의 토픽이라도 높은 처리량을 수행할 수 있습니다.

이 파티션의 메시지가 저장되는 위치를 오프셋이라고 부르며, 오프셋은 순차적으로 증가하는 숫자 형태로 되어 있습니다.

 

 

[ 고가용성 보장 ]

카프카는 앞서 설명한 것처럼 분산 시스템이기 때문에 하나의 서버나 노드가 다운되어도 다른 서버 또는 노드가 장애가 발생한 서버의 역할을 대신해 안정적인 서비스가 가능합니다.

이러한 고가용성을 보장하기 위해 카프카에서는 리플리케이션 기능을 제공합니다.

카프카에서 제공하는 리플리케이션 기능은 토픽 자체를 복제하는 것이 아니라 토픽의 파티션을 복제하는 것입니다. 원본을 리더, 복제본을 팔로워라고 부릅니다.

일반적으로 카프카에서는 리플리케이션 팩터 수를 3으로 구성하도록 권장합니다.

리더는 프로듀서, 컨슈머로부터 오는 모든 읽기와 쓰기요청을 처리하며, 팔로워는 오직 리더로부터 리플리케이션하게 됩니다.

 

 

[ 주키퍼의 의존성 ]

오늘날 카프카를 비롯해 아파치 산하 프로젝트의 많은 분산 애플리케이션에서 코디네이터 역할을 하는 애플리케이션으로 주키퍼를 사용하고 있습니다.

주키퍼는 여러 대의 서버를 클러스터로 구성하고, 살아 있는 노드 수가 과반수 이상 유지된다면 지속적인 서비스가 가능한 구조입니다. 따라서 주키퍼는 반드시 홀수로 구성해야 합니다.

지노드를 이용해 카프카의 메타 정보가 주키퍼에 기록되며, 주키퍼는 이러한 지노드를 이용해 브로커의 노드 관리, 토픽 관리, 컨트롤러 관리 등 매우 중요한 역할을 하고 있습니다.

 

 


 

프로듀서의 기본 동작

 

위 사진은 프로듀서의 전체 흐름을 나타낸 그림입니다.

ProducerRecord라고 표시된 부분은 카프카로 전송하기 위한 실제 데이터이며, 레코드는 토픽, 파티션, 키, 밸류로 구성됩니다.

레코드에서 토픽과 밸류는 필숫값이며, 파티션, 키 값은 옵션사항 입니다.

 

각 레코드들은 프로듀서의 send() 메서드를 통해 시리얼라이저, 파티셔너를 거치게 됩니다. 만약 프로듀서 레코드의 선택사항인 파티션을 지정했다면, 파티셔너는 아무 동작도 하지 않고 지정된 파티션으로 레코드를 전달합니다.

 

파티션을 지정하지 않은 경우에는 키를 가지고 파티션을 선택해 레코드를 전달하는데, 기본적으로 라운드 로빈 방식으로 동작합니다.

이렇게 프로듀서 내부에서는 send() 메서드 동작 이후 레코드들을 파티션별로 잠시 모아두게 됩니다.

레코드를 모아두는 이유는 프로듀서가 카프카로 전송하기 전, 배치 전송을 하기 위함입니다.

 

전송이 실패하면 재시도 동작이 이뤄지고, 지정된 횟수만큼의 잿도가 실패하면 최종 실패를 전달하며, 전송이 성공하면 메타데이터를 리턴하게 됩니다.

 

 

프로듀서의 전송 방법은 크게 세 가지 방식으로 나눌 수 있습니다.

 

  • 메시지를 보내고 확인하지 않기
ProducerRecord<String, String> record = new ProducerRecord<>("topic", "value");
producer.send(record);

프로듀서는 send 메서드를 사용해 메시지를 전송한 후 자바 Futer 객체로 RecordMetadata를 리턴받지만, 리턴값을 무시하므로 메시지가 성공적으로 전송됐는지 알 수 없습니다.

위 코드는 메시지를 전송하고 난 후 성공적으로 도착했는지 확인하지 않는 예제로, 실제 운영 환경에서 사용하는 것은 추천하지 않지만, 일반적으로 대부분 메시지는 성공적으로 전송됩니다.

 

 

  • 동기 전송
ProducerRecord<String, String> record = new ProducerRecord<>("topic", "value");
RecordMetadata metadata = producer.send(record).get();

get() 메서드를 이용해 카프카의 응답을 기다립니다. 메시지가 성공적으로 전송되지 않으면 예외가 발생하고, 에러가 없다면 RecordMetadata를 얻을 수 있습니다.

이와 같은 동기 전송 방식은 신뢰성 있는 메시지 전달 과정의 핵심입니다.

 

 

  • 비동기 전송
ProducerRecord<String, String> record = new ProducerRecord<>("topic", "value");
producer.send(record, new CallbackImpl(record));

---

public class CallbackImpl implements Callback {
	private ProducerRecord<String, String> record;
	
	public CallbackImpl(ProducerRecord<String, String> record) {
		this.record = record;
	}

	@Override
	public void onCompletion(RecordMetadata metadata, Exception e) {
		if (e != null) {
			e.printStackTrace();
		} else {
			System.out.printf(metadata.topic(), metadata.partition() ...);
		}
	}
}			

우선 콜백을 사용하기 위해 org.apache.kafka.clients.producer.Callback 을 구현하는 클래스가 필요합니다.

카프카가 오류를 리턴하면 onCompletion()은 예외를 갖게 되며, 실제 운영 환경에서는 추가적인 예외 처리가 필요합니다.

send() 메서드를 살펴보면 프로듀서에서 레코드를 보낼 때 콜백 객체를 같이 보내는 것을 알 수 있습니다.

이처럼 비동기 방식으로 전송하면 빠른 전송이 가능하고, 메시지 전송이 실패한 경우라도 예외를 처리할 수 있어서 이후 에러 로그 등에 기록할 수도 있습니다.

 

 


 

컨슈머의 기본 동작

 

컨슈머는 카프카의 토픽에 저장되어 있는 메시지를 가져오는 역할을 담당합니다. 내부적으로는 컨슈머 그룹, 리밸런싱 등 여러 동작을 수행합니다.

컨슈머 그룹은 하나 이상의 컨슈머들이 모여 있는 그룹을 의미하고, 컨슈머는 반드시 컨슈머 그룹에 속하게 됩니다.

그리고 이 컨슈머 그룹은 각 파티션의 리더에게 카프카 토픽에 저장된 메시지를 가져오기 위한 요청을 보냅니다. 이때 파티션 수와 컨슈머 수는 일대일로 매핑되는 것이 이상적입니다.

파티션 수보다 컨슈머 수가 많게 구현하는 것은 바람직한 구성이 아닙니다. 컨슈머 수가 파티션 수보다 더 많다고 해서 더 빠르게 토픽의 메시지를 가져오거나 처리량이 높아지는 것이 아니라 더 많은 수의 컨슈머들이 그냥 대기 상태로만 존재하기 때문입니다.

 

 

컨슈머에서 메시지를 가져오는 방법은 크게 세 가지 방식이 있습니다.

 

  • 오토 커밋
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
		System.out.printf(~~);
}

위 코드는 오토 커밋을 진행하는 코드입니다.

컨슈머 애플리케이션들의 기본값으로 가장 많이 사용되고 있는 것이 오토 커밋입니다.

오토 커밋은 오프셋을 주기적으로 커밋하므로 관리자가 오프셋을 따로 관리하지 않아도 된다는 장점이 있는 반면, 컨슈머 종료 등이 빈번히 일어나면 일부 메시지를 못 가져오거나 중복으로 가져오는 경우가 있습니다.

카프카가 굉장히 안정적으로 잘 동작하고, 컨슈머 역시 한번 구동하고 나면 자주 변경되거나 종료되는 현상이 없으므로 오토 커밋을 사용하는 경우가 많습니다.

 

 

  • 동기 가져오기
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
		System.out.printf(~~);
}
consumer.commitSync();

오토 커밋과 달리 poll() 메서드를 이용하여 메시지를 가져온 후 처리까지 오나료하고 현재의 오프셋을 커밋합니다.

동기 방식으로 가져오는 경우 속도는 느리지만, 메시지 손실은 거의 발생하지 않습니다.

메시지가 손실되면 안 되는 중요한 처리 작업들은 동기 방식으로 진행하는 것을 권장합니다. 하지만 이 방법도 메시지의 중복 이슈는 피할 수 없습니다.

 

 

  • 비동기 가져오기
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
		System.out.printf(~~);
}
consumer.commitAsync();

비동기 가져오기 방식은 현재 배치를 통해 읽은 모든 메시지를 처리한 후, 추가 메시지를 폴링하기 전 현재의 오프셋을 비동기 커밋합니다.

commitAsync()는 commitSync()와 달리 오프셋 커밋을 실패하더라도 재시도하지 않습니다.

재시도하지 않는 이유 총 10개의 메시지가 있고 오프셋 1번부터 10번까지 순차적으로 커밋한다고 가정

1번 오프셋의 메시지를 읽은 뒤 1번 오프셋을 비동기 커밋
2번 오프셋의 메시지를 읽은 뒤 2번 오프셋을 비동기 커밋하지만 실패
3번 오프셋의 메시지를 읽은 뒤 3번 오프셋을 비동기 커밋하지만 실패
4번 오프셋의 메시지를 읽은 뒤 4번 오프셋을 비동기 커밋하지만 실패
5번 오프셋의 메시지를 읽은 뒤 5번 오프셋을 비동기 커밋합니다.

현재 5번 오프셋의 메시지를 읽었고, 5번 오프셋의 비동기 커밋을 성공해 현재 마지막 오프셋은 5입니다. 하지만 여기서 비동기 커밋의 재시도로 인해 2번 오프셋의 비동기 커밋이 성공하게 되면, 마지막 오프셋이 2로 변경될 것입니다. 즉, 현재 컨슈머가 종료되고 다른 컨슈머가 이어서 작업을 진행한다면 다시 3번 오프셋부터 메시지를 가져오게 될 것입니다.

 

컨슈머 그룹

컨슈머는 컨슈머 그룹 안에 속한 것이 일반적인 구조로, 하나의 컨슈머 그룹 안에 여러 개의 컨슈머가 구성될 수 있습니다.

그리고 컨슈머들은 토픽의 파티션과 일대일로 매핑되어 메시지를 가져오게 됩니다.

컨슈머들은 하나의 컨슈머 그룹 안에 속해 있으며, 그룹 내의 컨슈머들은 서로의 정보를 공유합니다.

따라서 컨슈머01이 문제가 생겨 종료된다면, 다른 컨슈머가 기존 컨슈머01이 하던 일을 대신해 해당 토픽의 파티션을 컨슘하기 시작합니다.