ChannelPipeline — 이벤트가 흐르는 경로
네티 서버에 데이터가 들어오면 디코더, 비즈니스 로직, 인코더를 거쳐 응답이 나간다. 이 핸들러들은 어떤 순서로 실행되고, 인바운드와 아웃바운드 이벤트는 왜 반대 방향으로 흐를까?
ChannelPipeline이란
ChannelPipeline은 채널에 연결된 핸들러(ChannelHandler)들의 양방향 연결 리스트 입니다. 채널이 생성되면 자동으로 하나의 파이프라인이 만들어지고, 이 파이프라인 안에 디코더, 비즈니스 로직, 인코더 같은 핸들러들을 순서대로 꽂아 넣으면 됩니다.
// 부트스트랩에서 파이프라인에 핸들러를 등록하는 전형적인 패턴
ServerBootstrap b = new ServerBootstrap();
b.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast("decoder", new MyDecoder()); // 인바운드 핸들러
p.addLast("handler", new MyBusinessHandler()); // 인바운드 핸들러
p.addLast("encoder", new MyEncoder()); // 아웃바운드 핸들러
}
});
모든 파이프라인에는 내부적으로 HeadContext와 TailContext 라는 특수 핸들러가 양 끝에 자동으로 배치됩니다. 개발자가 addLast()로 등록한 핸들러들은 이 head와 tail 사이에 순서대로 끼워지는 구조입니다.
인바운드 vs 아웃바운드 이벤트 흐름
파이프라인을 이해할 때 가장 중요한 포인트가 이벤트의 흐름 방향 입니다. 네티의 이벤트는 크게 두 종류로 나뉩니다.
- **인바운드 이벤트 **: 외부에서 데이터가 들어올 때 발생 (
channelRead,channelActive,channelRegistered등) - ** 아웃바운드 이벤트 **: 애플리케이션이 외부로 데이터를 보낼 때 발생 (
write,flush,connect,bind등)
핵심은 ** 두 이벤트가 파이프라인에서 반대 방향으로 흐른다 **는 점입니다.
ChannelPipeline
I/O 요청 I/O 요청
(인바운드) (아웃바운드)
│ ▲
▼ │
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐
│ Head │───▶│ Decoder │───▶│ Handler │───▶│ Encoder │───▶│ Tail │
│ Context │ │(Inbound) │ │(Inbound) │ │(Outbound)│ │ Context │
└─────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────┘
▲ │
│ 아웃바운드 흐름 │
└──────────────────────────────────────────────┘
인바운드: Head ──▶ Decoder ──▶ Handler ──▶ Tail (head → tail)
아웃바운드: Tail ◀── Encoder ◀───────────────Head (tail → head)
인바운드 이벤트가 흐를 때 **ChannelOutboundHandler는 건너뜁니다 **. 마찬가지로 아웃바운드 이벤트가 흐를 때 ChannelInboundHandler는 건너뜁니다. 네티가 내부적으로 각 핸들러의 타입을 확인해서, 해당 방향에 맞는 핸들러만 찾아 실행하는 방식입니다.
파이프라인에 핸들러 10개가 등록되어 있어도, 인바운드 이벤트는 그 중
ChannelInboundHandler인 것들만 순서대로 통과한다. 아웃바운드도 마찬가지.
핸들러 실행 순서 — addLast와 addFirst
핸들러를 파이프라인에 추가하는 순서가 곧 실행 순서를 결정합니다.
ChannelPipeline p = ch.pipeline();
// addLast: tail 바로 앞에 추가
p.addLast("A", new InboundHandlerA()); // 인바운드: 1번째
p.addLast("B", new InboundHandlerB()); // 인바운드: 2번째
p.addLast("C", new InboundHandlerC()); // 인바운드: 3번째
위처럼 등록하면 인바운드 이벤트는 A → B → C 순서로 흐릅니다.
// addFirst: head 바로 뒤에 추가
p.addFirst("Z", new InboundHandlerZ()); // 인바운드: 가장 먼저 실행
addFirst()를 사용하면 head 바로 뒤에 배치되므로, 인바운드 기준 가장 먼저 실행됩니다.
순서가 결과를 바꾸는 예시
디코더와 비즈니스 로직 핸들러의 순서를 잘못 배치하면 어떤 일이 벌어질까요?
// 올바른 순서: 바이트 → 디코딩 → 비즈니스 로직
p.addLast(new ByteToMessageDecoder() { /* ... */ });
p.addLast(new SimpleChannelInboundHandler<MyMessage>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, MyMessage msg) {
// 디코딩된 MyMessage 객체를 받아서 처리
}
});
// 잘못된 순서: 비즈니스 로직이 디코더보다 앞에 있으면
p.addLast(new SimpleChannelInboundHandler<MyMessage>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, MyMessage msg) {
// ByteBuf가 넘어오는데 MyMessage 타입이 아니라서 무시됨!
}
});
p.addLast(new ByteToMessageDecoder() { /* ... */ });
디코더가 아직 바이트를 객체로 변환하기 전에 비즈니스 핸들러가 먼저 실행되면, 타입이 맞지 않아 메시지가 조용히 무시 됩니다. 파이프라인 순서는 실수하기 쉬운 부분이니, "바이트가 먼저 디코딩되고 나서 비즈니스 로직을 타야 한다"는 흐름을 항상 염두에 두는 게 좋습니다.
인바운드는 addLast 순서대로, 아웃바운드는 addLast의 역순으로 실행된다. 인코더를 아웃바운드 핸들러로 등록했다면, addLast로 맨 뒤에 넣어도 아웃바운드 이벤트 기준으로는 가장 먼저 만나게 된다.
ctx.fireChannelRead() vs ctx.write() — 이벤트 전파 방향의 차이
파이프라인 안에서 이벤트를 다음 핸들러로 넘기는 방법이 방향에 따라 다릅니다.
인바운드 전파: ctx.fireChannelRead()
public class LoggingHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("수신된 메시지: " + msg);
// 다음 인바운드 핸들러로 이벤트 전파 (head → tail 방향)
ctx.fireChannelRead(msg);
}
}
ctx.fireChannelRead(msg)는 현재 핸들러 기준으로 다음 인바운드 핸들러를 찾아서 이벤트를 넘깁니다. 여기서 "다음"은 파이프라인에서 tail 방향으로 가까운 인바운드 핸들러를 의미합니다.
아웃바운드 전파: ctx.write()
public class BusinessHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
MyMessage request = (MyMessage) msg;
MyResponse response = process(request);
// 아웃바운드 이벤트 발생 (tail → head 방향)
ctx.write(response);
ctx.flush();
}
}
ctx.write(msg)는 ** 현재 핸들러 기준으로 이전 아웃바운드 핸들러를 찾아서** 이벤트를 넘깁니다. "이전"은 head 방향으로 가까운 아웃바운드 핸들러입니다.
ctx vs channel — 미묘하지만 중요한 차이
// ctx.write(): 현재 핸들러 위치부터 head 방향으로 탐색
ctx.write(msg);
// channel().write(): 항상 tail부터 시작해서 head 방향으로 탐색
ctx.channel().write(msg);
ctx.write()는 현재 위치에서부터 아웃바운드 핸들러를 찾지만, ctx.channel().write()는 항상 파이프라인의 tail에서부터 시작합니다. 대부분의 경우 ctx.write()를 쓰는 게 더 효율적인데, 이미 지나온 핸들러를 다시 거치지 않기 때문입니다.
동적 파이프라인 수정
파이프라인은 채널이 살아 있는 동안 ** 언제든 핸들러를 추가하거나 제거, 교체 **할 수 있습니다. 이 기능은 프로토콜 업그레이드 같은 패턴에서 특히 유용합니다.
기본 API
ChannelPipeline p = ch.pipeline();
// 추가
p.addLast("newHandler", new SomeHandler());
p.addFirst("earlyHandler", new EarlyHandler());
p.addBefore("handler", "beforeHandler", new BeforeHandler());
p.addAfter("decoder", "afterDecoder", new AfterDecoder());
// 제거
p.remove("oldHandler");
p.remove(SomeHandler.class);
// 교체
p.replace("oldHandler", "newHandler", new NewHandler());
프로토콜 업그레이드 패턴
HTTP에서 WebSocket으로 업그레이드하는 시나리오를 생각해 보겠습니다. 처음에는 HTTP 핸들러가 파이프라인에 있다가, 업그레이드 요청이 들어오면 HTTP 핸들러를 제거하고 WebSocket 핸들러로 교체합니다.
public class HttpUpgradeHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof HttpRequest && isWebSocketUpgrade((HttpRequest) msg)) {
ChannelPipeline p = ctx.pipeline();
// HTTP 관련 핸들러 제거
p.remove("httpDecoder");
p.remove("httpEncoder");
p.remove(this); // 자기 자신도 제거
// WebSocket 핸들러로 교체
p.addLast("wsDecoder", new WebSocketFrameDecoder());
p.addLast("wsEncoder", new WebSocketFrameEncoder());
p.addLast("wsHandler", new WebSocketHandler());
return;
}
ctx.fireChannelRead(msg);
}
}
동적 파이프라인 수정은 SSL/TLS 핸드셰이크 후 핸들러 제거, 인증 완료 후 인증 핸들러 제거 같은 패턴에서도 자주 쓰인다.
핸들러 이름과 조회
핸들러를 등록할 때 이름을 부여하면, 나중에 이름으로 조회하거나 제거할 수 있습니다.
p.addLast("myDecoder", new MyDecoder());
// 이름으로 조회
ChannelHandler handler = p.get("myDecoder");
// 타입으로 조회
MyDecoder decoder = p.get(MyDecoder.class);
// 이름으로 제거
p.remove("myDecoder");
이름을 생략하면 네티가 클래스명 기반으로 자동 생성하지만, ** 동적 수정이 필요한 파이프라인에서는 명시적으로 이름을 붙이는 것이 관리하기 편합니다.**
파이프라인 디버깅 — 흔한 실수와 해결법
실수 1: 이벤트를 삼키는 핸들러
가장 흔한 실수입니다. channelRead()를 오버라이드하고 나서 ctx.fireChannelRead()를 호출하지 않으면, 이벤트가 다음 핸들러로 전달되지 않고 그 자리에서 사라집니다.
// 잘못된 코드: 이벤트 전파 누락
public class BadHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("메시지 로깅: " + msg);
// ctx.fireChannelRead(msg) 호출을 빼먹음!
// 이후 핸들러는 이 이벤트를 절대 받지 못한다
}
}
// 올바른 코드: 반드시 전파
public class GoodHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("메시지 로깅: " + msg);
ctx.fireChannelRead(msg); // 다음 핸들러로 전달
}
}
실수 2: 아웃바운드에서도 같은 문제
아웃바운드 핸들러에서 write()를 오버라이드한 뒤 ctx.write()를 호출하지 않으면, 데이터가 네트워크로 나가지 않습니다.
// 잘못된 코드: write 전파 누락으로 데이터가 전송되지 않음
public class BadOutboundHandler extends ChannelOutboundHandlerAdapter {
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
System.out.println("보내는 메시지: " + msg);
// ctx.write(msg, promise) 호출을 빼먹으면 데이터가 사라진다!
}
}
실수 3: ByteBuf 릴리스 누락
핸들러에서 메시지를 소비하고 다음으로 전파하지 않을 때, ByteBuf를 직접 릴리스해야 메모리 누수를 방지할 수 있습니다.
public class ConsumeHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
ByteBuf buf = (ByteBuf) msg;
// 메시지를 여기서 최종 소비하고, 다음으로 전파하지 않는 경우
processMessage(buf);
} finally {
// 전파하지 않을 거면 반드시 릴리스
ReferenceCountUtil.release(msg);
}
}
}
SimpleChannelInboundHandler를 상속하면channelRead0()실행 후 자동으로 릴리스해 준다. 메시지를 최종 소비하는 핸들러라면 이 클래스를 쓰는 것이 안전하다.
디버깅 팁: LoggingHandler 활용
파이프라인 어디에서 이벤트가 막히는지 모를 때, 네티가 제공하는 LoggingHandler를 의심되는 지점 사이에 끼워 넣으면 이벤트 흐름을 추적할 수 있습니다.
p.addLast("log-before", new LoggingHandler(LogLevel.DEBUG));
p.addLast("decoder", new MyDecoder());
p.addLast("log-after", new LoggingHandler(LogLevel.DEBUG));
p.addLast("handler", new MyBusinessHandler());
log-before에는 로그가 찍히는데 log-after에는 안 찍힌다면, MyDecoder가 이벤트를 삼키고 있다는 뜻입니다.
정리
이 글에서 다룬 ChannelPipeline의 핵심 포인트를 정리하면 이렇습니다.
- ChannelPipeline 은 핸들러들의 양방향 연결 리스트로, 채널 생성 시 자동으로 만들어진다.
- 인바운드 이벤트는 head → tail, ** 아웃바운드 이벤트는 tail → head** 방향으로 흐르며, 해당 방향에 맞는 타입의 핸들러만 통과한다.
- 핸들러 등록 순서가 실행 순서를 결정하므로, ** 디코더 → 비즈니스 로직 → 인코더** 순서를 지켜야 한다.
ctx.fireChannelRead()는 인바운드 방향,ctx.write()는 아웃바운드 방향으로 이벤트를 전파한다.- 파이프라인은 런타임에 동적으로 핸들러를 추가/제거/교체할 수 있어서, 프로토콜 업그레이드 패턴에 활용된다.
- 이벤트 전파 누락(
fireChannelRead미호출)과ByteBuf릴리스 누락은 파이프라인 디버깅에서 가장 흔한 실수다.