Kafka 브로커의 네트워크 레이어
Netty의 Boss/Worker EventLoop 패턴을 공부하고 나서 Kafka 소스를 열어봤더니 — Netty를 안 쓰는데도 구조가 거의 똑같았다. 왜 굳이 직접 만든 걸까?
이번 글에서는 Kafka 브로커의 네트워크 레이어 를 Netty 아키텍처와 비교하며 분석해 보겠습니다. Kafka가 Netty를 사용하지 않으면서도 어떻게 동일한 고성능 패턴을 구현했는지, 그리고 왜 독자적으로 구현하는 길을 선택했는지 정리합니다.
Kafka는 Netty를 쓰는가?
결론부터 말하면, Kafka 브로커는 Netty를 사용하지 않습니다. 하지만 java.nio를 기반으로 Netty와 거의 동일한 Reactor 패턴을 직접 구현했습니다.
Kafka와 Netty의 공통점을 먼저 짚어 보겠습니다.
- 둘 다
java.nio.channels.Selector를 기반으로 I/O 다중화를 수행한다 - 둘 다 연결 수락(accept)과 I/O 처리를 분리하는 Multi-Reactor 패턴을 따른다
- 둘 다 소수의 스레드로 수천~수만 개의 동시 연결을 처리한다
차이점이라면, Netty는 이 패턴을 ** 범용 프레임워크 **로 제공하고, Kafka는 자신의 프로토콜에 ** 특화된 구현 **을 직접 작성했다는 것입니다.
Kafka SocketServer 구조
Kafka 브로커의 네트워크 레이어는 kafka.network.SocketServer 클래스를 중심으로 세 가지 핵심 컴포넌트로 구성됩니다.
┌─────────────────────────────────────────┐
│ SocketServer │
│ │
클라이언트 ───────►│ Acceptor (1개, Boss 역할) │
│ │ │
│ ├──► Processor 0 ──► RequestChannel │
│ ├──► Processor 1 ──► RequestChannel ─┼──► RequestHandler
│ └──► Processor 2 ──► RequestChannel │ Pool (Worker)
│ (Worker 역할) │
└─────────────────────────────────────────┘
이 구조를 Netty에 대입하면 이렇습니다.
- Acceptor = Boss EventLoop
- Processor = Worker EventLoop
- RequestHandler = ChannelHandler (비즈니스 로직)
하나씩 뜯어 보겠습니다.
Acceptor — Boss EventLoop 역할
Acceptor는 ServerSocketChannel에서 새 연결을 accept()하고, 라운드 로빈으로 Processor에 분배하는 스레드 입니다.
// Kafka Acceptor — 단순화된 구조
// ServerSocketChannel을 열고 새 연결을 받아들이는 역할
class Acceptor(val endPoint: EndPoint,
val processors: Array[Processor]) extends Runnable {
val serverChannel = ServerSocketChannel.open()
serverChannel.bind(new InetSocketAddress(endPoint.host, endPoint.port))
serverChannel.configureBlocking(false)
// Selector에 ACCEPT 이벤트 등록
val acceptSelector = Selector.open()
serverChannel.register(acceptSelector, SelectionKey.OP_ACCEPT)
override def run(): Unit = {
while (isRunning) {
// 새 연결 대기
val ready = acceptSelector.select(500)
if (ready > 0) {
val keys = acceptSelector.selectedKeys().iterator()
while (keys.hasNext) {
val key = keys.next()
keys.remove()
if (key.isAcceptable) {
accept(key)
}
}
}
}
}
// 새 연결을 라운드 로빈으로 Processor에 분배
private def accept(key: SelectionKey): Unit = {
val serverSocketChannel = key.channel().asInstanceOf[ServerSocketChannel]
val socketChannel = serverSocketChannel.accept()
socketChannel.configureBlocking(false)
// 다음 Processor에 할당
val processor = processors(currentProcessorIndex)
processor.accept(socketChannel)
currentProcessorIndex = (currentProcessorIndex + 1) % processors.length
}
}
Netty의 Boss EventLoop와 비교하면 이렇습니다.
| 동작 | Kafka Acceptor | Netty Boss EventLoop |
|---|---|---|
| 핵심 역할 | ServerSocketChannel.accept() | ServerSocketChannel.accept() |
| 연결 분배 | 라운드 로빈으로 Processor 선택 | EventLoopGroup.next()로 Worker 선택 |
| Selector 소유 | 자체 Selector 1개 | 자체 Selector 1개 |
| 스레드 수 | 리스너당 1개 | 보통 1개 |
Netty에서
ServerBootstrap.group(bossGroup, workerGroup)으로 Boss와 Worker를 분리했던 것을 떠올리면, Kafka의 Acceptor/Processor 분리가 정확히 같은 패턴이라는 걸 알 수 있습니다.
Processor — Worker EventLoop 역할
Processor는 자신만의 Selector를 가지고, 할당받은 연결들의 읽기/쓰기 I/O를 처리하는 스레드 입니다. Netty의 Worker EventLoop에 해당합니다.
// Kafka Processor — 단순화된 구조
// 개별 연결의 I/O를 처리하고, 요청을 디코딩하며 응답을 인코딩하는 역할
class Processor(val id: Int,
val requestChannel: RequestChannel) extends Runnable {
// Processor마다 독립적인 Selector
val selector = new KSelector(...) // Kafka의 자체 Selector 래퍼
// Acceptor로부터 새 연결을 받는 큐
private val newConnections = new ConcurrentLinkedQueue[SocketChannel]()
override def run(): Unit = {
while (isRunning) {
// 1. 새 연결 등록
configureNewConnections()
// 2. I/O 이벤트 처리
selector.poll(300)
// 3. 완성된 요청을 RequestChannel에 전달
processCompletedReceives()
// 4. 완성된 응답을 클라이언트에 전송
processCompletedSends()
}
}
// Acceptor가 넘긴 새 연결을 Selector에 등록
private def configureNewConnections(): Unit = {
while (!newConnections.isEmpty) {
val channel = newConnections.poll()
// OP_READ 등록 — 클라이언트가 보낸 데이터를 읽을 준비
selector.register(channel)
}
}
// 완성된 요청을 디코딩하여 RequestChannel로 전달
private def processCompletedReceives(): Unit = {
for (receive <- selector.completedReceives) {
// Kafka 프로토콜에 따라 헤더 파싱
val header = RequestHeader.parse(receive.payload)
// RequestChannel에 요청 전달 → RequestHandler가 처리
val request = new Request(processor = id, header, receive.payload)
requestChannel.sendRequest(request)
}
}
}
Processor의 동작을 단계별로 정리하면 이렇습니다.
- **새 연결 등록 **: Acceptor가 넘긴
SocketChannel을 자신의 Selector에OP_READ로 등록 - **I/O 폴링 **:
selector.poll()로 읽기/쓰기 가능한 채널을 확인 - ** 요청 디코딩 **: 수신 완료된 데이터를 Kafka 프로토콜에 따라 파싱
- ** 응답 인코딩 **: RequestHandler가 처리한 결과를 바이트로 인코딩하여 전송
이 과정이 Netty에서는 어떻게 대응되는지 보겠습니다.
// Netty Worker EventLoop — 동일한 패턴을 프레임워크가 처리
// 1. 새 연결 등록 → Boss가 channel.register(workerEventLoop)
// 2. I/O 폴링 → EventLoop.run()에서 selector.select()
// 3. 요청 디코딩 → Pipeline의 Decoder(ByteToMessageDecoder)
// 4. 응답 인코딩 → Pipeline의 Encoder(MessageToByteEncoder)
핵심 차이는 Netty가 디코딩/인코딩을
ChannelPipeline의 핸들러 체인으로 분리하는 반면, Kafka의 Processor는 Kafka 프로토콜 전용 디코딩/인코딩을 하나의 클래스 안에서 직접 처리한다는 것입니다.
RequestHandler — ChannelHandler 역할
RequestHandler는 디코딩된 요청을 받아 실제 비즈니스 로직(KafkaApis)을 실행하는 스레드 풀 입니다.
// Kafka RequestHandler — 단순화된 구조
// 비즈니스 로직을 처리하는 워커 스레드
class KafkaRequestHandler(val id: Int,
val requestChannel: RequestChannel,
val apis: KafkaApis) extends Runnable {
override def run(): Unit = {
while (isRunning) {
// RequestChannel에서 요청 꺼내기 (블로킹)
val request = requestChannel.receiveRequest(300)
if (request != null) {
// KafkaApis로 비즈니스 로직 처리
apis.handle(request)
}
}
}
}
// KafkaApis — API 키에 따라 적절한 핸들러로 라우팅
class KafkaApis {
def handle(request: Request): Unit = {
request.header.apiKey match {
case ApiKeys.PRODUCE => handleProduceRequest(request)
case ApiKeys.FETCH => handleFetchRequest(request)
case ApiKeys.METADATA => handleMetadataRequest(request)
case ApiKeys.JOIN_GROUP => handleJoinGroupRequest(request)
// ... 수십 가지 API
}
}
}
여기서 중요한 설계 포인트가 있습니다.
- Processor(I/O 스레드)와 RequestHandler(비즈니스 스레드)가 분리 되어 있다
- 둘 사이는
RequestChannel이라는 공유 큐로 연결된다 - 디스크 I/O나 복잡한 처리가 Processor를 블로킹하지 않는다
이 구조는 Netty에서 EventLoop를 블로킹하면 안 되기 때문에 무거운 작업을 별도 EventExecutorGroup에 위임하는 패턴과 동일합니다.
// Netty에서 무거운 작업을 별도 스레드 풀로 위임하는 패턴
EventExecutorGroup businessGroup = new DefaultEventExecutorGroup(16);
pipeline.addLast(businessGroup, "handler", new MyBusinessHandler());
// → EventLoop가 블로킹되지 않음
전체 데이터 흐름
클라이언트 요청이 Kafka 브로커 내부에서 처리되는 전체 흐름을 정리합니다.
클라이언트 요청
│
▼
┌─────────┐ accept() ┌────────────┐ poll() ┌──────────────┐
│ Acceptor │ ──────────► │ Processor │ ────────► │ RequestChannel│
│ (Boss) │ 라운드로빈 │ (Worker) │ 디코딩 │ (공유 큐) │
└─────────┘ └────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│RequestHandler │
│ + KafkaApis │
│ (비즈니스로직) │
└──────┬───────┘
│
▼
┌─────────┐ ┌────────────┐ ┌──────────────┐
│클라이언트│ ◄─────────── │ Processor │ ◄──────── │ ResponseQueue│
│ (응답) │ 전송 │ (인코딩) │ 응답 전달 │ │
└─────────┘ └────────────┘ └──────────────┘
단계별로 보면 이렇습니다.
- Acceptor 가
ServerSocketChannel.accept()로 새 연결을 수락 - 라운드 로빈으로 Processor 에 소켓 채널 전달
- Processor가
Selector.poll()로 데이터를 읽고 Kafka 프로토콜로 디코딩 - 디코딩된 요청을 RequestChannel(공유 큐)에 전달
- RequestHandler 가 큐에서 요청을 꺼내 KafkaApis 로 비즈니스 로직 처리
- 처리 결과를 ResponseQueue 에 넣으면 Processor가 인코딩하여 클라이언트에 전송
Kafka vs Netty 아키텍처 비교
두 아키텍처를 1:1로 매핑하면 이렇습니다.
| 역할 | Kafka 컴포넌트 | Netty 컴포넌트 |
|---|---|---|
| 연결 수락 | Acceptor | Boss EventLoopGroup |
| I/O 처리 | Processor | Worker EventLoopGroup |
| 이벤트 감지 | KSelector (java.nio Selector 래퍼) | NioEventLoop의 Selector |
| 요청 디코딩 | Processor 내부 파싱 로직 | ByteToMessageDecoder |
| 응답 인코딩 | Processor 내부 인코딩 로직 | MessageToByteEncoder |
| 비즈니스 로직 | RequestHandler + KafkaApis | ChannelInboundHandler |
| I/O ↔ 비즈니스 분리 | RequestChannel (공유 큐) | EventExecutorGroup 위임 |
| 연결 추상화 | KafkaChannel | Channel |
| 프로토콜 정의 | Kafka Protocol (자체 바이너리) | 사용자 정의 코덱 |
구조만 보면 거의 동일합니다. 차이는 Netty가 범용 프레임워크 로서 파이프라인, 코덱, 다양한 프로토콜을 지원하는 반면, Kafka는 자기 프로토콜 하나에 최적화 했다는 것입니다.
왜 Kafka는 Netty를 안 쓰는가
Netty가 충분히 훌륭한 프레임워크인데도 Kafka가 직접 구현한 이유는 크게 세 가지입니다.
1. 독자적 바이너리 프로토콜
Kafka는 자체 바이너리 프로토콜을 사용합니다. HTTP도 아니고 gRPC도 아닌, Kafka만의 요청/응답 포맷입니다.
Kafka 프로토콜 메시지 구조:
┌──────────┬────────┬──────────┬──────────────┐
│ 길이(4B) │ API키 │ 버전 │ 페이로드 │
│ │ (2B) │ (2B) │ (가변) │
└──────────┴────────┴──────────┴──────────────┘
이 프로토콜은 Produce, Fetch, Metadata 등 수십 가지 API를 포함하며, 각 API마다 버전별 스키마가 다릅니다. Netty의 범용 코덱 체계를 거치기보다, Kafka 프로토콜에 특화된 파서를 직접 작성하는 것이 더 효율적이었을 것입니다.
2. 제로카피(sendfile) 직접 제어
이전 글에서 다뤘던 FileRegion과 sendfile()을 기억하시나요? Kafka는 이걸 핵심 성능 전략 으로 활용합니다.
// Kafka의 제로카피 — FileChannel.transferTo() 직접 사용
// 로그 세그먼트 파일을 네트워크로 전송할 때
public long writeTo(TransferableChannel channel, long position, int length) {
// OS의 sendfile() 시스템콜로 매핑
// 데이터가 유저 공간을 거치지 않고 커널에서 직접 소켓으로 전달
return fileChannel.transferTo(position, length, channel);
}
Kafka의 Fetch 요청(컨슈머가 메시지를 가져갈 때)은 디스크의 로그 파일을 네트워크로 그대로 전송 하는 것이 핵심입니다. 이때 데이터를 유저 공간으로 올렸다가 다시 내리는 과정을 생략하면 성능이 크게 향상됩니다.
Netty에서도 FileRegion으로 동일한 최적화가 가능하지만, Kafka는 FileChannel.transferTo()를 자신의 네트워크 레이어와 직접 통합하여 더 세밀한 제어를 원했습니다.
3. 최소 의존성 철학
Kafka 프로젝트는 외부 의존성을 최소화하는 철학을 가지고 있습니다.
- 네트워크 레이어:
java.nio직접 사용 (Netty 의존성 없음) - 직렬화: 자체 프로토콜 (Protobuf, Avro 등의 런타임 의존성 없음)
- 코디네이션: Zookeeper → KRaft로 내재화 (외부 의존성 제거 추세)
외부 프레임워크에 의존하면 해당 프레임워크의 버전 업그레이드, 보안 패치, API 변경에 영향을 받습니다. 인프라의 핵심 컴포넌트인 Kafka 입장에서는 이 부분을 직접 제어하고 싶었을 것입니다.
반면, 같은 Apache 생태계의 Cassandra 는 Netty를 사용합니다. Spark, Flink 등 다른 빅데이터 프로젝트도 Netty를 네트워크 레이어로 활용합니다. Kafka가 특수한 경우인 거지, Netty를 안 쓰는 것이 일반적인 것은 아닙니다.
Netty를 쓰는 Kafka 컴포넌트
재밌는 점은, Kafka 생태계 전체로 보면 Netty를 쓰는 부분이 있다 는 것입니다.
- Kafka Connect: REST API 서버에 Netty 기반 Jetty/Jersey 사용
- Confluent Schema Registry: HTTP 서버에 Netty 활용
- Kafka Streams: 내부 RPC에 간접적으로 Netty를 사용하는 경우
즉, ** 브로커의 핵심 네트워크 레이어 **만 직접 구현하고, 부가적인 HTTP API 등에서는 Netty 기반 프레임워크를 활용하는 하이브리드 전략입니다.
Reactor 패턴의 보편성
공부하다 보니 결국 이런 결론에 도달했습니다. ** 고성능 네트워크 서버를 만들려면, Netty를 쓰든 안 쓰든 결국 같은 패턴에 도달한다는 것 **입니다.
[Reactor 패턴의 핵심 구조]
┌─ Reactor (accept) ─── 연결 수락
│
이벤트 루프 ──────┼─ Reactor (I/O) ─── 읽기/쓰기 처리
│
└─ Worker Pool ─── 비즈니스 로직
이 패턴은 Kafka만의 것이 아닙니다.
- Nginx: master process + worker process
- Redis: 단일 스레드 이벤트 루프 (싱글 Reactor)
- Node.js: libuv 기반 이벤트 루프
- Netty: Boss EventLoop + Worker EventLoop
Netty를 공부하면서 이 패턴을 이해했다면, Kafka든 Nginx든 Redis든 네트워크 레이어의 구조를 빠르게 파악할 수 있습니다.
정리
- Kafka 브로커는 Netty를 사용하지 않지만,
java.nio기반으로 ** 동일한 Multi-Reactor 패턴 **을 구현했다 - Acceptor(Boss) → Processor(Worker) → RequestHandler(비즈니스)로 이어지는 3단 구조
- Netty를 안 쓴 이유: 독자적 프로토콜,
sendfile()직접 제어, 최소 의존성 철학 - 결국 고성능 네트워크 서버는 Reactor 패턴이라는 동일한 설계 원리 에 수렴한다
- Netty의 Boss/Worker 패턴을 이해하면, Kafka뿐 아니라 대부분의 고성능 서버 아키텍처를 빠르게 읽을 수 있다