[실전 카프카 개발부터 운영까지 정리] 5장. 프로듀서 내부 동작 원리
http://www.yes24.com/Product/Goods/104410708
해당 책을 공부하며 정리한 내용입니다.
파티셔너
카프카 토픽은 성능 향상을 위한 병렬 처리가 가능하도록 하기 위해 파티션으로 나뉘고, 최소 하나 또는 둘 이상의 파티션으로 구성된다.
- 프로듀서가 카프카로 전송한 메시지는 해당 토픽 내 각 파티션의 로그 세그먼트에 저장된다.
- 따라서 프로듀서는 토픽으로 메시지를 보낼 때 해당 토픽의 어느 파티션으로 메시지를 보내야 할지를 결정해야 하는데, 이때 사용하는 것이 파티셔너이다.
프로듀서가 파티션을 결정하는 알고리즘은 기본적으로 메시지의 키를 해시 처리해 파티션을 구하는 방식을 사용한다.
- 메시지의 키 값이 동일하면 해당 메시지들은 모두 같은 파티션으로 전송된다.
- 메시지의 키를 이용해 카프카로 메시지를 전송하는 경우, 되도록 파티션 수를 변경하지 않는 것을 권장한다.
라운드 로빈 전략
- 프로듀서의 메시지 중 레코드의 키값은 필수 값이 아니므로 레코드 키 값을 지정하지 않고 메시지를 전송할 수 있다. 키값을 지정하지 않는다면 키값은 null이 되고, 기본값은 라운드 로빈 알고리즘을 사용해 프로듀서는 목적지 토픽의 파티션들로 레코드들을 랜덤 전송한다.
- 파티셔너를 거친 후의 레코드들은 배치 처리를 위해 프로듀서의 버퍼 메모리 영역에서 잠시 대기한 후 카프카로 전송된다.
스티키 파티셔닝 전략
- 라운드 로빈 전략에서는 배치 전송을 위한 필요 레코드 수를 채우지 못해 카프카로 배치 전송을 하지 못하는 등 배치 처리를 위해 잠시 메시지들이 대기하는 과정에서 라운드 로빈 전략은 효율을 떨어트릴 수 있다.
- 라운드 로빈 전략에서 지연시간이 불필요하게 증가되는 비효율적인 전송을 개선하기 위해 카프카 2.4 버전부터 스티키 파티셔닝 전략을 사용한다.
- 스티키 파티셔닝이란 하나의 파티션에 레코드 수를 먼저 채워서 카프카로 빠르게 배치 전송하는 전략을 의미.
- 카프카로 전송되는 메시지의 순서가 그다지 중요하지 않은 경우라면 스티키 파티셔닝 전략을 적용하기를 권장한다고 한다.
프로듀서의 배치
- 카프카에서는 토픽의 처리량을 높이기 위한 방법으로 토픽을 파티션으로 나눠 처리하며, 카프카 클라이언트인 프로듀서에서는 처리량을 높이기 위해 배치 전송을 권장한다.
- 따라서 프로듀서에서는 카프카로 전송하기 전, 배치 전송을 위해 토픽의 파티션 별로 레코드들을 잠시 보관하고 있는다.
- 프로듀서는 배치 전송을 위해 다음과 같은 옵션을 제공한다.
buffer.memory: 프로듀서의 버퍼 메모리 옵션 (기본값 32MB)
batch.size: 배치 전송을 위해 메시지들을 묶는 단위를 설정하는 배치 크기 옵션 (기본값은 16KB)
linger.ms: 배치 전송을 위해 버퍼 메모리에서 대기하는 메시지들의 최대 대기시간을 설정하는 옵션 (기본값은 0으로, 배치 전송을 위해 기다리지 않고 즉시 전송됨)
- 프로듀서의 배치 전송 방식은 단 건의 메시지를 전송하는 것이 아니라, 한 번에 다량의 메시지를 묶어서 전송하는 방법으로, 불필요한 I/O를 줄일 수 있어 매우 효율적이며, 카프카의 요청 수를 줄여주는 효과도 있다.
- 하지만 무조건 배치 전송이 좋은 것은 아니며, 처리량을 높일지, 지연 없는 전송을 해야 할지에 따라 선택할 수 있다.
- 처리량을 높이려면 batch.size와 linger.ms의 값을 크기 설정해야 하고, 지연 없는 전송이 목표라면 batch.size와 linger.ms의 값을 작게 설정해야 한다.
메시지 전송 방식
카프카의 메시지 전송 방식에는 다음과 같은 방식이 존재한다.
- 적어도 한 번 전송
- 최대 한 번 전송
- 정확히 한 번 전송
- 중복 없는 전송
적어도 한 번 전송
- 프로듀서가 브로커의 특정 토픽으로 메시지 A를 전송한다.
- 브로커는 메시지 A를 기록하고, 잘 받았다는 ACK를 프로듀서에게 응답한다.
- 브로커의 ACK를 받은 프로듀서는 다음 메시지인 메시지 B를 브로커에 전송한다.
- 브로커는 메시지B를 기록하고, ACK를 프로듀서에 전송하려 했지만, 네트워크 오류 등의 이유로 프로듀서는 메시지 B에 대한 ACK를 받지 못하였다.
- 메시지 B를 전송한 후, 브로커로부터 ACK를 받지 못한 프로듀서는 브로커가 메시지 B를 받지 못했다고 판단해 메시지 B를 재전송한다.
- 이처럼 프로듀서 입장에서는 브로커가 메시지를 저장하고 ACK만 전송하지 못한 것인지, 메시지를 저장하지 못해서 ACK를 전송하지 않은 것인지 정확히 알 수 없다.
- 메시지 B에 대한 ACK를 받지 못한 프로듀서는 적어도 한 번 전송 방식에 따라 메시지B를 다시 한 번 전송한다.
- 만약 브로커가 메시지 B를 받지 못한 상황이었다면 브로커는 처음으로 메시지B를 저장할 것이고, 브로커가 메시지 B를 저장하고 ACK만 전송하지 못한 상황이었다면 메시지 B는 브로커에 중복 저장될 것이다.
이처럼 네트워크의 회선 장애나 기타 장애 상황에 따라 일부 메시지 중복이 발생할 수 있지만, 최소한 하나의 메시지는 반드시 보장한다는 것이 적어도 한 번 전송 방식이며, 카프카는 기본적으로 이와 같은 적어도 한 번 전송 방식을 기반으로 동작한다.
최대 한번 전송
- 프로듀서가 브로커의 특정 토픽으로 메시지 A를 전송한다.
- 브로커는 메시지 A를 기록하고, ACK를 프로듀서에게 응답한다.
- 프로듀서는 다음 메시지인 메시지 B를 브로커에 전송한다.
- 브로커는 메시지 B를 기록하지 못하고, ACK를 프로듀서에게 전송하지 못한다.
- 프로듀서는 브로커가 메시지 B를 받았다고 가정하고, 재전송 없이 다음 메시지인 메시지 C를 전송한다.
(실제로는 ACK를 응답하는 과정은 없어도 되지만, 이해를 위해 추가함)
- 최대 한 번 전송은 ACk를 받지 못하더라도 재전송을 하지 않습니다.
- 메시지의 중복 가능성을 회피하기 위해 재전송을 하지 않는다. (일부 메시지의 손실을 감안하더라도 중복 전송을 하지 않는 경우)
- 일부 메시지가 손실되더라도 높은 처리량을 필요로 하는 대량의 로그 수집이나 IOT 같은 환경에서 사용하곤 한다.
중복 없는 전송
- 프로듀서가 브로커의 특정 토픽으로 메시지 A를 전송한다. 이때 PID 0과 메시지 번호 0을 헤더에 포함해 함께 전송한다.
- 브로커는 메시지 A를 저장하고 PID와 메시지 번호 0을 메모리에 기록한다. 그리고 메시지를 잘 받았다는 ACK를 프로듀서에 응답한다.
- 프로듀서는 다음 메시지인 메시지 B를 브로커에 전송한다. PID는 동일하게 0이고, 메시지 번호는 1이 증가하여 1이 된다.
- 브로커는 메시지 B를 저장하고, PID와 메시지 번호 1을 메모리에 기록한다. 그리고 메시지를 잘 받았다는 ACK를 프로듀서에게 전송하려고 하지만, 네트워크 오류 등의 이유로 프로듀서는 메시지 B에 대한 ACK를 받지 못한다.
- 브로커로부터 ACK를 받지 못한 프로듀서는 브로커가 메시지 B를 받지 못했다고 판단해 메시지B를 재전송한다.
- 지금까지의 과정은 적어도 한 번 전송 과정과 동일하지만, 이후 프로듀서가 재전송한 메시지 B의 헤더에서 PID(0)와 메시지 번호(1)를 비교해서 메시지 B가 이미 브로커에 저장되어 있는 것을 확인한 브로커는 메시지를 중복 저장하지 않고 ACK만 보낸다.
- 이러한 브로커의 동작 덕분에 브로커에 저장된 메시지는 중복을 피할 수 있게 된다.
카프카에서 이용하는 PID와 메시지 번호가 바로 중복 없는 전송의 핵심이다.
- 프로듀서가 중복 없는 전송을 시작하면, 프로듀서는 고유한 PID를 할당받게 되고, PID와 메시지 번호에 대한 번호를 메시지의 헤더에 포함해 메시지를 전송한다.
- 브로커에서는 각 메시지마다 PID 값과 메시지 번호를 메모리에 유지하게 되며, 이 정보를 이용해 브로커에 기록된 메시지들의 중복 여부를 알 수 있다.
- PID는 사용자가 별도로 생성하는 것이 아닌, 프로듀서에 의해 내부적으로 자동 생성된다.
- 메시지 번호는 0번부터 시작해 순차적으로 증가한다.
중복 없는 전송은 매우 유용해 보이지만, 중복을 피하기 위한 메시지 비교 동작에는 오버헤드가 존재할 수밖에 없다. (카프카에서는 메시지 비교 동작에 대한 오버헤드가 생각보다 높은 편은 아니지만, 오버헤드가 존재하긴 함)
- 중복 없는 전송을 적용한 후 기존 대비 최대 20% 정도만 성능이 감소하였다고 한다.
- 프로듀서 전송 성능에 그다지 민감하지 않은 상황에서 중복 없는 메시지 전송이 필요하다면 이 방식을 설정해 적용할 것을 권장한다.
중복 없는 전송을 위해서는 다음과 같이 프로듀서의 일부 설정들을 변경해야 한다. (누락될 경우 ConfigException 발생)
enable.idempotence: true // 프로듀서가 중복 없는 전송을 허용할지 결정하는 옵션 (기본값은 false)
max.in.flight.requests.per.connection: 1 ~ 5 // ACK를 받지 않은 상태에서 하나의 커넥션에서 보낼 수 있는 최대 요청 수. (기본값은 5이며, 5 이하로 설정해야 한다)
acks: all // 기본값은 1이며 all로 설정해야 한다.
retries: 5 // ACK를 받지 못한 경우 재시도를 해야 하므로 0보다 큰 값으로 설정해야 한다.
정확히 한 번 전송
앞서 중복 없는 전송 방식이 정확히 한 번 전송한다는 의미는 아니다.
- 카프카에서 정확히 한 번 전송은 트랜잭션과 같은 전체적인 프로세스 처리를 의미하며, 중복 없는 전송은 정확히 한 번 전송의 일부 기능이라고 할 수 있다.
전체적인 프로세스를 관리하기 위해 카프카에서는 정확히 한 번 처리를 담당하는 별도의 프로세스가 있는데, 이를 트랜잭션 API라고 부른다.
프로듀서가 카프카로 정확히 한 번 방식으로 메시지를 전송할 때, 프로듀서가 보내는 메시지들은 원자적으로 처리되어 전송에 성공하거나 실패하게 된다.
- 이런 프로듀서의 전송을 위해 카프카에서는 컨슈머 그룹 코디네이터와 동일한 개념으로 트랜잭션 코디네이터라는 것이 서버 측에 존재한다.
- 트랜잭션 코디네이터의 역할은 프로듀서에 의해 전송된 메시지를 관리하며, 커밋 또는 중단 등을 표시한다.
- 컨슈머 오프셋 관리를 위해 오프셋 정보를 내부 토픽에 저장하는 것과 같이, 트랜잭션도 동일하게 트랜잭션 로그를 카프카의 내부 토픽인 __transaction_state에 저장한다
정확히 한 번 전송을 이용해 전송된 메시지들이 카프카에 저장되면, 카프카의 메시지를 다루는 클라이언트들은 해당 메시지들이 정상적으로 커밋된 것인지 또는 실패한 것인지 식별할 수 있어야 한다.
- 카프카에서는 이를 식별하기 위한 정보로 컨트롤 메시지라고 불리는 특별한 타입의 메시지가 추가로 사용된다. (컨트롤 메시지)
중복 없는 전송과 정확히 한 번 전송의 옵션 설정에서 가장 큰 차이점은 TRANSACTION_ID_CONFIG로, 프로듀서의 TRANSACTIONAL_ID_CONFIG 옵션은 실행하는 프로듀서 프로세스마다 고유한 아이디로 설정해야 한다.
정확히 한번 전송 단계별 동작
- 트랜잭션 코디네이터 찾기
- 정확히 한 번 전송을 위해서는 트랜잭션 API를 이용해야 한다. 따라서 가장 먼저 수행하는 작업은 트랜잭션 코디네이터 찾기이다.
- 프로듀서는 브로커에게 FindCoordinatorRequest를 보내서 트랜잭션 코디네이터의 위치를 찾는다.
- 트랜잭션 코디네이터는 브로커에 위치하며, 주 역할은 PID와 transactional.id를 매핑하고 해당 트랜잭션 전체를 관리하는 것.
- 만약 트랜잭션 코디네이터가 존재하지 않는다면 신규 트랜잭션 코디네이터가 생성된다.
- __transaction_state 토픽의 파티션 번호는 transaction.id를 기반으로 해시하여 결정되고, 이 파티션의 리더가 있는 브로커가 트랜잭션 코디네이터의 브로커로 최종 선정된다.
- 프로듀서 초기화
- 프로듀서는 initTransactions() 메소드를 이용해 트랜잭션 전송을 위한 InitPidRequest를 트랜잭션 코디네이터로 보낸다. 이떄 TID가 설정된 경우에는 InitPidRequest와 함께 TID가 트랜잭션 코디네이터에게 전송된다.
- 트랜잭션 코디네이터는 TID, PID를 매핑하고 해당 정보를 트랜잭션 로그에 기록한다.
- 이후 PID 에포크를 한 단계 올리는 동작을 하게 되고, PID 에포크가 올라감에 따라 이전의 동일한 PID와 이전 에포크에 대한 쓰기 요청은 무시된다. (신뢰성 있는 메시지 전송을 하기 위해서)
- 트랜잭션 시작
- 프로듀서는 beginTransaction() 메소드를 이용해 새로운 트랜잭션의 시작을 알리게 된다.
- (프로듀서는 내부적으로 트랜잭션이 시작됐음을 기록하지만, 트랜잭션 코디네이터 관점에서는 첫 번째 레코드가 전송될 때까지 트랜잭션이 시작된 것은 아님)
- 트랜잭션 상태 추가
- 트랜잭션 코디네이터는 전체 트랜잭션을 관리한다.
- 프로듀서는 토픽 파티션 정보를 트랜잭션 코디네이터에게 전달하고, 트랜잭션 코디네이터는 해당 정보를 트랜잭션 로그에 기록한다.
- TID와 P0의 정보가 트랜잭션 로그에 기록되며, 트랜잭션의 현재 상태를 Ongoing으로 표시한다.
- 기본값으로 1분 동안 트랜잭션 상태에 대한 업데이트가 없다면, 해당 트랜잭션은 실패로 처리된다.
- 메시지 전송
- 프로듀서는 대상 토픽의 파티션으로 메시지를 전송한다.
- 트랜잭션 종료 요청
- 메시지 전송을 완료한 프로듀서는 commitTransaction() 메소드 또는 abortTransaction() 메소드 중 하나를 반드시 호출해야 한다.
- 해당 메소드의 호출을 통해 트랜잭션이 완료됨을 트랜잭션 코디네이터에게 알린다.
- 첫 번째 단계로 트랜잭션 로그에 해당 트랜잭션에 대한 PrepareCommit 또는 PrepareAbort를 기록한다.
- 두 번째 단계로 트랜잭션 로그에 기록된 토픽의 파티션에 트랜잭션 커밋 표시를 기록한다. (여기서 기록하는 메시지가 컨트롤 메시지)
- 트랜잭션 완료
- 트랜잭션 코디네이터는 완료됨이라고 트랜잭션 로그에 기록한다.
- 프로듀서에게 해당 트랜잭션이 완료됨을 알린 다음 해당 트랜잭션에 대한 처리는 모두 마무리된다.