C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]

C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]

이 글의 핵심

C++ 프로토콜 설계와 직렬화에 대한 실전 가이드입니다. TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3] 등을 예제와 함께 상세히 설명합니다.

들어가며: “TCP 스트림에서 메시지가 잘리거나 합쳐져요”

문제 시나리오

채팅 서버를 만들었는데, 클라이언트가 보낸 메시지가 이상하게 수신됩니다:

// 클라이언트: "안녕" + "하세요" 두 번 send
send(sock, "안녕", 6, 0);
send(sock, "하세요", 9, 0);

// 서버 recv 결과 (예상: "안녕" → "하세요")
// 실제: "안녕하세요" 한 번에 옴! 또는 "안" → "녕하세요" 로 나뉨!
char buf[1024];
recv(sock, buf, sizeof(buf), 0);  // 💥 메시지 경계를 알 수 없음

왜 이런 일이 발생할까요?

TCP바이트 스트림 프로토콜입니다. “한 번 send = 한 번 recv”가 보장되지 않습니다. 네트워크 스택이 데이터를 버퍼링하고, Nagle 알고리즘으로 여러 패킷을 합치며, MTU에 따라 분할합니다.

결과:

  • 메시지 합침: 여러 send가 한 recv에 도착
  • 메시지 잘림: 한 send가 여러 recv로 나뉨
  • 부분 수신: 헤더는 왔는데 payload가 아직 안 옴

해결책: 프로토콜에서 “메시지 경계”를 명시해야 합니다.

추가 문제 시나리오

시나리오 2: 게임 60fps 위치 전송 — 여러 send가 한 recv에 합쳐지거나, 한 send가 여러 recv로 나뉨 → 플레이어가 순간이동하거나 끊김.

시나리오 3: IoT 센서 — 온도(4B)+습도(4B)+조도(4B) 순차 전송 시, recv가 5바이트만 반환하면 어떤 필드가 잘렸는지 알 수 없음.

시나리오 4: 대용량 전송 — 4바이트 길이만 수신 후 연결 끊김. payload가 올지 모르는 상태에서 타임아웃 없으면 영원히 블로킹.

목표:

  • 길이 프리픽스 프로토콜 완전 구현 (파서 포함)
  • 직렬화 포맷 비교 (JSON/Protobuf/MessagePack/FlatBuffers)
  • 엔디안 처리 실전 예시
  • 일반적인 에러와 해결법
  • 성능 벤치마크
  • 프로덕션 예시 (채팅, 게임)

요구 환경: C++17 이상, Boost.Asio (선택)

이 글을 읽으면:

  • TCP 위에 안정적인 프로토콜을 설계할 수 있습니다.
  • 메시지 파서를 직접 구현할 수 있습니다.
  • 요구사항에 맞는 직렬화 포맷을 선택할 수 있습니다.

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. 메시지 경계 방식
  2. 길이 프리픽스 프로토콜 완전 구현
  3. 바이너리 직렬화 기초
  4. JSON vs Protobuf vs MessagePack vs FlatBuffers 비교
  5. 엔디안 처리
  6. 일반적인 에러와 해결법
  7. 성능 벤치마크
  8. 프로덕션 예시
  9. 버전·호환성
  10. 모범 사례와 프로덕션 패턴

1. 메시지 경계 방식

메시지 프레이밍 개요

flowchart LR
    subgraph TCP["TCP 스트림 (경계 없음)"]
        B1[바이트1]
        B2[바이트2]
        B3[바이트3]
        B4[...]
    end
    
    subgraph Protocol["프로토콜이 경계 정의"]
        M1[메시지1]
        M2[메시지2]
        M3[메시지3]
    end
    
    TCP --> Protocol

세 가지 방식 비교

방식설명장점단점사용 예
길이 프리픽스헤더에 payload 길이 저장임의 크기, 효율적구현 복잡대부분의 바이너리 프로토콜
구분자\n 또는 \r\n으로 분리구현 간단payload에 구분자 포함 불가HTTP, Redis, 텍스트 프로토콜
고정 크기모든 메시지 동일 크기파싱 없음낭비, 유연성 없음게임 입력, 센서 데이터

길이 프리픽스가 가장 범용적입니다. 이 글에서는 이를 완전히 구현합니다.


2. 길이 프리픽스 프로토콜 완전 구현

프로토콜 포맷

flowchart LR
    subgraph Frame["프레임 구조"]
        H[헤더 4B]
        P[Payload N bytes]
    end
    
    subgraph Header["헤더 상세"]
        L[Length: uint32_t little-endian]
    end
    
    H --> L

프레임 구조: [4바이트 길이 (little-endian)][N바이트 payload]

송신 구현

#include <cstdint>
#include <cstring>
#include <vector>
#include <string>
#include <boost/asio.hpp>

namespace protocol {

// 네트워크 바이트 순서로 uint32_t 변환 (little-endian)
inline uint32_t to_network_order(uint32_t value) {
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    return value;  // x86/ARM: 이미 little-endian
#else
    return __builtin_bswap32(value);
#endif
}

// 송신: 길이(4바이트) + payload
void send_message(
    boost::asio::ip::tcp::socket& socket,
    const std::string& payload
) {
    uint32_t len = static_cast<uint32_t>(payload.size());
    
    // 최대 크기 검증 (DoS 방지)
    constexpr uint32_t MAX_MESSAGE_SIZE = 1024 * 1024;  // 1MB
    if (len > MAX_MESSAGE_SIZE) {
        throw std::runtime_error("Message too large");
    }
    
    std::vector<char> buffer(4 + payload.size());
    uint32_t len_net = to_network_order(len);
    std::memcpy(buffer.data(), &len_net, 4);
    std::memcpy(buffer.data() + 4, payload.data(), payload.size());
    
    boost::asio::write(socket, boost::asio::buffer(buffer));
}

}  // namespace protocol

수신 파서 구현 (핵심)

TCP 스트림에서 메시지 경계를 찾는 상태 기반 파서입니다.

#include <cstdint>
#include <cstring>
#include <vector>
#include <functional>
#include <boost/asio.hpp>

namespace protocol {

class MessageParser {
public:
    using MessageCallback = std::function<void(std::string_view)>;
    
    static constexpr uint32_t MAX_MESSAGE_SIZE = 1024 * 1024;
    static constexpr size_t HEADER_SIZE = 4;
    
    explicit MessageParser(MessageCallback on_message)
        : on_message_(std::move(on_message)) {}
    
    // 버퍼에 데이터 추가 후 파싱 시도
    // recv로 받은 데이터를 그대로 append_buffer에 넣고 parse() 호출
    void append_and_parse(const char* data, size_t size) {
        buffer_.insert(buffer_.end(), data, data + size);
        parse();
    }
    
    void append_and_parse(std::string_view data) {
        buffer_.insert(buffer_.end(), data.begin(), data.end());
        parse();
    }
    
private:
    std::vector<char> buffer_;
    MessageCallback on_message_;
    
    static uint32_t from_network_order(uint32_t value) {
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
        return value;
#else
        return __builtin_bswap32(value);
#endif
    }
    
    void parse() {
        while (true) {
            // 1. 헤더(4바이트) 수신 대기
            if (buffer_.size() < HEADER_SIZE) {
                return;  // 더 데이터 필요
            }
            
            uint32_t payload_len;
            std::memcpy(&payload_len, buffer_.data(), HEADER_SIZE);
            payload_len = from_network_order(payload_len);
            
            // 2. 유효성 검사 (보안)
            if (payload_len > MAX_MESSAGE_SIZE) {
                throw std::runtime_error("Invalid message length: too large");
            }
            
            // 3. payload 수신 대기
            size_t frame_size = HEADER_SIZE + payload_len;
            if (buffer_.size() < frame_size) {
                return;  // 더 데이터 필요
            }
            
            // 4. 완전한 메시지 추출
            std::string_view message(
                buffer_.data() + HEADER_SIZE,
                payload_len
            );
            on_message_(message);
            
            // 5. 처리한 데이터 제거
            buffer_.erase(buffer_.begin(), buffer_.begin() + frame_size);
        }
    }
};

}  // namespace protocol

Boost.Asio와 연동

#include <boost/asio.hpp>
#include <iostream>

using boost::asio::ip::tcp;

class LengthPrefixSession : public std::enable_shared_from_this<LengthPrefixSession> {
    tcp::socket socket_;
    std::array<char, 4096> recv_buffer_;
    protocol::MessageParser parser_;
    
public:
    LengthPrefixSession(tcp::socket socket)
        : socket_(std::move(socket)),
          parser_([this](std::string_view msg) { on_message(msg); }) {}
    
    void start() {
        do_read();
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        
        socket_.async_read_some(
            boost::asio::buffer(recv_buffer_),
            [this, self](boost::system::error_code ec, std::size_t bytes) {
                if (!ec) {
                    parser_.append_and_parse(
                        recv_buffer_.data(),
                        bytes
                    );
                    do_read();  // 다음 읽기
                }
            }
        );
    }
    
    void on_message(std::string_view msg) {
        std::cout << "Received: " << msg << "\n";
        
        // Echo back
        protocol::send_message(
            socket_,
            std::string(msg)
        );
    }
};

// 사용 예시
void run_echo_server() {
    boost::asio::io_context io;
    tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
    
    std::function<void()> do_accept;
    do_accept = [&]() {
        acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
            if (!ec) {
                std::make_shared<LengthPrefixSession>(std::move(socket))->start();
            }
            do_accept();
        });
    };
    do_accept();
    
    io.run();
}

3. 바이너리 직렬화 기초

완전한 바이너리 프로토콜 예시 (길이 프리픽스 + 타입)

// 프로토콜: [4B length LE][1B type][payload]
// type: 0=ping, 1=pong, 2=chat, 3=game_input
#include <cstdint>
#include <vector>
#include <cstring>

enum class MsgType : uint8_t { Ping = 0, Pong = 1, Chat = 2, GameInput = 3 };

std::vector<char> encode_chat(const std::string& user, const std::string& text) {
    std::vector<char> payload;
    payload.push_back(static_cast<char>(MsgType::Chat));
    
    uint32_t ulen = user.size();
    payload.insert(payload.end(), (char*)&ulen, (char*)&ulen + 4);
    payload.insert(payload.end(), user.begin(), user.end());
    
    uint32_t tlen = text.size();
    payload.insert(payload.end(), (char*)&tlen, (char*)&tlen + 4);
    payload.insert(payload.end(), text.begin(), text.end());
    
    uint32_t total = 4 + payload.size();  // 헤더 4B + payload
    std::vector<char> frame(4 + payload.size());
    std::memcpy(frame.data(), &total, 4);  // LE (x86)
    std::memcpy(frame.data() + 4, payload.data(), payload.size());
    
    return frame;
}

직렬화 흐름

flowchart LR
    subgraph App["애플리케이션"]
        O[객체/구조체]
    end
    
    subgraph Serialize["직렬화"]
        S[Serialize]
        D[Deserialize]
    end
    
    subgraph Wire["전송"]
        B[바이트 스트림]
    end
    
    O -->|Serialize| S
    S --> B
    B -->|Deserialize| D
    D --> O

고정 필드 레이아웃

#include <cstdint>
#include <cstring>

#pragma pack(push, 1)  // 패딩 제거 (네트워크 프로토콜 필수)
struct PlayerPosition {
    int32_t x;
    int32_t y;
    int32_t z;
    uint32_t timestamp;
};
#pragma pack(pop)

// 직렬화
void serialize_position(const PlayerPosition& pos, char* buffer) {
    std::memcpy(buffer, &pos, sizeof(PlayerPosition));
    // 주의: 엔디안 통일 필요 (다음 섹션 참조)
}

// 역직렬화
PlayerPosition deserialize_position(const char* buffer) {
    PlayerPosition pos;
    std::memcpy(&pos, buffer, sizeof(PlayerPosition));
    return pos;
}

가변 필드 (길이 + 데이터)

// 문자열: [4바이트 길이][UTF-8 바이트]
void serialize_string(const std::string& s, std::vector<char>& out) {
    uint32_t len = static_cast<uint32_t>(s.size());
    out.resize(4 + s.size());
    std::memcpy(out.data(), &len, 4);
    std::memcpy(out.data() + 4, s.data(), s.size());
}

std::string deserialize_string(const char* data, size_t& offset) {
    uint32_t len;
    std::memcpy(&len, data + offset, 4);
    offset += 4;
    
    std::string result(data + offset, len);
    offset += len;
    return result;
}

4. JSON vs Protobuf vs MessagePack 비교

포맷별 특성

포맷크기속도가독성스키마호환성Zero-copy
JSON느림높음없음최고
Protobuf작음빠름낮음필수좋음
MessagePack중간빠름낮음없음좋음
FlatBuffers작음매우 빠름낮음필수좋음

JSON (nlohmann/json)

#include <nlohmann/json.hpp>
#include <string>

using json = nlohmann::json;

// 채팅 메시지
struct ChatMessage {
    std::string user;
    std::string text;
    int64_t timestamp;
};

// 직렬화
std::string serialize_chat_json(const ChatMessage& msg) {
    json j;
    j["user"] = msg.user;
    j["text"] = msg.text;
    j["timestamp"] = msg.timestamp;
    return j.dump();
}

// 역직렬화
ChatMessage deserialize_chat_json(const std::string& data) {
    auto j = json::parse(data);
    return {
        j["user"].get<std::string>(),
        j["text"].get<std::string>(),
        j["timestamp"].get<int64_t>()
    };
}

// 사용
void example_json() {
    ChatMessage msg{"alice", "Hello!", 1234567890};
    auto serialized = serialize_chat_json(msg);
    // 결과: {"user":"alice","text":"Hello!","timestamp":1234567890}
    // 크기: ~50 bytes
}

Protocol Buffers

// chat.proto
syntax = "proto3";

message ChatMessage {
    string user = 1;
    string text = 2;
    int64 timestamp = 3;
}
// C++ (protoc로 생성된 코드 사용)
#include "chat.pb.h"
#include <string>

std::string serialize_chat_protobuf(const ChatMessage& msg) {
    chat::ChatMessage pb;
    pb.set_user(msg.user);
    pb.set_text(msg.text);
    pb.set_timestamp(msg.timestamp);
    
    std::string out;
    pb.SerializeToString(&out);
    return out;
}

ChatMessage deserialize_chat_protobuf(const std::string& data) {
    chat::ChatMessage pb;
    pb.ParseFromString(data);
    
    return {
        pb.user(),
        pb.text(),
        pb.timestamp()
    };
}

// 동일 데이터 크기: ~25 bytes (JSON의 50%)

MessagePack

#include <msgpack.hpp>
#include <string>
#include <vector>

std::vector<char> serialize_chat_msgpack(const ChatMessage& msg) {
    msgpack::sbuffer sbuf;
    msgpack::pack(sbuf, std::make_tuple(msg.user, msg.text, msg.timestamp));
    return std::vector<char>(sbuf.data(), sbuf.data() + sbuf.size());
}

ChatMessage deserialize_chat_msgpack(const char* data, size_t size) {
    msgpack::object_handle oh = msgpack::unpack(data, size);
    auto obj = oh.get();
    
    std::string user, text;
    int64_t timestamp;
    obj.convert(std::tie(user, text, timestamp));
    
    return {user, text, timestamp};
}

// 동일 데이터 크기: ~35 bytes (JSON의 70%, Protobuf보다 큼)

FlatBuffers (Zero-copy 직렬화)

특징: 직렬화된 버퍼를 파싱 없이 직접 접근. 게임, 고성능 서버에 적합.

// ChatMessage.fbs
table ChatMessage {
    user: string;
    text: string;
    timestamp: long;
}
root_type ChatMessage;
// C++ 직렬화
flatbuffers::FlatBufferBuilder builder(1024);
auto msg = chat::CreateChatMessage(builder,
    builder.CreateString("alice"), builder.CreateString("Hello!"), 1234567890);
builder.Finish(msg);
// builder.GetBufferPointer(), GetSize()로 전송

// 역직렬화: Zero-copy! 파싱 없이 직접 접근
auto parsed = chat::GetChatMessage(buf);
std::string user = parsed->user()->str();

Protobuf vs FlatBuffers: Protobuf는 파싱 시 객체 생성(복사), FlatBuffers는 버퍼를 그대로 참조.

선택 가이드

// JSON: REST API, 웹 연동, 디버깅 용이
if (need_web_compatibility || need_debugging) {
    use_json();
}

// Protobuf: 고성능, 스키마 진화, 다국어
if (need_performance && have_schema) {
    use_protobuf();
}

// MessagePack: JSON보다 빠르고 작음, 스키마 없음
if (need_smaller_than_json && no_schema) {
    use_msgpack();
}

// FlatBuffers: 게임, 실시간 스트리밍, 메모리 제약 환경
if (need_zero_copy || need_minimal_latency) {
    use_flatbuffers();
}

5. 엔디안 처리

문제: 바이트 순서 불일치

// x86 (little-endian): 0x12345678 → 78 56 34 12
// 네트워크 (big-endian): 0x12345678 → 12 34 56 78

uint32_t value = 0x12345678;
send(sock, &value, 4, 0);  // 💥 다른 CPU에서 잘못 해석!

해결: 명시적 변환

#include <cstdint>
#include <cstring>

// 방법 1: 수동 바이트 스왑
inline uint32_t htonl_custom(uint32_t host_long) {
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    return __builtin_bswap32(host_long);
#else
    return host_long;
#endif
}

inline uint32_t ntohl_custom(uint32_t net_long) {
    return htonl_custom(net_long);  // 대칭
}

// 방법 2: POSIX 함수 (네트워크 바이트 순서 = big-endian)
#include <arpa/inet.h>

void serialize_with_endianness() {
    uint32_t value = 12345;
    uint32_t net_value = htonl(value);  // Host to Network (big-endian)
    
    char buffer[4];
    std::memcpy(buffer, &net_value, 4);
    send(sock, buffer, 4, 0);
}

void deserialize_with_endianness(const char* buffer) {
    uint32_t net_value;
    std::memcpy(&net_value, buffer, 4);
    uint32_t value = ntohl(net_value);  // Network to Host
}

// 방법 3: 프로토콜에서 little-endian 고정 (많은 게임/실시간 프로토콜)
inline uint32_t to_le(uint32_t v) {
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
    return __builtin_bswap32(v);
#else
    return v;
#endif
}

다중 타입 지원

#include <type_traits>

template<typename T>
T to_network_order(T value) {
    if constexpr (sizeof(T) == 2) {
        return __builtin_bswap16(value);
    } else if constexpr (sizeof(T) == 4) {
        return __builtin_bswap32(value);
    } else if constexpr (sizeof(T) == 8) {
        return __builtin_bswap64(value);
    }
    return value;
}

// 사용
uint16_t port = to_network_order(static_cast<uint16_t>(8080));
uint64_t id = to_network_order(static_cast<uint64_t>(12345));

프로토콜별 엔디안 관례

프로토콜엔디안비고
TCP/IPBig-endianhtonl/ntohl
게임Little-endianx86/ARM 호환
ProtobufLittle-endian (Varint)가변 길이

6. 일반적인 에러와 해결법

에러 1: 불완전한 메시지 (Incomplete Message)

증상: 헤더는 왔는데 payload가 부족

// ❌ 잘못된 처리: recv한 만큼만 파싱
void bad_parse(const char* data, size_t size) {
    if (size >= 4) {
        uint32_t len;
        memcpy(&len, data, 4);
        if (size >= 4 + len) {
            // OK
        } else {
            // 💥 부족한 데이터 버림! 다음 recv와 이어받아야 함
        }
    }
}

해결: 버퍼에 누적 후 파싱 (위 MessageParser 참조)

// ✅ 올바른 처리
class MessageParser {
    std::vector<char> buffer_;  // 누적 버퍼
    
    void append_and_parse(const char* data, size_t size) {
        buffer_.insert(buffer_.end(), data, data + size);
        while (can_extract_message()) {
            extract_and_dispatch();
        }
    }
};

에러 2: 파싱 오류 (Invalid Data)

증상: 잘못된 길이 값으로 메모리 초과 할당

// ❌ 위험: 길이 검증 없음
uint32_t len;
memcpy(&len, data, 4);
std::vector<char> payload(len);  // 💥 len = 0xFFFFFFFF → 4GB 할당!

해결: 최대 크기 검증

// ✅ 안전
constexpr uint32_t MAX_SIZE = 1024 * 1024;
if (len > MAX_SIZE || len == 0) {
    throw std::runtime_error("Invalid message length");
}

에러 3: 엔디안 혼동

증상: 다른 플랫폼에서 숫자가 잘못 해석됨

// ❌ 플랫폼 의존
uint32_t len;
memcpy(&len, data, 4);  // x86에서만 올바름

해결: 프로토콜 스펙에 엔디안 명시 후 일관 적용

에러 4: JSON 파싱 예외

// ❌ 예외 무시
ChatMessage msg = deserialize_chat_json(data);  // 잘못된 JSON 시 예외

해결: try-catch 및 로깅

// ✅
try {
    auto msg = deserialize_chat_json(data);
    handle_message(msg);
} catch (const json::parse_error& e) {
    spdlog::error("Invalid JSON: {}", e.what());
    disconnect_client();
}

에러 5: Protobuf 필드 누락

// 구버전 클라이언트가 새 필드 없이 전송
// ✅ Protobuf는 optional/기본값으로 호환
// proto3: 필드 없으면 기본값 (0, "", false)

에러 6: recv 반환값 무시

// ❌ n=0(연결종료), n=-1(에러) 처리 없음
// ✅ if (n > 0) 파싱; else if (n == 0) close; else errno 체크

에러 7: 패딩/정렬 불일치

// ❌ 플랫폼마다 구조체 크기 다름
struct BadLayout {
    char a;      // 1 byte
    int32_t b;   // 4 bytes → a 뒤에 3바이트 패딩 (플랫폼 의존)
};
// sizeof(BadLayout): 32비트=8, 일부 플랫폼=5 (패킹 시)

해결: #pragma pack(push, 1) 또는 __attribute__((packed))로 명시

// ✅ 네트워크 프로토콜용
#pragma pack(push, 1)
struct NetworkLayout {
    char a;
    int32_t b;
};
#pragma pack(pop)

에러 8: 버퍼 오버플로우 (길이 필드 조작)

// ❌ length=0x7FFFFFFF → 2GB 할당 시도 (DoS)
// ✅ 최대 크기 검증 + rate limiting

7. 성능 벤치마크

직렬화/역직렬화 속도 (ChatMessage 10만 회)

포맷직렬화 (μs)역직렬화 (μs)크기 (bytes)
JSON2,1002,80052
MessagePack18022038
Protobuf455524
FlatBuffers358 (zero-copy)26
수동 바이너리121520

메시지 크기 비교 (동일 데이터)

원본: user="alice", text="Hello, World!", timestamp=1234567890

JSON:        {"user":"alice","text":"Hello, World!","timestamp":1234567890}
             → 52 bytes

MessagePack: [0xa5, 0x75, 0x73, 0x65, 0x72, ...]  (바이너리)
             → 38 bytes (-27%)

Protobuf:    [0x0a, 0x05, 0x61, 0x6c, 0x69, ...]
             → 24 bytes (-54%)

처리량 (메시지/초, 단일 스레드)

포맷100B 메시지1KB 메시지10KB 메시지
JSON45,0008,000900
MessagePack450,00085,0009,500
Protobuf1,200,000220,00025,000

결론: 고성능이 필요하면 Protobuf, 웹 호환이 필요하면 JSON, 중간은 MessagePack.


8. 프로덕션 예시

예시 1: 채팅 프로토콜

// 채팅 메시지 타입
enum class ChatMessageType : uint8_t {
    Text = 1,
    Join = 2,
    Leave = 3,
    Whisper = 4
};

// 프레임: [4B length][1B type][payload]
struct ChatProtocol {
    static std::vector<char> encode_text(const std::string& user, const std::string& text) {
        std::vector<char> payload;
        payload.push_back(static_cast<char>(ChatMessageType::Text));
        
        // user (length-prefixed)
        uint32_t ulen = user.size();
        payload.insert(payload.end(), (char*)&ulen, (char*)&ulen + 4);
        payload.insert(payload.end(), user.begin(), user.end());
        
        // text
        uint32_t tlen = text.size();
        payload.insert(payload.end(), (char*)&tlen, (char*)&tlen + 4);
        payload.insert(payload.end(), text.begin(), text.end());
        
        // 전체 프레임
        uint32_t total = 4 + payload.size();
        std::vector<char> frame(4 + payload.size());
        uint32_t net_total = to_network_order(total);
        std::memcpy(frame.data(), &net_total, 4);
        std::memcpy(frame.data() + 4, payload.data(), payload.size());
        
        return frame;
    }
    
    static void decode_text(const char* data, size_t size,
                            std::string& user, std::string& text) {
        size_t offset = 1;  // type 건너뛰기
        
        uint32_t ulen;
        std::memcpy(&ulen, data + offset, 4);
        offset += 4;
        user.assign(data + offset, ulen);
        offset += ulen;
        
        uint32_t tlen;
        std::memcpy(&tlen, data + offset, 4);
        offset += 4;
        text.assign(data + offset, tlen);
    }
};

예시 2: 게임 프로토콜 (고정 + 가변)

// 게임 입력: 고정 크기 (빠른 파싱)
#pragma pack(push, 1)
struct GameInput {
    uint8_t type;      // 1=이동, 2=공격, 3=스킬
    int16_t x, y;      // 좌표
    uint32_t seq;      // 시퀀스 번호 (재전송용)
    uint32_t timestamp;
};
#pragma pack(pop)

// 게임 상태 스냅샷: 가변 (덜티)
struct GameStateUpdate {
    uint32_t entity_count;
    struct Entity {
        uint32_t id;
        float x, y, z;
        uint16_t health;
    };
    // entity_count만큼 Entity 반복
};

void serialize_game_input(const GameInput& input, char* buf) {
    // 엔디안 변환
    buf[0] = input.type;
    *(int16_t*)(buf + 1) = to_network_order(static_cast<uint16_t>(input.x));
    *(int16_t*)(buf + 3) = to_network_order(static_cast<uint16_t>(input.y));
    *(uint32_t*)(buf + 5) = to_network_order(input.seq);
    *(uint32_t*)(buf + 9) = to_network_order(input.timestamp);
}

9. 버전·호환성

프로토콜 버전 필드

// 헤더: [4B length][2B version][2B type][payload]
struct ProtocolHeader {
    uint32_t length;
    uint16_t version;  // 1, 2, 3...
    uint16_t message_type;
};

// 구버전 클라이언트: version=1, 새 필드 무시
// 신버전: version=2, 선택 필드 해석

Protobuf 호환성

  • 필드 번호 변경 금지
  • 삭제 대신 reserved 사용
  • 새 필드 추가 시 optional 또는 기본값
message ChatMessage {
    reserved 2;  // 삭제된 필드
    string user = 1;
    string text = 3;  // 2 대신 3 사용
    int64 timestamp = 4;
    optional string room = 5;  // 새 필드 (구버전은 무시)
}

10. 모범 사례와 프로덕션 패턴

모범 사례 요약

항목권장비권장
메시지 경계길이 프리픽스 (4B 또는 2B)구분자만 사용 (payload 제한)
최대 크기1MB 이하, DoS 방지무제한
엔디안프로토콜 스펙에 명시 (LE/BE)플랫폼 의존
버전헤더에 버전 필드스키마 없이 변경
에러 처리try-catch, 로깅, 연결 종료무시
직렬화요구사항에 맞게 선택무조건 JSON

프로덕션 패턴 1: 메시지 타입 디스패칭

// 헤더: [4B length][2B type][payload]
void dispatch_message(std::string_view payload) {
    uint16_t type;
    std::memcpy(&type, payload.data(), 2);
    type = ntohs(type);
    std::string_view body(payload.data() + 2, payload.size() - 2);
    switch (type) {
        case 1: handle_chat(body); break;
        case 2: handle_heartbeat(body); break;
        case 3: handle_auth(body); break;
        default: log_unknown_type(type);
    }
}

프로덕션 패턴 2: 타임아웃과 재시도

// 불완전 메시지 대기 시 타임아웃
class MessageParserWithTimeout {
    MessageParser parser_;
    std::chrono::steady_clock::time_point last_data_;
    static constexpr auto TIMEOUT = std::chrono::seconds(30);
public:
    void append_and_parse(const char* data, size_t size) {
        last_data_ = std::chrono::steady_clock::now();
        parser_.append_and_parse(data, size);
    }
    bool is_stale() const {
        return std::chrono::steady_clock::now() - last_data_ > TIMEOUT;
    }
    // 주기적으로 is_stale() 체크 → 타임아웃 시 연결 종료
};

프로덕션 패턴 3: 완전한 바이너리 프로토콜 헤더

// 실전 게임: [4B len][2B ver][2B type][4B seq][payload]
#pragma pack(push, 1)
struct GameProtocolHeader {
    uint32_t length;
    uint16_t version;
    uint16_t msg_type;
    uint32_t sequence;
};
#pragma pack(pop)

체크리스트

구현 체크리스트

  • 길이 프리픽스 파서 구현 (버퍼 누적)
  • 최대 메시지 크기 제한 (DoS 방지)
  • 엔디안 통일 (프로토콜 스펙 명시)
  • 직렬화 포맷 선택 (JSON/Protobuf/MessagePack/FlatBuffers)
  • 파싱 에러 처리 (try-catch, 로깅)
  • 프로토콜 버전 필드 (호환성)

프로덕션 체크리스트

  • 압축 (선택, 큰 payload)
  • 암호화 (TLS 위에서)
  • 메시지 타임아웃
  • 재연결 시 시퀀스 번호

정리

항목내용
경계길이 프리픽스, 구분자, 고정 크기
파서버퍼 누적 → 헤더 파싱 → payload 완성 시 추출
직렬화JSON(호환), Protobuf(성능), MessagePack(중간), FlatBuffers(zero-copy)
엔디안프로토콜 스펙에 명시, htonl/ntohl 또는 수동
에러불완전 메시지(누적), 잘못된 길이(검증), 파싱 예외
버전헤더에 버전, 선택 필드로 확장

핵심 원칙:

  1. TCP는 스트림이므로 프로토콜이 경계를 정의해야 함
  2. 길이 프리픽스가 가장 범용적
  3. 버퍼 누적 없이 파싱 불가
  4. 최대 크기 검증 필수
  5. 엔디안 통일 필수

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 채팅 서버, 게임 서버, 실시간 통신, IoT 프로토콜 등 TCP 기반 애플리케이션에서 필수입니다. 메시지 경계와 직렬화 포맷 선택은 서비스 성능과 확장성에 직접적인 영향을 미칩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. Protocol Buffers, MessagePack, nlohmann/json 문서도 활용하면 좋습니다.

Q. JSON과 Protobuf 중 뭘 써야 하나요?

A. 웹/REST 연동이 필요하면 JSON. 고성능·저지연이 필요하면 Protobuf. 디버깅 용이성이 중요하면 JSON. 대역폭 절약이 중요하면 Protobuf.

Q. UDP는 어떻게 하나요?

A. UDP는 데이터그램이라 한 번 send = 한 번 recv가 보장됩니다. 하지만 패킷 손실·재ordering이 있으므로, 게임 등에서는 커스텀 프로토콜(시퀀스 번호, ACK)을 올립니다.

한 줄 요약: 길이 프리픽스와 버퍼 누적 파서로 TCP 스트림에서 안정적인 메시지 경계를 만들 수 있습니다.

이전 글: C++ 실전 가이드 #30-2: SSL/TLS

다음 글: [C++ 실전 가이드 #31-1] 채팅 서버 만들기: 다중 클라이언트와 메시지 브로드캐스트


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
  • C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]
  • C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]

이 글에서 다루는 키워드 (관련 검색어)

C++, 프로토콜, 직렬화, TCP, 메시지경계, 바이너리, Protobuf, MessagePack, FlatBuffers 등으로 검색하시면 이 글이 도움이 됩니다.


관련 글

  • C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
  • C++ WebSocket 완벽 가이드 | Beast 핸드셰이크·프레임·Ping/Pong [#30-1]
  • C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]
  • C++ WebSocket 심화 가이드 | 핸드셰이크·프레임·Ping/Pong·에러·프로덕션 패턴
  • C++ Protocol Buffers 완벽 가이드 | 직렬화·스키마 진화·성능 최적화·프로덕션 패턴