TCP 게임 서버를 위한 Buffer와 바이너리 데이터 이해하기

ssini·2025년 1월 15일
0

Buffer란

Node.js에서 Buffer는 바이너리 데이터를 처리하기 위한 객체이다.
일반적으로 자바스크립트는 텍스트 기반(유니코드) 데이터 처리를 잘하지만, 바이너리 데이터를 직접 다룰 때는 부족한 점이 있다. 이러한 한계를 극복하기 위해 버퍼가 바이너리 데이터를 효율적으로 처리하고 저장하도록 만들어졌다.


비트(Bit)와 바이트(Byte)의 이해

1. 비트(Bit)란?

  • 컴퓨터가 이해하는 가장 작은 데이터 단위이다.
  • 0 또는 1, 단 두 가지 값만 가질 수 있다.
  • 전기 신호로는 켜짐(1) 또는 꺼짐(0)을 의미한다.
  • 예시:
    1 (하나의 비트)
    0 (하나의 비트)

2. 바이트(Byte)란?

  • 8개의 비트가 모여 만들어진 기본 단위이다.
  • 1바이트는 정확히 8비트로 구성된다.
  • 2^8 = 256가지의 서로 다른 값을 표현할 수 있다 (0~255).
  • 예시:
    01000001 (1바이트, 'A'의 ASCII 코드)
    11111111 (1바이트, 십진수로 255)

3. 실제 사용 예시

  1. 문자 표현

    'A' = 01000001 (1바이트)
    '가' = 11101010 10100100 (2바이트, UTF-8)
  2. 숫자 표현

    숫자 5 (1바이트) = 00000101
    숫자 1000 (2바이트) = 00000011 11101000
  3. 파일 크기 단위

    1KB (킬로바이트) = 1,024바이트
    1MB (메가바이트) = 1,024KB
    1GB (기가바이트) = 1,024MB

4. Buffer에서의 활용

  1. Buffer 크기 지정
// 1바이트 크기의 버퍼
const buf1 = Buffer.alloc(1); // 8비트 저장 가능

// 4바이트 크기의 버퍼 (32비트 정수 저장 가능)
const buf4 = Buffer.alloc(4); // 32비트 저장 가능
  1. 실제 데이터 저장
const buf = Buffer.alloc(1);
buf[0] = 65; // 'A'의 ASCII 코드 (01000001)
console.log(buf.toString()); // 출력: 'A'
  1. 게임에서의 활용
// 플레이어 위치 데이터 (8바이트)
const position = Buffer.alloc(8);
position.writeInt32BE(x, 0); // x좌표 (4바이트)
position.writeInt32BE(y, 4); // y좌표 (4바이트)

바이너리 데이터란?

바이너리 데이터는 컴퓨터가 이해하는 가장 기본적인 데이터 형식으로, 0과 1로만 이루어진 이진수 데이터이다.

1. 텍스트 vs 바이너리

  1. 텍스트 데이터

    • 사람이 직접 읽고 이해할 수 있는 형태이다 (예: "Hello", "안녕하세요").
    • JSON 형식: {"name": "John", "age": 25}
    • 상대적으로 더 많은 저장 공간을 차지한다.
    • 네트워크로 전송할 때 더 많은 대역폭이 필요하다.
  2. 바이너리 데이터

    • 컴퓨터가 직접 처리할 수 있는 순수한 이진 형식이다.
    • 예: 01001000 01100101 01101100 01101100 01101111 (Hello의 바이너리 표현)
    • 최소한의 저장 공간만을 사용한다.
    • 네트워크 전송 시 매우 효율적이다.

2. 바이너리 데이터의 예시

  1. 이미지 파일

    FF D8 FF E0 (JPEG 파일의 시작 부분)
  2. 숫자의 바이너리 표현

    • 숫자 5 (32비트 정수):
    00000000 00000000 00000000 00000101
  3. 게임에서의 위치 데이터

    • JSON 형식: {"x": 100, "y": 200} (약 20바이트)
    • 바이너리 형식: 00000064 000000C8 (8바이트)

3. 바이너리 데이터를 사용하는 이유

  1. 효율성

    • 데이터 크기를 최소화하여 저장 공간을 절약한다.
    • 네트워크 전송 시 대역폭 사용을 최적화한다.
    • 파싱 과정이 단순하여 처리 속도가 매우 빠르다.
  2. 정확성

    • 데이터 손실 없이 완벽하게 값을 표현할 수 있다.
    • 부동 소수점, 이미지 등 복잡한 데이터도 정확하게 처리할 수 있다.
  3. 보안

    • 데이터의 암호화와 압축이 매우 용이하다.
    • 텍스트 형식에 비해 데이터 조작이 훨씬 어렵다.

Buffer의 기초 개념

1. Buffer 생성 메서드

  1. Buffer.alloc(size)

    • 지정된 크기(바이트)만큼의 새로운 버퍼를 생성한다.
    • 생성된 모든 바이트는 0으로 자동 초기화된다.
    • Buffer.alloc(0)은 0바이트 크기의 빈 버퍼를 생성한다.
    • 예: const buf = Buffer.alloc(5); // <Buffer 00 00 00 00 00>
  2. Buffer.from(data)

    • 주어진 데이터로부터 새로운 버퍼를 즉시 생성한다.
    • 예: const buf = Buffer.from('Hello'); // <Buffer 48 65 6c 6c 6f>
  3. Buffer.concat([buffer1, buffer2, ...])

    • 여러 개의 버퍼를 하나의 버퍼로 효율적으로 결합한다.
    • TCP 통신에서 분할된 패킷을 하나로 모을 때 주로 사용한다.
    • 예:
     const buf1 = Buffer.from('Hello');
     const buf2 = Buffer.from('World');
     const buf3 = Buffer.concat([buf1, buf2]); // HelloWorld

2. Buffer 읽기/쓰기 메서드

  1. readUInt32BE(offset)

    • 버퍼의 특정 위치(offset)에서 4바이트를 읽어 부호 없는 32비트 정수로 변환한다.
    • BE(Big Endian)는 네트워크 통신에서 표준으로 사용되는 바이트 순서를 의미한다.
    • 주로 패킷의 길이 정보를 읽을 때 사용한다.
  2. readUInt8(offset)

    • 버퍼의 특정 위치에서 1바이트를 읽어 부호 없는 8비트 정수로 변환한다.
    • 주로 패킷의 타입 정보를 읽을 때 사용한다.
  3. slice(start, end)

    • 버퍼의 특정 범위를 새로운 버퍼로 추출한다.
    • 원본 버퍼의 메모리를 공유하므로 매우 효율적이다.

패킷 구조와 처리 과정 상세 설명

1. 패킷이란?

패킷은 네트워크에서 전송되는 데이터의 기본 단위이다. 우리 프로젝트의 각 패킷은 다음과 같은 구조로 설계되어 있다:

[패킷 길이(4바이트)] [패킷 타입(1바이트)] [실제 데이터(N바이트)]

2. 패킷 길이를 읽는 이유

  1. 데이터 완전성 확인

    • TCP는 필요에 따라 데이터를 여러 조각으로 나누어 전송할 수 있다.
    • 패킷 길이를 통해 모든 데이터가 정확히 도착했는지 확인할 수 있다.
  2. 패킷 경계 구분

    • 여러 패킷이 연속으로 도착할 때 각 패킷의 정확한 시작과 끝을 구분할 수 있다.
    • 예: 두 개의 패킷이 동시에 도착하면 첫 번째 패킷의 길이를 읽어 경계를 정확히 파악할 수 있다.

3. 실제 처리 과정 예시

// 1. 빈 버퍼로 시작
socket.buffer = Buffer.alloc(0); // 새로운 연결이 생성될 때 빈 버퍼 할당

// 2. 새로운 데이터가 도착하면 기존 버퍼에 추가
socket.buffer = Buffer.concat([socket.buffer, data]);
// 예: 첫 번째 데이터 조각이 도착하면 [buffer(0바이트) + 새로운 데이터]
// 두 번째 데이터 조각이 도착하면 [이전 데이터 + 새로운 데이터]

// 3. 패킷 길이 확인 (4바이트)
const length = socket.buffer.readUInt32BE(0); // 예: 100바이트

// 4. 전체 패킷이 도착했는지 확인
if (socket.buffer.length >= length) {
  // 5. 패킷 추출 및 처리
  const packet = socket.buffer.slice(5, length); // 헤더(5바이트) 이후부터 패킷 길이까지
  socket.buffer = socket.buffer.slice(length); // 처리한 패킷은 버퍼에서 제거
}

왜 Buffer를 사용해야 하는가

1. TCP 통신의 특성 때문

TCP는 데이터를 패킷(packet) 단위로 주고 받는다.
여기서 중요한 것은, 한 번에 보내려는 데이터가 쪼개지거나 여러 패킷이 합쳐질 수 있다는 것이다.

예를 들어, 게임 클라이언트에서 "플레이어 위치 데이터"를 서버로 보낼 때:

  • 보내는 데이터가 너무 크면 패킷으로 나뉘어진다.
  • 반대로 서버에서 받을 땐 여러 패킷이 합쳐져서 한 번에 처리되어야 할 수 있다.

버퍼는 이런 쪼개진 데이터를 모으거나, 합쳐진 데이터를 분리해서 원래 의미 있는 데이터로 복원하는데 필수적이다.

2. 바이너리 데이터 최적화

게임 데이터는 텍스트보다 바이너리로 압축해서 보내는게 훨씬 효율적이다.
텍스트(JSON 같은 거)는 크기가 크고 파싱이 느리지만, 바이너리는 데이터 크기가 작아서 네트워크 비용을 줄이고 성능을 높여준다.

3. protobuf.js와 함께 사용

protobuf.js는 Protocol Buffers라는 데이터 직렬화 라이브러리다.

  • 이 라이브러리는 데이터를 바이너리 형식으로 변환해서 보내고 받는다.
  • 직렬화된 데이터가 버퍼 형태로 저장되고, 전송된 데이터를 다시 버퍼에서 읽어와서 해석한다.

실제 프로젝트에서의 Buffer 사용 예시

1. 패킷 구조 정의

우리 프로젝트에서는 다음과 같은 패킷 구조를 사용한다:

// common.proto
message CommonPacket {
  uint32 handlerId = 1; // 핸들러 ID (4바이트)
  string userId = 2;    // 유저 ID (UUID)
  string version = 3;   // 클라이언트 버전
  bytes payload = 4;    // 실제 데이터
}

2. 실제 데이터 처리 과정

서버에서 데이터를 받을 때의 처리 과정을 보면:

  1. 데이터 수신 및 버퍼 관리
// 각 소켓 연결마다 고유한 버퍼 할당
socket.buffer = Buffer.alloc(0);

// 새로운 데이터가 오면 기존 버퍼에 추가
socket.buffer = Buffer.concat([socket.buffer, data]);
  1. 패킷 파싱
// 1. 패킷 길이 정보 읽기 (4바이트)
const length = socket.buffer.readUInt32BE(0);

// 2. 패킷 타입 정보 읽기 (1바이트)
const packetType = socket.buffer.readUInt8(4);

// 3. 실제 데이터 추출
const packet = socket.buffer.slice(5, length);
  1. 데이터 처리
// Protocol Buffers로 디코딩
const { handlerId, payload, userId } = packetParser(packet);

// 적절한 핸들러로 처리
const handler = getHandlerById(handlerId);
await handler({ socket, userId, payload });

Buffer를 사용할 때의 주의사항

  1. 메모리 관리

    • 버퍼는 V8 힙 외부에 별도로 할당되므로, 큰 버퍼를 과도하게 생성하면 메모리 문제가 발생할 수 있다.
    • 사용이 완료된 버퍼는 즉시 처리하여 메모리 누수를 방지해야 한다.
  2. 데이터 무결성

    • 패킷이 완전히 도착했는지 반드시 확인한 후에 처리해야 한다.
    • 우리 프로젝트에서는 패킷 길이를 먼저 확인하고, 전체 패킷이 도착한 경우에만 처리한다.
  3. 에러 처리

    • 버퍼 조작 과정에서 발생할 수 있는 모든 예외 상황을 철저히 고려해야 한다.
    • 잘못된 데이터가 수신되었을 때의 처리 방법을 명확하게 정의해야 한다.

결론

Buffer는 TCP 게임 서버에서 필수적인 핵심 요소이다. 효율적인 데이터 전송과 처리를 위해 Protocol Buffers와 함께 사용되며, 적절한 관리와 주의사항을 철저히 지키면서 사용해야 한다.

0개의 댓글