Channel — 연결의 생명주기
네트워크 연결 하나가 열리고, 데이터를 주고받다가, 닫히기까지 — 이 과정을 코드로 어떻게 표현하고 추적할 수 있을까?
Channel이란
네티에서 Channel은 네트워크 연결 하나를 추상화한 객체 입니다. 자바 표준 라이브러리의 java.net.Socket이나 java.nio.channels.SocketChannel이 하던 역할을, 네티가 한 단계 더 감싸서 비동기 I/O에 적합한 형태로 만들어 놓은 것이라고 보면 됩니다.
// java.nio의 SocketChannel — 논블로킹이지만 저수준
SocketChannel ch = SocketChannel.open();
ch.configureBlocking(false);
// Netty의 Channel — 비동기 이벤트 모델 위에서 동작
Channel channel = ...; // Bootstrap이 생성해 줌
channel.writeAndFlush(msg); // 비동기로 메시지 전송
직접 SocketChannel을 열고 Selector에 등록하는 대신, 네티의 Bootstrap이 채널 생성부터 EventLoop 등록까지를 자동으로 처리해 줍니다. 개발자가 직접 다루는 건 이 Channel 인터페이스이고, 실제 구현체는 전송 방식에 따라 달라집니다.
NioSocketChannel—java.nio기반, 가장 범용적EpollSocketChannel— 리눅스epoll네이티브 전송, 더 나은 성능KQueueSocketChannel— macOS/BSDkqueue네이티브 전송
어떤 구현체를 쓰든 Channel 인터페이스는 동일하기 때문에, 전송 계층을 바꿔도 애플리케이션 코드를 수정할 필요가 없습니다.
Channel 상태 머신
Channel은 생성되고 나서 닫힐 때까지 ** 네 가지 상태 **를 순서대로 거칩니다. 이 상태 전이를 이해하면 ChannelHandler에서 어떤 콜백이 언제 호출되는지 자연스럽게 파악할 수 있습니다.
┌──────────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐
│ REGISTERED │────▶│ ACTIVE │────▶│ INACTIVE │────▶│ UNREGISTERED │
│ │ │ │ │ │ │ │
│ EventLoop에 │ │ 연결 완료, │ │ 연결 끊김, │ │ EventLoop에서 │
│ 등록됨 │ │ I/O 가능 │ │ I/O 불가 │ │ 해제됨 │
└──────────────┘ └──────────┘ └──────────────┘ └────────────────┘
각 상태에 대응하는 ChannelHandler 콜백이 있어서, 상태가 바뀔 때마다 핸들러에서 적절한 처리를 할 수 있습니다.
| 상태 | 의미 | 핸들러 콜백 |
|---|---|---|
| registered | Channel이 EventLoop에 등록됨 | channelRegistered() |
| active | 원격 피어와 연결이 완료되어 데이터 송수신 가능 | channelActive() |
| inactive | 원격 피어와의 연결이 끊김 | channelInactive() |
| unregistered | Channel이 EventLoop에서 해제됨 | channelUnregistered() |
실제로 핸들러에서 이 콜백들을 구현하면 이런 모습입니다.
public class LifecycleHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRegistered(ChannelHandlerContext ctx) {
System.out.println("Channel이 EventLoop에 등록됨");
ctx.fireChannelRegistered(); // 다음 핸들러로 전파
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
System.out.println("연결 완료 — 상대방: " + ctx.channel().remoteAddress());
ctx.fireChannelActive();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
System.out.println("연결 끊김 — 리소스 정리");
ctx.fireChannelInactive();
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) {
System.out.println("EventLoop에서 해제됨");
ctx.fireChannelUnregistered();
}
}
상태 전이는 항상 registered → active → inactive → unregistered 순서로만 진행됩니다. 역방향 전이는 없기 때문에, 한번 inactive가 된 Channel을 다시 active로 되돌릴 수는 없습니다.
channelActive()는 실무에서 자주 쓰이는 콜백인데, 연결이 완료된 직후 초기 메시지를 보내거나 인증 핸드셰이크를 시작하는 용도로 많이 활용됩니다.
ChannelFuture: 비동기 작업의 결과
네티에서 write(), connect(), close() 같은 I/O 작업은 ** 모두 비동기 **입니다. 메서드를 호출한 시점에는 작업이 아직 완료되지 않았을 수 있고, 대신 ChannelFuture라는 객체가 즉시 반환 됩니다. 이 Future를 통해 작업의 완료 여부를 확인하거나, 완료 시 실행할 콜백을 등록할 수 있습니다.
// connect()는 바로 반환 — 아직 연결이 완료되지 않았을 수 있음
ChannelFuture future = bootstrap.connect("localhost", 8080);
addListener() 패턴
비동기 결과를 처리하는 가장 권장되는 방법은 addListener()로 콜백을 등록 하는 것입니다. 작업이 완료되면 EventLoop 스레드에서 리스너가 호출되므로, 별도의 스레드 블로킹 없이 결과를 처리할 수 있습니다.
ChannelFuture future = channel.writeAndFlush(message);
future.addListener((ChannelFutureListener) f -> {
if (f.isSuccess()) {
System.out.println("메시지 전송 성공");
} else {
System.err.println("전송 실패: " + f.cause().getMessage());
f.channel().close(); // 실패 시 채널 닫기
}
});
네티가 미리 정의해 둔 리스너도 있어서, 자주 쓰는 패턴은 한 줄로 표현할 수 있습니다.
// 작업 완료 후 채널 닫기
channel.writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
// 실패 시 채널 닫기
channel.writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
sync() vs await()
addListener() 외에도 동기적으로 결과를 기다리는 방법이 있지만, 사용 시 주의가 필요합니다.
| 메서드 | 동작 | 예외 처리 |
|---|---|---|
sync() | 작업 완료까지 블로킹 + 실패 시 예외 발생 | 예외를 rethrow |
await() | 작업 완료까지 블로킹 | 예외를 던지지 않음, isSuccess()로 확인 |
// sync() — 실패 시 예외가 바로 던져짐
try {
channel.writeAndFlush(msg).sync();
System.out.println("전송 완료");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// await() — 예외를 직접 확인해야 함
ChannelFuture future = channel.writeAndFlush(msg).await();
if (!future.isSuccess()) {
System.err.println("실패 원인: " + future.cause());
}
sync()와await()는 현재 스레드를 블로킹합니다. EventLoop 스레드 안에서 이 메서드를 호출하면 데드락 이 발생할 수 있기 때문에, 핸들러 내부에서는 반드시addListener()패턴을 사용해야 합니다.
이 부분은 실수하기 쉬운 포인트인데, 핸들러의 channelRead() 같은 메서드는 EventLoop 스레드에서 실행되기 때문에 거기서 sync()를 호출하면 자기 자신의 스레드를 블로킹하게 됩니다. 결과를 처리할 스레드가 블로킹되어 있으니 작업이 영원히 완료되지 않는 교착 상태에 빠지는 것입니다.
ChannelPromise: 쓰기 가능한 Future
ChannelFuture가 읽기 전용이라면, ChannelPromise는 결과를 직접 설정할 수 있는 쓰기 가능한 Future 입니다. ChannelPromise는 ChannelFuture를 확장한 인터페이스로, setSuccess()와 setFailure()를 통해 작업의 성공/실패를 알릴 수 있습니다.
ChannelFuture (읽기 전용)
│
├── isSuccess() — 성공 여부 확인
├── cause() — 실패 원인 확인
└── addListener() — 완료 콜백 등록
ChannelPromise (읽기 + 쓰기)
│
├── (ChannelFuture의 모든 기능)
├── setSuccess() — 작업 성공 알림
└── setFailure(cause) — 작업 실패 알림
핸들러의 아웃바운드 메서드에는 ChannelPromise가 파라미터로 전달됩니다. 커스텀 핸들러에서 비동기 처리를 한 뒤, 이 Promise를 통해 결과를 알려줘야 합니다.
public class CustomOutboundHandler extends ChannelOutboundHandlerAdapter {
@Override
public void write(ChannelHandlerContext ctx, Object msg,
ChannelPromise promise) {
// 메시지 변환 등의 처리
ByteBuf transformed = transform(msg);
// 다음 핸들러로 전달하면서 원래의 promise를 넘김
ctx.write(transformed, promise);
}
}
직접 Promise를 완료시키는 경우도 있습니다. 예를 들어 캐시된 응답을 바로 돌려줄 때는 실제 I/O 없이 Promise를 성공으로 마킹할 수 있습니다.
@Override
public void write(ChannelHandlerContext ctx, Object msg,
ChannelPromise promise) {
if (isCached(msg)) {
// 실제 I/O 없이 바로 성공 처리
promise.setSuccess();
return;
}
// 캐시 미스 — 다음 핸들러로 전달
ctx.write(msg, promise);
}
setSuccess()나setFailure()를 호출하지 않으면, 해당 Promise에 등록된 리스너가 영원히 호출되지 않습니다. 아웃바운드 핸들러에서 Promise를 받았으면 반드시 완료시키거나 다음 핸들러에 넘겨야 합니다.
Channel의 주요 메서드들
Channel에서 자주 사용하는 I/O 메서드들을 정리해 보겠습니다.
| 메서드 | 설명 |
|---|---|
write(msg) | 메시지를 아웃바운드 버퍼에 기록 (아직 전송하지 않음) |
flush() | 버퍼에 쌓인 메시지를 실제로 소켓에 전송 |
writeAndFlush(msg) | write + flush를 한 번에 수행 |
read() | 데이터 읽기를 요청 (auto-read가 꺼져 있을 때 사용) |
close() | 채널을 닫고 연결 종료 |
disconnect() | 연결 해제 (UDP 등에서 사용) |
isActive() | 채널이 active 상태인지 확인 |
isWritable() | 쓰기 버퍼가 가득 차지 않았는지 확인 |
write()와 writeAndFlush()의 차이를 이해하는 것이 중요합니다.
// write()만 호출 — 버퍼에만 쌓이고 아직 전송되지 않음
channel.write(msg1);
channel.write(msg2);
channel.write(msg3);
channel.flush(); // 이 시점에 msg1, msg2, msg3이 한꺼번에 전송
// writeAndFlush() — 즉시 전송
channel.writeAndFlush(msg); // write + flush 를 한 번에 수행
여러 메시지를 연속으로 보내야 할 때는 write()로 모아 두었다가 마지막에 flush()를 한 번 호출하는 것이 시스템 콜 횟수를 줄여 성능상 유리합니다. 반면 단일 메시지를 바로 보내야 할 때는 writeAndFlush()가 간편합니다.
isWritable()도 실무에서 알아 두면 좋은 메서드입니다. 상대방이 데이터를 소비하는 속도보다 빠르게 쓰면 아웃바운드 버퍼가 계속 쌓이게 되는데, isWritable()이 false를 반환하면 잠시 쓰기를 멈추는 배압(backpressure) 제어를 구현할 수 있습니다.
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (ctx.channel().isWritable()) {
ctx.writeAndFlush(processMessage(msg));
} else {
// 버퍼가 가득 참 — 쓰기를 잠시 보류하거나 큐잉
pendingQueue.add(msg);
}
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
// 쓰기 가능 상태가 바뀌면 호출됨
if (ctx.channel().isWritable()) {
flushPendingQueue(ctx);
}
}
Channel vs ChannelHandlerContext: 메시지 경로의 차이
네티에서 메시지를 보낼 때 channel.write()와 ctx.write()는 ** 파이프라인에서 메시지가 시작하는 위치가 다릅니다.** 이 차이는 핸들러를 여러 개 조합할 때 동작에 직접적인 영향을 미칩니다.
파이프라인 구조 (아웃바운드 방향: tail → head)
┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────┐
│ HEAD │◀───│ Handler A │◀───│ Handler B │◀───│ TAIL │
└──────┘ └──────────┘ └──────────┘ └──────┘
▲ ▲ ▲
│ │ │
소켓으로 ctx.write() channel.write()
전송 (B에서 호출 시 (항상 여기서 시작)
A부터 시작)
channel.write(msg)— 파이프라인의 tail(끝)부터 시작 해서 모든 아웃바운드 핸들러를 거칩니다.ctx.write(msg)— ** 현재 핸들러 위치의 바로 이전 아웃바운드 핸들러부터 시작 **합니다.
public class HandlerB extends ChannelOutboundHandlerAdapter {
@Override
public void write(ChannelHandlerContext ctx, Object msg,
ChannelPromise promise) {
// ctx.write() — Handler A부터 시작 (자기 앞의 핸들러)
ctx.write(msg, promise);
// channel.write() — TAIL부터 시작 (전체 파이프라인)
// 이렇게 하면 Handler B 자신도 다시 호출됨 → 무한 루프 위험!
// ctx.channel().write(msg); // 주의!
}
}
핸들러 안에서
ctx.channel().write()를 호출하면 파이프라인 전체를 다시 타기 때문에 자기 자신이 다시 호출되어 ** 무한 루프 **에 빠질 수 있습니다. 핸들러 내부에서는ctx.write()를 사용하는 것이 일반적입니다.
정리하면 이렇습니다.
| 호출 방식 | 시작 위치 | 사용 시점 |
|---|---|---|
channel.write() | 파이프라인 tail | 파이프라인 바깥(외부 코드)에서 메시지를 보낼 때 |
ctx.write() | 현재 핸들러의 이전 아웃바운드 핸들러 | 핸들러 내부에서 다음 단계로 전달할 때 |
인바운드도 마찬가지입니다. ctx.fireChannelRead(msg)는 현재 핸들러의 다음 인바운드 핸들러부터 시작하고, channel.pipeline().fireChannelRead(msg)는 파이프라인의 head부터 다시 시작합니다.
정리
이번 글에서 살펴본 내용을 간단히 요약하면 다음과 같습니다.
- Channel 은 네트워크 연결 하나를 추상화한 객체이고,
NioSocketChannel,EpollSocketChannel등 전송 방식에 따른 구현체가 존재합니다. - Channel은 registered → active → inactive → unregistered 순서로 상태가 전이되며, 각 전이마다 대응하는 핸들러 콜백이 호출됩니다.
- ChannelFuture 는 비동기 I/O 작업의 결과를 나타내고,
addListener()로 논블로킹 콜백을 등록하는 것이 권장 패턴입니다. - ChannelPromise 는 ChannelFuture의 쓰기 가능한 버전으로, 아웃바운드 핸들러에서 작업 성공/실패를 직접 알릴 수 있습니다.
channel.write()는 파이프라인 전체를,ctx.write()는 현재 위치 이전의 핸들러부터 순회합니다. 핸들러 안에서는ctx.write()를 사용해야 무한 루프를 피할 수 있습니다.