코덱 기초에서 ByteToMessageDecoder와 MessageToByteEncoder를 살펴봤는데, 실무에서는 "바이트가 아직 다 안 왔을 때" 처리나 "여러 단계를 거치는 복잡한 프로토콜 디코딩"이 진짜 문제다. 네티는 이런 상황에 대응하는 고급 코덱 패턴을 여러 가지 제공한다.

ReplayingDecoder — 바이트 부족 체크를 자동화하는 디코더

ByteToMessageDecoder를 쓸 때 가장 번거로운 부분은 매번 readableBytes()를 체크해야 한다 는 점입니다. 4바이트 int를 읽으려면 if (in.readableBytes() >= 4)를 먼저 확인해야 하죠.

ReplayingDecoder는 이 반복 패턴을 자동으로 처리해 줍니다.

JAVA
// ReplayingDecoder를 사용하면 readableBytes() 체크가 필요 없다
public class SimpleReplayingDecoder extends ReplayingDecoder<Void> {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        // 바이트가 부족하면 자동으로 Signal을 던져서 재시도한다
        int magicNumber = in.readInt();   // 4바이트 필요
        int dataLength = in.readInt();    // 4바이트 필요
        byte[] data = new byte[dataLength];
        in.readBytes(data);               // dataLength만큼 필요

        out.add(new MyMessage(magicNumber, data));
    }
}

내부적으로 ReplayingDecoderByteBuf라는 특수한 ByteBuf 래퍼를 사용합니다. 이 래퍼가 읽기 연산 중에 바이트가 부족하면 Signal 예외를 던지고, 네티가 이를 잡아서 다음 데이터가 도착할 때까지 대기했다가 처음부터 다시 decode()를 호출합니다.

ByteToMessageDecoder와의 비교

항목ByteToMessageDecoderReplayingDecoder
readableBytes() 체크직접 해야 함자동
성능더 빠름Signal 던지기/잡기 오버헤드
코드 가독성조건 분기 많음깔끔함
디버깅직관적Signal 흐름 추적이 어려움
제약사항없음일부 ByteBuf 연산 미지원

간단한 프로토콜이라면 ReplayingDecoder가 코드가 깔끔해서 좋지만, 복잡한 프로토콜에서는 성능과 디버깅 편의를 위해 ByteToMessageDecoder를 쓰는 경우가 더 많습니다. 실무에서는 ByteToMessageDecoder + 상태 머신 조합이 주류입니다.


상태 머신 디코더 — enum 상태로 다단계 디코딩

실제 프로토콜은 "헤더 읽기 → 바디 길이 파악 → 바디 읽기"처럼 여러 단계를 거칩니다. 이런 다단계 디코딩을 깔끔하게 처리하는 방법이 ** 상태 머신 패턴 **입니다.

ReplayingDecoder의 제네릭 타입에 enum을 넣으면, 각 상태별로 디코딩 로직을 분리할 수 있습니다.

JAVA
// 상태를 enum으로 정의
public enum MyProtocolState {
    READ_HEADER,  // 헤더 읽기 단계
    READ_BODY     // 바디 읽기 단계
}

public class StateMachineDecoder extends ReplayingDecoder<MyProtocolState> {

    private int bodyLength;

    // 초기 상태를 READ_HEADER로 설정
    public StateMachineDecoder() {
        super(MyProtocolState.READ_HEADER);
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        switch (state()) {
            case READ_HEADER:
                // 헤더에서 매직 넘버(4바이트)와 바디 길이(4바이트)를 읽는다
                int magic = in.readInt();
                bodyLength = in.readInt();

                // 상태를 전환하고 checkpoint를 설정
                checkpoint(MyProtocolState.READ_BODY);
                break;

            case READ_BODY:
                // 헤더에서 읽은 길이만큼 바디를 읽는다
                byte[] body = new byte[bodyLength];
                in.readBytes(body);

                // 디코딩 완료, 다음 메시지를 위해 상태 초기화
                checkpoint(MyProtocolState.READ_HEADER);
                out.add(new MyProtocolMessage(body));
                break;
        }
    }
}

checkpoint()의 역할

checkpoint()가 하는 일은 두 가지입니다.

  1. ** 상태 전환 **: 현재 디코딩 상태를 변경
  2. ** 위치 저장 **: 현재 readerIndex를 기록

바이트가 부족해서 Signal이 발생하면, 저장된 checkpoint 상태와 readerIndex부터 다시 시작합니다. checkpoint가 없으면 매번 맨 처음부터 decode()를 다시 실행하게 되니, 헤더를 이미 읽었는데도 다시 읽는 낭비가 생깁니다.

JAVA
// checkpoint 없이 사용하면 매번 처음부터 다시 읽는다
// checkpoint를 사용하면 저장된 상태부터 이어서 읽는다

// 잘못된 예시 — checkpoint 없음
case READ_HEADER:
    int magic = in.readInt();
    bodyLength = in.readInt();
    state = READ_BODY;  // 상태만 바꾸고 checkpoint를 안 찍음
    break;

// 올바른 예시 — checkpoint 사용
case READ_HEADER:
    int magic = in.readInt();
    bodyLength = in.readInt();
    checkpoint(MyProtocolState.READ_BODY);  // 상태 전환 + 위치 저장
    break;

상태 머신 디코더의 핵심은 "어디까지 읽었는지 기억하는 것"입니다. checkpoint()를 빠뜨리면 이미 파싱한 헤더를 또 읽게 되고, 복잡한 프로토콜에서는 버그의 원인이 됩니다.


다단계 디코딩 파이프라인 — 역할 분리의 정석

하나의 디코더에 모든 로직을 넣는 대신, ** 파이프라인에 여러 디코더를 단계별로 배치 **하는 것이 네티의 권장 패턴입니다.

PLAINTEXT
바이트 스트림

[프레임 디코더]        — TCP 스트림 → 프레임 단위 분리

[프로토콜 디코더]      — 프레임 → 비즈니스 객체 변환

[비즈니스 핸들러]      — 비즈니스 로직 처리

[프로토콜 인코더]      — 비즈니스 객체 → 프레임 변환

바이트 스트림

실제 파이프라인 구성

JAVA
public class MyServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // 1단계: 프레임 디코더 — 길이 기반으로 프레임 분리
        pipeline.addLast("frameDecoder",
            new LengthFieldBasedFrameDecoder(
                65535,  // 최대 프레임 크기
                0,      // 길이 필드 오프셋
                4,      // 길이 필드 크기 (4바이트 int)
                0,      // 길이 조정값
                4       // 길이 필드를 결과에서 제거할 바이트 수
            ));

        // 2단계: 프로토콜 디코더 — 프레임을 비즈니스 객체로 변환
        pipeline.addLast("protocolDecoder", new MyProtocolDecoder());

        // 3단계: 비즈니스 핸들러
        pipeline.addLast("businessHandler", new MyBusinessHandler());

        // 4단계: 프로토콜 인코더 — 응답 객체를 바이트로 변환
        pipeline.addLast("protocolEncoder", new MyProtocolEncoder());

        // 5단계: 프레임 인코더 — 길이 헤더 추가
        pipeline.addLast("frameEncoder",
            new LengthFieldPrepender(4));
    }
}

이렇게 나누면 각 디코더는 자신의 역할만 신경 쓰면 됩니다.

  • ** 프레임 디코더 **: TCP 스트림의 경계 문제만 해결 (반쪽 메시지, 여러 메시지 합쳐짐 등)
  • ** 프로토콜 디코더 **: 완전한 프레임을 받아서 비즈니스 객체로 변환만 담당
  • ** 비즈니스 핸들러 **: 바이트 파싱을 전혀 신경 쓸 필요 없이 객체만 처리

프레임 디코더를 별도로 두면 프로토콜 디코더 구현이 훨씬 단순해집니다. "이 ByteBuf에는 반드시 완전한 메시지 하나가 들어있다"고 가정할 수 있기 때문입니다.


HttpObjectAggregator — HTTP 청크를 합치는 패턴

HTTP/1.1의 청크 전송(chunked transfer encoding)에서는 하나의 요청이 여러 조각으로 나뉘어 도착합니다. 네티에서는 이를 HttpRequest, HttpContent, LastHttpContent로 나눠서 전달하는데, 비즈니스 로직에서 이걸 일일이 모아서 처리하는 건 번거롭습니다.

HttpObjectAggregator는 이 조각들을 ** 하나의 FullHttpRequest로 합쳐 주는 핸들러 **입니다.

JAVA
public class HttpServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // HTTP 요청/응답 디코딩
        pipeline.addLast("httpDecoder", new HttpServerCodec());

        // 청크를 하나의 FullHttpRequest로 합침 (최대 1MB)
        pipeline.addLast("aggregator",
            new HttpObjectAggregator(1024 * 1024));

        // 비즈니스 핸들러 — FullHttpRequest를 받아서 처리
        pipeline.addLast("handler", new MyHttpHandler());
    }
}

FullHttpRequest를 다루는 핸들러

JAVA
public class MyHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
        // 청크 걱정 없이 완전한 요청을 받는다
        String uri = request.uri();
        ByteBuf content = request.content();

        // 응답 생성
        FullHttpResponse response = new DefaultFullHttpResponse(
            HttpVersion.HTTP_1_1,
            HttpResponseStatus.OK,
            Unpooled.copiedBuffer("OK", CharsetUtil.UTF_8)
        );
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
        response.headers().set(HttpHeaderNames.CONTENT_LENGTH,
            response.content().readableBytes());

        ctx.writeAndFlush(response);
    }
}

**주의할 점 **: maxContentLength 파라미터는 반드시 적절한 값으로 설정해야 합니다. 너무 크게 잡으면 악의적으로 거대한 요청을 보내서 서버 메모리를 고갈시킬 수 있고, 너무 작으면 정상 요청이 TooLongFrameException으로 거부됩니다.


MessageToMessageCodec — 양방향 변환 코덱

MessageToMessageCodec은 ** 바이트가 아닌 객체 간의 양방향 변환 **을 하나의 클래스에서 처리합니다. 인바운드에서는 decode(), 아웃바운드에서는 encode()를 구현합니다.

JAVA
// Integer ↔ String 양방향 변환 코덱
public class IntegerStringCodec
        extends MessageToMessageCodec<Integer, String> {

    @Override
    protected void encode(ChannelHandlerContext ctx,
                          String msg, List<Object> out) {
        // 아웃바운드: String → Integer
        out.add(Integer.valueOf(msg));
    }

    @Override
    protected void decode(ChannelHandlerContext ctx,
                          Integer msg, List<Object> out) {
        // 인바운드: Integer → String
        out.add(msg.toString());
    }
}

실무에서의 활용 — 프로토콜 버전 변환

서로 다른 프로토콜 버전 간 변환이 필요할 때 유용합니다.

JAVA
// 프로토콜 v1 ↔ v2 변환 코덱
public class ProtocolVersionCodec
        extends MessageToMessageCodec<ProtocolV1Message, ProtocolV2Message> {

    @Override
    protected void encode(ChannelHandlerContext ctx,
                          ProtocolV2Message msg, List<Object> out) {
        // 내부(v2) → 외부(v1)로 변환하여 전송
        ProtocolV1Message v1 = convertToV1(msg);
        out.add(v1);
    }

    @Override
    protected void decode(ChannelHandlerContext ctx,
                          ProtocolV1Message msg, List<Object> out) {
        // 외부(v1) → 내부(v2)로 변환하여 처리
        ProtocolV2Message v2 = convertToV2(msg);
        out.add(v2);
    }

    private ProtocolV1Message convertToV1(ProtocolV2Message v2) {
        // v2 → v1 변환 로직
        return new ProtocolV1Message(v2.getPayload());
    }

    private ProtocolV2Message convertToV2(ProtocolV1Message v1) {
        // v1 → v2 변환 로직
        return new ProtocolV2Message(v1.getData(), DEFAULT_VERSION);
    }
}

MessageToMessageCodec은 "같은 계층에서의 형식 변환"에 적합합니다. 바이트 ↔ 객체 변환이 아니라, 객체 ↔ 객체 변환이 필요할 때 사용한다고 기억하면 됩니다.


코덱 팩토리 패턴 — ChannelInitializer 정리하기

프로토콜이 복잡해지면 ChannelInitializer에 코덱을 추가하는 코드가 길어집니다. 이를 깔끔하게 관리하는 패턴이 ** 코덱 팩토리 **입니다.

JAVA
// 프로토콜별 코덱 체인을 팩토리로 분리
public final class MyProtocolCodecFactory {

    private MyProtocolCodecFactory() {
        // 인스턴스 생성 방지
    }

    // 서버 측 코덱 체인 구성
    public static void addServerCodecs(ChannelPipeline pipeline) {
        // 프레임 디코딩
        pipeline.addLast("frameDecoder",
            new LengthFieldBasedFrameDecoder(65535, 0, 4, 0, 4));
        pipeline.addLast("frameEncoder",
            new LengthFieldPrepender(4));

        // 프로토콜 디코딩/인코딩
        pipeline.addLast("protocolDecoder", new MyProtocolDecoder());
        pipeline.addLast("protocolEncoder", new MyProtocolEncoder());

        // 공통 핸들러
        pipeline.addLast("loggingHandler", new LoggingHandler(LogLevel.DEBUG));
    }

    // 클라이언트 측 코덱 체인 구성
    public static void addClientCodecs(ChannelPipeline pipeline) {
        pipeline.addLast("frameDecoder",
            new LengthFieldBasedFrameDecoder(65535, 0, 4, 0, 4));
        pipeline.addLast("frameEncoder",
            new LengthFieldPrepender(4));

        pipeline.addLast("protocolDecoder", new MyProtocolDecoder());
        pipeline.addLast("protocolEncoder", new MyProtocolEncoder());
    }
}

ChannelInitializer에서 사용

JAVA
// 팩토리를 사용하면 ChannelInitializer가 깔끔해진다
public class MyServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // 코덱 체인을 한 줄로 구성
        MyProtocolCodecFactory.addServerCodecs(pipeline);

        // 비즈니스 핸들러만 추가
        pipeline.addLast("businessHandler", new MyBusinessHandler());
    }
}

이 패턴의 장점은 다음과 같습니다.

  • ** 코덱 구성을 한 곳에서 관리 **: 서버와 클라이언트가 동일한 프로토콜을 쓸 때 코덱 불일치를 방지
  • ** 테스트 용이 **: 팩토리 단위로 코덱 체인을 테스트할 수 있음
  • ** 변경 용이 **: 프로토콜이 바뀌어도 팩토리만 수정하면 됨

코덱 성능 고려사항

코덱은 모든 인바운드/아웃바운드 데이터가 거쳐가는 구간이라, 여기서의 비효율이 전체 성능에 직접 영향을 미칩니다.

1. 불필요한 객체 생성 줄이기

JAVA
// 나쁜 예시 — decode()가 호출될 때마다 새 바이트 배열을 생성
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    int length = in.readInt();
    byte[] data = new byte[length];  // 매번 new 배열 생성 → GC 부담
    in.readBytes(data);
    out.add(new MyMessage(data));
}

// 개선 예시 — ByteBuf를 직접 전달하여 복사 줄이기
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    int length = in.readInt();
    ByteBuf payload = in.readRetainedSlice(length);  // 복사 없이 슬라이스
    out.add(new MyMessage(payload));  // 참조 카운팅 주의!
}

readRetainedSlice()를 사용하면 바이트를 복사하지 않고 원본 버퍼의 일부를 참조합니다. 다만 참조 카운팅을 직접 관리해야 하므로, MyMessage를 다 쓴 후 반드시 release()를 호출해야 합니다.

2. ByteBuf 재사용 전략

JAVA
public class EfficientEncoder extends MessageToByteEncoder<MyMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx,
                          MyMessage msg, ByteBuf out) {
        // out은 네티가 미리 할당한 ByteBuf — 이걸 그대로 쓰면 된다
        out.writeInt(msg.getType());
        out.writeInt(msg.getPayload().length);
        out.writeBytes(msg.getPayload());
        // 별도의 ByteBuf를 할당하지 않는 것이 핵심
    }
}

MessageToByteEncoderencode() 메서드에서 제공하는 out 파라미터는 네티가 미리 할당한 ByteBuf입니다. 별도로 ctx.alloc().buffer()를 호출해서 새 버퍼를 만들 필요가 없습니다.

3. 핸들러 공유와 @Sharable

상태가 없는 코덱은 @Sharable을 붙여서 여러 채널에서 공유할 수 있습니다.

JAVA
// 상태가 없는 인코더 — 인스턴스 하나를 공유 가능
@ChannelHandler.Sharable
public class MyProtocolEncoder extends MessageToByteEncoder<MyMessage> {
    // 인스턴스 필드 없음 → 스레드 안전

    @Override
    protected void encode(ChannelHandlerContext ctx,
                          MyMessage msg, ByteBuf out) {
        out.writeInt(msg.getType());
        out.writeBytes(msg.getPayload());
    }
}

// 사용 시 인스턴스를 한 번만 생성
public class MyServerInitializer extends ChannelInitializer<SocketChannel> {
    // 인코더 인스턴스를 공유
    private final MyProtocolEncoder sharedEncoder = new MyProtocolEncoder();

    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast("encoder", sharedEncoder);  // 동일 인스턴스 재사용
    }
}

** 주의 **: ByteToMessageDecoderReplayingDecoder는 내부에 상태를 유지하기 때문에 @Sharable로 만들 수 없습니다. 채널마다 새 인스턴스를 생성해야 합니다.

성능 체크리스트

항목확인 포인트
객체 할당decode/encode에서 불필요한 byte[] 생성을 하고 있지 않은지
ByteBuf 복사copy() 대신 slice(), retainedSlice()를 쓸 수 있는지
핸들러 공유상태 없는 코덱에 @Sharable을 붙여 인스턴스를 재사용하는지
프레임 크기 제한maxFrameLength를 적절히 설정해서 OOM을 방지하는지
참조 카운팅슬라이스를 사용할 때 release()를 빠뜨리지 않는지

정리

고급 코덱 패턴의 핵심을 요약하면 이렇습니다.

  • ReplayingDecoder: 코드는 간결하지만 성능 오버헤드가 있다. 간단한 프로토콜에 적합
  • ** 상태 머신 디코더 **: 다단계 프로토콜 디코딩의 정석. checkpoint()로 이미 읽은 부분을 다시 읽지 않게 하는 것이 핵심
  • ** 다단계 파이프라인 **: 프레임 분리와 프로토콜 파싱을 별도 핸들러로 분리하면 각 디코더가 단순해진다
  • HttpObjectAggregator: HTTP 청크를 하나의 FullHttpRequest로 합쳐주는 편의 핸들러. maxContentLength 설정 필수
  • MessageToMessageCodec: 객체 ↔ 객체 양방향 변환이 필요할 때 사용
  • ** 코덱 팩토리 **: 코덱 구성을 팩토리로 분리하면 서버/클라이언트 간 일관성 유지가 쉽다
  • ** 성능 최적화 **: 불필요한 복사 줄이기, ByteBuf 슬라이스 활용, @Sharable 인코더 공유
댓글 로딩 중...