본문 바로가기
Java

Netty 이벤트 루프와 Non-Blocking I/O 동작 원리 가이드

by 정권이 내 2026. 3. 10.

🚀 Netty 이벤트 루프와 Non-Blocking I/O: 소수 스레드로 수십만 커넥션 다루기

백엔드 시스템을 설계하고 운영하다 보면, 어느 순간 트래픽의 벽에 부딪히는 시점이 옵니다.

특히 실시간 채팅 서버, 수백만 대의 IoT 디바이스에서 쏟아지는 센서 데이터를 수집하는 서버, 혹은 끊임없이 양방향 통신을 유지해야 하는 시스템을 구축할 때 기존의 전통적인 웹 서버 아키텍처로는 한계에 직면하게 됩니다.

이런 극한의 환경에서 소수의 스레드만으로 수십만 개의 커넥션을 가볍게 유지하고 데이터를 수집하는 원리, 그 중심에 있는 Netty의 이벤트 루프와 Non-Blocking I/O 메커니즘에 대해 깊이 있게 파헤쳐 보겠습니다.

🎯 왜 우리는 이 기술에 열광해야 하는가?

전통적인 웹 서버 아키텍처인 Thread-per-Connection(커넥션 당 스레드 할당) 모델을 먼저 떠올려 보겠습니다.

클라이언트가 서버에 연결을 요청할 때마다 서버는 새로운 스레드를 생성하거나 스레드 풀에서 하나를 꺼내어 해당 커넥션에 전담시킵니다. 이 방식은 구현하기 쉽고 직관적이라는 장점이 있습니다. 하나의 커넥션에서 발생하는 모든 작업이 하나의 스레드 내에서 순차적으로 처리되기 때문입니다.

하지만 대규모 동시 접속(C10K 문제) 상황이 오면 이야기가 완전히 달라집니다. 10만 개의 커넥션이 유지되어야 한다면, 서버는 10만 개의 스레드를 유지해야 합니다.

스레드 하나당 기본적으로 할당되는 메모리(Stack)만 계산해도 엄청난 메모리 낭비가 발생하며, 수만 개의 스레드 사이에서 발생하는 Context Switching(문맥 교환) 비용은 CPU를 압도해 버립니다. 결국 서버는 실제 데이터를 처리하기도 전에 스레드를 관리하느라 뻗어버리게 됩니다.

특히 IoT 센서 데이터 수집이나 채팅처럼 커넥션은 오래 유지되지만 실제 데이터 전송은 간헐적으로 일어나는 환경에서는 최악의 효율을 보여줍니다. 스레드가 할 일도 없이 연결을 유지하기 위해 Blocking 상태로 대기하면서 시스템 자원만 축내는 셈입니다.

이러한 물리적 한계를 극복하기 위해 등장한 구원자가 바로 Non-Blocking I/O와 이를 극도로 발전시킨 Netty 프레임워크입니다. Netty는 단 몇 개의 스레드만으로 수십만 개의 멈춰있는(Idle) 커넥션을 아무런 자원 낭비 없이 관찰하다가, 실제 데이터가 들어오는 순간에만 자원을 할당하여 즉각적으로 처리합니다. 이것이 바로 우리가 고성능 데이터 수집 서버를 구축할 때 반드시 Netty와 이벤트 루프 메커니즘을 이해해야 하는 이유입니다.

💥 현업에서 가장 많이 하는 실수들

Netty를 시스템에 도입후, 많은 개발자들이 예상치 못한 장애를 겪습니다. 원리를 완벽히 이해하지 못한 채 코드를 작성했을 때 흔히 발생하는 치명적인 시행착오들을 짚고 넘어가겠습니다.

1. 이벤트 루프 내의 Blocking 작업

Netty의 이벤트 루프 스레드는 수천, 수만 개의 채널(커넥션)을 혼자서 관리합니다. 만약 이벤트 루프가 처리해야 할 핸들러 내부에서 데이터베이스의 응답을 기다리거나, 외부 API를 동기 방식으로 호출하거나, 무거운 암호화 연산을 수행하는 등 Blocking 작업을 실행해버리면 어떻게 될까요?

해당 이벤트 루프가 관리하는 수천 개의 다른 커넥션들도 그 작업이 끝날 때까지 멈춰버리게 됩니다. 이를 Thread Starvation(스레드 기아 상태)이라고 부릅니다. 멀쩡하던 서버의 응답 속도가 갑자기 수십 초로 늘어나거나 타임아웃이 대량으로 발생한다면, 십중팔구 핸들러 어딘가에서 이벤트 루프 스레드를 점유하고 놓아주지 않는 Blocking 코드가 숨어있기 때문입니다. 무거운 작업은 반드시 별도의 비동기 스레드 풀(예: EventExecutorGroup)로 넘겨서 처리해야 합니다.

2. ByteBuf 메모리 누수 방치

Netty는 가비지 컬렉터(GC)의 부담을 줄이고 I/O 성능을 극대화하기 위해 JVM 힙 영역 밖의 메모리를 직접 제어하는 Direct Buffer와 객체를 재사용하는 Pooled Buffer를 기본적으로 사용합니다. 이는 성능 향상에 큰 도움이 되지만, 개발자가 사용이 끝난 버퍼의 메모리를 명시적으로 해제 해주지 않으면 심각한 메모리 누수(Memory Leak)가 발생합니다.

초보자들은 데이터를 읽고 나서 채널 파이프라인의 다음 핸들러로 넘기지 않거나, 처리가 끝난 후 ReferenceCountUtil.release() 메서드를 호출하지 않아 시스템의 오프힙 메모리가 고갈되어 결국 서버가 OOM(Out Of Memory)으로 죽는 현상을 자주 겪습니다.

3. 과도한 EventLoopGroup 스레드 수

성능을 높이겠다는 욕심에 NioEventLoopGroup의 스레드 개수를 수백 개로 설정하는 경우가 있습니다. 하지만 Netty는 소수의 스레드로 동작하도록 설계되었습니다.

기본값인 CPU 코어 수의 2배가 가장 최적의 성능을 내는 경우가 많습니다. 스레드를 무의미하게 늘리면 오히려 Non-Blocking 아키텍처의 장점이 퇴색되고 다시 불필요한 문맥 교환 비용만 증가시켜 성능이 저하됩니다.

🧠 핵심 : 기적의 비동기 메커니즘 파헤치기

이제 본격적으로 소수의 스레드가 어떻게 수많은 커넥션을 가볍게 유지하고 데이터를 수집하는지 그 핵심 원리를 파헤쳐 보겠습니다.

1. Non-Blocking I/O와 Selector의 마법

Netty의 근간에는 Java NIO의 Selector(멀티플렉서) 메커니즘이 있습니다. 수만 개의 커넥션(Channel)이 맺어지면, 운영체제는 각각의 커넥션에 대해 File Descriptor를 생성합니다. 전통적인 방식은 앞서 말했듯 이 File Descriptor 하나당 스레드를 하나씩 붙여서 데이터가 오는지 감시했습니다.

하지만 NIO의 Selector는 수만 개의 File Descriptor를 하나의 리스트로 묶어서 단일 스레드가 감시하게 만듭니다. 스레드는 커넥션들 앞에서 대기하는 것이 아니라, Selector에게 "이 수만 개의 커넥션 중에 데이터를 보내온 녀석이 있으면 내게 알려줘"라고 등록만 해둡니다.

이후 커넥션들은 메모리 상의 객체 상태로 가볍게 유지되다가 특정 디바이스에서 데이터를 전송하면, 운영체제 수준에서 이벤트가 발생하고 Selector는 단일 스레드를 깨워 "현재 152번, 3090번 커넥션에 읽을 데이터가 도착했어"라고 알려줍니다. 스레드는 멈춰있지 않고, 오직 데이터가 준비된 커넥션에 대해서만 I/O 작업을 수행한 뒤 바로 다음 작업으로 넘어갑니다. 이것이 소수의 스레드로 수십만 커넥션을 다루는 Non-Blocking I/O의 핵심입니다.

2. Netty의 심장, 이벤트 루프(Event Loop)

Netty는 이 Selector 메커니즘을 EventLoop라는 개념으로 포장하고 극대화했습니다.

이벤트 루프는 문자 그대로 끊임없이 회전하며 이벤트를 처리하는 단일 스레드입니다.

  • 이벤트 루프는 내부에 자신의 Selector를 가지고 있습니다.
  • 수많은 채널(커넥션)이 생성되면 각각 특정 이벤트 루프에 할당됩니다.
  • 이벤트 루프는 무한 루프를 돌며 다음 작업들을 수행합니다.
    1. Selector를 통해 I/O 이벤트를 감지
    2. 파이프라인에 등록된 핸들러들을 실행하여 데이터를 처리
    3. 사용자가 등록한 비동기 예약 작업들을 실행

여기서 가장 중요한 규칙은 "하나의 채널의 모든 I/O 작업은 언제나 그 채널이 할당된 동일한 단일 이벤트 루프 스레드에서만 처리된다"는 것입니다. 이 원칙 덕분에 우리는 멀티 스레드 프로그래밍에서 가장 머리 아픈 동기화 문제(Locking, Race Condition)를 전혀 신경 쓰지 않고, 단일 스레드 프로그래밍을 하듯 안전하고 빠르게 코드를 작성할 수 있습니다.

3. 데이터를 조립하는 컨베이어 벨트, ChannelPipeline

데이터가 들어오면 어떻게 처리될까요? Netty는 ChannelPipeline이라는 강력한 인터페이스를 제공합니다. 이는 마치 공장의 컨베이어 벨트와 같습니다. 데이터가 소켓을 통해 들어오면 파이프라인이라는 벨트 위에 올려지고, 개발자가 미리 조립해둔 여러 개의 ChannelHandler라는 작업자들이 순서대로 데이터를 가공합니다.

예를 들어 IoT 센서에서 바이트 스트림 데이터가 들어온다면 파이프라인을 다음과 같이 구성할 수 있습니다.

1번째 핸들러: 깨진 바이트를 온전한 패킷 단위로 조립 (Decoder)

2번째 핸들러: 바이트 데이터를 Java 객체로 변환

3번째 핸들러: 인증 검사

4번째 핸들러: 실제 비즈니스 로직 처리 및 데이터베이스 저장 (이때 DB 저장은 앞서 말했듯 이벤트 루프를 막지 않기 위해 비동기로 처리해야 합니다.)

이 모든 과정이 군더더기 없이 깔끔하게 소수의 스레드 위에서 논블로킹으로 흘러갑니다.

4. 실전 코드 예제: 가벼운 데이터 수집 서버 구축

아래는 Netty를 이용해 데이터를 수집하는 기본 서버 설정 코드입니다. 직관적인 API 덕분에 복잡한 NIO 메커니즘을 쉽게 다룰 수 있습니다.

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class DataCollectionServer {

    private final int port;

    public DataCollectionServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        // 커넥션 수락을 전담하는 보스 스레드 그룹 (보통 1개)
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        // 실제 데이터 I/O를 처리하는 워커 스레드 그룹 (기본값: CPU 코어 * 2)
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class) // Non-Blocking 소켓 채널 사용
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     // 컨베이어 벨트(파이프라인)에 작업자(핸들러) 배치
                     ChannelPipeline p = ch.pipeline();
                     // 데이터가 들어왔을 때 처리할 커스텀 핸들러 추가
                     p.addLast(new DataCollectionHandler());
                 }
             })
             // 최대 연결 유지 수 등 TCP 파라미터 튜닝
             .option(ChannelOption.SO_BACKLOG, 128)          
             .childOption(ChannelOption.SO_KEEPALIVE, true); 

            // 서버 바인딩 및 무한 대기
            ChannelFuture f = b.bind(port).sync();
            System.out.println("수집 서버가 포트 " + port + "에서 시작되었습니다.");
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new DataCollectionServer(8080).start();
    }
}

이제 파이프라인에 등록된 커스텀 핸들러 코드를 살펴보겠습니다. 여기서 데이터가 수집됩니다.

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.ReferenceCountUtil;

public class DataCollectionHandler extends ChannelInboundHandlerAdapter {

    // 데이터가 소켓을 통해 들어올 때마다 이벤트 루프에 의해 자동으로 호출됨
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        try {
            // 들어온 바이트 데이터를 읽어들임 (실제로는 여기서 역직렬화 및 가공 수행)
            int readableBytes = in.readableBytes();
            System.out.println("디바이스로부터 " + readableBytes + " 바이트의 데이터를 수집했습니다.");

            // 주의: 이곳에서 Blocking 되는 DB 저장 작업을 직접 수행하면 안 됨!
            // Kafka나 외부 비동기 큐로 메시지를 던지는 방식이 성능에 매우 유리함.

        } finally {
            // 시행착오에서 언급했던 메모리 누수를 막기 위한 필수 작업
            // 다 쓴 버퍼의 Reference Count를 감소시켜 메모리를 해제함
            ReferenceCountUtil.release(msg);
        }
    }

    // 커넥션이 비정상적으로 끊기거나 예외가 발생했을 때 호출됨
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close(); // 문제 발생 시 채널 닫기
    }
}

이처럼 Netty는 이벤트 발생을 감지하는 Selector, 이벤트를 단일 스레드로 안전하게 처리하는 EventLoop, 그리고 데이터 가공을 모듈화하는 ChannelPipeline의 조화를 통해 수십만 개의 커넥션을 가볍게 유지하며 쏟아지는 데이터를 수집해 냅니다.

📝 마무리: 3줄 요약

  • Non-Blocking I/O와 Selector 메커니즘을 통해, 기존처럼 커넥션마다 스레드를 만들지 않고 소수의 스레드만으로 수만 개의 커넥션을 감시하고 제어합니다.
  • 단일 스레드로 도는 이벤트 루프 덕분에 복잡한 동기화(Lock) 고민 없이 빠르고 안전하게 이벤트를 처리할 수 있습니다.
  • 단, 이벤트 루프 스레드를 Blocking하는 작업은 절대 금물이며, 다 쓴 ByteBuf 메모리는 반드시 해제하여 누수를 막아야 완벽한 성능을 끌어낼 수 있습니다.
반응형

댓글