왜 패킷 포맷이 필요한가
TCP 스트림의 본질
- TCP는 메시지 단위가 아니라 바이트 스트림입니다.
- 따라서 1회
send가 1회 recv와 1:1로 대응된다는 보장이 없습니다.
- 실무에서는 다음 두 현상이 항상 발생 가능하다고 가정해야 합니다.
| 현상 | 설명 |
|---|
| 부분 수신(쪼개짐) | 한 패킷이 여러 번의 recv로 나뉘어 옴 |
| 합쳐짐(coalescing) | 여러 패킷이 한 번의 recv로 붙어서 옴 |
게임에서의 위험
- 스킬/이동/채팅 같은 명령이 중간에서 끊기면 파싱 자체가 불가능해집니다.
- 경계 정의 없이 파싱하면 데이터 오염, 크래시, 해킹 취약점으로 이어집니다.
기본 해법: 고정 헤더 + 가변 페이로드
권장 구조
#pragma pack(push, 1)
struct PacketHeader {
uint16_t packetId;
uint16_t payloadSize;
};
#pragma pack(pop)
┌──────────┬──────────────┬────────────────────────┐
│ PacketID │ PayloadSize │ Payload (가변 길이) │
│ 2B │ 2B │ payloadSize 바이트 │
└──────────┴──────────────┴────────────────────────┘
필드 의미
| 필드 | 크기 | 의미 |
|---|
packetId | 2B | 패킷 타입 식별(로그인/이동/스킬 등) |
payloadSize | 2B | 뒤에 오는 payload 길이 |
payload | 가변 | 실제 게임 데이터 |
바이트 오더 규칙
- 멀티바이트 헤더 필드는 송신 시
htons, 수신 시 ntohs를 적용합니다.
- 팀 규약으로 "헤더는 network byte order"를 문서에 고정하세요.
수신 파서 핵심 알고리즘(누적 버퍼)
처리 개요
recv한 데이터를 누적 버퍼에 append
- 헤더 크기(4B) 미만이면 더 받기
- 헤더를 읽고
payloadSize 검증
- 전체 패킷 길이(4 + payloadSize)가 모였으면 1개 처리
- 남은 데이터로 루프 반복
예시 코드
constexpr size_t kHeaderSize = sizeof(PacketHeader);
constexpr size_t kMaxPayload = 4096;
std::vector<uint8_t> recvBuf;
void OnRecvBytes(const uint8_t* data, size_t len) {
recvBuf.insert(recvBuf.end(), data, data + len);
while (true) {
if (recvBuf.size() < kHeaderSize) break;
PacketHeader h{};
memcpy(&h, recvBuf.data(), kHeaderSize);
h.packetId = ntohs(h.packetId);
h.payloadSize = ntohs(h.payloadSize);
if (h.payloadSize > kMaxPayload) {
return;
}
size_t packetSize = kHeaderSize + h.payloadSize;
if (recvBuf.size() < packetSize) break;
const uint8_t* payload = recvBuf.data() + kHeaderSize;
HandlePacket(h.packetId, payload, h.payloadSize);
recvBuf.erase(recvBuf.begin(), recvBuf.begin() + packetSize);
}
}
이 루프가 해결하는 문제
- 쪼개져 온 패킷: 충분히 모일 때까지 대기
- 합쳐져 온 패킷: 한 번에 여러 개 순차 처리
- 즉, TCP 스트림 위에서 앱 레벨 경계를 복원합니다.
송신 포맷(직렬화) 기본 패턴
송신 버퍼 만들기
bool SendPacket(SOCKET s, uint16_t id, const uint8_t* payload, uint16_t payloadSize) {
PacketHeader h;
h.packetId = htons(id);
h.payloadSize = htons(payloadSize);
std::vector<uint8_t> out(sizeof(PacketHeader) + payloadSize);
memcpy(out.data(), &h, sizeof(PacketHeader));
memcpy(out.data() + sizeof(PacketHeader), payload, payloadSize);
return SendAll(s, reinterpret_cast<const char*>(out.data()),
static_cast<int>(out.size()));
}
실무 포인트
- 헤더와 페이로드를 반드시 같은 규약으로 직렬화/역직렬화해야 합니다.
SendAll 없이 1회 send로 끝내면 packet truncation이 발생할 수 있습니다.
검증/보안 체크리스트
| 체크 항목 | 이유 |
|---|
payloadSize <= MAX 검증 | 메모리 폭주/버퍼 오버런 방지 |
packetId 유효 범위 검증 | 잘못된 핸들러 진입 방지 |
| 누적 버퍼 상한 설정 | 악성 입력으로 인한 메모리 고갈 방지 |
| 파싱 실패 시 세션 정책 | 즉시 종료/카운트 누적/로그 등 일관 규칙 필요 |
방어 원칙
- "클라이언트 입력은 항상 신뢰하지 않는다"를 기본 가정으로 둡니다.
- 파서는 성능 코드이면서 동시에 보안 코드라는 관점이 필요합니다.
강의 시 유의사항
강조 포인트
- Part 9의 핵심은 헤더 정의보다 수신 파싱 루프입니다.
- TCP 경계 복원은 모든 게임 서버 프로토콜의 출발점입니다.
- 엔디언 변환(Part 7)과 반드시 연결해서 설명하세요.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
recv 한 번이면 패킷 하나가 온다 | 스트림 특성상 보장되지 않음 |
| Size 필드는 편의 기능일 뿐 | 경계 복원/보안 검증의 핵심 |
| 파싱 오류는 그냥 무시해도 된다 | 세션 상태 오염과 취약점으로 확대 가능 |
체크 질문 (스스로 답해보기)
- 누적 버퍼 파싱 루프에서 "break"가 필요한 시점은 언제인가?
- 왜
payloadSize 최대값 검증이 필수인가?
- 합쳐져 온 패킷 2개를 한 번의
recv에서 어떻게 처리할 것인가?