카프카(Kafka)의 기본 개념
카프카를 학습하기 전에 왜 카프카를 사용하는지 궁금해서 카프카의 탄생부터 알아보았다.
카프카(kafka)의 탄생
2011년, 구인/구직 및 동종업계의 동향을 살펴볼 수 있는 소셜 네트워크 사이트인 '링크드인(LinkedIn)'에서 출발한 기술로 파편화된 데이터를 수집 및 분배 아키텍처를 운영하는데 큰 어려움을 겪었다.
데이터를 생성하고 적재하기 위해서는 데이터를 생성하는 소스 애플리케이션과 데이터가 최종 적재되는 타깃 애플리케이션을 연결해야 한다.
초기 운영 시에는 단방향 통신을 통해 소스 애플리케이션과 타깃 애플리케이션으로 연동하는 소스코드를 작성했고 아키텍처가 복잡하지 않았으므로 운영에 문제가 없었으나 시간이 지날수록 아키텍처는 거대해지고 소스 애플리케이션과 타깃 애플리케이션의 개수가 점점 많아지면서 연결하는 파이프라인 개수 또한 많아지고 복잡해지며 소스코드 및 버전 관리에서 이슈가 생겼다. 그리고 타깃 애플리케이션에서 장애가 발생할 경우 그 영향이 소스 애플리케이션에 그대로 전달되었다.
이렇게 시간이 갈수록 복잡해지는 백엔드 아키텍쳐에서 파편화된 데이터 파이프라인을 개선하려고 다양한 메시징 플랫폼을 적용하여 아키텍처를 변경하려고 노력했지만 파편화된 데이터 파이프라인의 복잡도를 낮추는 아키텍처가 되지는 못했다.
결국, 링크드인은 신규 시스템을 만들기로 결정했고 그 결과물이 바로 아파치 카프카(Apache Kafka)다.
빅데이터 파이프 라인에서 카프카(kafka) 의 역할
카프카는 각각의 애플리케이션끼리 연결하여 데이터를 처리하는 것이 아니라 한 곳에 모아 처리할 수 있도록 중앙집중화했다. 대량의 데이터를 수집하고 이를 사용자들이 실시간 스트림으로 소비할 수 있게 만들어주는 일종의 중추 신경으로 동작하는 플랫폼이다.
카프카 내부에 데이터가 저장되는 파티션의 동작은 FIFO (First In First Out) 방식의 큐 자료구조와 유사하다. 큐에 데이터를 보내는 것이 프로듀서이고 큐에서 데이터를 가져가는 것이 '컨슈머'다.
높은 처리량
프로듀서가 브로커로 데이터를 보낼 때와 컨슈머가 브로커로부터 데이터를 받을 때 모두 묶어서 전송하기 때문에 네트워크 통신 횟수를 최소한으로 줄여서 동일한 양의 데이터를 동일 시간 내에 더 많이 전송할 수 있다. 그렇기 때문에 대용량의 실시간 로그 데이터를 처리하는데 적합하며 동일 목적의 데이터를 여러 파티션에 분배하고 데이터를 병렬처리 할 수 있다. 파티션의 개수만큼 컨슈머 개수를 늘려서 동일 시간당 데이터 처리량을 늘리는 것이다.
확장성
데이터 파이프라인에서 데이터를 모을 때 얼마나 들어올지는 예측하기 어렵다. 하루에 1,000건 가량 들어오는 로그 데이터라도 예상치 못한 특정 이벤트로 인해 100만 건 이상의 데이터가 들어오는 경우가 있다. 카프카는 이러한 가변적인 환경에서 안정적으로 확장 가능하도록 설계되었다. 데이터가 적을 때는 카프카 클러스터의 브로커를 최소한의 개수로 운영하다가 데이터가 많아지면 클러스터의 브로커 개수를 늘려 스케일 아웃(scale-out) 할 수 있고 반대로 데이터 개수가 적어지고 추가 서버들이 더는 필요 없어지면 브로커 개수를 줄여 스케일 인(scale-in)을 할 수 있다.
영속성
카프카는 전송받은 데이터를 메모리에 저장하지 않고 파일 시스템에 저장한다. 파일 시스템으로 저장하기 때문에 파일 입출력으로 인해 속도 이슈가 발생할 수 있을까 의문을 가질수 있지만 페이지 캐시를 사용하여 디스크 입출력의 속도를 높여서 이 문제를 해결했다. 운영체제 레벨에서 페이지 캐시 영역을 메모리에 따로 생성하고 사용하여 한번 읽은 파일 내용은 메모리에 저장시켰다가 다시 사용하는 방식이다.
고가용성
프로듀서로 전송받은 데이터를 여러 브로커 중 1대의 브로커에만 저장하는 것이 아니라 또 다른 브로커에도 저장하는 것이다. 그렇기 때문에 클러스터를 1대, 2대가 아닌 3대 이상의 브로커로 구성해야 한다. 1대로 구성할 경우 브로커의 장애는 서비스의 장애로 이어진다. 2대의 구성은 나머지 한대가 살아있으므로 안정적으로 데이터 처리를 할 수 있지만 복제되는 시간 차이로 인해 일부 데이터가 유실될 가능성이 있어서 3대 이상으로 운영해야 한다.
카프카의 기본 개념과 용어
카프카 클러스터 · 브로커 · 주키퍼
- 클러스터: 카프카는 3대 이상의 브로커 서버를 1개의 클러스터로 묶어서 운영한다. 기술적으로는 단 하나의 브로커로 구성될 수는 있지만 데이터를 안전하게 보관하고 처리하기 위해 권장되는 최소 구성이 3개의 브로커를 포함하는 클러스터이다.
- 브로커: 카프카 클러스터를 구성하는 각 서버를 의미한다. 클라이언트는 이 브로커들과 통신하여 데이터를 생산하고 소비한다. 프로듀서로부터 데이터를 전달받으면 카프카 브로커는 프로듀서가 요청한 토픽의 파티션에 데이터를 저장하고 컨슈머가 데이터를 요청하면 파티션에 저장된 데이터를 전달하는 것이다. 일반적으로 하나의 카프카 컨테이너는 하나의 kafka 브로커 인스턴스만을 실행하며 브로커는 고유한 ID, 포트, 로그 저장소 등을 가진다.
- 주키퍼: 이러한 카프카 클러스터에 연동되어 클러스터를 관리하는 역할을 하는게 주키퍼이다. 카프카 버전 2.8.0 이전에는 필수적인 컴포넌트로 사용되었으나 2.8.0 이상에서는 Kraft(Kafka Raft) 모드라고 불리는 새로운 구성을 통해 ZooKeeper 없이 카프카를 운영할 수 있는 옵션이 도입되었다. KRaft 모드는 카프카 내부적으로 클러스터 메타데이터를 관리하는데 모든 기능을 제공하여 ZooKeeper에 대한 의존성을 제거하고 구성을 단순화한다.
토픽 · 파티션 · 레코드
- 토픽: 토픽은 카프카에서 데이터를 구분(데이터의 얼굴같은)하기 위해 사용하는 단위로 1개 이상의 파티션을 소유하고 있다. 프로듀서는 데이터를 토픽에 전송하고 컨슈머는 토픽을 구독하여 데이터를 소비한다. 이를 통해 프로듀서와 컨슈머 사이의 분리가 가능하며, 다양한 소스에서 오는 데이터를 효과적으로 관리할 수 있다.
- 파티션: 파티션은 프로듀서가 보낸 데이터들이 저장되는데 이 데이터를 '레코드(record)'라고 부른다. 파티션은 카프카의 병렬처리의 핵심으로써 그룹으로 묶인 컨슈머들이 레코드를 병렬로 처리할 수 있도록 매칭된다. 컨슈머의 처리량이 한정된 상황에서 많은 레코드를 병렬로 처리하는 가장 좋은 방법은 컨슈머의 개수를 늘려 스케일 아웃(브로커 수 증가)하는 것이다. 파티션의 구조는 FIFO 구조인 큐(queue)와 비슷한 구조라고 생각하면 된다. 파티션의 레코드는 컨슈머가 가져가는 것과 별개로 관리되어 컨슈머가 데이터를 가져가도 삭제하지 않는다. 이러한 특징 때문에 토픽의 레코드는 다양한 목적을 가진 여러 컨슈머 그룹들이 토픽의 데이터를 여러번 가져갈 수 있다. 각 파티션은 순서가 지정된 변경이 불가능한 메시지들의 시퀀스로 메시지는 파티션에 추가될 때 각각 고유한 오프셋(Offset)을 할당받는다.
- 레코드: 레코드는 타임스탬프, 메시지 키, 메시지 값, 오프셋, 헤더로 구성되어 있다. 프로듀서가 생성한 레코드가 브로커로 전송되면 오프셋과 타임스탬프가 지정되어 저장된다. 브로커에 한번 적재된 레코드는 수정할 수 없고 로그 리텐션 기간 또한 용량에 따라서만 삭제된다.
- 타임 스탬프: 프로듀서에서 해당 레코드가 생성된 시점의 유닉스 타임이 설정된다. 프로듀서가 레코드를 생성할 때 임의의 타임스탬프 값을 설정하거나 토픽의 설정에 따라 브로커에 적재된 시간(LogAppendTime)으로 설정할 수 있다.
- 메시지 키: 메시지 값을 순서대로 처리하거나 메시지 값의 종류를 나타내기 위해 사용한다. 동일한 메시지 키라면 동일한 파티션에 들어가는 것이다. 다만 어느 파티션에 지정될지 알 수 없고 파티션 개수가 변경되면 메시지 키와 파티션 매칭이 달라지게 되므로 주의해야한다. 메시지 키를 사용하지 않는다면 프로듀서에서 레코드를 전송할 때 메시지 키를 선언하지 않으면 null로 설정된다. 메시지 키가 null로 설정된 레코드는 프로듀서 기본 설정 파티셔너에 따라 파티션에 분배되어 적재된다.
- 메시지 값: 실질적으로 처리할 데이터가 들어 있다. 메시지 키와 메시지 값은 직렬화되어 브로커로 전송되기 때문에 컨슈머가 이용할 때는 직렬화한 형태와 동일한 형태로 역직렬화를 수행해야 한다. 즉, 직렬화/역직렬화는 반드시 동일한 형태로 처리해야 한다. 만약 프로듀서가 StringSerializer로 직렬화한 메시지 값을 컨슈머가 IntegerDeserializer로 역직렬화하면 정상적인 데이터를 얻을 수 없다.
- 오프셋: 레코드의 오프셋은 0이상의 숫자로 이루어져 있다. 레코드의 오프셋은 직접 지정할 수 없고 브로커에 저장될 때 이전에 전송한 레코드의 오프셋 + 1 값으로 생성된다. 오프셋은 컨슈머가 데이터를 가져갈 때 사용된다. 컨슈머 그룹으로 이루어진 카프카 컨슈머들이 파티션의 데이터를 어디까지 가져갔는지 명확하게 지정할 수 있다.
- 헤더: 레코드의 추가적인 정보를 담는 메타데이터 저장소 용도로 사용한다. 헤더는 키/값 형태로 데이터를 추가하여 레코드의 속성(스키마 버전 등)을 지정하여 컨슈머에서 참조할 수 있다.
데이터 복제, 싱크
데이터 복제는 카프카를 장애 허용 시스템으로 동작하도록 하는 원동력이다. 복제의 이유는 클러스터로 묶인 브로커 중 일부에 장애가 발생하더라도 데이터를 유실하지 않고 안전하게 사용하기 위함이다.
카프카의 데이터 복제는 파티션 단위로 이루어진다. 토픽을 생성할 때 파티션의 복제 개수도 같이 설정되는데 직접 옵션을 선택하지 않으면 브로커에 설정된 옵션(server.properties 파일) 값을 따라간다. 복제 개수의 최소값은 1(복제 없음)이고 최대값은 브로커 개수만큼 설정할 수 있다. 토픽을 생성할 때, --replication-factor 옵션에 복제 수를 3으로 지정하면 해당 토픽의 모든 파티션은 카프카 클러스터 내의 3개의 브로커에 복제되어 저장된다.
만약 한 토픽 내 파티션이 3개이고 복제 수를 3으로 지정한다면 한 토픽의 3파티션이 리더가 된다.
위 그림은 복제 개수를 3으로 설정한 경우다. 복제된 파티션은 리더와 팔로워로 구성된다. 프로듀서 또는 컨슈머와 직접 통신하는 파티션을 리더, 나머지 복제 데이터를 가지고 있는 파티션을 팔로워라고 부른다.
팔로워 파티션들은 리더 파티션의 오프셋을 확인하여 현재 자신이 가지고 있는 오프셋과 차이가 나는 경우 리더 파티션으로부터 데이터를 가져와서 자신의 파티션에 저장하는데 이 과정을 복제(replication)라고 부른다.
만약 리더 파티션이 고장나거나 접근할 수 없게 되면, 팔로워 파티션 중 하나가 새로운 리더로 선출된다.
리더 파티션의 주요 역할
- 읽기 및 쓰기 요청 처리: 프로듀서로부터의 데이터 쓰기 요청과 컨슈머로부터의 데이터 읽기 요청을 담당한다. 모든 요청은 리더 파티션을 통해 처리된다. (리더 파티션을 가진 브로커가 요청을 받은 후 데이터 쓰기, 읽기 처리 후 팔로워파티션을 가진 브로커가 복제를 하는 방식)
- 데이터 복제 관리: 자신의 복제본인 팔로워 파티션에게 데이터를 복제한다. 팔로워 파티션들은 리더의 데이터를 지속적으로 복사하여 동기화 상태를 유지한다.
컨트롤러 (Controller)
클러스터의 다수 브로커 중 한 대가 컨트롤러 역할을 한다. 컨트롤러는 카프카 내부 메커니즘으로 자동 지정되며 다른 브로커들의 상태를 체크하고 브로커가 클러스터에서 빠지는 경우 해당 브로커에 존재하는 리더 파티션을 재분배한다.
만약 컨트롤러 역할을 하는 브로커에 장애가 생기면 다른 브로커가 컨트롤러 역할을 한다.
데이터 삭제
카프카는 다른 메시징 플랫폼과 다르게 컨슈머가 데이터를 가져가더라도 토픽의 데이터는 삭제되지 않는다. 또한 컨슈머나 프로듀서가 데이터 삭제 요청할 수도 없다. 오직 브로커만이 데이터를 삭제할 수 있다. 데이터 삭제는 파일 단위로 이루어지는데 이 단위를 로그 세그먼트(log segment)라고 부른다. 이 세그먼트에는 다수의 데이터가 들어 있기 때문에 일반적인 데이터베이스처럼 특정 데이터를 선별해서 삭제할 수 없다. 세그먼트는 데이터가 쌓이는 동안 파일 시스템으로 열려있으며 카프카 브로커에 log.segement.bytes 또는 log.segment.ms 옵션에 값이 설정되면 세그먼트 파일이 닫힌다.
세그먼트 파일이 닫히게 되는 기본 값은 1GB 용량에 도달했을 때인데 간격을 더 줄일 수 있다. 그러나 너무 작은 용량으로 설정하면 데이터들을 저장하는 동안 세그먼트 파일을 자주 여닫음으로 부하가 발생할 수 있으므로 주의해야 한다.
닫힌 세그먼트 파일은 log.retention.bytes 또는 log.retention.ms 옵션에 설정 값을 넘어가면 삭제된다. 닫힌 세그먼트 파일을 체크하는 간격은 카프카 브로커의 옵션에 설정된 log.retention.check.interval.ms에 따른다.
컨슈머 오프셋 저장
오프셋(offset)은 카프카 토픽의 파티션 내에서 메시지의 위치를 가리키는 고유 식별자다. 컨슈머 그룹은 토픽이 특정 파티션으로부터 데이터를 가져와서 처리하고 이 파티션의 어느 레코드까지 가져갔는지 확인하기 위해 오프셋을 커밋한다. 커밋한 오프셋은 _consumer_offsets 토픽에 저장된다. 여기에 저장된 오프셋을 토대로 컨슈머 그룹은 다음 레코드를 가져가서 처리한다.
코디네이터 (coordinator)
클러스터의 다수 브로커 중 한 대는 코디네이터 역할을 수행한다.
컨트롤러와 마찬가지로 자동 지정되며 컨트롤러와 코디네이터를 동시에 수행할 수 있다.
- 컨슈머 그룹 코디네이터: 컨슈머가 컨슈머 그룹에서 빠지면 매칭되지 않은 파티션을 정상 동작하는 컨슈머로 할당하는 컨슈머 그룹의 멤버 관리, 파티션 할당, 오프셋 추적 등 컨슈머 관련 작업을 관리한다. 이렇게 파티션을 재할당하는 과정을 '리밸런스(rebalance)'라고 부른다
- 컨슈머 그룹 생성: 컨슈머 인스턴스들은 동일한 그룹ID를 가지고 클러스터에 연결된다.
- 파티션 할당: 그룹 내 각 인스턴스들은 토픽의 하나 이상의 파티션을 할당받는다. (컨슈머 그룹 코디네이터가 수행)
- 데이터 처리: 각 컨슈머 인스턴스가 서로 다른 파티션을 처리하므로 분산 처리가 가능하다.
- 동적 재조정: 그룹에 새로운 인스턴스가 추가, 제거될 때 코디네이터가 재조정한다.
* 컨슈머 그룹이란? 여러 컨슈머 인스턴스들을 그룹으로 묶은 개념으로 같은 토픽의 메시지를 소비한다.
- 트랜잭션 코디네이터: 프로듀서가 트랜잭션을 안전하게 처리할 수 있도록 트랜잭션 상태 관리, 트랜잭션 로그 기록 등 트랜잭션과 관련된 작업을 관리한다.
카프카 클라이언트
카프카 클러스터에 명령을 내리거나 데이터를 송수신하기 위해 카프카 클라이언트를 사용하여 애플리케이션을 개발한다.
카프카 클라이언트는 프로듀서, 컨슈머, 어드민 클라이언트를 제공하는 라이브러리이기 때문에 자체적인 라이프사이클을 가진 애플리케이션 위에서 구현하고 실행해야 한다.
- 프로듀서 API: 카프카에서 데이터의 시작점은 프로듀서이다. 프로듀서 애플리케이션은 카프카에 필요한 데이터를 선언하고 브로커의 특정 토픽의 파티션에 전송한다. 프로듀서는 데이터를 전송할 때 리더 파티션을 가지고 있는 카프카 브로커와 직접 통신한다. 프로듀서는 데이터를 직렬화하여 카프카 브로커로 전송하기 때문에 동영상, 이미지같은 바이너리 데이터도 전송할 수 있다.
- 컨슈머 API: 카프카에 적재된 프로듀서가 전송한 데이터를 컨슈머가 브로커로부터 데이터를 가져와 필요한 처리를 한다. 예를 들면 토픽으로부터 고객 데이터를 가져와 문자 발송 처리를 하거나 다른 DB에 데이터를 적재할 수도 있다.