EventLoop, Channel, Pipeline, Handler — 이전 글에서 네티의 핵심 구성 요소를 살펴봤는데, 그러면 실제로 서버를 띄우려면 이것들을 어떻게 조립해야 할까?

이전 글에서 네티가 java.netjava.nio의 한계를 어떻게 해결하는지, 그리고 EventLoop · Channel · ChannelPipeline · ChannelHandler라는 네 가지 핵심 요소를 살펴봤습니다. 이번 글에서는 이 구성 요소들을 하나로 묶어서 실제로 서버(또는 클라이언트)를 부팅하는 역할을 하는 Bootstrap 과 ServerBootstrap 을 정리해 보겠습니다.


Bootstrap vs ServerBootstrap — 왜 두 개로 나뉘어 있을까?

네티에서 네트워크 애플리케이션을 시작하려면 "부트스트랩" 객체를 통해 설정을 조립합니다. 이때 클라이언트용 인 Bootstrap서버용 인 ServerBootstrap, 두 개의 클래스가 분리되어 있습니다.

분리된 이유는 단순합니다. 서버와 클라이언트는 채널 구조가 근본적으로 다르기 때문입니다.

구분Bootstrap (클라이언트)ServerBootstrap (서버)
역할원격 서버에 ** 연결**포트에 ** 바인딩 **하고 연결을 수락
EventLoopGroup1개 (연결 + I/O 처리)2개 (boss: 수락, worker: I/O 처리)
채널 타입NioSocketChannelNioServerSocketChannel
핸들러 설정handler()handler() + childHandler()
시작 메서드connect()bind()

클라이언트는 연결을 "하나 만들면 끝"이지만, 서버는 연결을 "계속 받아야" 합니다. 서버 소켓 채널과 그 아래 수락되는 자식 채널을 구분해야 하니, 자연스럽게 설정 API도 분리된 것입니다.


ServerBootstrap 구성 요소

서버 쪽이 구성 요소가 더 많고, 실무에서도 서버를 구현하는 경우가 훨씬 많으니 ServerBootstrap을 중심으로 살펴보겠습니다.

group(bossGroup, workerGroup) — 두 개의 EventLoopGroup

JAVA
EventLoopGroup bossGroup = new NioEventLoopGroup(1);    // 연결 수락 전담
EventLoopGroup workerGroup = new NioEventLoopGroup();    // I/O 처리 전담

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup);
  • bossGroup: ServerSocketChannel에서 새 연결을 수락(accept)하는 역할만 담당합니다. 연결 수락 자체는 가벼운 작업이라 보통 스레드 1개면 충분합니다.
  • workerGroup: 수락된 SocketChannel의 실제 I/O(읽기/쓰기)를 처리합니다. 기본적으로 CPU 코어 수 × 2개의 스레드가 할당됩니다.

이 구조가 왜 효율적이냐면, 새 연결을 받는 작업과 기존 연결의 데이터를 처리하는 작업을 분리함으로써, 트래픽이 몰려도 새 연결 수락이 밀리지 않게 됩니다.


channel(NioServerSocketChannel.class) — 서버 채널 타입 지정

JAVA
b.channel(NioServerSocketChannel.class);

어떤 타입의 채널을 생성할지 지정합니다. NioServerSocketChanneljava.nioServerSocketChannel을 네티가 감싼 구현체입니다. 리눅스 환경이라면 EpollServerSocketChannel을 사용해 더 높은 성능을 얻을 수도 있습니다.


childHandler(ChannelInitializer) — 자식 채널 파이프라인 구성

JAVA
b.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) {
        // 새 연결이 들어올 때마다 이 메서드가 호출됨
        ch.pipeline().addLast(new StringDecoder());
        ch.pipeline().addLast(new StringEncoder());
        ch.pipeline().addLast(new MyBusinessHandler());
    }
});

childHandler()는 클라이언트 연결이 수락될 때마다 생성되는 ** 자식 채널(SocketChannel)의 파이프라인을 어떻게 구성할지** 정의하는 곳입니다. 여기서 ChannelInitializer가 등장합니다.


ChannelInitializer — 새 연결마다 파이프라인을 세팅하는 패턴

ChannelInitializer는 네티에서 가장 자주 보게 되는 패턴 중 하나입니다. 핵심 동작은 간단합니다.

  1. 새 연결이 수락되어 자식 채널이 생성된다
  2. initChannel()이 호출되어 해당 채널의 파이프라인에 핸들러를 추가한다
  3. 초기화가 끝나면 ChannelInitializer 자기 자신은 파이프라인에서 제거 된다
JAVA
public class ServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline p = ch.pipeline();

        // 인바운드: 바이트 → 문자열 디코딩
        p.addLast("decoder", new StringDecoder(CharsetUtil.UTF_8));

        // 아웃바운드: 문자열 → 바이트 인코딩
        p.addLast("encoder", new StringEncoder(CharsetUtil.UTF_8));

        // 비즈니스 로직 핸들러
        p.addLast("handler", new EchoServerHandler());
    }
}

ChannelInitializer가 초기화 후 자기 자신을 파이프라인에서 제거하는 이유는 간단합니다. 초기화 로직은 채널 생성 시 딱 한 번만 필요하고, 이후에는 실제 데이터 처리 핸들러들만 파이프라인에 남아 있으면 되기 때문입니다.


option() vs childOption() — 소켓 옵션을 누구에게 적용할 것인가

ServerBootstrap에는 소켓 옵션을 설정하는 메서드가 두 가지 입니다. 이 차이를 혼동하면 의도와 다른 채널에 옵션이 적용될 수 있으니 구분이 중요합니다.

option() — 서버 소켓 채널에 적용

JAVA
b.option(ChannelOption.SO_BACKLOG, 128);

option()ServerSocketChannel 자체 에 적용되는 옵션입니다. 대표적으로 SO_BACKLOG가 있습니다.

  • SO_BACKLOG: 서버 소켓이 accept()를 호출하기 전에, 커널이 임시로 보관할 수 있는 연결 요청 큐의 최대 크기입니다. 이 값을 넘으면 새 연결 요청이 거부됩니다.

childOption() — 수락된 자식 채널에 적용

JAVA
b.childOption(ChannelOption.SO_KEEPALIVE, true);
b.childOption(ChannelOption.TCP_NODELAY, true);

childOption()은 클라이언트가 접속할 때 생성되는 ** 자식 SocketChannel**에 적용되는 옵션입니다.

  • SO_KEEPALIVE: TCP 연결이 유휴 상태일 때 주기적으로 프로브 패킷을 보내 연결이 살아 있는지 확인합니다. 장시간 연결을 유지하는 서비스에서 유용합니다.
  • TCP_NODELAY: Nagle 알고리즘을 비활성화합니다. 작은 패킷을 즉시 전송해야 하는 실시간 서비스(게임 서버, 채팅 등)에서 사용합니다.
메서드적용 대상대표 옵션
option()ServerSocketChannel (서버 소켓)SO_BACKLOG
childOption()수락된 SocketChannel (자식 채널)SO_KEEPALIVE, TCP_NODELAY

bind() & connect() — 비동기로 시작하기

ServerBootstrap.bind() — 서버 바인딩

JAVA
ChannelFuture f = b.bind(8080).sync();  // 바인딩 완료까지 대기

bind()는 지정한 포트에 서버 소켓을 바인딩합니다. 네티의 모든 I/O 작업은 비동기이기 때문에, bind()는 즉시 ChannelFuture를 반환합니다.

  • .sync(): 바인딩이 완료될 때까지 현재 스레드를 블로킹합니다. 서버 시작 시점에서는 바인딩이 확실히 끝난 뒤 로직을 진행해야 하므로 보통 sync()를 붙입니다.
  • .addListener(): 콜백 방식으로 완료를 처리할 수도 있습니다.
JAVA
b.bind(8080).addListener((ChannelFutureListener) future -> {
    if (future.isSuccess()) {
        System.out.println("서버 바인딩 성공");
    } else {
        System.err.println("서버 바인딩 실패");
        future.cause().printStackTrace();
    }
});

Bootstrap.connect() — 클라이언트 연결

JAVA
ChannelFuture f = b.connect("localhost", 8080).sync();

클라이언트용 Bootstrap에서는 bind() 대신 connect()를 사용합니다. 마찬가지로 ChannelFuture를 반환하며, 연결이 완료된 뒤 채널을 통해 데이터를 주고받을 수 있습니다.

ChannelFuture는 네티에서 비동기 작업의 결과를 나타내는 객체입니다. 자바의 Future와 비슷하지만, 리스너를 등록해서 완료 시점에 콜백을 받을 수 있다는 점이 다릅니다.


전체 서버 부트스트랩 코드

지금까지 살펴본 구성 요소를 모두 합쳐서, 에코(Echo) 서버를 구현한 전체 코드입니다.

JAVA
public class EchoServer {

    private final int port;

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

    public void start() throws InterruptedException {
        // boss: 연결 수락, worker: I/O 처리
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();

            b.group(bossGroup, workerGroup)
             // 서버 소켓 채널 타입 지정
             .channel(NioServerSocketChannel.class)
             // 서버 소켓 옵션: 연결 대기 큐 크기
             .option(ChannelOption.SO_BACKLOG, 128)
             // 자식 채널 옵션: TCP 연결 유지 확인
             .childOption(ChannelOption.SO_KEEPALIVE, true)
             // 자식 채널 파이프라인 구성
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoServerHandler());
                 }
             });

            // 포트 바인딩 후 완료까지 대기
            ChannelFuture f = b.bind(port).sync();
            System.out.println("에코 서버가 포트 " + port + "에서 시작되었습니다.");

            // 서버 채널이 닫힐 때까지 대기
            f.channel().closeFuture().sync();

        } finally {
            // 리소스 정리: EventLoopGroup 종료
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

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

비즈니스 로직을 담당하는 EchoServerHandler는 다음과 같이 구현합니다.

JAVA
public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 받은 메시지를 그대로 되돌려 보냄
        ctx.writeAndFlush(msg);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // 예외 발생 시 로그 출력 후 채널 닫기
        cause.printStackTrace();
        ctx.close();
    }
}

서버 시작부터 종료까지의 흐름 정리

코드만 보면 체이닝이 많아서 흐름이 헷갈릴 수 있는데, 실제 동작 순서를 정리하면 이렇습니다.

  1. bossGroup, workerGroup 생성 — 연결 수락용, I/O 처리용 스레드 그룹을 각각 만든다
  2. ServerBootstrap 설정 — 채널 타입, 소켓 옵션, 파이프라인 핸들러를 조립한다
  3. bind(port).sync() — 지정한 포트에 바인딩하고 완료를 기다린다
  4. ** 클라이언트 연결 수락** — bossGroup이 새 연결을 수락하면, workerGroup의 EventLoop에 해당 채널을 등록한다
  5. ChannelInitializer.initChannel() 호출 — 새 채널의 파이프라인에 핸들러를 추가한다
  6. I/O 이벤트 처리 — workerGroup의 EventLoop가 해당 채널의 읽기/쓰기를 처리한다
  7. closeFuture().sync() — 서버 채널이 닫힐 때까지 메인 스레드가 대기한다
  8. shutdownGracefully() — EventLoopGroup의 스레드들을 안전하게 종료한다

클라이언트 Bootstrap 코드

비교를 위해 클라이언트 측 Bootstrap 코드도 간단히 살펴보겠습니다.

JAVA
public class EchoClient {

    public void start() throws InterruptedException {
        // 클라이언트는 EventLoopGroup 하나만 사용
        EventLoopGroup group = new NioEventLoopGroup();

        try {
            Bootstrap b = new Bootstrap();

            b.group(group)
             // 클라이언트 소켓 채널
             .channel(NioSocketChannel.class)
             // 클라이언트 파이프라인 구성
             .handler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoClientHandler());
                 }
             });

            // 서버에 연결 후 완료까지 대기
            ChannelFuture f = b.connect("localhost", 8080).sync();

            // 채널이 닫힐 때까지 대기
            f.channel().closeFuture().sync();

        } finally {
            group.shutdownGracefully();
        }
    }
}

서버와 비교하면 차이가 명확합니다.

  • EventLoopGroup이 ** 하나** — 연결 수락이 없으니 boss/worker 구분이 필요 없습니다
  • childHandler() 대신 handler() — 자식 채널 개념이 없으니 직접 핸들러를 설정합니다
  • bind() 대신 connect() — 포트를 열고 기다리는 게 아니라, 서버에 연결을 시도합니다

정리

이번 글에서 다룬 내용을 요약하면 이렇습니다.

  • Bootstrap은 클라이언트, ServerBootstrap은 서버 — 채널 구조의 차이 때문에 API가 분리되어 있다
  • ServerBootstrap은 boss/worker 두 개의 EventLoopGroup을 사용 — 연결 수락과 I/O 처리를 분리한다
  • option()은 서버 소켓, childOption()은 자식 채널 — 옵션 적용 대상이 다르다
  • ChannelInitializer로 새 연결마다 파이프라인을 구성 — 초기화 후 자기 자신은 제거된다
  • bind()와 connect()는 ChannelFuture를 반환 — 네티의 비동기 특성을 보여주는 대표적인 API다

다음 글에서는 이벤트 루프의 내부 동작과 스레드 모델을 더 깊이 살펴보겠습니다.

댓글 로딩 중...