[2026] C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]
이 글의 핵심
C++ 프로토콜 설계와 직렬화: TCP 메시지 경계·길이 프리픽스·바이너리 포맷 [#3…. 실무에서 겪은 문제·메시지 경계 방식.
들어가며: “TCP 스트림에서 메시지가 잘리거나 합쳐져요”
문제 시나리오
채팅 서버를 만들었는데, 클라이언트가 보낸 메시지가 이상하게 수신됩니다: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 클라이언트: "안녕" + "하세요" 두 번 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는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 메시지 경계 방식
- 길이 프리픽스 프로토콜 완전 구현
- 바이너리 직렬화 기초
- JSON vs Protobuf vs MessagePack vs FlatBuffers 비교
- 엔디안 처리
- 일반적인 에러와 해결법
- 성능 벤치마크
- 프로덕션 예시
- 버전·호환성
- 모범 사례와 프로덕션 패턴
1. 메시지 경계 방식
메시지 프레이밍 개요
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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. 길이 프리픽스 프로토콜 완전 구현
프로토콜 포맷
아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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]
송신 구현
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 스트림에서 메시지 경계를 찾는 상태 기반 파서입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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와 연동
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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. 바이너리 직렬화 기초
완전한 바이너리 프로토콜 예시 (길이 프리픽스 + 타입)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 프로토콜: [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;
}
직렬화 흐름
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
고정 필드 레이아웃
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
가변 필드 (길이 + 데이터)
다음은 cpp를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 문자열: [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
아래 코드는 protobuf를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// chat.proto
syntax = "proto3";
message ChatMessage {
string user = 1;
string text = 2;
int64 timestamp = 3;
}
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 직렬화)
특징: 직렬화된 버퍼를 파싱 없이 직접 접근. 게임, 고성능 서버에 적합. 아래 코드는 flatbuffers를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ChatMessage.fbs
table ChatMessage {
user: string;
text: string;
timestamp: long;
}
root_type ChatMessage;
아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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는 버퍼를 그대로 참조.
선택 가이드
다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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. 엔디안 처리
문제: 바이트 순서 불일치
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 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에서 잘못 해석!
해결: 명시적 변환
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
}
다중 타입 지원
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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/IP | Big-endian | htonl/ntohl |
| 게임 | Little-endian | x86/ARM 호환 |
| Protobuf | Little-endian (Varint) | 가변 길이 |
6. 일반적인 에러와 해결법
에러 1: 불완전한 메시지 (Incomplete Message)
증상: 헤더는 왔는데 payload가 부족 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 처리: 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 참조)
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 올바른 처리
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)
증상: 잘못된 길이 값으로 메모리 초과 할당 다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험: 길이 검증 없음
uint32_t len;
memcpy(&len, data, 4);
std::vector<char> payload(len); // 💥 len = 0xFFFFFFFF → 4GB 할당!
해결: 최대 크기 검증 아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 안전
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 및 로깅 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅
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: 패딩/정렬 불일치
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 플랫폼마다 구조체 크기 다름
struct BadLayout {
char a; // 1 byte
int32_t b; // 4 bytes → a 뒤에 3바이트 패딩 (플랫폼 의존)
};
// sizeof(BadLayout): 32비트=8, 일부 플랫폼=5 (패킹 시)
해결: #pragma pack(push, 1) 또는 __attribute__((packed))로 명시
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 네트워크 프로토콜용
#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) |
|---|---|---|---|
| JSON | 2,100 | 2,800 | 52 |
| MessagePack | 180 | 220 | 38 |
| Protobuf | 45 | 55 | 24 |
| FlatBuffers | 35 | 8 (zero-copy) | 26 |
| 수동 바이너리 | 12 | 15 | 20 |
메시지 크기 비교 (동일 데이터)
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
원본: 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 메시지 |
|---|---|---|---|
| JSON | 45,000 | 8,000 | 900 |
| MessagePack | 450,000 | 85,000 | 9,500 |
| Protobuf | 1,200,000 | 220,000 | 25,000 |
| 결론: 고성능이 필요하면 Protobuf, 웹 호환이 필요하면 JSON, 중간은 MessagePack. |
8. 프로덕션 예시
예시 1: 채팅 프로토콜
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 채팅 메시지 타입
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: 게임 프로토콜 (고정 + 가변)
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 게임 입력: 고정 크기 (빠른 파싱)
#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. 버전·호환성
프로토콜 버전 필드
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 헤더: [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 또는 기본값 아래 코드는 protobuf를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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: 메시지 타입 디스패칭
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 헤더: [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: 타임아웃과 재시도
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 불완전 메시지 대기 시 타임아웃
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: 완전한 바이너리 프로토콜 헤더
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 실전 게임: [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 또는 수동 |
| 에러 | 불완전 메시지(누적), 잘못된 길이(검증), 파싱 예외 |
| 버전 | 헤더에 버전, 선택 필드로 확장 |
| 핵심 원칙: |
- TCP는 스트림이므로 프로토콜이 경계를 정의해야 함
- 길이 프리픽스가 가장 범용적
- 버퍼 누적 없이 파싱 불가
- 최대 크기 검증 필수
- 엔디안 통일 필수
자주 묻는 질문 (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]