네티 파이프라인에 핸들러를 추가하기만 하면 된다는 건 알겠는데, 핸들러가 정확히 뭘 하는 객체이고, 인바운드와 아웃바운드는 왜 나누어져 있을까?

ChannelHandler란

ChannelHandler는 네티에서 이벤트를 처리하는 콜백 인터페이스 입니다. 네트워크로 데이터가 들어오거나, 데이터를 써서 내보내거나, 연결 상태가 바뀌는 등의 이벤트가 발생했을 때, 그 이벤트에 반응하는 로직을 담는 단위가 바로 ChannelHandler입니다.

ChannelPipeline이 "이벤트가 지나가는 경로"라면, ChannelHandler는 "그 경로 위의 각 처리 정거장" 에 해당합니다. 디코딩, 비즈니스 로직, 인코딩, 로깅 등 역할별로 핸들러를 쪼개서 파이프라인에 조합하는 것이 네티의 기본 설계 철학입니다.

JAVA
// ChannelHandler 인터페이스의 핵심 메서드
public interface ChannelHandler {
    // 핸들러가 파이프라인에 추가될 때
    void handlerAdded(ChannelHandlerContext ctx) throws Exception;
    // 핸들러가 파이프라인에서 제거될 때
    void handlerRemoved(ChannelHandlerContext ctx) throws Exception;
}

ChannelInboundHandler vs ChannelOutboundHandler

ChannelHandler는 ** 처리하는 이벤트의 방향에 따라 두 가지 하위 인터페이스로 나뉩니다.**


ChannelInboundHandler: 읽기 방향 이벤트

** 원격 피어로부터 데이터가 들어오거나, 연결 상태가 변경되는 등 "수신 방향" 이벤트를 처리 **합니다. 파이프라인에서 head → tail 방향으로 이벤트가 전파됩니다.

JAVA
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 방향으로 이벤트가 전파됩니다.

JAVA
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인바운드 + 아웃바운드 모두 처리
JAVA
// 실무에서 가장 많이 보는 패턴: 어댑터 상속 후 필요한 메서드만 오버라이드
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()가 자동으로 호출 **됩니다. 개발자는 비즈니스 로직에만 집중하면 됩니다.

JAVA
// 문자열 메시지만 처리하는 핸들러
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

항목ChannelInboundHandlerAdapterSimpleChannelInboundHandler
release 책임개발자가 직접자동
타입 매칭없음 (Object로 받음)제네릭으로 필터링
메시지 전달ctx.fireChannelRead()로 전달 가능기본적으로 소비 (전달 X)
사용 시점메시지를 다음 핸들러로 넘길 때최종 소비 핸들러

@Sharable

기본적으로 ChannelHandler 인스턴스 하나는 하나의 ChannelPipeline에만 추가할 수 있습니다. 같은 핸들러 인스턴스를 여러 파이프라인에 추가하려고 하면 예외가 발생합니다. 하지만 @ChannelHandler.Sharable 어노테이션을 붙이면 이 제약이 해제됩니다.

JAVA
@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); // 다음 핸들러로 전달
    }
}
JAVA
// 하나의 인스턴스를 여러 채널에서 공유
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의 핵심 조건: 무상태

여러 파이프라인에서 동시에 사용된다는 것은, ** 서로 다른 채널의 이벤트가 같은 핸들러 인스턴스를 통해 처리 **된다는 뜻입니다. 따라서 인스턴스 필드에 연결별 상태를 저장하면 동시성 문제가 발생합니다.

JAVA
// 잘못된 예: @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에는 ** 파이프라인에 추가되고 제거될 때 호출되는 생명주기 콜백 **이 있습니다. 리소스 초기화나 정리 작업을 이 시점에 하면 깔끔합니다.

JAVA
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);
    }
}

인바운드 핸들러의 전체 생명주기를 순서대로 나열하면 다음과 같습니다.

  1. handlerAdded() — 파이프라인에 핸들러가 추가됨
  2. channelRegistered() — Channel이 EventLoop에 등록됨
  3. channelActive() — 연결이 활성화됨 (상대방과 연결 완료)
  4. channelRead() — 데이터 수신
  5. channelReadComplete() — 현재 읽기 배치 완료
  6. channelInactive() — 연결이 비활성화됨 (연결 종료)
  7. channelUnregistered() — Channel이 EventLoop에서 해제됨
  8. handlerRemoved() — 파이프라인에서 핸들러가 제거됨

리소스 초기화는 handlerAdded()에서, 정리는 handlerRemoved()에서 하는 것이 가장 안전한 패턴입니다. channelActive()/channelInactive()는 연결 상태에 따른 로직에 사용하고, channelRegistered()/channelUnregistered()는 EventLoop 등록 시점과 관련된 경우에 활용합니다.


ChannelHandlerContext

ChannelHandlerContext(ctx)는 핸들러와 파이프라인 사이를 연결하는 실행 컨텍스트 입니다. 핸들러가 파이프라인에 추가될 때마다 핸들러와 1:1로 매칭되는 Context 객체가 생성됩니다. 핸들러 메서드의 첫 번째 파라미터로 항상 전달되는 이 객체를 통해, 다음 핸들러로 이벤트를 전파하거나 채널에 직접 쓰기 작업을 수행할 수 있습니다.

JAVA
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(끝) 부터 시작해서 모든 아웃바운드 핸들러 를 거칩니다.
PLAINTEXT
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도 쓸 수 없게 됩니다. 더 큰 문제는, 핸들러가 여러 채널에서 공유될 때 상태가 섞이는 버그가 발생한다는 것입니다.

JAVA
// 안 좋은 예: 핸들러 인스턴스에 연결별 상태 저장
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 메커니즘을 제공합니다. 상태가 핸들러가 아닌 채널에 붙으므로, 핸들러는 무상태를 유지할 수 있습니다.

JAVA
@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 패턴 을 사용해서 채널에 상태를 붙이는 방식이 안전합니다.
댓글 로딩 중...