패킷 포맷 설계

Jaemyeong Lee·2025년 1월 10일

게임 서버1

목록 보기
128/220

왜 패킷 포맷이 필요한가

TCP 스트림의 본질

  • TCP는 메시지 단위가 아니라 바이트 스트림입니다.
  • 따라서 1회 send가 1회 recv와 1:1로 대응된다는 보장이 없습니다.
  • 실무에서는 다음 두 현상이 항상 발생 가능하다고 가정해야 합니다.
현상설명
부분 수신(쪼개짐)한 패킷이 여러 번의 recv로 나뉘어 옴
합쳐짐(coalescing)여러 패킷이 한 번의 recv로 붙어서 옴

게임에서의 위험

  • 스킬/이동/채팅 같은 명령이 중간에서 끊기면 파싱 자체가 불가능해집니다.
  • 경계 정의 없이 파싱하면 데이터 오염, 크래시, 해킹 취약점으로 이어집니다.

기본 해법: 고정 헤더 + 가변 페이로드

권장 구조

#pragma pack(push, 1)
struct PacketHeader {
    uint16_t packetId;     // 패킷 종류
    uint16_t payloadSize;  // payload 바이트 수
};
#pragma pack(pop)
┌──────────┬──────────────┬────────────────────────┐
│ PacketID │ PayloadSize  │ Payload (가변 길이)    │
│   2B     │     2B       │ payloadSize 바이트     │
└──────────┴──────────────┴────────────────────────┘

필드 의미

필드크기의미
packetId2B패킷 타입 식별(로그인/이동/스킬 등)
payloadSize2B뒤에 오는 payload 길이
payload가변실제 게임 데이터

바이트 오더 규칙

  • 멀티바이트 헤더 필드는 송신 시 htons, 수신 시 ntohs를 적용합니다.
  • 팀 규약으로 "헤더는 network byte order"를 문서에 고정하세요.

수신 파서 핵심 알고리즘(누적 버퍼)

처리 개요

  1. recv한 데이터를 누적 버퍼에 append
  2. 헤더 크기(4B) 미만이면 더 받기
  3. 헤더를 읽고 payloadSize 검증
  4. 전체 패킷 길이(4 + payloadSize)가 모였으면 1개 처리
  5. 남은 데이터로 루프 반복

예시 코드

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에서 어떻게 처리할 것인가?

profile
李家네_공부방

0개의 댓글