고급 코덱 패턴
코덱 기초에서 ByteToMessageDecoder와 MessageToByteEncoder를 살펴봤는데, 실무에서는 "바이트가 아직 다 안 왔을 때" 처리나 "여러 단계를 거치는 복잡한 프로토콜 디코딩"이 진짜 문제다. 네티는 이런 상황에 대응하는 고급 코덱 패턴을 여러 가지 제공한다.
ReplayingDecoder — 바이트 부족 체크를 자동화하는 디코더
ByteToMessageDecoder를 쓸 때 가장 번거로운 부분은 매번 readableBytes()를 체크해야 한다 는 점입니다. 4바이트 int를 읽으려면 if (in.readableBytes() >= 4)를 먼저 확인해야 하죠.
ReplayingDecoder는 이 반복 패턴을 자동으로 처리해 줍니다.
// 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와의 비교
| 항목 | ByteToMessageDecoder | ReplayingDecoder |
|---|---|---|
| readableBytes() 체크 | 직접 해야 함 | 자동 |
| 성능 | 더 빠름 | Signal 던지기/잡기 오버헤드 |
| 코드 가독성 | 조건 분기 많음 | 깔끔함 |
| 디버깅 | 직관적 | Signal 흐름 추적이 어려움 |
| 제약사항 | 없음 | 일부 ByteBuf 연산 미지원 |
간단한 프로토콜이라면 ReplayingDecoder가 코드가 깔끔해서 좋지만, 복잡한 프로토콜에서는 성능과 디버깅 편의를 위해 ByteToMessageDecoder를 쓰는 경우가 더 많습니다. 실무에서는 ByteToMessageDecoder + 상태 머신 조합이 주류입니다.
상태 머신 디코더 — enum 상태로 다단계 디코딩
실제 프로토콜은 "헤더 읽기 → 바디 길이 파악 → 바디 읽기"처럼 여러 단계를 거칩니다. 이런 다단계 디코딩을 깔끔하게 처리하는 방법이 ** 상태 머신 패턴 **입니다.
ReplayingDecoder의 제네릭 타입에 enum을 넣으면, 각 상태별로 디코딩 로직을 분리할 수 있습니다.
// 상태를 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()가 하는 일은 두 가지입니다.
- ** 상태 전환 **: 현재 디코딩 상태를 변경
- ** 위치 저장 **: 현재
readerIndex를 기록
바이트가 부족해서 Signal이 발생하면, 저장된 checkpoint 상태와 readerIndex부터 다시 시작합니다. checkpoint가 없으면 매번 맨 처음부터 decode()를 다시 실행하게 되니, 헤더를 이미 읽었는데도 다시 읽는 낭비가 생깁니다.
// 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()를 빠뜨리면 이미 파싱한 헤더를 또 읽게 되고, 복잡한 프로토콜에서는 버그의 원인이 됩니다.
다단계 디코딩 파이프라인 — 역할 분리의 정석
하나의 디코더에 모든 로직을 넣는 대신, ** 파이프라인에 여러 디코더를 단계별로 배치 **하는 것이 네티의 권장 패턴입니다.
바이트 스트림
↓
[프레임 디코더] — TCP 스트림 → 프레임 단위 분리
↓
[프로토콜 디코더] — 프레임 → 비즈니스 객체 변환
↓
[비즈니스 핸들러] — 비즈니스 로직 처리
↓
[프로토콜 인코더] — 비즈니스 객체 → 프레임 변환
↓
바이트 스트림
실제 파이프라인 구성
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로 합쳐 주는 핸들러 **입니다.
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를 다루는 핸들러
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()를 구현합니다.
// 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());
}
}
실무에서의 활용 — 프로토콜 버전 변환
서로 다른 프로토콜 버전 간 변환이 필요할 때 유용합니다.
// 프로토콜 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에 코덱을 추가하는 코드가 길어집니다. 이를 깔끔하게 관리하는 패턴이 ** 코덱 팩토리 **입니다.
// 프로토콜별 코덱 체인을 팩토리로 분리
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에서 사용
// 팩토리를 사용하면 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. 불필요한 객체 생성 줄이기
// 나쁜 예시 — 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 재사용 전략
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를 할당하지 않는 것이 핵심
}
}
MessageToByteEncoder의 encode() 메서드에서 제공하는 out 파라미터는 네티가 미리 할당한 ByteBuf입니다. 별도로 ctx.alloc().buffer()를 호출해서 새 버퍼를 만들 필요가 없습니다.
3. 핸들러 공유와 @Sharable
상태가 없는 코덱은 @Sharable을 붙여서 여러 채널에서 공유할 수 있습니다.
// 상태가 없는 인코더 — 인스턴스 하나를 공유 가능
@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); // 동일 인스턴스 재사용
}
}
** 주의 **: ByteToMessageDecoder와 ReplayingDecoder는 내부에 상태를 유지하기 때문에 @Sharable로 만들 수 없습니다. 채널마다 새 인스턴스를 생성해야 합니다.
성능 체크리스트
| 항목 | 확인 포인트 |
|---|---|
| 객체 할당 | decode/encode에서 불필요한 byte[] 생성을 하고 있지 않은지 |
| ByteBuf 복사 | copy() 대신 slice(), retainedSlice()를 쓸 수 있는지 |
| 핸들러 공유 | 상태 없는 코덱에 @Sharable을 붙여 인스턴스를 재사용하는지 |
| 프레임 크기 제한 | maxFrameLength를 적절히 설정해서 OOM을 방지하는지 |
| 참조 카운팅 | 슬라이스를 사용할 때 release()를 빠뜨리지 않는지 |
정리
고급 코덱 패턴의 핵심을 요약하면 이렇습니다.
- ReplayingDecoder: 코드는 간결하지만 성능 오버헤드가 있다. 간단한 프로토콜에 적합
- ** 상태 머신 디코더 **: 다단계 프로토콜 디코딩의 정석. checkpoint()로 이미 읽은 부분을 다시 읽지 않게 하는 것이 핵심
- ** 다단계 파이프라인 **: 프레임 분리와 프로토콜 파싱을 별도 핸들러로 분리하면 각 디코더가 단순해진다
- HttpObjectAggregator: HTTP 청크를 하나의 FullHttpRequest로 합쳐주는 편의 핸들러. maxContentLength 설정 필수
- MessageToMessageCodec: 객체 ↔ 객체 양방향 변환이 필요할 때 사용
- ** 코덱 팩토리 **: 코덱 구성을 팩토리로 분리하면 서버/클라이언트 간 일관성 유지가 쉽다
- ** 성능 최적화 **: 불필요한 복사 줄이기, ByteBuf 슬라이스 활용, @Sharable 인코더 공유