TCP 위에 프로토콜 설계하기: 메시지 경계, 길이 프리픽스, 바이너리 직렬화, 엔디안과 버전 관리
들어가며: “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가 아직 안 옴 해결책: 프로토콜에서 “메시지 경계”를 명시해야 합니다.
이 문제가 까다로운 이유는 로컬 테스트에서는 거의 재현되지 않는다는 점입니다. 루프백(127.0.0.1)에서는 지연이 거의 없고 버퍼도 넉넉해서, 작은 메시지는 대부분 “send 한 번 = recv 한 번”처럼 도착합니다. 그래서 “잘 동작하는” 코드가 실제 네트워크에 올라가 지연이 생기거나 부하가 걸리는 순간에야 메시지가 붙거나 잘리기 시작합니다. 저는 프로토콜 파서를 테스트할 때 받은 데이터를 일부러 1바이트씩, 또는 임의의 크기로 쪼개서 파서에 넣는 테스트를 꼭 추가하는데, 이 테스트 하나로 경계 처리 버그 대부분이 드러납니다.
추가 문제 시나리오
시나리오 2: 게임 60fps 위치 전송 — 여러 send가 한 recv에 합쳐지거나, 한 send가 여러 recv로 나뉨 → 플레이어가 순간이동하거나 끊김. 시나리오 3: IoT 센서 — 온도(4B)+습도(4B)+조도(4B) 순차 전송 시, recv가 5바이트만 반환하면 어떤 필드가 잘렸는지 알 수 없음. 시나리오 4: 대용량 전송 — 4바이트 길이만 수신 후 연결 끊김. payload가 올지 모르는 상태에서 타임아웃 없으면 영원히 블로킹. 목표:
- 길이 프리픽스 프로토콜 완전 구현 (파서 포함)
- 직렬화 포맷 비교 (JSON/Protobuf/MessagePack/FlatBuffers)
- 엔디안 처리 실전 예시
- 자주 만나는 파싱 버그와 해결법
- 포맷별 실제 인코딩 크기 비교
- 프로덕션 예시 (채팅, 게임) 요구 환경: C++17 이상, Boost.Asio (선택)
메시지 경계 방식
메시지 프레이밍 개요
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, 텍스트 프로토콜 |
| 고정 크기 | 모든 메시지 동일 크기 | 파싱 없음 | 낭비, 유연성 없음 | 게임 입력, 센서 데이터 |
길이 프리픽스가 가장 범용적입니다. 이 글에서는 이를 완전히 구현합니다.
구분자 방식이 “구현 간단”으로 분류되지만, 실제로는 payload에 구분자가 들어갈 수 있는 순간 이스케이프 규칙이 필요해져 전혀 간단하지 않게 됩니다. 또 수신 측은 구분자를 찾기 위해 받은 바이트를 전부 훑어야 하고, 구분자가 오지 않으면 버퍼가 끝없이 커지므로 최대 줄 길이 제한도 반드시 필요합니다. HTTP/1.1이 헤더는 \r\n으로 나누면서 본문은 Content-Length(길이 프리픽스)나 청크 크기로 경계를 정하는 것도 이 때문입니다. 반면 길이 프리픽스는 헤더만 읽으면 필요한 바이트 수를 정확히 알 수 있어, 본문을 한 바이트씩 검사하지 않고 한 번에 복사할 수 있습니다.
길이 프리픽스 프로토콜 완전 구현
프로토콜 포맷
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 {
// 이 프로토콜의 와이어 순서(little-endian)로 uint32_t 변환
// (주의: 관례적인 "네트워크 바이트 순서"는 big-endian이며, 여기서는 LE를 선택함)
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
이 파서에서 중요한 줄은 두 곳입니다. 헤더 4바이트가 모두 모이기 전에는 길이를 해석하지 않는다는 1번 검사와, payload가 다 모이기 전에는 아무것도 소비하지 않는 3번 검사입니다. 둘 중 하나라도 빠지면 메시지가 잘려 들어오는 순간 경계가 어긋나고, 그 뒤로는 모든 메시지가 쓰레기로 해석됩니다. 길이 기반 프로토콜은 한 번 경계를 잃으면 스스로 복구할 방법이 없으므로, 파싱 오류가 나면 “다음 메시지부터 다시”가 아니라 연결을 끊는 것이 유일하게 안전한 대응입니다.
실제로 쓸 때 알아 둘 한계도 있습니다. 콜백에 넘기는 string_view는 buffer_ 내부를 가리키므로, 콜백이 그 뷰를 저장해 두었다가 나중에 쓰면 바로 다음 erase로 댕글링이 됩니다. 필요하면 콜백 안에서 std::string으로 복사해야 합니다. 또 vector::erase(begin, ...)는 남은 데이터를 앞으로 옮기므로, 작은 메시지가 한꺼번에 많이 들어오면 메시지마다 버퍼 전체를 이동하는 O(n²) 비용이 생깁니다. 고부하 서버에서는 읽기 위치(offset)만 옮기다가 루프가 끝날 때 한 번만 압축하거나, 링 버퍼를 쓰는 방식이 일반적입니다.
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();
}
이 예제는 흐름을 보여 주기 위해 단순화했기 때문에 그대로 운영에 쓰기에는 두 가지 문제가 있습니다. on_message에서 호출하는 protocol::send_message는 boost::asio::write를 쓰는 동기 쓰기라서, 상대가 읽지 않아 송신 버퍼가 가득 차면 이벤트 루프 스레드 전체가 멈춥니다. 실제 서버에서는 송신 큐를 두고 async_write를 한 번에 하나씩 이어 거는 방식이 필요합니다. 또 MessageParser::parse가 크기 초과로 던진 예외가 완료 핸들러 밖으로 빠져나가면 io.run()까지 전파되어 서버가 종료되므로, do_read 핸들러 안에서 try/catch로 잡고 해당 연결만 닫아야 합니다.
바이너리 직렬화 기초
완전한 바이너리 프로토콜 예시 (길이 프리픽스 + 타입)
// 프로토콜: [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 = payload.size(); // 길이 필드 = payload 길이 (헤더 4B 제외, 위 파서와 동일 규칙)
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;
}
길이 필드가 헤더를 포함하는지 여부는 사소해 보여도 가장 흔한 상호 운용 버그의 원인입니다. 송신 측이 4 + payload를 쓰고 수신 측이 payload 길이로 해석하면, 수신 측은 매 메시지마다 4바이트를 더 기다리다가 다음 메시지의 헤더를 payload로 먹어 버립니다. 짧은 테스트에서는 마지막 메시지가 도착하지 않는 “가끔 멈춤”으로 나타나 원인을 찾기 어렵습니다. 어느 쪽으로 정하든 프로토콜 문서의 첫 줄에 적고, 송신과 수신이 같은 상수와 같은 함수를 공유하게 하는 것이 가장 확실한 예방책입니다.
직렬화 흐름
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) // 패딩 제거 (구조체를 통째로 memcpy할 때만 필요)
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;
}
구조체를 통째로 memcpy하는 방식은 빠르고 코드가 짧지만, 와이어 형식이 컴파일러와 CPU의 메모리 배치에 묶인다는 대가가 있습니다. 엔디안, 패딩, int/long의 크기, bool의 표현이 모두 플랫폼에 따라 다를 수 있기 때문입니다. 필드 순서를 바꾸거나 필드 하나를 추가하는 것만으로 모든 기존 클라이언트와 호환이 깨집니다. 양쪽이 같은 코드로 빌드되는 게임 클라이언트·서버처럼 통제된 환경이 아니라면, 아래처럼 필드를 하나씩 정해진 순서와 엔디안으로 쓰는 명시적 직렬화가 더 안전합니다.
가변 필드 (길이 + 데이터)
// 문자열: [4바이트 길이][UTF-8 바이트] — out 뒤에 이어 붙임
void serialize_string(const std::string& s, std::vector<char>& out) {
uint32_t len = static_cast<uint32_t>(s.size());
size_t pos = out.size();
out.resize(pos + 4 + s.size());
std::memcpy(out.data() + pos, &len, 4);
std::memcpy(out.data() + pos + 4, s.data(), s.size());
}
// size: 전체 버퍼 크기. 남은 바이트보다 긴 길이 값은 거부
std::string deserialize_string(const char* data, size_t size, size_t& offset) {
if (size - offset < 4) throw std::runtime_error("truncated length");
uint32_t len;
std::memcpy(&len, data + offset, 4);
offset += 4;
if (len > size - offset) throw std::runtime_error("length exceeds buffer");
std::string result(data + offset, len);
offset += len;
return result;
}
역직렬화 함수가 남은 버퍼 크기를 받지 않으면, 공격자가 문자열 길이 필드에 큰 값을 넣는 것만으로 버퍼 밖을 읽게 만들 수 있습니다. 프레임 전체의 최대 크기를 검사했더라도 프레임 안쪽 필드의 길이는 별도로 검사해야 한다는 점을 놓치기 쉽습니다. 비교할 때 offset + len > size처럼 더하기로 쓰면 len이 거의 UINT32_MAX일 때 오버플로로 검사를 통과할 수 있으므로, 위처럼 len > size - offset 형태로 쓰는 것이 안전합니다.
JSON vs Protobuf vs MessagePack 비교
포맷별 특성
| 포맷 | 크기 | 속도 | 가독성 | 스키마 | 호환성 | Zero-copy |
|---|---|---|---|---|---|---|
| JSON | 큼 | 느림 | 높음 | 없음 | 최고 | ❌ |
| Protobuf | 작음 | 빠름 | 낮음 | 필수 | 좋음 | ❌ |
| MessagePack | 중간 | 빠름 | 낮음 | 없음 | 좋음 | ❌ |
| FlatBuffers | 작음 | 매우 빠름 | 낮음 | 필수 | 좋음 | ✅ |
표의 “크기·속도”는 상대적인 경향일 뿐이고, 실제 순위는 데이터의 모양에 따라 바뀝니다. 예를 들어 FlatBuffers는 임의 접근을 위해 오프셋 테이블과 정렬 패딩을 넣기 때문에 작은 메시지에서는 Protobuf보다 커지는 경우가 흔하고, 숫자가 대부분인 데이터는 Protobuf의 varint 덕분에 매우 작아지지만 이미 압축된 바이너리 덩어리가 대부분이면 포맷 간 차이가 거의 없습니다. 포맷을 고를 때는 성능 숫자보다 스키마 진화 방식(필드 추가·삭제가 기존 클라이언트를 깨뜨리는지)과 다른 언어 지원이 더 오래 영향을 미칩니다.
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}
// 크기: 55 bytes
}
JSON을 C++ 프로토콜에 쓸 때 조심할 점은 숫자입니다. JSON 명세 자체는 정수 크기를 제한하지 않지만, JavaScript 클라이언트는 모든 숫자를 double로 읽기 때문에 2^53을 넘는 int64 ID(예: Snowflake ID)는 조용히 값이 바뀝니다. 그래서 큰 ID는 문자열로 보내는 관례가 흔합니다. 또 위 역직렬화 코드의 j["user"]는 키가 없을 때 null을 넣고 get<std::string>()에서 type_error를 던지므로, 외부 입력이라면 j.at("user")나 j.value("user", "")로 의도를 분명히 하는 것이 좋습니다.
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()
};
}
// 동일 데이터 크기: 21 bytes (필드마다 태그 1B + 길이/varint)
ParseFromString의 반환값을 무시한 것도 이 예제의 약점입니다. 잘못된 바이트가 들어오면 false를 반환할 뿐 예외를 던지지 않으므로, 확인하지 않으면 일부만 채워진 메시지를 정상 데이터처럼 처리하게 됩니다. 또 Protobuf 바이너리는 자기 경계를 표시하지 않기 때문에, TCP로 여러 메시지를 보내려면 이 글의 길이 프리픽스가 여전히 필요합니다(Protobuf 라이브러리의 SerializeDelimitedToZeroCopyStream처럼 varint 길이를 앞에 붙이는 헬퍼도 있습니다).
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};
}
// 동일 데이터 크기: 19 bytes (배열 헤더 1 + "alice" 6 + "Hello!" 7 + uint32 5)
MessagePack이 여기서 Protobuf보다 작게 나오는 이유는 std::make_tuple로 배열을 만들어 필드 이름을 아예 보내지 않았기 때문입니다. 이렇게 하면 크기는 작지만 “첫 번째는 user, 두 번째는 text”라는 순서가 암묵적인 스키마가 되어, 필드를 중간에 추가하면 호환성이 깨집니다. 필드 이름을 키로 넣은 맵({"user": ..., "text": ..., "timestamp": ...})으로 보내면 JSON처럼 스키마 없이도 확장할 수 있지만, 키 문자열만큼 크기가 커집니다. “MessagePack은 스키마가 없다”는 말은 이 두 가지 사용법 중 무엇을 택하느냐에 따라 의미가 달라집니다.
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는 버퍼를 그대로 참조.
zero-copy에는 대가가 있습니다. FlatBuffers의 접근자는 버퍼 안의 오프셋을 따라가므로, 신뢰할 수 없는 입력이라면 접근하기 전에 반드시 flatbuffers::Verifier로 버퍼를 검증해야 합니다. 검증 없이 조작된 버퍼의 오프셋을 따라가면 범위 밖 메모리를 읽게 됩니다. 또 parsed는 원래 버퍼를 가리키는 뷰이므로 수신 버퍼를 재사용하거나 해제하면 댕글링이 되고, 한 번 만든 버퍼의 값을 고치기도 어렵습니다(같은 크기 스칼라만 in-place 변경 가능). 수신한 메시지를 오래 들고 있거나 수정하는 코드라면 이 장점이 줄어듭니다.
선택 가이드
// 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();
}
엔디안 처리
문제: 바이트 순서 불일치
// 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));
이 템플릿은 호스트가 리틀엔디안이라고 가정하고 무조건 바이트를 뒤집으므로, 빅엔디안 호스트에서는 반대로 틀린 값을 만듭니다. 앞 절의 to_network_order(리틀엔디안 와이어 순서로 변환)와 이름이 같은데 의미는 정반대라는 점도 위험합니다. 한 프로젝트에 이런 두 함수가 공존하면 길이 필드는 LE, 타입 필드는 BE로 쓰이는 식의 버그가 생기기 쉽습니다. C++20이라면 std::endian::native == std::endian::little로 컴파일 타임에 분기할 수 있고, C++23에는 std::byteswap이 추가되었습니다. 가장 이식성 있는 방법은 CPU 엔디안과 무관하게 시프트 연산으로 바이트를 하나씩 쓰는 것입니다(buf[0] = v >> 24; buf[1] = v >> 16; ...). 컴파일러가 이 패턴을 인식해 한 번의 bswap 명령어로 바꿔 주므로 성능 손해도 거의 없습니다.
프로토콜별 엔디안 관례
| 프로토콜 | 엔디안 | 비고 |
|---|---|---|
| TCP/IP 헤더 | Big-endian | htonl/ntohl |
| 자체 게임 프로토콜 (흔한 선택) | Little-endian | 주요 CPU가 LE라 변환 비용 없음 |
| Protobuf | Little-endian (varint·fixed32/64) | varint는 7비트 단위 가변 길이 |
길이 필드·엔디안·패딩에서 생기는 파싱 버그
에러 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) { // len == 0 거부는 빈 메시지가 없는 프로토콜일 때만
throw std::runtime_error("Invalid message length");
}
최대 크기 검사는 할당 폭탄을 막지만, 그것만으로 충분하지 않은 공격도 있습니다. 클라이언트가 “1MB짜리 메시지”라는 헤더만 보내고 payload를 아주 천천히 보내면, 서버는 연결마다 버퍼를 붙잡은 채 기다리게 됩니다(slowloris 유형). 연결 수가 많아지면 최대 크기 검사를 통과한 버퍼들만으로 메모리가 고갈되므로, 아래 “타임아웃” 패턴처럼 메시지 하나를 다 받는 데 걸리는 시간에도 상한을 두어야 합니다.
에러 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)
proto3의 기본값 규칙에는 함정이 있습니다. optional이 없는 스칼라 필드는 “값이 0”과 “보내지 않음”을 구분할 수 없습니다. 예를 들어 int32 discount = 4;에서 구버전 클라이언트가 이 필드를 모르고 보내지 않으면 서버는 “할인 0”으로 읽습니다. 이 차이가 의미를 가진다면 optional int32 discount = 4;로 선언해 has_discount()로 확인해야 합니다.
에러 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): 대부분의 플랫폼에서 8, 패킹하면 5
해결: #pragma pack(push, 1) 또는 __attribute__((packed))로 명시. 다만 패킹한 구조체의 멤버는 정렬되지 않은 주소에 놓이므로, 그 멤버의 주소를 int32_t*로 꺼내 쓰면 일부 ARM 등에서 정렬 오류(SIGBUS)가 나고 GCC는 taking address of packed member ... may result in an unaligned pointer value 경고를 냅니다. 패킹한 구조체는 memcpy로 통째로 복사하는 용도로만 쓰고, 멤버에 직접 포인터를 걸지 않는 것이 안전합니다.
// ✅ 네트워크 프로토콜용
#pragma pack(push, 1)
struct NetworkLayout {
char a;
int32_t b;
};
#pragma pack(pop)
에러 8: 버퍼 오버플로우 (길이 필드 조작)
// ❌ length=0x7FFFFFFF → 2GB 할당 시도 (DoS)
// ✅ 최대 크기 검증 + rate limiting
같은 메시지의 포맷별 인코딩 크기
메시지 크기 비교 (인코딩 규칙으로 계산한 값)
원본: user="alice", text="Hello, World!", timestamp=1234567890
JSON (공백 없음): {"user":"alice","text":"Hello, World!","timestamp":1234567890}
→ 62 bytes
MessagePack (맵): 키 문자열 포함 → 46 bytes
MessagePack (배열): 0x93 | a5 "alice" | ad "Hello, World!" | ce 4B → 26 bytes
Protobuf: 0a 05 "alice" | 12 0d "Hello, World!" | 18 varint 5B → 28 bytes
수동 바이너리: [4B len]"alice"[4B len]"Hello, World!"[8B int64] → 34 bytes
크기는 인코딩 규칙에서 바로 계산할 수 있어서 위 숫자는 환경과 무관합니다. 흥미로운 점은 필드 이름을 빼면 MessagePack 배열이 Protobuf보다도 작고, 4바이트 고정 길이를 쓰는 수동 바이너리가 가변 길이(varint)를 쓰는 Protobuf보다 크다는 것입니다. 짧은 문자열이 많은 메시지에서는 길이 필드 자체가 큰 비중을 차지하기 때문입니다.
반면 속도는 라이브러리 버전, 컴파일 옵션, 메시지 모양, 메모리 할당 방식에 따라 크게 달라지므로 다른 사람의 벤치마크 숫자를 그대로 가져오는 것은 의미가 적습니다. 일반적인 경향은 텍스트를 파싱해야 하는 JSON이 가장 느리고, 스키마 기반 바이너리 포맷이 빠르며, FlatBuffers는 “역직렬화” 단계 자체가 거의 없다는 것입니다. 실제 선택이 필요하다면 서비스의 대표 메시지 몇 종류로 Google Benchmark 같은 도구를 써서 직접 측정하십시오. 이때 직렬화 시간뿐 아니라 할당 횟수와 p99 지연을 함께 보면, 평균에서는 비슷하던 포맷 간 차이가 드러나는 경우가 많습니다.
결론: 외부 공개 API·디버깅이 중요하면 JSON, 여러 언어가 스키마를 공유하며 진화해야 하면 Protobuf, 스키마 도구 없이 JSON보다 작게 보내고 싶으면 MessagePack, 수신 측 파싱 비용을 없애야 하면 FlatBuffers.
프로덕션 예시
예시 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());
// 전체 프레임 (길이 필드 = payload 길이)
uint32_t total = 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) {
// 엔디안 변환 후 memcpy (buf+1 같은 홀수 주소에 포인터 캐스팅으로 쓰면
// 정렬 위반·strict aliasing 위반으로 미정의 동작)
buf[0] = static_cast<char>(input.type);
uint16_t x = to_network_order(static_cast<uint16_t>(input.x));
uint16_t y = to_network_order(static_cast<uint16_t>(input.y));
uint32_t seq = to_network_order(input.seq);
uint32_t ts = to_network_order(input.timestamp);
std::memcpy(buf + 1, &x, 2);
std::memcpy(buf + 3, &y, 2);
std::memcpy(buf + 5, &seq, 4);
std::memcpy(buf + 9, &ts, 4);
}
원래 흔히 보이는 *(uint32_t*)(buf + 5) = ... 형태는 x86에서는 정렬되지 않은 쓰기도 허용되어 문제없이 동작하기 때문에 오래 살아남습니다. 하지만 일부 ARM·임베디드 CPU에서는 버스 오류로 죽고, 최적화 컴파일러는 strict aliasing 규칙을 근거로 이런 코드의 순서를 바꿀 수 있습니다. memcpy는 컴파일러가 단일 명령어로 최적화하므로 성능 걱정 없이 쓸 수 있는 표준적인 방법입니다. GameStateUpdate처럼 float을 보낼 때는 IEEE 754를 전제로 비트 패턴을 uint32_t로 옮겨(std::bit_cast 또는 memcpy) 엔디안 변환하는 것이 일반적이고, 좌표 정밀도가 크게 필요 없다면 고정 소수점 정수(예: 센티미터 단위 int32_t)로 바꿔 보내면 크기와 플랫폼 차이를 함께 줄일 수 있습니다.
버전·호환성
프로토콜 버전 필드
// 헤더: [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 또는 기본값
// v1이 user=1, avatar_url=2, text=3, timestamp=4였다고 가정하고 avatar_url을 삭제한 v2
message ChatMessage {
string user = 1;
reserved 2; // 삭제된 필드 번호: 다른 필드에 재사용 금지
reserved "avatar_url"; // 이름도 막아 JSON 매핑 충돌 방지
string text = 3; // 기존 번호 그대로 유지
int64 timestamp = 4;
optional string room = 5; // 새 필드 (구버전은 알 수 없는 필드로 건너뜀)
}
번호를 재사용하면 안 되는 이유는 Protobuf 바이너리에 필드 이름이 아니라 번호만 들어가기 때문입니다. 삭제한 2번을 나중에 int32 priority = 2;로 재사용하면, 아직 옛 스키마를 쓰는 클라이언트가 보낸 avatar_url 문자열 바이트를 새 서버가 priority로 해석하려다 파싱 오류가 나거나 엉뚱한 값을 읽습니다. 기존 필드의 번호를 바꾸는 것(예: text를 2에서 3으로)도 같은 이유로 호환성을 깨는 변경입니다. reserved는 이런 실수를 protoc 컴파일 단계에서 에러로 막아 줍니다.
타입 디스패치·수신 타임아웃·헤더 설계
설계 결정 요약
| 항목 | 권장 | 비권장 |
|---|---|---|
| 메시지 경계 | 길이 프리픽스 (4B 또는 2B) | 구분자만 사용 (payload 제한) |
| 최대 크기 | 1MB 이하, DoS 방지 | 무제한 |
| 엔디안 | 프로토콜 스펙에 명시 (LE/BE) | 플랫폼 의존 |
| 버전 | 헤더에 버전 필드 | 스키마 없이 변경 |
| 에러 처리 | try-catch, 로깅, 연결 종료 | 무시 |
| 직렬화 | 요구사항에 맞게 선택 | 무조건 JSON |
프로덕션 패턴 1: 메시지 타입 디스패칭
// 헤더: [4B length][2B type][payload]
void dispatch_message(std::string_view payload) {
if (payload.size() < 2) { log_malformed(); return; } // 타입 필드조차 없는 메시지
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 위에서)
- 메시지 타임아웃
- 재연결 시 시퀀스 번호
자주 묻는 질문 (FAQ)
Q. 길이 프리픽스로 들어온 값이 비정상적으로 크면 어떻게 처리하나요?
A. 길이 필드를 그대로 믿고 그만큼 버퍼를 할당하면, 손상된 데이터나 악의적인 클라이언트 때문에 거대한 할당이 일어나 메모리가 고갈될 수 있습니다. 네트워크 바이트 순서를 호스트 순서로 변환한 뒤 프로토콜에서 정한 최대 메시지 크기와 비교하고, 초과하면 파싱 오류로 보고 연결을 끊습니다. 헤더를 끝까지 받기 전에는 길이 값을 해석하지 않는 것도 중요합니다.
Q. JSON과 Protobuf 중 뭘 써야 하나요?
A. 웹/REST 연동이 필요하면 JSON. 고성능·저지연이 필요하면 Protobuf. 디버깅 용이성이 중요하면 JSON. 대역폭 절약이 중요하면 Protobuf.
Q. UDP는 어떻게 하나요?
A. UDP는 데이터그램이라 한 번 send = 한 번 recv로 경계가 유지되므로 길이 프리픽스가 필요 없습니다. 다만 recv 버퍼가 데이터그램보다 작으면 나머지가 잘려 버려지고(리눅스에서는 MSG_TRUNC 플래그로 확인), 패킷 손실·중복·순서 뒤바뀜이 있으므로 게임 등에서는 시퀀스 번호와 ACK를 담은 커스텀 프로토콜을 올립니다. 경로 MTU(보통 1500바이트, 헤더 제외 약 1472바이트)를 넘는 데이터그램은 IP 단편화되어 조각 하나만 잃어도 전체가 버려지므로, 메시지를 그보다 작게 유지하는 것이 좋습니다.
길이 프리픽스와 버퍼 누적 파서로 TCP 스트림에서 안정적인 메시지 경계를 만들 수 있습니다.
이전 글: C++ 실전 가이드 #30-2: SSL/TLS
다음 글: [C++ 실전 가이드 #31-1] 채팅 서버 만들기: 다중 클라이언트와 메시지 브로드캐스트