HTTP나 WebSocket 같은 범용 프로토콜로 대부분의 통신을 해결할 수 있는데, 왜 굳이 바이트 단위로 프로토콜을 직접 만들어야 하는 상황이 생기는 걸까?

왜 커스텀 프로토콜이 필요한가

HTTP는 범용적이고 편리하지만, 모든 상황에 적합한 건 아닙니다. 특히 성능과 효율이 중요한 시스템 에서는 HTTP의 오버헤드가 부담이 됩니다.

  • **HTTP 헤더 오버헤드 **: 간단한 heartbeat 하나 보내는데도 수백 바이트의 헤더가 따라붙습니다
  • ** 텍스트 기반 파싱 비용 **: HTTP는 텍스트 프로토콜이라 파싱에 CPU를 더 소모합니다
  • ** 게임 서버 **: 초당 수만 개의 패킷을 처리해야 하는데, HTTP로는 지연 시간을 맞출 수 없습니다
  • **IoT 디바이스 **: 메모리와 대역폭이 제한된 환경에서 바이트 하나가 아쉽습니다
  • ** 사내 RPC**: 마이크로서비스 간 통신에서 불필요한 HTTP 오버헤드를 제거하고 싶을 때

이런 상황에서는 ** 필요한 정보만 담은 바이너리 프로토콜 **을 직접 설계하는 게 훨씬 효율적입니다. 네티는 이런 커스텀 프로토콜을 구현하기 위한 도구를 풍부하게 제공합니다.


프로토콜 구조 설계 — 헤더와 바디

커스텀 바이너리 프로토콜은 보통 ** 고정 길이 헤더 + 가변 길이 바디** 구조로 설계합니다.

PLAINTEXT
+--------+---------+----------+------------+------------------+
| 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 Number04 byteint0xCAFEBABE — 프로토콜 식별자
Version41 bytebyte프로토콜 버전 (현재 1)
Command Code51 bytebyte명령 타입 (0=heartbeat, 1=login, ...)
Body Length64 byteint바디의 바이트 수
Body10가변byte[]페이로드 데이터

** 제약 조건:**

  • 최대 바디 크기: 1MB (1024 * 1024)
  • Magic Number가 불일치하면 즉시 연결 종료
  • Body Length가 0이면 바디 없는 패킷 (heartbeat 등)

메시지 객체 정의

프로토콜 스펙을 Java 객체로 옮깁니다. 네트워크에서 읽은 바이트를 이 객체로 변환하고, 이 객체를 바이트로 직렬화하게 됩니다.

명령 타입 enum

JAVA
// 프로토콜에서 사용하는 명령 코드 정의
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

JAVA
// 프로토콜 메시지를 표현하는 객체
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 객체를 프로토콜 포맷에 맞게 바이트로 직렬화합니다.

JAVA
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단계 디코딩 파이프라인

PLAINTEXT
바이트 스트림

[LengthFieldBasedFrameDecoder]  ← TCP 프레이밍 해결, 완전한 프레임으로 자름

[MyProtocolDecoder]             ← 프레임을 MyProtocolMessage 객체로 변환

비즈니스 핸들러

LengthFieldBasedFrameDecoder가 바이트 스트림에서 완전한 메시지 프레임을 잘라주면, 우리 디코더는 ** 항상 완전한 메시지를 받는다고 가정 **하고 파싱만 하면 됩니다.

LengthFieldBasedFrameDecoder 설정

JAVA
// 프레이밍 디코더 설정값 계산
// 헤더: [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
);

각 파라미터가 어떤 의미인지 확인합니다.

파라미터의미
maxFrameLength1MB + 10한 프레임의 최대 크기. 초과하면 예외 발생
lengthFieldOffset6Magic(4) + Version(1) + Command(1) = 6바이트 뒤에 길이 필드
lengthFieldLength4Body Length 필드가 int (4바이트)
lengthAdjustment0길이 필드 값이 바디 크기 그대로이므로 보정 불필요
initialBytesToStrip0헤더도 포함해서 통째로 넘김 (우리 디코더에서 헤더도 읽어야 하므로)

커스텀 디코더

JAVA
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 객체를 받아 실제 로직을 처리하는 핸들러입니다.

서버 측 핸들러

JAVA
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();
    }
}

클라이언트 측 핸들러

JAVA
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를 받을 수 있습니다. 읽은 메시지의 참조 카운트 해제도 자동으로 처리됩니다.


전체 코드 — 서버와 클라이언트 부트스트랩

서버 부트스트랩

JAVA
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();
        }
    }
}

클라이언트 부트스트랩

JAVA
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();
        }
    }
}

실행 결과

서버를 먼저 실행하고, 클라이언트를 실행하면 다음과 같은 출력을 볼 수 있습니다.

PLAINTEXT
// 서버 콘솔
서버 시작 — 포트: 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}
  응답 내용: 안녕하세요, 커스텀 프로토콜!

파이프라인 구성 요약

전체 흐름을 한 그림으로 정리하면 이렇습니다.

PLAINTEXT
[클라이언트]                                    [서버]

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 필드와 예약 비트를 넉넉히 두면, 나중에 필드를 추가해야 할 때 기존 클라이언트를 깨뜨리지 않을 수 있습니다.

댓글 로딩 중...