목차
ServerBootstrap으로 로컬 에코 서버 만들기
ServerBootstrap은 서버 소켓을 만들고 연결을 받는 과정을 설정한다. 초기 메모에는 NIO·OIO·epoll의 객체 생성만 있고 실제 bind() 호출이 없어 서버가 요청을 받지 못했다. 또한 OIO 전송은 현재 Netty 4.1 API에서 deprecated다. 이 글은 먼저 플랫폼 의존성이 낮은 NIO 서버 하나를 끝까지 만들고, 전송 방식 선택은 뒤에서 비교한다.
서버가 처리할 메시지는 한 줄의 UTF-8 텍스트다. 줄바꿈을 메시지 경계로 사용한다. 이 경계를 정하지 않으면 TCP가 받은 바이트 조각을 한 요청으로 오해하기 쉽다. 길이 기반 프로토콜이나 바이너리 객체 전송은 별도의 프레임 디코더가 필요하다.
flowchart LR A["ServerBootstrap"] --> B["boss EventLoopGroup: 연결 수락"] B --> C["worker EventLoopGroup: 채널 I/O"] C --> D["새 SocketChannel"] D --> E["LineBasedFrameDecoder"] E --> F["StringDecoder"] F --> G["EchoHandler"] G --> H["StringEncoder → 소켓"]
boss는 연결 수락을, worker는 수락된 채널의 이벤트를 처리한다. childHandler에 등록한 파이프라인은 각 자식 채널에서 초기화된다. 한 채널의 이벤트는 해당 EventLoop에서 순서대로 처리되지만 여러 채널은 서로 다른 EventLoop에서 동시에 실행될 수 있다. 따라서 공유 핸들러의 가변 필드를 아무 보호 없이 사용하지 않는다.
실제로 포트에 바인딩하는 코드
아래 예제는 Netty 4.1 계열 API를 기준으로 한다. 의존성 버전은 프로젝트의 Netty BOM 등에서 일관되게 관리한다. 127.0.0.1에만 바인딩하므로 개발 컴퓨터 안에서 시험하는 절차다.
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import java.nio.charset.StandardCharsets;
public final class EchoServer {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup workers = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, workers)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
channel.pipeline()
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder(StandardCharsets.UTF_8))
.addLast(new StringEncoder(StandardCharsets.UTF_8))
.addLast(new EchoHandler());
}
});
ChannelFuture bound = bootstrap.bind("127.0.0.1", 9190).sync();
bound.channel().closeFuture().sync();
} finally {
workers.shutdownGracefully().sync();
boss.shutdownGracefully().sync();
}
}
private static final class EchoHandler
extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String line) {
ctx.writeAndFlush(line + "\n");
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// 운영 환경에서는 원인별 지표와 연결 ID를 별도로 기록한다.
ctx.close();
}
}
}
bind(...).sync()가 실패하면 포트 사용 중인지, 바인딩 주소가 이 컴퓨터에 있는지 확인한다. 이 호출이 성공해야 연결을 받을 수 있다. closeFuture().sync()는 채널이 닫힐 때까지 대기한다. finally에서 worker와 boss를 모두 종료해 이벤트 루프 스레드를 정리한다. 원래 코드처럼 모든 예외를 잡아 무시하면 서버가 시작되지 않았는데도 정상으로 보일 수 있다.
LineBasedFrameDecoder(1024)는 너무 긴 한 줄을 거부한다. 수신한 줄바꿈은 디코더가 제거하고, EchoHandler가 응답에 다시 \n을 붙인다. 클라이언트가 줄바꿈을 보내지 않으면 서버는 완전한 프레임을 기다린다. 실제 서비스에서는 읽기 타임아웃도 넣어 이런 연결이 오래 자원을 점유하지 않게 한다.
NIO, epoll, OIO는 어떻게 고를까?
| 방식 | 적용 조건 | 이 글에서의 판단 |
|---|---|---|
| NIO | Java NIO 기반, 일반적인 시작점 | 우선 이 방식으로 프로토콜과 처리 흐름 확인 |
| epoll | 지원되는 Linux와 Netty native 전송 의존성 | Epoll.isAvailable() 확인 후 성능 측정으로 선택 |
| OIO | 오래된 블로킹 전송 | Netty 4.1에서 deprecated, 신규 서버 예제로 사용하지 않음 |
epoll 클래스 이름만 바꿔서는 충분하지 않다. 운영체제와 native 라이브러리가 맞아야 하며, Epoll.isAvailable()로 실제 사용 가능성을 확인해야 한다. 전송 방식을 바꿔도 핸들러에서 오래 걸리는 DB 조회나 파일 작업을 EventLoop 스레드에서 직접 수행하면 다른 채널 처리가 지연될 수 있다. 블로킹 작업은 경계를 분리하고, 작업 큐·스레드 풀의 용량과 실패 정책을 정한다.
확인 순서
- 서버 시작 시 9190 포트 바인딩이 성공하는지 확인한다.
- 로컬 클라이언트에서
hello\n을 보내hello\n이 돌아오는지 확인한다. - 줄바꿈 없이 보내 대기하는 동작과 1024바이트를 넘기는 입력의 거부를 확인한다.
- 동시에 두 연결을 열어 서로의 응답이 섞이지 않는지 본다.
- 서버를 종료한 뒤 9190 포트와 Netty 스레드가 남지 않는지 확인한다.
공개 서비스로 옮길 때는 TLS, 인증, 요청 크기·빈도 제한, 오류 관측을 추가한다. 에코 서버 예제가 그대로 외부 입력을 안전하게 처리하는 서버라는 뜻은 아니다.
참고: Netty 4.x 사용자 가이드, LineBasedFrameDecoder API, OioEventLoopGroup deprecated 안내, epoll 지원 검사.