Bootstrap & ServerBootstrap
EventLoop, Channel, Pipeline, Handler — 이전 글에서 네티의 핵심 구성 요소를 살펴봤는데, 그러면 실제로 서버를 띄우려면 이것들을 어떻게 조립해야 할까?
이전 글에서 네티가 java.net과 java.nio의 한계를 어떻게 해결하는지, 그리고 EventLoop · Channel · ChannelPipeline · ChannelHandler라는 네 가지 핵심 요소를 살펴봤습니다. 이번 글에서는 이 구성 요소들을 하나로 묶어서 실제로 서버(또는 클라이언트)를 부팅하는 역할을 하는 Bootstrap 과 ServerBootstrap 을 정리해 보겠습니다.
Bootstrap vs ServerBootstrap — 왜 두 개로 나뉘어 있을까?
네티에서 네트워크 애플리케이션을 시작하려면 "부트스트랩" 객체를 통해 설정을 조립합니다. 이때 클라이언트용 인 Bootstrap과 서버용 인 ServerBootstrap, 두 개의 클래스가 분리되어 있습니다.
분리된 이유는 단순합니다. 서버와 클라이언트는 채널 구조가 근본적으로 다르기 때문입니다.
| 구분 | Bootstrap (클라이언트) | ServerBootstrap (서버) |
|---|---|---|
| 역할 | 원격 서버에 ** 연결** | 포트에 ** 바인딩 **하고 연결을 수락 |
| EventLoopGroup | 1개 (연결 + I/O 처리) | 2개 (boss: 수락, worker: I/O 처리) |
| 채널 타입 | NioSocketChannel | NioServerSocketChannel |
| 핸들러 설정 | handler() | handler() + childHandler() |
| 시작 메서드 | connect() | bind() |
클라이언트는 연결을 "하나 만들면 끝"이지만, 서버는 연결을 "계속 받아야" 합니다. 서버 소켓 채널과 그 아래 수락되는 자식 채널을 구분해야 하니, 자연스럽게 설정 API도 분리된 것입니다.
ServerBootstrap 구성 요소
서버 쪽이 구성 요소가 더 많고, 실무에서도 서버를 구현하는 경우가 훨씬 많으니 ServerBootstrap을 중심으로 살펴보겠습니다.
group(bossGroup, workerGroup) — 두 개의 EventLoopGroup
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) — 서버 채널 타입 지정
b.channel(NioServerSocketChannel.class);
어떤 타입의 채널을 생성할지 지정합니다. NioServerSocketChannel은 java.nio의 ServerSocketChannel을 네티가 감싼 구현체입니다. 리눅스 환경이라면 EpollServerSocketChannel을 사용해 더 높은 성능을 얻을 수도 있습니다.
childHandler(ChannelInitializer) — 자식 채널 파이프라인 구성
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는 네티에서 가장 자주 보게 되는 패턴 중 하나입니다. 핵심 동작은 간단합니다.
- 새 연결이 수락되어 자식 채널이 생성된다
initChannel()이 호출되어 해당 채널의 파이프라인에 핸들러를 추가한다- 초기화가 끝나면
ChannelInitializer자기 자신은 파이프라인에서 제거 된다
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() — 서버 소켓 채널에 적용
b.option(ChannelOption.SO_BACKLOG, 128);
option()은 ServerSocketChannel 자체 에 적용되는 옵션입니다. 대표적으로 SO_BACKLOG가 있습니다.
SO_BACKLOG: 서버 소켓이accept()를 호출하기 전에, 커널이 임시로 보관할 수 있는 연결 요청 큐의 최대 크기입니다. 이 값을 넘으면 새 연결 요청이 거부됩니다.
childOption() — 수락된 자식 채널에 적용
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() — 서버 바인딩
ChannelFuture f = b.bind(8080).sync(); // 바인딩 완료까지 대기
bind()는 지정한 포트에 서버 소켓을 바인딩합니다. 네티의 모든 I/O 작업은 비동기이기 때문에, bind()는 즉시 ChannelFuture를 반환합니다.
.sync(): 바인딩이 완료될 때까지 현재 스레드를 블로킹합니다. 서버 시작 시점에서는 바인딩이 확실히 끝난 뒤 로직을 진행해야 하므로 보통sync()를 붙입니다..addListener(): 콜백 방식으로 완료를 처리할 수도 있습니다.
b.bind(8080).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
System.out.println("서버 바인딩 성공");
} else {
System.err.println("서버 바인딩 실패");
future.cause().printStackTrace();
}
});
Bootstrap.connect() — 클라이언트 연결
ChannelFuture f = b.connect("localhost", 8080).sync();
클라이언트용 Bootstrap에서는 bind() 대신 connect()를 사용합니다. 마찬가지로 ChannelFuture를 반환하며, 연결이 완료된 뒤 채널을 통해 데이터를 주고받을 수 있습니다.
ChannelFuture는 네티에서 비동기 작업의 결과를 나타내는 객체입니다. 자바의Future와 비슷하지만, 리스너를 등록해서 완료 시점에 콜백을 받을 수 있다는 점이 다릅니다.
전체 서버 부트스트랩 코드
지금까지 살펴본 구성 요소를 모두 합쳐서, 에코(Echo) 서버를 구현한 전체 코드입니다.
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는 다음과 같이 구현합니다.
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();
}
}
서버 시작부터 종료까지의 흐름 정리
코드만 보면 체이닝이 많아서 흐름이 헷갈릴 수 있는데, 실제 동작 순서를 정리하면 이렇습니다.
bossGroup,workerGroup생성 — 연결 수락용, I/O 처리용 스레드 그룹을 각각 만든다ServerBootstrap설정 — 채널 타입, 소켓 옵션, 파이프라인 핸들러를 조립한다bind(port).sync()— 지정한 포트에 바인딩하고 완료를 기다린다- ** 클라이언트 연결 수락** — bossGroup이 새 연결을 수락하면, workerGroup의 EventLoop에 해당 채널을 등록한다
ChannelInitializer.initChannel()호출 — 새 채널의 파이프라인에 핸들러를 추가한다- I/O 이벤트 처리 — workerGroup의 EventLoop가 해당 채널의 읽기/쓰기를 처리한다
closeFuture().sync()— 서버 채널이 닫힐 때까지 메인 스레드가 대기한다shutdownGracefully()— EventLoopGroup의 스레드들을 안전하게 종료한다
클라이언트 Bootstrap 코드
비교를 위해 클라이언트 측 Bootstrap 코드도 간단히 살펴보겠습니다.
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다
다음 글에서는 이벤트 루프의 내부 동작과 스레드 모델을 더 깊이 살펴보겠습니다.