개요
네트워크 패킷을 만들 때 “구조체를 그대로 보내면 편하지 않을까?”라는 생각은 누구나 한 번쯤 한다.
실제로 예전 C 네트워크 코드에서는 구조체 캐스팅 기반의 패킷이 흔했다.
하지만 가변 데이터(문자열/리스트/옵션/중첩 가변 등)가 등장하는 순간, 구조체 방식은 빠르게 한계에 부딪힌다.
그래서 현대 서버/클라이언트 구조에서는 대부분 직렬화 버퍼(Serialization Buffer)를 둬서 패킷을 “조립”한다.
1) 구조체 기반 메시지 방식의 전형적인 형태
예시로 흔히 나오는 구조체는 이런 모양이다.
struct ChatMsg
{
uint64 id; // 8 bytes
uint16 type; // 2 bytes
char msg[0]; // 0 bytes - 뒤 데이터에 대한 포인터 역할
};
여기서 char msg[0]는 C 시대의 트릭이다.
- 구조체 뒤에 가변 크기 데이터를 “붙였다”는 의미
- 실제로는 메모리를 차지하지 않는다(0바이트)
- old C network code에서 종종 쓰던 방식
즉, ChatMsg 헤더를 만들고 그 뒤에 문자열을 그대로 이어 붙여서 전송하는 스타일이다.
2) 구조체 방식의 장점
- 간단하다
- 직관적이다
- 메모리를 한 번에 할당해서 헤더 + 데이터를 묶어 보낼 수 있다
“고정 길이 데이터만” 있다면 확실히 편하다.
3) 구조체 방식의 치명적인 단점
하지만 실전 게임/서비스 패킷에서는 아래 단점이 너무 크다.
- 가변 필드는 반드시 구조체의 마지막에 위치해야 한다.
- 가변 필드가 여러 개면 불가능 (예: 인벤 목록 + 옵션 목록 + 옵션의 옵션…)
- 언어가 다르면(C++/C#/Lua 등) 바이너리 구조 호환이 깨지기 쉽다
- 구조 확장성(버전업/필드 추가)이 약하다
- “직렬화 규칙”을 구조체에 자연스럽게 넣기 어렵다
결론적으로 구조체 패킷은 “현대 서버 구조에서 거의 버려진 방식”에 가깝다.
특히 가변/중첩 가변을 다루는 순간 구조체는 사실상 막힌다.
4) 직렬화 버퍼(Serialization Buffer)가 필요한 이유
직렬화 버퍼는 한 줄로 정의하면 이렇다.
메시지를 원하는 순서와 형식으로 안전하게 쌓고, 반대로 안전하게 꺼내는 장치
왜 필요하냐?
- 구조체는 확장이 어렵다
- 가변-가변-가변(중첩 가변) 구조는 구조체로 표현 불가
- 언어 간 호환성을 위해선 “명확한 직렬화 규칙”이 필요하다
- 비트/바이트 정렬을 사람이 직접 관리하면 실수하기 쉽다
그래서 네트워크 레이어에서 흔히 말하는 “조립식 패킷 생성기(Serialized Packet Builder)”가 필요해진다.
5) 예시 1: 채팅 메시지 — “길이 + 문자열” 패턴
채팅은 대표적으로 가변 데이터(문자열)를 가진다.
보통 이런 형태다.
ChatMsg
{
1 Byte len
len bytes string
}
보내는 쪽은 보통 이렇게 쌓는다.
char* pString;
Packet << (char)strlen(pString);
Packet.PutData(pString, strlen(pString));
“그럼 Packet << pString;으로 자동화하면 되지 않나?”
겉보기엔 가능해 보인다.char*에 대해 operator<<를 만들어서 “길이 + 문자열”을 자동으로 넣게 할 수도 있다.
하지만 네트워크 코드에서 보통 그렇게 안 하는 이유가 있다.
- 컨텐츠에 따라 길이 표현이
- 1바이트일 수도 있고
- 2바이트/4바이트일 수도 있고
- 어떤 문자열은 null-terminated 일 수도 있고
- 어떤 건 UTF-8 / UTF-16처럼 인코딩 정책이 다르기도 하다
즉,
“길이 필드 크기/문자열 규칙”은 컨텐츠 설계에 종속이다.
그런데 그걸 네트워크 라이브러리(하부 계층)가 고정해버리면 유연성이 깨진다.
그래서 보통은 char* 같은 “의미가 강한 타입”을 네트워크 공용 오버로딩으로 박지 않는다.
해결 방향: 우리 규격 타입(string)을 두자
자동화를 하고 싶다면, char*가 아니라 의미가 정의된 타입이 필요하다.
chatmsg
{
string str
}
여기서 string은 “우리 규약(길이 정책/인코딩 정책)이 고정된 문자열 타입”이라는 의미다.
6) 예시 2: 아이템 리스트 — 핵심은 “가변의 중첩”
구조체 방식이 완전히 무너지는 대표 예가 인벤/아이템이다.
- 아이템 목록(itemcnt)이 가변
- 각 아이템 내부 옵션(optattrcnt)도 가변
- 옵션이 더 확장될 수도 있음(옵션의 옵션…)
개념적으로는 이런 형태:
PLAYER_ITEM_LIST
{
itemcnt
item * itemcnt
{
type, att1...
optattrcnt
optattr * optattrcnt
{
type, attr
}
}
}
이걸 패킷으로 만들 때는 보통 직접 쌓는다.
unsigned char itemcnt = Player.itemList.size();
Packet << itemcnt;
for (...)
{
item = *itr;
Packet << item.type;
Packet << item.attr1;
Packet << (char)item.attrlist.size();
...
}
“그럼 for에서 packet << *item; 하면 더 깔끔하지 않나?”
편해 보이지만 문제는 이것이다.
- 어떤 패킷에서는 아이템 전체 정보가 필요하고
- 어떤 패킷에서는 아이템의 좌표만, HP만, 일부 필드만 필요할 수 있다
즉,
packet << item을 고정 오버로딩해버리면
상황과 상관없이 항상 “풀 정보”만 직렬화되는 구조가 된다.
그래서 실전에서는 SerializeItemFull(), SerializeItemBrief(), SerializeItemForShop() 같은 식으로
컨텍스트별 직렬화 함수를 두는 편이 더 자연스럽다.
7) 직렬화의 단점
직렬화 버퍼는 만능은 아니다. 단점도 분명 있다.
- 실수하기 쉽다 (순서/타입/길이 정책 불일치)
- 로컬 변수로 꺼내고 저장하는 과정에서 복사 과정이 늘어난다
- 최적화 안 하면 구조체 방식보다 느릴 수 있다
하지만 가변/확장/다국어/버전업을 고려하면
현대 네트워크에선 이 비용을 감수하는 쪽이 대부분 더 안전하다.
8) 직렬화 버퍼 구현 예시 (SerializedBuffer)
아래는 “쌓기(Write) / 꺼내기(Read)”를 할 수 있는 간단한 직렬화 버퍼 예시다.
핵심 컨셉은:
- 내부에
mBuffer(고정 크기 배열) - 현재 들어있는 데이터 크기
mDataSize PutData()로 뒤에 붙이고GetData()로 앞에서 꺼내면서 앞으로 당김(memmove)operator<<,operator>>로 기본 타입 직렬화 지원
사용 흐름 요약
- 쓰기:
<<또는PutData() - 읽기:
>>또는GetData() - 읽으면 버퍼에서 제거됨(큐처럼 동작)
코드: 헤더/구현.
SerializedBuffer.h
#pragma once
#include <Windows.h>
class SerializedBuffer
{
public:
enum Constants
{
eBUFFER_DEFAULT = 1024
};
SerializedBuffer();
SerializedBuffer(int bufferSize);
SerializedBuffer(const SerializedBuffer& src);
virtual ~SerializedBuffer() noexcept;
void Clear() noexcept;
int GetBufferSize() const noexcept { return mBufferSize; }
int GetDataSize() const noexcept { return static_cast<int>(mWrite - mRead); }
int GetFreeSize() const noexcept { return static_cast<int>(mEnd - mWrite); }
const char* GetBufferPtr() noexcept { return mBuffer; }
const char* GetBufferPtr() const noexcept { return mBuffer; }
const char* GetBufferBegin() noexcept { return mBegin; }
const char* GetBufferBegin() const noexcept { return mBegin; }
const char* GetBufferRead() noexcept { return mRead; }
const char* GetBufferRead() const noexcept { return mRead; }
const char* GetBufferWrite() noexcept { return mWrite; }
const char* GetBufferWrite() const noexcept { return mWrite; }
int MoveWritePos(int size) noexcept;
int MoveReadPos(int size) noexcept;
int MoveWritePointer(int size) noexcept { return MoveWritePos(size); }
int MoveReadPointer(int size) noexcept { return MoveReadPos(size); }
SerializedBuffer& operator=(const SerializedBuffer& src);
SerializedBuffer& operator<<(unsigned char byteValue) noexcept;
SerializedBuffer& operator<<(char charValue) noexcept;
SerializedBuffer& operator<<(unsigned short ushortValue) noexcept;
SerializedBuffer& operator<<(short shortValue) noexcept;
SerializedBuffer& operator<<(int intValue) noexcept;
SerializedBuffer& operator<<(unsigned long ulongValue) noexcept;
SerializedBuffer& operator<<(long longValue) noexcept;
SerializedBuffer& operator<<(float floatValue) noexcept;
SerializedBuffer& operator<<(__int64 int64Value) noexcept;
SerializedBuffer& operator<<(double doubleValue) noexcept;
SerializedBuffer& operator>>(unsigned char& byteValue) noexcept;
SerializedBuffer& operator>>(char& charValue) noexcept;
SerializedBuffer& operator>>(unsigned short& ushortValue) noexcept;
SerializedBuffer& operator>>(short& shortValue) noexcept;
SerializedBuffer& operator>>(int& intValue) noexcept;
SerializedBuffer& operator>>(unsigned long& ulongValue) noexcept;
SerializedBuffer& operator>>(long& longValue) noexcept;
SerializedBuffer& operator>>(float& floatValue) noexcept;
SerializedBuffer& operator>>(__int64& int64Value) noexcept;
SerializedBuffer& operator>>(double& doubleValue) noexcept;
int GetData(char* dest, int size) noexcept;
int PutData(const char* src, int srcSize) noexcept;
private:
void InitializePointers() noexcept;
void ResetPointers() noexcept;
private:
char* mBuffer;
char* mBegin;
char* mRead;
char* mWrite;
char* mEnd;
int mBufferSize;
};
SerializedBuffer.cpp
#include "SerializedBuffer.h"
#include <cstring>
SerializedBuffer::SerializedBuffer()
: SerializedBuffer(eBUFFER_DEFAULT)
{
}
SerializedBuffer::SerializedBuffer(int bufferSize)
: mBuffer(nullptr),
mBegin(nullptr),
mRead(nullptr),
mWrite(nullptr),
mEnd(nullptr),
mBufferSize(bufferSize)
{
if (mBufferSize <= 0)
mBufferSize = eBUFFER_DEFAULT;
mBuffer = new char[mBufferSize];
InitializePointers();
}
SerializedBuffer::SerializedBuffer(const SerializedBuffer& src)
: mBuffer(nullptr),
mBegin(nullptr),
mRead(nullptr),
mWrite(nullptr),
mEnd(nullptr),
mBufferSize(src.mBufferSize)
{
if (mBufferSize <= 0)
mBufferSize = eBUFFER_DEFAULT;
mBuffer = new char[mBufferSize];
InitializePointers();
const int readOffset = static_cast<int>(src.mRead - src.mBegin);
const int writeOffset = static_cast<int>(src.mWrite - src.mBegin);
if (writeOffset > 0)
{
std::memcpy(mBuffer, src.mBuffer, static_cast<size_t>(writeOffset));
mRead = mBegin + readOffset;
mWrite = mBegin + writeOffset;
}
}
SerializedBuffer::~SerializedBuffer() noexcept
{
delete[] mBuffer;
mBuffer = nullptr;
mBegin = nullptr;
mRead = nullptr;
mWrite = nullptr;
mEnd = nullptr;
}
void SerializedBuffer::InitializePointers() noexcept
{
mBegin = mBuffer;
mRead = mBuffer;
mWrite = mBuffer;
mEnd = mBuffer + mBufferSize;
}
void SerializedBuffer::ResetPointers() noexcept
{
mRead = mBegin;
mWrite = mBegin;
}
void SerializedBuffer::Clear() noexcept
{
ResetPointers();
}
int SerializedBuffer::MoveWritePos(int size) noexcept
{
if (size <= 0)
return 0;
if (size > GetFreeSize())
return 0;
mWrite += size;
return size;
}
int SerializedBuffer::MoveReadPos(int size) noexcept
{
if (size <= 0)
return 0;
int moveSize = size;
const int dataSize = GetDataSize();
if (moveSize > dataSize)
moveSize = dataSize;
if (moveSize <= 0)
return 0;
mRead += moveSize;
if (mRead == mWrite)
ResetPointers();
return moveSize;
}
SerializedBuffer& SerializedBuffer::operator=(const SerializedBuffer& src)
{
if (this == &src)
return *this;
if (mBufferSize != src.mBufferSize)
{
delete[] mBuffer;
mBufferSize = src.mBufferSize;
if (mBufferSize <= 0)
mBufferSize = eBUFFER_DEFAULT;
mBuffer = new char[mBufferSize];
}
InitializePointers();
const int readOffset = static_cast<int>(src.mRead - src.mBegin);
const int writeOffset = static_cast<int>(src.mWrite - src.mBegin);
if (writeOffset > 0)
{
std::memcpy(mBuffer, src.mBuffer, static_cast<size_t>(writeOffset));
mRead = mBegin + readOffset;
mWrite = mBegin + writeOffset;
}
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(unsigned char byteValue) noexcept
{
PutData(reinterpret_cast<const char*>(&byteValue), sizeof(byteValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(char charValue) noexcept
{
PutData(&charValue, sizeof(charValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(unsigned short ushortValue) noexcept
{
PutData(reinterpret_cast<const char*>(&ushortValue), sizeof(ushortValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(short shortValue) noexcept
{
PutData(reinterpret_cast<const char*>(&shortValue), sizeof(shortValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(int intValue) noexcept
{
PutData(reinterpret_cast<const char*>(&intValue), sizeof(intValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(unsigned long ulongValue) noexcept
{
PutData(reinterpret_cast<const char*>(&ulongValue), sizeof(ulongValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(long longValue) noexcept
{
PutData(reinterpret_cast<const char*>(&longValue), sizeof(longValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(float floatValue) noexcept
{
PutData(reinterpret_cast<const char*>(&floatValue), sizeof(floatValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(__int64 int64Value) noexcept
{
PutData(reinterpret_cast<const char*>(&int64Value), sizeof(int64Value));
return *this;
}
SerializedBuffer& SerializedBuffer::operator<<(double doubleValue) noexcept
{
PutData(reinterpret_cast<const char*>(&doubleValue), sizeof(doubleValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(unsigned char& byteValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(byteValue)))
GetData(reinterpret_cast<char*>(&byteValue), sizeof(byteValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(char& charValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(charValue)))
GetData(&charValue, sizeof(charValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(unsigned short& ushortValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(ushortValue)))
GetData(reinterpret_cast<char*>(&ushortValue), sizeof(ushortValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(short& shortValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(shortValue)))
GetData(reinterpret_cast<char*>(&shortValue), sizeof(shortValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(int& intValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(intValue)))
GetData(reinterpret_cast<char*>(&intValue), sizeof(intValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(unsigned long& ulongValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(ulongValue)))
GetData(reinterpret_cast<char*>(&ulongValue), sizeof(ulongValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(long& longValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(longValue)))
GetData(reinterpret_cast<char*>(&longValue), sizeof(longValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(float& floatValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(floatValue)))
GetData(reinterpret_cast<char*>(&floatValue), sizeof(floatValue));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(__int64& int64Value) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(int64Value)))
GetData(reinterpret_cast<char*>(&int64Value), sizeof(int64Value));
return *this;
}
SerializedBuffer& SerializedBuffer::operator>>(double& doubleValue) noexcept
{
if (GetDataSize() >= static_cast<int>(sizeof(doubleValue)))
GetData(reinterpret_cast<char*>(&doubleValue), sizeof(doubleValue));
return *this;
}
int SerializedBuffer::GetData(char* dest, int size) noexcept
{
if (size <= 0 || dest == nullptr)
return 0;
int copySize = size;
const int dataSize = GetDataSize();
if (copySize > dataSize)
copySize = dataSize;
if (copySize <= 0)
return 0;
std::memcpy(dest, mRead, static_cast<size_t>(copySize));
MoveReadPos(copySize);
return copySize;
}
int SerializedBuffer::PutData(const char* src, int srcSize) noexcept
{
if (srcSize <= 0 || src == nullptr)
return 0;
if (srcSize > GetFreeSize())
return 0;
std::memcpy(mWrite, src, static_cast<size_t>(srcSize));
mWrite += srcSize;
return srcSize;
}
9) 구현을 보면서 수정이 필요한 포인트
(1) operator>>의 “조용한 실패”
지금은 데이터가 부족하면 그냥 아무 것도 안 하고 넘어간다.
편하긴 한데 버그가 숨어들기 쉬워서,
bool Read(T& out)처럼 성공/실패를 반환하거나- assert/log를 넣는 방식도 많이 쓴다
(2) PutData 실패 처리(버퍼 부족)
현재는 공간 부족이면 0 반환.
상위 레벨에서 반드시 체크하거나, 필요하면 자동 확장(realloc)을 고려할 수 있다.
마무리
정리하면,
- 구조체 기반 패킷은 “고정 필드만 있을 때”는 편하지만
- 현대 컨텐츠(문자열/리스트/옵션/중첩 가변/버전업)를 만나면 확장성이 급격히 떨어진다
- 그래서 직렬화 버퍼가 필요하고,
- 이 버퍼는 “패킷을 조립하는 도구”로서 네트워크 코드의 기본기가 된다
다음 단계로는 보통 이런 것들이 붙는다.
WriteString/ReadString(길이 정책 포함)- 배열/벡터 직렬화 헬퍼
- 헤더(패킷 길이, 타입, 체크섬) 포함한 Packet 클래스
- readPos/writePos 기반으로 성능 개선
'TIL(Today I Learn) > TIL' 카테고리의 다른 글
| 심볼릭 링크란 무엇인가: Git, Windows, macOS에서 다르게 보이는 이유 (0) | 2026.06.29 |
|---|---|
| 바이브 코딩 중인 내가 쓰는 Agent Skills - 260611 (0) | 2026.06.02 |
| 메모리 풀과 프리리스트 – 고정 크기 객체의 Alloc/Free를 빠르게 만드는 방법 (0) | 2026.02.19 |
| 링버퍼(Ring Buffer) - 원활한 통신을 위한 자료구조 (0) | 2026.02.19 |
| 컴파일 시간을 크게 줄여주는 사전 컴파일된 헤더, PCH ( Visual C++ ) (0) | 2026.02.18 |