Java 네트워크 I/O 모델 비교: BIO부터 Netty까지

1. BIO (Blocking I/O)

가장 기본적인 네트워크 I/O 모델로, 모든 I/O 작업이 블로킹 방식으로 동작합니다. 서버는 클라이언트 연결을 기다리는 accept() 호출과 데이터 읽기/쓰기 과정에서 모두 스레드가 멈춥니다.

서버 예제

package network.bio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;

public class BioServer {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress("127.0.0.1", 8888));
        
        while (true) {
            Socket clientSocket = serverSocket.accept(); // 블로킹 호출
            new Thread(() -> processClient(clientSocket)).start();
        }
    }

    static void processClient(Socket socket) {
        try {
            byte[] buffer = new byte[1024];
            int bytesRead = socket.getInputStream().read(buffer); // 블로킹 read
            System.out.println(new String(buffer, 0, bytesRead));
            
            socket.getOutputStream().write(buffer, 0, bytesRead);
            socket.getOutputStream().flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

클라이언트 예제

package network.bio;

import java.io.IOException;
import java.net.Socket;

public class BioClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 8888);
        socket.getOutputStream().write("HelloServer".getBytes());
        socket.getOutputStream().flush();
        
        System.out.println("메시지 전송 완료, 응답 대기 중...");
        byte[] response = new byte[1024];
        int length = socket.getInputStream().read(response);
        System.out.println(new String(response, 0, length));
        socket.close();
    }
}

BIO의 특징

  • ServerSocket.accept()는 클라이언트가 연결될 때까지 블로킹
  • InputStream.read()OutputStream.write()도 블로킹
  • 각 연결마다 별도의 스레드가 필요하여 리소스 소모가 큼
  • 연결 수가 적은 환경에서는 단순하고 유지보수가 쉬움

2. NIO (Non-blocking I/O)

NIO는 셀렉터(Selector)를 사용하여 단일 스레드로 여러 채널을 관리할 수 있습니다. 채널을 논블로킹 모드로 설정하고, 이벤트가 발생한 채널만 처리합니다.

단일 스레드 NIO 서버

package network.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class NioServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
        serverChannel.configureBlocking(false); // 논블로킹 모드

        System.out.println("서버 시작됨: " + serverChannel.getLocalAddress());
        
        Selector selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            selector.select(); // 이벤트 발생 시까지 블로킹
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectedKeys.iterator();
            
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                processKey(key);
            }
        }
    }

    static void processKey(SelectionKey key) {
        if (key.isAcceptable()) {
            try {
                ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                SocketChannel clientChannel = serverChannel.accept();
                clientChannel.configureBlocking(false);
                clientChannel.register(key.selector(), SelectionKey.OP_READ);
            } catch (IOException e) {
                e.printStackTrace();
            }
        } else if (key.isReadable()) {
            SocketChannel clientChannel = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(512);
            try {
                int bytesRead = clientChannel.read(buffer);
                if (bytesRead != -1) {
                    buffer.flip();
                    System.out.println(new String(buffer.array(), 0, bytesRead));
                    
                    ByteBuffer response = ByteBuffer.wrap("HelloClient".getBytes());
                    clientChannel.write(response);
                }
                clientChannel.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

리액터(Reactor) 패턴을 적용한 NIO

셀렉터가 연결 수락만 담당하고, 실제 I/O 처리는 별도의 워커 스레드 풀에서 수행합니다.

package network.nio;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ReactorServer {

    private ExecutorService workerPool = Executors.newFixedThreadPool(50);
    private Selector selector;

    public static void main(String[] args) throws IOException {
        ReactorServer server = new ReactorServer();
        server.initialize(8000);
        server.startListening();
    }

    public void initialize(int port) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);
        serverChannel.socket().bind(new InetSocketAddress(port));
        this.selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("리액터 서버 시작 성공!");
    }

    public void startListening() throws IOException {
        while (true) {
            selector.select();
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel client = server.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));
                    workerPool.execute(new IoTask(key));
                }
            }
        }
    }

    static class IoTask extends Thread {
        private SelectionKey key;

        IoTask(SelectionKey key) {
            this.key = key;
        }

        @Override
        public void run() {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            ByteArrayOutputStream output = new ByteArrayOutputStream();
            
            try {
                int bytesRead;
                while ((bytesRead = client.read(buffer)) > 0) {
                    buffer.flip();
                    output.write(buffer.array(), 0, bytesRead);
                    buffer.clear();
                }
                output.close();

                byte[] data = output.toByteArray();
                ByteBuffer writeBuffer = ByteBuffer.allocate(data.length);
                writeBuffer.put(data);
                writeBuffer.flip();
                client.write(writeBuffer);

                if (bytesRead == -1) {
                    client.close();
                } else {
                    key.interestOps(key.interestOps() | SelectionKey.OP_READ);
                    key.selector().wakeup();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

NIO의 핵심 개념

  • 셀렉터가 여러 채널의 이벤트를 모니터링
  • 채널은 논블로킹 모드로 설정하여 I/O 대기 없음
  • 버퍼(ByteBuffer)를 통해 데이터를 읽고 쓰기
  • 이벤트 기반으로 불필요한 컨텍스트 스위칭 감소

3. AIO (Asynchronous I/O)

AIO는 운영체제 커널이 I/O 작업 완료를 비동기적으로 통지하는 모델입니다. NIO와 달리 폴링(polling)이 필요 없으며, CompletionHandler를 통해 결과를 받습니다.

단일 스레드 AIO 서버

package network.aio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;

public class AioServer {

    public static void main(String[] args) throws Exception {
        final AsynchronousServerSocketChannel serverChannel = 
            AsynchronousServerSocketChannel.open()
                .bind(new InetSocketAddress(8888));

        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel client, Object attachment) {
                serverChannel.accept(null, this);
                try {
                    System.out.println("클라이언트 연결됨: " + client.getRemoteAddress());
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer attachment) {
                            attachment.flip();
                            System.out.println(new String(attachment.array(), 0, result));
                            client.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer attachment) {
                            exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });

        while (true) {
            Thread.sleep(1000);
        }
    }
}

스레드 풀을 사용한 AIO 서버

package network.aio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousChannelGroup;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AioThreadPoolServer {

    public static void main(String[] args) throws Exception {
        ExecutorService threadPool = Executors.newCachedThreadPool();
        AsynchronousChannelGroup channelGroup = 
            AsynchronousChannelGroup.withCachedThreadPool(threadPool, 1);

        final AsynchronousServerSocketChannel serverChannel = 
            AsynchronousServerSocketChannel.open(channelGroup)
                .bind(new InetSocketAddress(8888));

        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel client, Object attachment) {
                serverChannel.accept(null, this);
                try {
                    System.out.println("클라이언트 연결됨: " + client.getRemoteAddress());
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer attachment) {
                            attachment.flip();
                            System.out.println(new String(attachment.array(), 0, result));
                            client.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer attachment) {
                            exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });

        while (true) {
            Thread.sleep(1000);
        }
    }
}

AIO의 특징

  • 운영체제 커널이 I/O 완료를 콜백으로 통지
  • NIO보다 추상화 수준이 높아 코드가 간결
  • Linux에서는 NIO와 내부 구현이 유사하여 성능 차이가 크지 않음
  • Windows 환경에서는 IOCP 기반으로 동작

4. Netty

Netty는 NIO를 기반으로 한 고성능 네트워크 프레임워크로, AIO 스타일의 비동기 처리를 지원합니다. 내부적으로 NIO를 사용하지만 AIO와 유사한 프로그래밍 모델을 제공합니다.

Maven 의존성

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.72.Final</version>
</dependency>

Netty 서버 구현

package network.netty;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.util.CharsetUtil;

public class NettyServerExample {

    public static void main(String[] args) {
        new NettyEchoServer(8888).start();
    }
}

class NettyEchoServer {
    private int port;

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

    public void start() {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        ServerBootstrap bootstrap = new ServerBootstrap();

        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(new EchoHandler());
                    }
                });

        try {
            ChannelFuture future = bootstrap.bind(port).sync();
            System.out.println("Netty 서버 시작됨 - 포트: " + port);
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

class EchoHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        System.out.println("서버: 데이터 수신됨");
        ByteBuf buffer = (ByteBuf) msg;
        System.out.println("수신 내용: " + buffer.toString(CharsetUtil.UTF_8));
        ctx.writeAndFlush(msg);
        ctx.close();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

Netty 클라이언트 구현

package network.netty;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.util.ReferenceCountUtil;

public class NettyClientExample {

    public static void main(String[] args) {
        new NettyClient().connect();
    }
}

class NettyClient {
    private void connect() {
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();
        
        bootstrap.group(workerGroup)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        System.out.println("채널 초기화 완료!");
                        ch.pipeline().addLast(new ClientMessageHandler());
                    }
                });

        try {
            System.out.println("서버에 연결 중...");
            ChannelFuture future = bootstrap.connect("127.0.0.1", 8888).sync();
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            workerGroup.shutdownGracefully();
        }
    }
}

class ClientMessageHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        System.out.println("채널 활성화됨.");
        ChannelFuture future = ctx.writeAndFlush(
            Unpooled.copiedBuffer("HelloNetty".getBytes())
        );
        future.addListener((ChannelFutureListener) f -> 
            System.out.println("메시지 전송 완료!")
        );
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        try {
            ByteBuf buffer = (ByteBuf) msg;
            System.out.println("서버 응답: " + buffer.toString());
        } finally {
            ReferenceCountUtil.release(msg);
        }
    }
}

Netty의 주요 특징

  • bossGroup과 workerGroup으로 구분된 이벤트 루프 그룹 사용
  • bossGroup: 클라이언트 연결 수락 담당
  • workerGroup: 실제 I/O 처리 담당
  • 파이프라인(Pipeline)을 통한 핸들러 체인 방식
  • 비동기 이벤트 기반 아키텍처
  • Linux 환경에서 높은 성능 제공

모델 비교

  • BIO: 간단하지만 스레드 리소스 소모가 큼, 소규모 연결에 적합
  • NIO: 단일 스레드로 다중 연결 처리 가능, 폴링 필요
  • AIO: 비동기 콜백 기반, 코드가 간결하나 Linux에서 성능 이점이 제한적
  • Netty: NIO 기반 고성능 프레임워크, 생산성과 성능의 균형

태그: BIO NIO AIO Netty selector

6월 20일 18:59에 게시됨