ChannelHandler — 이벤트 처리의 단위
네티 파이프라인에 핸들러를 추가하기만 하면 된다는 건 알겠는데, 핸들러가 정확히 뭘 하는 객체이고, 인바운드와 아웃바운드는 왜 나누어져 있을까?
ChannelHandler란
ChannelHandler는 네티에서 이벤트를 처리하는 콜백 인터페이스 입니다. 네트워크로 데이터가 들어오거나, 데이터를 써서 내보내거나, 연결 상태가 바뀌는 등의 이벤트가 발생했을 때, 그 이벤트에 반응하는 로직을 담는 단위가 바로 ChannelHandler입니다.
ChannelPipeline이 "이벤트가 지나가는 경로"라면, ChannelHandler는 "그 경로 위의 각 처리 정거장" 에 해당합니다. 디코딩, 비즈니스 로직, 인코딩, 로깅 등 역할별로 핸들러를 쪼개서 파이프라인에 조합하는 것이 네티의 기본 설계 철학입니다.
// ChannelHandler 인터페이스의 핵심 메서드
public interface ChannelHandler {
// 핸들러가 파이프라인에 추가될 때
void handlerAdded(ChannelHandlerContext ctx) throws Exception;
// 핸들러가 파이프라인에서 제거될 때
void handlerRemoved(ChannelHandlerContext ctx) throws Exception;
}
ChannelInboundHandler vs ChannelOutboundHandler
ChannelHandler는 ** 처리하는 이벤트의 방향에 따라 두 가지 하위 인터페이스로 나뉩니다.**
ChannelInboundHandler: 읽기 방향 이벤트
** 원격 피어로부터 데이터가 들어오거나, 연결 상태가 변경되는 등 "수신 방향" 이벤트를 처리 **합니다. 파이프라인에서 head → tail 방향으로 이벤트가 전파됩니다.
public interface ChannelInboundHandler extends ChannelHandler {
void channelRegistered(ChannelHandlerContext ctx); // Channel이 EventLoop에 등록
void channelActive(ChannelHandlerContext ctx); // 연결 활성화 (TCP 핸드셰이크 완료)
void channelRead(ChannelHandlerContext ctx, Object msg); // 데이터 수신
void channelReadComplete(ChannelHandlerContext ctx); // 읽기 작업 완료
void channelInactive(ChannelHandlerContext ctx); // 연결 비활성화
void exceptionCaught(ChannelHandlerContext ctx, Throwable cause);
}
ChannelOutboundHandler: 쓰기 방향 이벤트
** 데이터를 쓰거나, 연결을 맺거나, 소켓을 닫는 등 "송신 방향" 이벤트를 처리 **합니다. 파이프라인에서 tail → head 방향으로 이벤트가 전파됩니다.
public interface ChannelOutboundHandler extends ChannelHandler {
void bind(ChannelHandlerContext ctx, SocketAddress addr, ChannelPromise promise);
void connect(ChannelHandlerContext ctx, SocketAddress addr,
SocketAddress localAddr, ChannelPromise promise);
void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise);
void flush(ChannelHandlerContext ctx);
void close(ChannelHandlerContext ctx, ChannelPromise promise);
}
어댑터 클래스: 필요한 메서드만 오버라이드
인터페이스를 직접 구현하면 모든 메서드를 다 오버라이드해야 하므로, 네티는 ** 기본 구현을 제공하는 어댑터 클래스 **를 준비해 두었습니다.
| 어댑터 클래스 | 역할 |
|---|---|
ChannelInboundHandlerAdapter | 인바운드 이벤트 기본 구현 (다음 핸들러로 전달) |
ChannelOutboundHandlerAdapter | 아웃바운드 이벤트 기본 구현 (다음 핸들러로 전달) |
ChannelDuplexHandler | 인바운드 + 아웃바운드 모두 처리 |
// 실무에서 가장 많이 보는 패턴: 어댑터 상속 후 필요한 메서드만 오버라이드
public class MyInboundHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 수신한 메시지 처리
ByteBuf buf = (ByteBuf) msg;
try {
System.out.println("수신: " + buf.toString(CharsetUtil.UTF_8));
} finally {
buf.release(); // 직접 release 해야 함
}
}
}
어댑터 클래스의 기본 구현은 이벤트를 다음 핸들러로 그대로 전달(fire)합니다. 오버라이드할 때
ctx.fireChannelRead(msg)같은 전달 호출을 빠뜨리면, 그 이벤트가 다음 핸들러에 도달하지 못하고 중간에 사라지게 됩니다.
SimpleChannelInboundHandler
ChannelInboundHandlerAdapter를 직접 사용할 때는 메시지를 처리한 뒤 release()를 직접 호출해야 합니다. 이걸 빠뜨리면 메모리 누수가 발생하는데, 실수하기 쉬운 부분입니다. SimpleChannelInboundHandler는 이 문제를 자동으로 해결해 줍니다.
제네릭 타입 매칭
SimpleChannelInboundHandler는 제네릭 타입 파라미터를 받아서, ** 파이프라인을 타고 내려온 메시지 중 해당 타입에 매칭되는 메시지만 처리 **합니다. 타입이 맞지 않으면 자동으로 다음 핸들러로 넘깁니다.
자동 release
channelRead0()(네티 4 기준) 메서드가 정상적으로 완료되든 예외가 발생하든, ** 메시지의 release()가 자동으로 호출 **됩니다. 개발자는 비즈니스 로직에만 집중하면 됩니다.
// 문자열 메시지만 처리하는 핸들러
public class ChatHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
// msg는 이미 String 타입으로 캐스팅되어 있음
// 메서드 종료 후 자동 release (String은 참조 카운트 대상이 아니지만, ByteBuf 등은 자동 해제)
System.out.println("메시지 수신: " + msg);
ctx.writeAndFlush("에코: " + msg + "\n");
}
}
SimpleChannelInboundHandler는 메시지를 ** 소비 **하는 핸들러에 적합합니다. 만약 수신한 메시지를 다음 핸들러로 넘겨야 한다면, 자동 release가 오히려 문제가 되므로
ChannelInboundHandlerAdapter를 직접 사용하는 것이 맞습니다.
ChannelInboundHandlerAdapter vs SimpleChannelInboundHandler
| 항목 | ChannelInboundHandlerAdapter | SimpleChannelInboundHandler |
|---|---|---|
| release 책임 | 개발자가 직접 | 자동 |
| 타입 매칭 | 없음 (Object로 받음) | 제네릭으로 필터링 |
| 메시지 전달 | ctx.fireChannelRead()로 전달 가능 | 기본적으로 소비 (전달 X) |
| 사용 시점 | 메시지를 다음 핸들러로 넘길 때 | 최종 소비 핸들러 |
@Sharable
기본적으로 ChannelHandler 인스턴스 하나는 하나의 ChannelPipeline에만 추가할 수 있습니다. 같은 핸들러 인스턴스를 여러 파이프라인에 추가하려고 하면 예외가 발생합니다. 하지만 @ChannelHandler.Sharable 어노테이션을 붙이면 이 제약이 해제됩니다.
@ChannelHandler.Sharable
public class LoggingHandler extends ChannelInboundHandlerAdapter {
// 인스턴스 필드 없음 — 무상태
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("[LOG] " + ctx.channel().remoteAddress() + " → " + msg);
ctx.fireChannelRead(msg); // 다음 핸들러로 전달
}
}
// 하나의 인스턴스를 여러 채널에서 공유
LoggingHandler sharedLogger = new LoggingHandler();
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(sharedLogger); // 모든 채널에서 같은 인스턴스 사용
ch.pipeline().addLast(new BusinessHandler()); // 이건 채널마다 새 인스턴스
}
});
@Sharable의 핵심 조건: 무상태
여러 파이프라인에서 동시에 사용된다는 것은, ** 서로 다른 채널의 이벤트가 같은 핸들러 인스턴스를 통해 처리 **된다는 뜻입니다. 따라서 인스턴스 필드에 연결별 상태를 저장하면 동시성 문제가 발생합니다.
// 잘못된 예: @Sharable인데 상태를 가지고 있음
@ChannelHandler.Sharable
public class CountHandler extends ChannelInboundHandlerAdapter {
private int count = 0; // 여러 채널에서 동시 접근 → 경쟁 조건(race condition)
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
count++; // 위험! 동시성 보장 안 됨
ctx.fireChannelRead(msg);
}
}
@Sharable을 붙이는 순간 "이 핸들러는 동시에 여러 스레드에서 호출될 수 있다"고 선언하는 것입니다. 인스턴스 필드가 필요하다면@Sharable을 빼고 채널마다 새 인스턴스를 만들거나, 뒤에서 다룰AttributeKey패턴을 사용해야 합니다.
핸들러 생명주기 콜백
ChannelHandler에는 ** 파이프라인에 추가되고 제거될 때 호출되는 생명주기 콜백 **이 있습니다. 리소스 초기화나 정리 작업을 이 시점에 하면 깔끔합니다.
public class ResourceHandler extends ChannelInboundHandlerAdapter {
private BufferedWriter writer;
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 파이프라인에 추가될 때 — 리소스 초기화
writer = new BufferedWriter(new FileWriter("access.log", true));
System.out.println("핸들러 추가됨: " + ctx.channel());
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 파이프라인에서 제거될 때 — 리소스 정리
if (writer != null) {
writer.close();
}
System.out.println("핸들러 제거됨: " + ctx.channel());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
writer.write(ctx.channel().remoteAddress() + ": " + msg);
writer.newLine();
ctx.fireChannelRead(msg);
}
}
인바운드 핸들러의 전체 생명주기를 순서대로 나열하면 다음과 같습니다.
handlerAdded()— 파이프라인에 핸들러가 추가됨channelRegistered()— Channel이 EventLoop에 등록됨channelActive()— 연결이 활성화됨 (상대방과 연결 완료)channelRead()— 데이터 수신channelReadComplete()— 현재 읽기 배치 완료channelInactive()— 연결이 비활성화됨 (연결 종료)channelUnregistered()— Channel이 EventLoop에서 해제됨handlerRemoved()— 파이프라인에서 핸들러가 제거됨
리소스 초기화는
handlerAdded()에서, 정리는handlerRemoved()에서 하는 것이 가장 안전한 패턴입니다.channelActive()/channelInactive()는 연결 상태에 따른 로직에 사용하고,channelRegistered()/channelUnregistered()는 EventLoop 등록 시점과 관련된 경우에 활용합니다.
ChannelHandlerContext
ChannelHandlerContext(ctx)는 핸들러와 파이프라인 사이를 연결하는 실행 컨텍스트 입니다. 핸들러가 파이프라인에 추가될 때마다 핸들러와 1:1로 매칭되는 Context 객체가 생성됩니다. 핸들러 메서드의 첫 번째 파라미터로 항상 전달되는 이 객체를 통해, 다음 핸들러로 이벤트를 전파하거나 채널에 직접 쓰기 작업을 수행할 수 있습니다.
public class MyHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// ctx를 통해 할 수 있는 것들
ctx.fireChannelRead(msg); // 다음 인바운드 핸들러로 이벤트 전달
ctx.write(response); // 다음 아웃바운드 핸들러로 쓰기 이벤트 전달
ctx.channel(); // 현재 채널 참조
ctx.pipeline(); // 파이프라인 참조
ctx.alloc(); // ByteBuf 할당자 참조
}
}
ctx.write() vs channel.write()
공부하다 보니 여기서 많이 헷갈렸는데, 둘 다 데이터를 쓰는 메서드이지만 이벤트가 시작되는 위치가 다릅니다.
ctx.write()— 현재 핸들러 위치에서 ** 다음 아웃바운드 핸들러 **부터 이벤트를 전달합니다. 파이프라인의 일부만 거칩니다.channel.write()— 파이프라인의 tail(끝) 부터 시작해서 모든 아웃바운드 핸들러 를 거칩니다.
Pipeline: Head ↔ Encoder ↔ LogHandler ↔ BusinessHandler ↔ Tail
BusinessHandler에서 ctx.write()를 호출하면:
→ LogHandler → Encoder → Head → 네트워크 (BusinessHandler 이전의 아웃바운드만 거침)
BusinessHandler에서 channel.write()를 호출하면:
→ Tail → BusinessHandler → LogHandler → Encoder → Head → 네트워크 (전체)
대부분의 경우
ctx.write()를 사용하는 것이 맞습니다.channel.write()는 파이프라인 전체를 다시 태워야 하는 특수한 상황에서만 씁니다. 성능 측면에서도ctx.write()가 불필요한 핸들러 순회를 건너뛰므로 더 효율적입니다.
상태 관리
핸들러에 상태를 직접 두면 안 되는 이유
핸들러 인스턴스에 연결별 상태를 저장하면, 핸들러 재사용이 불가능하고 @Sharable도 쓸 수 없게 됩니다. 더 큰 문제는, 핸들러가 여러 채널에서 공유될 때 상태가 섞이는 버그가 발생한다는 것입니다.
// 안 좋은 예: 핸들러 인스턴스에 연결별 상태 저장
public class BadHandler extends ChannelInboundHandlerAdapter {
private int messageCount = 0; // 어느 채널의 카운트인지 구분 불가
private StringBuilder buffer = new StringBuilder(); // 채널 간 데이터 섞임
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
messageCount++;
buffer.append(msg);
ctx.fireChannelRead(msg);
}
}
AttributeKey 패턴
네티는 Channel이나 ChannelHandlerContext에 키-값 형태로 상태를 저장 할 수 있는 AttributeKey 메커니즘을 제공합니다. 상태가 핸들러가 아닌 채널에 붙으므로, 핸들러는 무상태를 유지할 수 있습니다.
@ChannelHandler.Sharable
public class StatefulHandler extends ChannelInboundHandlerAdapter {
// 키 정의 — static final로 한 번만 생성
private static final AttributeKey<Integer> MSG_COUNT =
AttributeKey.valueOf("msgCount");
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 채널별 초기값 설정
ctx.channel().attr(MSG_COUNT).set(0);
ctx.fireChannelActive();
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 채널별 상태 읽기/쓰기
Attribute<Integer> attr = ctx.channel().attr(MSG_COUNT);
int count = attr.get() + 1;
attr.set(count);
System.out.println(ctx.channel().remoteAddress() + " 메시지 수: " + count);
ctx.fireChannelRead(msg);
}
}
이 패턴을 사용하면 핸들러 자체는 무상태이면서, 채널별로 독립적인 상태를 안전하게 관리할 수 있습니다. @Sharable을 붙여 여러 파이프라인에서 공유해도 채널 간 상태가 섞이지 않습니다.
정리
- ChannelHandler 는 네티에서 이벤트를 처리하는 기본 단위이며, 읽기 방향(
ChannelInboundHandler)과 쓰기 방향(ChannelOutboundHandler)으로 나뉩니다. - SimpleChannelInboundHandler 는 제네릭 타입 매칭과 자동 release를 제공해서, 메시지를 최종 소비하는 핸들러에 적합합니다.
- @Sharable 핸들러는 여러 파이프라인에서 공유할 수 있지만, 반드시 무상태여야 합니다.
- ChannelHandlerContext 는 핸들러의 실행 컨텍스트로,
ctx.write()와channel.write()의 이벤트 시작 위치 차이를 이해하는 것이 중요합니다. - 연결별 상태가 필요하면 핸들러 필드 대신 AttributeKey 패턴 을 사용해서 채널에 상태를 붙이는 방식이 안전합니다.