기존 스레드 기반 폴링의 한계
WaterDrop 프로듀서가 생성될 때마다 rdkafka-ruby는 내부적으로 rd_kafka_poll을 반복 호출하는 백그라운드 스레드를 실행합니다. 이 방식은 다음과 같은 문제점을 안고 있습니다. - 자원 낭비: 각 스레드는 약 1MB의 스택 공간을 차지하며 프로듀서 수가 늘어날수록 메모리 사용량이 급증합니다. 25개의 프로듀서를 사용하면 25MB 이상의 메모리가 폴링 스레드에만 할당됩니다. - GVL 경합: rd_kafka_poll은 C 확장 함수 호출 시 GVL을 해제하고 반환 시 다시 획득하는 과정을 반복합니다. 수십 개의 스레드가 이 과정을 짧은 주기로 반복하면 Ruby 애플리케이션의 메인 로직과 경합이 발생하여 전체적인 성능이 저하됩니다. - 비효율적 대기: 이벤트가 없는 상황에서도 스레드들은 타임아웃까지 대기하며 불필요한 컨텍스트 스위칭을 유발합니다. 이는 특히 처리량이 적은 환경에서도 시스템 자원을 지속적으로 소모하게 만듭니다.
파일 디스크립터(FD)와 파이프를 활용한 혁신
이러한 문제를 해결하기 위해 librdkafka의 rd_kafka_queue_io_event_enable API를 도입했습니다. 이 API는 특정 큐에 이벤트가 발생할 때 파일 디스크립터로 신호를 보낼 수 있게 해줍니다. - 작동 원리: OS 파이프를 생성하여 쓰기 끝단을 librdkafka에 전달합니다. 이벤트 큐에 데이터가 쌓이면 librdkafka가 파이프에 1바이트를 기록하여 신호를 보냅니다. 이는 엣지 트리거 방식으로 작동하여 큐가 비어 있다가 채워질 때만 신호가 발생합니다. - IO.select 활용: Ruby의 IO.select를 사용하여 단일 스레드에서 여러 파이프를 동시에 모니터링합니다. 이는 시스템 레벨에서 효율적으로 관리되며 데이터가 있을 때만 스레드가 깨어나므로 CPU 사용률을 최적화할 수 있습니다. - Non-blocking Poll: 데이터 감지 시 poll_nb(0)를 호출합니다. 이 함수는 GVL을 해제하지 않고 실행되므로 기존 방식보다 약 1.6배 빠른 실행 속도를 보여줍니다. 10만 번의 반복 테스트에서 rd_kafka_poll은 초당 5.1M 호출을 처리한 반면 poll_nb는 8.1M 호출을 처리했습니다.
성능 벤치마크 결과
로컬 Kafka 브로커 환경에서 실시한 테스트 결과는 다음과 같습니다. - 단일 프로듀서: 초당 메시지 처리량이 27,300개에서 41,900개로 약 54% 증가했습니다. - 다중 프로듀서 (25개): 초당 메시지 처리량이 24,140개에서 36,110개로 약 50% 증가했습니다. 이러한 성능 향상은 즉각적인 이벤트 알림 GVL 오버헤드 제거 그리고 스레드 통합에 따른 경합 감소라는 세 가지 요소가 결합된 결과입니다. 특히 프로듀서 수가 많아질수록 단일 폴러 스레드의 효율성이 더욱 빛을 발합니다.
주의사항 및 고급 설정
FD 모드 사용 시 몇 가지 고려해야 할 점이 있습니다. - 콜백 실행 환경: 모든 프로듀서의 콜백이 단일 폴러 스레드에서 실행되므로 콜백 내부에서 시간이 오래 걸리는 작업을 수행하면 다른 프로듀서의 폴링이 지연될 수 있습니다. message.acknowledged나 statistics.emitted 콜백은 최대한 가볍게 유지해야 합니다. - 전용 폴러(Dedicated Poller): 특정 프로듀서의 콜백 부하가 크다면 config.polling.poller 설정을 통해 독립적인 폴러 스레드를 할당하여 격리할 수 있습니다. 이를 통해 중요도가 높은 프로듀서의 응답성을 보장할 수 있습니다. - 점진적 도입 계획: WaterDrop 2.8에서는 선택 사항(Opt-in)으로 시작하여 2.9에서는 기본값으로 설정하고 2.10에서는 기존 스레드 모드를 완전히 제거할 예정입니다. 이는 대규모 운영 환경에서의 안정적인 전환을 위함입니다.