코덱 기초 — 인코더 & 디코더
네트워크로 날아오는 건 결국 바이트 덩어리인데, 우리 코드에서 다루는 건 Java 객체다. 이 둘 사이의 변환은 누가 책임져야 할까?
코덱이란 — 인코더와 디코더의 조합
네티에서 코덱(Codec) 이라는 용어는 인코더(Encoder)와 디코더(Decoder)를 합쳐서 부르는 말 입니다.
- **디코더 **: 네트워크에서 들어온 바이트 → Java 객체로 변환 (인바운드)
- ** 인코더 **: Java 객체 → 네트워크로 나갈 바이트로 변환 (아웃바운드)
네티의 파이프라인 구조에서 보면, 디코더는 ChannelInboundHandler이고 인코더는 ChannelOutboundHandler입니다. 결국 ** 코덱도 핸들러의 일종 **이라는 점이 중요합니다. 파이프라인에 추가하는 방식도, 이벤트가 흐르는 방향도 다른 핸들러와 동일합니다.
코덱을 별도 핸들러로 분리하는 이유는 간단합니다. 바이트 ↔ 객체 변환 로직과 비즈니스 로직을 섞어 놓으면 재사용도 어렵고 테스트도 힘들어지기 때문입니다.
ByteToMessageDecoder — 바이트를 객체로 변환하는 디코더
가장 자주 쓰이는 디코더 베이스 클래스입니다. ** 네트워크에서 들어온 원시 바이트(ByteBuf)를 의미 있는 메시지 객체로 변환 **하는 역할을 합니다.
decode() 메서드
ByteToMessageDecoder를 상속하면 decode() 메서드 하나만 구현하면 됩니다.
public class IntegerDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 최소 4바이트(int 크기)가 쌓여야 디코딩 가능
if (in.readableBytes() >= 4) {
out.add(in.readInt()); // 디코딩된 결과를 out 리스트에 추가
}
}
}
세 가지 파라미터를 살펴보면:
ChannelHandlerContext ctx— 파이프라인 컨텍스트로, 다음 핸들러에 이벤트를 전달하거나 채널 정보를 조회할 때 사용합니다.ByteBuf in— 네티가 관리하는 ** 누적 버퍼 **입니다. 네트워크에서 데이터가 들어올 때마다 이 버퍼에 쌓이고,decode()가 호출됩니다.List<Object> out— 디코딩 결과를 담는 리스트입니다. 여기에add()하면 네티가 자동으로 파이프라인의 다음 핸들러에 전달합니다.
누적 버퍼의 동작 방식
TCP는 스트림 프로토콜이기 때문에, 보낸 쪽에서 100바이트를 한 번에 보내도 받는 쪽에서는 50바이트씩 두 번에 걸쳐 도착할 수 있습니다. ByteToMessageDecoder는 이런 상황을 내부적으로 처리합니다.
- 데이터가 도착할 때마다 내부 ** 누적 버퍼(cumulation buffer)**에 추가
- 누적 버퍼를
decode()에 전달 decode()에서 충분한 데이터가 쌓이지 않았다면 아무것도out에 넣지 않고 리턴- 다음 데이터가 도착하면 다시 누적 버퍼에 추가 후
decode()재호출
공부하다 보니,
decode()에서 "아직 데이터가 부족하면 그냥 return하면 된다"는 점이 핵심이었습니다. 네티가 알아서 버퍼에 쌓아 두고 다시 호출해 주니까요.
MessageToByteEncoder — 객체를 바이트로 변환하는 인코더
디코더의 반대 방향입니다. Java 객체를 네트워크로 보낼 바이트(ByteBuf)로 변환 합니다.
public class IntegerEncoder extends MessageToByteEncoder<Integer> {
@Override
protected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) {
out.writeInt(msg); // Integer를 4바이트로 인코딩
}
}
MessageToByteEncoder는 제네릭 타입 파라미터로 처리할 메시지 타입을 지정합니다. 위 예제에서는 Integer를 받아서 ByteBuf에 씁니다. 파이프라인에서 write()가 호출되면 이 인코더가 객체를 바이트로 변환한 뒤 네트워크로 내보냅니다.
encode() 메서드의 파라미터를 정리하면:
ChannelHandlerContext ctx— 파이프라인 컨텍스트Integer msg— 인코딩할 메시지 객체 (제네릭 타입에 따라 달라짐)ByteBuf out— 인코딩 결과를 쓸 버퍼. 여기에 쓴 내용이 네트워크로 전송됨
디코더의
out은List<Object>이고, 인코더의out은ByteBuf입니다. 방향이 반대이니 결과물의 타입도 반대라는 점이 직관적입니다.
MessageToMessageDecoder / Encoder — 객체 간 변환
바이트가 아닌 ** 이미 디코딩된 객체를 다른 타입의 객체로 변환 **해야 할 때 사용합니다.
MessageToMessageDecoder
public class StringToCommandDecoder extends MessageToMessageDecoder<String> {
@Override
protected void decode(ChannelHandlerContext ctx, String msg, List<Object> out) {
// 문자열을 파싱해서 Command 객체로 변환
String[] parts = msg.split(" ");
out.add(new Command(parts[0], parts.length > 1 ? parts[1] : ""));
}
}
ByteToMessageDecoder와 비슷하지만, 입력이 ByteBuf가 아니라 ** 이미 디코딩된 메시지 객체 **라는 점이 다릅니다. 예를 들어 앞단에 StringDecoder가 바이트를 문자열로 변환해 주면, 그 뒤에 이 핸들러가 문자열을 Command 객체로 변환하는 식입니다.
MessageToMessageEncoder
public class CommandToStringEncoder extends MessageToMessageEncoder<Command> {
@Override
protected void encode(ChannelHandlerContext ctx, Command msg, List<Object> out) {
// Command 객체를 문자열로 변환
out.add(msg.getType() + " " + msg.getPayload());
}
}
객체를 바이트가 아닌 ** 다른 타입의 객체로 변환 **합니다. out이 ByteBuf가 아니라 List<Object>인 점에 주의합니다. 변환된 객체는 파이프라인의 다음 아웃바운드 핸들러(예: StringEncoder)로 전달됩니다.
정리하면,
ByteToMessage와MessageToMessage의 차이는 "입력이 바이트냐 객체냐"입니다. 다단계 변환이 필요할 때MessageToMessage계열을 조합하면 각 단계를 깔끔하게 분리할 수 있습니다.
CombinedChannelDuplexHandler — 인코더 + 디코더 묶기
인코더와 디코더는 보통 쌍으로 쓰입니다. CombinedChannelDuplexHandler는 이 둘을 ** 하나의 논리적 단위로 묶어서 관리 **할 수 있게 해줍니다.
public class IntegerCodec
extends CombinedChannelDuplexHandler<IntegerDecoder, IntegerEncoder> {
public IntegerCodec() {
super(new IntegerDecoder(), new IntegerEncoder());
}
}
이렇게 묶으면 파이프라인에 추가할 때 ** 하나의 핸들러로 취급 **할 수 있습니다.
// 묶기 전: 두 개를 따로 추가
pipeline.addLast(new IntegerDecoder());
pipeline.addLast(new IntegerEncoder());
// 묶은 후: 하나로 추가
pipeline.addLast(new IntegerCodec());
CombinedChannelDuplexHandler를 사용하면 인코더와 디코더가 항상 함께 움직이기 때문에, 파이프라인 구성 실수를 줄일 수 있습니다.
ByteToMessageCodec이라는 추상 클래스도 있는데, 이 클래스는decode()와encode()를 한 클래스에서 구현하는 방식입니다. 다만CombinedChannelDuplexHandler가 인코더와 디코더를 독립적으로 재사용할 수 있어서, 일반적으로는CombinedChannelDuplexHandler방식이 더 권장됩니다.
코덱 조합 패턴 — 파이프라인에서의 배치 순서
파이프라인에서 핸들러 배치 순서를 이해하려면, 인바운드와 아웃바운드의 이벤트 흐름 방향을 먼저 떠올려야 합니다.
- ** 인바운드(수신): 파이프라인의 ** 앞(head)에서 뒤(tail) 방향으로 흐름
- ** 아웃바운드(송신): 파이프라인의 ** 뒤(tail)에서 앞(head) 방향으로 흐름
이 규칙에 따라 일반적인 배치 패턴은 아래와 같습니다.
// 전형적인 파이프라인 구성
pipeline.addLast("decoder", new MyDecoder()); // 인바운드: 바이트 → 객체
pipeline.addLast("handler", new MyBusinessHandler()); // 인바운드: 비즈니스 로직
pipeline.addLast("encoder", new MyEncoder()); // 아웃바운드: 객체 → 바이트
데이터 흐름을 그림으로 보면:
[수신 바이트]
↓ 인바운드
Decoder ← 바이트를 객체로 변환
↓
Handler ← 비즈니스 로직 처리 후 write() 호출
↓ 아웃바운드 (역방향)
Encoder ← 객체를 바이트로 변환
↓
[송신 바이트]
다단계 코덱 조합
실무에서는 한 번에 바이트 → 최종 객체로 변환하지 않고, ** 여러 단계를 거쳐 점진적으로 변환 **하는 경우가 많습니다.
pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); // 프레이밍
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8)); // 바이트 → 문자열
pipeline.addLast(new StringToCommandDecoder()); // 문자열 → Command
pipeline.addLast(new CommandHandler()); // 비즈니스 로직
pipeline.addLast(new CommandToStringEncoder()); // Command → 문자열
pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8)); // 문자열 → 바이트
이렇게 각 단계를 독립적인 핸들러로 분리하면, ** 프레이밍 로직을 바꾸고 싶을 때 첫 번째 핸들러만 교체 **하면 되고, 문자열 인코딩을 바꿀 때도 해당 핸들러만 수정하면 됩니다.
내장 코덱 — 자주 쓰이는 코덱 소개
네티는 자주 사용되는 프로토콜과 데이터 형식에 대한 코덱을 이미 내장하고 있습니다. 직접 구현하기 전에 먼저 확인해 보는 게 좋습니다.
StringDecoder / StringEncoder
바이트와 문자열 간 변환을 담당합니다.
// 파이프라인에 추가
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8)); // ByteBuf → String
pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8)); // String → ByteBuf
텍스트 기반 프로토콜을 구현할 때 거의 필수로 사용됩니다. CharsetUtil로 인코딩을 지정할 수 있으며, 기본값은 UTF-8입니다.
ObjectDecoder / ObjectEncoder
Java의 직렬화(Serializable)를 이용해 객체를 바이트로 변환합니다.
pipeline.addLast(new ObjectDecoder(ClassResolvers.cacheDisabled(null)));
pipeline.addLast(new ObjectEncoder());
간편하지만 Java 직렬화 특유의 보안 이슈와 호환성 문제 가 있어서, 프로덕션에서는 JSON이나 Protobuf 같은 포맷을 더 많이 사용합니다. 빠른 프로토타이핑이나 Java ↔ Java 통신에서 가끔 쓰이는 정도입니다.
HttpServerCodec / HttpClientCodec
HTTP 프로토콜을 처리하는 코덱입니다.
// HTTP 서버 파이프라인
pipeline.addLast(new HttpServerCodec()); // HTTP 요청 디코딩 + 응답 인코딩
pipeline.addLast(new HttpObjectAggregator(65536)); // HTTP 메시지 조각을 하나로 합침
pipeline.addLast(new MyHttpHandler()); // 비즈니스 로직
HttpServerCodec은 내부적으로 HttpRequestDecoder와 HttpResponseEncoder를 합쳐 놓은 것입니다. HTTP/1.1의 요청 파싱, 청크 전송, 응답 인코딩 등을 모두 처리해 주기 때문에, HTTP 프로토콜을 직접 파싱할 필요가 없습니다.
그 외 주요 내장 코덱
| 코덱 | 용도 |
|---|---|
LengthFieldBasedFrameDecoder | 길이 필드 기반 프레이밍 |
DelimiterBasedFrameDecoder | 구분자 기반 프레이밍 |
LineBasedFrameDecoder | 줄바꿈 기반 프레이밍 |
ProtobufDecoder / ProtobufEncoder | Protocol Buffers |
SslHandler | TLS/SSL 암호화 |
WebSocketServerProtocolHandler | WebSocket |
정리
네티의 코덱 구조를 한 문장으로 요약하면, "바이트 ↔ 객체 변환 로직을 핸들러로 분리하여 파이프라인에서 조합하는 패턴" 입니다.
ByteToMessageDecoder: 바이트 → 객체, 누적 버퍼로 TCP 스트림의 파편화 처리MessageToByteEncoder: 객체 → 바이트, 제네릭으로 타입 안전성 확보MessageToMessageDecoder/Encoder: 객체 → 객체, 다단계 변환에 활용CombinedChannelDuplexHandler: 인코더 + 디코더를 하나로 묶어 관리- 파이프라인 배치: 디코더 → 비즈니스 핸들러 → 인코더 순서
- 내장 코덱 먼저 확인: 직접 구현 전에 네티가 제공하는 코덱이 있는지 살펴보기
코덱을 잘 활용하면 네트워크 프로토콜의 복잡한 바이트 처리를 비즈니스 로직과 완전히 분리할 수 있습니다. 네티가 인바운드/아웃바운드 방향에 맞춰 핸들러를 자동으로 호출해 주기 때문에, 개발자는 각 단계의 변환 로직에만 집중하면 됩니다.