구조체 패킷의 한계와 직렬화 버퍼(Serialization Buffer)가 필요한 이유

2026. 2. 19. 02:44·TIL(Today I Learn)/TIL

개요

네트워크 패킷을 만들 때 “구조체를 그대로 보내면 편하지 않을까?”라는 생각은 누구나 한 번쯤 한다.
실제로 예전 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) 구조체 방식의 치명적인 단점

하지만 실전 게임/서비스 패킷에서는 아래 단점이 너무 크다.

  1. 가변 필드는 반드시 구조체의 마지막에 위치해야 한다.
  2. 가변 필드가 여러 개면 불가능 (예: 인벤 목록 + 옵션 목록 + 옵션의 옵션…)
  3. 언어가 다르면(C++/C#/Lua 등) 바이너리 구조 호환이 깨지기 쉽다
  4. 구조 확장성(버전업/필드 추가)이 약하다
  5. “직렬화 규칙”을 구조체에 자연스럽게 넣기 어렵다

결론적으로 구조체 패킷은 “현대 서버 구조에서 거의 버려진 방식”에 가깝다.
특히 가변/중첩 가변을 다루는 순간 구조체는 사실상 막힌다.

 


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) 직렬화의 단점

직렬화 버퍼는 만능은 아니다. 단점도 분명 있다.

  1. 실수하기 쉽다 (순서/타입/길이 정책 불일치)
  2. 로컬 변수로 꺼내고 저장하는 과정에서 복사 과정이 늘어난다
  3. 최적화 안 하면 구조체 방식보다 느릴 수 있다

하지만 가변/확장/다국어/버전업을 고려하면
현대 네트워크에선 이 비용을 감수하는 쪽이 대부분 더 안전하다.


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
'TIL(Today I Learn)/TIL' 카테고리의 다른 글
  • 심볼릭 링크란 무엇인가: Git, Windows, macOS에서 다르게 보이는 이유
  • 바이브 코딩 중인 내가 쓰는 Agent Skills - 260611
  • 메모리 풀과 프리리스트 – 고정 크기 객체의 Alloc/Free를 빠르게 만드는 방법
  • 링버퍼(Ring Buffer) - 원활한 통신을 위한 자료구조
메카인
메카인
  • 메카인
    메카인의 지식창고
    메카인
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 게임개발
        • 언데드서바이벌_골드메탈_클론코딩
        • 3D_골드메탈_클론코딩
        • 유니티_문제해결
        • 게임 수학
      • C#
      • C++
        • C++
        • C++ STL
        • C,C++ mCoding yotube
      • 코딩테스트(C++)
      • TIL(Today I Learn) N
        • TIL N
      • 강의
        • UE5 GameDev
        • Unreal Engine 4 C++ The Ult..
      • 교재 문제 풀이
        • 운영체제
      • 정보처리기사
        • 정처기 요약
        • 정처기 오답노트
      • CS
        • 데이터베이스
        • 프로그래밍 언어론
        • 리눅스 시스템
        • 네트워크
      • 기타
        • Python
        • 알고리즘
        • 프로젝트 회고
      • 일상
        • 주식
        • 독서
        • 일기
      • (비공개 전용)
        • memory
        • Build
        • OOP
        • Smart Pointer
        • lamda
        • 게임 수학
        • 모던 C++
        • 모던 C++ STL
        • 모던 C++ Concurrency, Paralle..
        • 책
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
    • 블로그 관리
  • 링크

  • 공지사항

    • 공지사항 - 인생과 블로그의 목표
  • 인기 글

  • 태그

    ~
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
메카인
구조체 패킷의 한계와 직렬화 버퍼(Serialization Buffer)가 필요한 이유
상단으로

티스토리툴바