커스텀 프로토콜 설계 & 구현
HTTP나 WebSocket 같은 범용 프로토콜로 대부분의 통신을 해결할 수 있는데, 왜 굳이 바이트 단위로 프로토콜을 직접 만들어야 하는 상황이 생기는 걸까?
왜 커스텀 프로토콜이 필요한가
HTTP는 범용적이고 편리하지만, 모든 상황에 적합한 건 아닙니다. 특히 성능과 효율이 중요한 시스템 에서는 HTTP의 오버헤드가 부담이 됩니다.
- **HTTP 헤더 오버헤드 **: 간단한 heartbeat 하나 보내는데도 수백 바이트의 헤더가 따라붙습니다
- ** 텍스트 기반 파싱 비용 **: HTTP는 텍스트 프로토콜이라 파싱에 CPU를 더 소모합니다
- ** 게임 서버 **: 초당 수만 개의 패킷을 처리해야 하는데, HTTP로는 지연 시간을 맞출 수 없습니다
- **IoT 디바이스 **: 메모리와 대역폭이 제한된 환경에서 바이트 하나가 아쉽습니다
- ** 사내 RPC**: 마이크로서비스 간 통신에서 불필요한 HTTP 오버헤드를 제거하고 싶을 때
이런 상황에서는 ** 필요한 정보만 담은 바이너리 프로토콜 **을 직접 설계하는 게 훨씬 효율적입니다. 네티는 이런 커스텀 프로토콜을 구현하기 위한 도구를 풍부하게 제공합니다.
프로토콜 구조 설계 — 헤더와 바디
커스텀 바이너리 프로토콜은 보통 ** 고정 길이 헤더 + 가변 길이 바디** 구조로 설계합니다.
+--------+---------+----------+------------+------------------+
| Magic | Version | Command | Body | Body |
| Number | | Code | Length | (가변) |
+--------+---------+----------+------------+------------------+
| 4 byte | 1 byte | 1 byte | 4 byte | bodyLength bytes |
+--------+---------+----------+------------+------------------+
각 필드가 존재하는 이유가 있습니다.
- Magic Number (4바이트): 이 패킷이 우리 프로토콜인지 빠르게 판별합니다. 잘못된 연결이 들어와도 첫 4바이트만 보면 바로 걸러낼 수 있습니다.
- Version (1바이트): 프로토콜이 업그레이드될 때 하위 호환성을 유지합니다. 서버가 v1과 v2 클라이언트를 동시에 지원할 수 있게 됩니다.
- Command Code (1바이트): 이 패킷이 어떤 종류의 요청/응답인지 구분합니다. 로그인, 메시지 전송, heartbeat 등.
- Body Length (4바이트): 뒤에 따라오는 바디의 크기입니다. TCP 프레이밍 문제를 해결하는 핵심 필드입니다.
- Body (가변): 실제 페이로드 데이터입니다. JSON, Protobuf, 또는 직접 정의한 바이너리 포맷이 될 수 있습니다.
헤더를 고정 길이(10바이트)로 설계하면 파싱이 단순해집니다. "일단 10바이트를 읽고 → Body Length만큼 더 읽으면 메시지 하나가 완성된다"는 로직으로 처리할 수 있기 때문입니다.
프로토콜 스펙 정의
실제 구현에 들어가기 전에 스펙을 명확히 정의합니다.
| 필드 | 오프셋 | 크기 | 타입 | 설명 |
|---|---|---|---|---|
| Magic Number | 0 | 4 byte | int | 0xCAFEBABE — 프로토콜 식별자 |
| Version | 4 | 1 byte | byte | 프로토콜 버전 (현재 1) |
| Command Code | 5 | 1 byte | byte | 명령 타입 (0=heartbeat, 1=login, ...) |
| Body Length | 6 | 4 byte | int | 바디의 바이트 수 |
| Body | 10 | 가변 | byte[] | 페이로드 데이터 |
** 제약 조건:**
- 최대 바디 크기: 1MB (
1024 * 1024) - Magic Number가 불일치하면 즉시 연결 종료
- Body Length가 0이면 바디 없는 패킷 (heartbeat 등)
메시지 객체 정의
프로토콜 스펙을 Java 객체로 옮깁니다. 네트워크에서 읽은 바이트를 이 객체로 변환하고, 이 객체를 바이트로 직렬화하게 됩니다.
명령 타입 enum
// 프로토콜에서 사용하는 명령 코드 정의
public enum CommandType {
HEARTBEAT((byte) 0), // 연결 유지 확인
LOGIN((byte) 1), // 로그인 요청
LOGIN_RESP((byte) 2), // 로그인 응답
MESSAGE((byte) 3), // 메시지 전송
MESSAGE_RESP((byte) 4); // 메시지 응답
private final byte code;
CommandType(byte code) {
this.code = code;
}
public byte getCode() {
return code;
}
// 바이트 코드로부터 enum을 찾는 유틸리티 메서드
public static CommandType fromCode(byte code) {
for (CommandType type : values()) {
if (type.code == code) {
return type;
}
}
throw new IllegalArgumentException("알 수 없는 명령 코드: " + code);
}
}
메시지 POJO
// 프로토콜 메시지를 표현하는 객체
public class MyProtocolMessage {
// 헤더 상수
public static final int MAGIC_NUMBER = 0xCAFEBABE;
public static final byte VERSION = 1;
public static final int HEADER_LENGTH = 10; // 4 + 1 + 1 + 4
private byte version;
private CommandType commandType;
private byte[] body;
public MyProtocolMessage() {
this.version = VERSION;
}
public MyProtocolMessage(CommandType commandType, byte[] body) {
this.version = VERSION;
this.commandType = commandType;
this.body = body;
}
// heartbeat처럼 바디가 없는 메시지용 팩토리 메서드
public static MyProtocolMessage heartbeat() {
return new MyProtocolMessage(CommandType.HEARTBEAT, new byte[0]);
}
public byte getVersion() { return version; }
public void setVersion(byte version) { this.version = version; }
public CommandType getCommandType() { return commandType; }
public void setCommandType(CommandType commandType) { this.commandType = commandType; }
public byte[] getBody() { return body; }
public void setBody(byte[] body) { this.body = body; }
// 바디를 문자열로 변환하는 편의 메서드
public String getBodyAsString() {
return body != null ? new String(body, java.nio.charset.StandardCharsets.UTF_8) : "";
}
@Override
public String toString() {
return "MyProtocolMessage{" +
"version=" + version +
", commandType=" + commandType +
", bodyLength=" + (body != null ? body.length : 0) +
'}';
}
}
HEADER_LENGTH를 상수로 빼두면 인코더/디코더 양쪽에서 일관되게 사용할 수 있어서 실수를 줄일 수 있습니다.
인코더 구현 — 객체를 바이트로
MessageToByteEncoder를 상속해서 MyProtocolMessage 객체를 프로토콜 포맷에 맞게 바이트로 직렬화합니다.
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
// MyProtocolMessage를 바이트 스트림으로 변환하는 인코더
public class MyProtocolEncoder extends MessageToByteEncoder<MyProtocolMessage> {
@Override
protected void encode(ChannelHandlerContext ctx, MyProtocolMessage msg, ByteBuf out) {
// 1. Magic Number (4바이트)
out.writeInt(MyProtocolMessage.MAGIC_NUMBER);
// 2. Version (1바이트)
out.writeByte(msg.getVersion());
// 3. Command Code (1바이트)
out.writeByte(msg.getCommandType().getCode());
// 4. Body Length (4바이트)
byte[] body = msg.getBody();
out.writeInt(body != null ? body.length : 0);
// 5. Body (가변)
if (body != null && body.length > 0) {
out.writeBytes(body);
}
}
}
인코더는 단순합니다. 스펙에서 정의한 순서대로 writeInt(), writeByte(), writeBytes()를 호출하면 됩니다. 네티가 out ByteBuf를 자동으로 네트워크에 flush해 줍니다.
인코더 작성 시 주의할 점:
- 필드 순서가 스펙과 정확히 일치해야 합니다
writeInt()는 4바이트,writeByte()는 1바이트를 씁니다 — 크기를 헷갈리면 디코더에서 엉뚱한 값을 읽게 됩니다- body가 null일 수 있으므로 방어 코드가 필요합니다
디코더 구현 — 바이트를 객체로
디코더는 인코더보다 조금 더 복잡합니다. TCP의 점착/분할 패킷 문제를 처리해야 하기 때문입니다. LengthFieldBasedFrameDecoder + 커스텀 디코더 조합으로 이 문제를 해결합니다.
전략: 2단계 디코딩 파이프라인
바이트 스트림
↓
[LengthFieldBasedFrameDecoder] ← TCP 프레이밍 해결, 완전한 프레임으로 자름
↓
[MyProtocolDecoder] ← 프레임을 MyProtocolMessage 객체로 변환
↓
비즈니스 핸들러
LengthFieldBasedFrameDecoder가 바이트 스트림에서 완전한 메시지 프레임을 잘라주면, 우리 디코더는 ** 항상 완전한 메시지를 받는다고 가정 **하고 파싱만 하면 됩니다.
LengthFieldBasedFrameDecoder 설정
// 프레이밍 디코더 설정값 계산
// 헤더: [Magic(4)] [Version(1)] [Command(1)] [BodyLength(4)] [Body(...)]
//
// maxFrameLength = 1024 * 1024 + 10 (최대 바디 1MB + 헤더 10바이트)
// lengthFieldOffset = 6 (BodyLength 필드의 시작 위치)
// lengthFieldLength = 4 (BodyLength 필드 크기, int)
// lengthAdjustment = 0 (BodyLength 값이 바디 크기 그 자체)
// initialBytesToStrip = 0 (프레임 전체를 다음 핸들러에 전달)
new LengthFieldBasedFrameDecoder(
1024 * 1024 + 10, // maxFrameLength
6, // lengthFieldOffset
4, // lengthFieldLength
0, // lengthAdjustment
0 // initialBytesToStrip
);
각 파라미터가 어떤 의미인지 확인합니다.
| 파라미터 | 값 | 의미 |
|---|---|---|
| maxFrameLength | 1MB + 10 | 한 프레임의 최대 크기. 초과하면 예외 발생 |
| lengthFieldOffset | 6 | Magic(4) + Version(1) + Command(1) = 6바이트 뒤에 길이 필드 |
| lengthFieldLength | 4 | Body Length 필드가 int (4바이트) |
| lengthAdjustment | 0 | 길이 필드 값이 바디 크기 그대로이므로 보정 불필요 |
| initialBytesToStrip | 0 | 헤더도 포함해서 통째로 넘김 (우리 디코더에서 헤더도 읽어야 하므로) |
커스텀 디코더
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
// 프레이밍이 완료된 바이트를 MyProtocolMessage로 변환하는 디코더
public class MyProtocolDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// LengthFieldBasedFrameDecoder가 완전한 프레임을 보장하므로
// readableBytes() 체크는 방어 코드로만 둔다
if (in.readableBytes() < MyProtocolMessage.HEADER_LENGTH) {
return;
}
// 1. Magic Number 검증 (4바이트)
int magicNumber = in.readInt();
if (magicNumber != MyProtocolMessage.MAGIC_NUMBER) {
throw new IllegalStateException(
"잘못된 Magic Number: " + Integer.toHexString(magicNumber)
);
}
// 2. Version (1바이트)
byte version = in.readByte();
// 3. Command Code (1바이트)
byte commandCode = in.readByte();
CommandType commandType = CommandType.fromCode(commandCode);
// 4. Body Length (4바이트)
int bodyLength = in.readInt();
// 5. Body 읽기 (가변)
byte[] body = new byte[bodyLength];
if (bodyLength > 0) {
in.readBytes(body);
}
// 메시지 객체 생성
MyProtocolMessage message = new MyProtocolMessage(commandType, body);
message.setVersion(version);
out.add(message);
}
}
디코더에서 Magic Number를 검증하는 건 보안과 안정성 측면에서 중요합니다. 잘못된 클라이언트가 연결하거나 데이터가 손상됐을 때, 첫 4바이트에서 바로 걸러낼 수 있습니다.
비즈니스 핸들러
디코더가 만들어 준 MyProtocolMessage 객체를 받아 실제 로직을 처리하는 핸들러입니다.
서버 측 핸들러
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.nio.charset.StandardCharsets;
// 서버가 수신한 메시지를 처리하는 비즈니스 핸들러
public class ServerBusinessHandler extends SimpleChannelInboundHandler<MyProtocolMessage> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, MyProtocolMessage msg) {
System.out.println("서버 수신: " + msg);
switch (msg.getCommandType()) {
case HEARTBEAT:
// heartbeat에는 heartbeat로 응답
ctx.writeAndFlush(MyProtocolMessage.heartbeat());
break;
case LOGIN:
System.out.println("로그인 요청: " + msg.getBodyAsString());
// 로그인 성공 응답
MyProtocolMessage resp = new MyProtocolMessage(
CommandType.LOGIN_RESP,
"로그인 성공".getBytes(StandardCharsets.UTF_8)
);
ctx.writeAndFlush(resp);
break;
case MESSAGE:
System.out.println("메시지 수신: " + msg.getBodyAsString());
// 에코 응답
MyProtocolMessage echo = new MyProtocolMessage(
CommandType.MESSAGE_RESP,
msg.getBody()
);
ctx.writeAndFlush(echo);
break;
default:
System.out.println("알 수 없는 명령: " + msg.getCommandType());
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
클라이언트 측 핸들러
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.nio.charset.StandardCharsets;
// 클라이언트가 연결 후 메시지를 보내고 응답을 처리하는 핸들러
public class ClientBusinessHandler extends SimpleChannelInboundHandler<MyProtocolMessage> {
// 연결이 완료되면 로그인 요청을 보낸다
@Override
public void channelActive(ChannelHandlerContext ctx) {
MyProtocolMessage loginMsg = new MyProtocolMessage(
CommandType.LOGIN,
"user123".getBytes(StandardCharsets.UTF_8)
);
ctx.writeAndFlush(loginMsg);
System.out.println("클라이언트: 로그인 요청 전송");
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MyProtocolMessage msg) {
System.out.println("클라이언트 수신: " + msg);
System.out.println(" 응답 내용: " + msg.getBodyAsString());
// 로그인 성공 응답을 받으면 메시지를 보낸다
if (msg.getCommandType() == CommandType.LOGIN_RESP) {
MyProtocolMessage chatMsg = new MyProtocolMessage(
CommandType.MESSAGE,
"안녕하세요, 커스텀 프로토콜!".getBytes(StandardCharsets.UTF_8)
);
ctx.writeAndFlush(chatMsg);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
SimpleChannelInboundHandler<MyProtocolMessage>를 상속하면 타입 캐스팅 없이 바로 MyProtocolMessage를 받을 수 있습니다. 읽은 메시지의 참조 카운트 해제도 자동으로 처리됩니다.
전체 코드 — 서버와 클라이언트 부트스트랩
서버 부트스트랩
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
public class MyProtocolServer {
private static final int PORT = 8888;
public static void main(String[] args) throws InterruptedException {
// boss: 연결 수락, worker: I/O 처리
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 1단계: 프레이밍 — TCP 스트림을 메시지 단위로 자른다
pipeline.addLast("frameDecoder",
new LengthFieldBasedFrameDecoder(
1024 * 1024 + 10, 6, 4, 0, 0
));
// 2단계: 프로토콜 디코딩 — 바이트 → MyProtocolMessage
pipeline.addLast("protocolDecoder", new MyProtocolDecoder());
// 3단계: 프로토콜 인코딩 — MyProtocolMessage → 바이트
pipeline.addLast("protocolEncoder", new MyProtocolEncoder());
// 4단계: 비즈니스 로직
pipeline.addLast("businessHandler", new ServerBusinessHandler());
}
});
ChannelFuture future = bootstrap.bind(PORT).sync();
System.out.println("서버 시작 — 포트: " + PORT);
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
클라이언트 부트스트랩
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
public class MyProtocolClient {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 서버와 동일한 코덱 구성
pipeline.addLast("frameDecoder",
new LengthFieldBasedFrameDecoder(
1024 * 1024 + 10, 6, 4, 0, 0
));
pipeline.addLast("protocolDecoder", new MyProtocolDecoder());
pipeline.addLast("protocolEncoder", new MyProtocolEncoder());
pipeline.addLast("businessHandler", new ClientBusinessHandler());
}
});
ChannelFuture future = bootstrap.connect("localhost", 8888).sync();
System.out.println("서버에 연결됨");
future.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}
실행 결과
서버를 먼저 실행하고, 클라이언트를 실행하면 다음과 같은 출력을 볼 수 있습니다.
// 서버 콘솔
서버 시작 — 포트: 8888
서버 수신: MyProtocolMessage{version=1, commandType=LOGIN, bodyLength=7}
로그인 요청: user123
서버 수신: MyProtocolMessage{version=1, commandType=MESSAGE, bodyLength=36}
메시지 수신: 안녕하세요, 커스텀 프로토콜!
// 클라이언트 콘솔
서버에 연결됨
클라이언트: 로그인 요청 전송
클라이언트 수신: MyProtocolMessage{version=1, commandType=LOGIN_RESP, bodyLength=15}
응답 내용: 로그인 성공
클라이언트 수신: MyProtocolMessage{version=1, commandType=MESSAGE_RESP, bodyLength=36}
응답 내용: 안녕하세요, 커스텀 프로토콜!
파이프라인 구성 요약
전체 흐름을 한 그림으로 정리하면 이렇습니다.
[클라이언트] [서버]
MyProtocolMessage 바이트 수신
↓ encode ↓
[Encoder] [FrameDecoder]
↓ ↓
바이트 전송 ─── TCP 네트워크 ───→ [ProtocolDecoder]
↓
MyProtocolMessage
↓
[BusinessHandler]
↓ 응답
[Encoder]
↓
바이트 수신 ←── TCP 네트워크 ─── 바이트 전송
↓
[FrameDecoder]
↓
[ProtocolDecoder]
↓
MyProtocolMessage
↓
[BusinessHandler]
실무에서 추가로 고려할 점
이 글의 예제는 핵심 원리를 보여주기 위해 단순화한 버전입니다. 실무에서는 몇 가지를 더 고려해야 합니다.
- **직렬화 포맷 **: 바디를 단순 byte[]로 주고받으면 구조화된 데이터를 다루기 어렵습니다. JSON, Protobuf, MessagePack 같은 직렬화 라이브러리와 조합하는 것이 일반적입니다.
- **Idle 감지 **:
IdleStateHandler를 파이프라인에 추가해서, 일정 시간 동안 통신이 없으면 heartbeat를 보내거나 연결을 끊습니다. - ** 요청-응답 매칭 **: 비동기로 여러 요청을 보내면 응답 순서가 뒤섞일 수 있습니다. 헤더에 requestId 필드를 추가해서 요청과 응답을 매칭합니다.
- ** 압축 **: 바디가 크면 Snappy나 Gzip 압축을 적용합니다. 헤더에 압축 여부 플래그를 추가하면 됩니다.
- ** 보안 **: 민감한 데이터라면
SslHandler를 파이프라인 맨 앞에 추가하거나, 바디를 암호화합니다.
커스텀 프로토콜 설계에서 가장 중요한 건 "처음부터 확장 가능하게 만드는 것"입니다. Version 필드와 예약 비트를 넉넉히 두면, 나중에 필드를 추가해야 할 때 기존 클라이언트를 깨뜨리지 않을 수 있습니다.