개발일지/TIL(Today I Learned)

2025-01-06 <프로토콜 버퍼를 이용한 TCP 서버 통신>

프린스 알리 2025. 1. 6.

내일배움캠프 Node.js 트랙 48일차

프로토콜 버퍼를 이용한 TCP 서버 통신

직렬화와 역직렬화

웹소켓을 통해 게임서버를 만들 때부터, 우리는 클라이언트와 서버 간에 패킷을 주고받았다. 아래는 이번 프로젝트에서 사용하게 될 공통 패킷 구조이다.

 

  • 프로토버프 구조(요청/응답)
필드 명 타입 설명
handlerId uint32 핸들러 ID (4바이트)
userId string 유저 ID (UUID)
version string 클라이언트 버전 (문자열)
payload bytes 실제 데이터

 

필드 명 타입 설명
handlerId uint32 핸들러 ID
responseCode uint32 응답 코드 (성공: 0, 실패: 에러 코드)
timestamp int64 메시지 생성 타임스탬프 (Unix 타임스탬프)
data bytes 실제 응답 데이터 (선택적 필드)

 

  • 이니셜 패킷 구조
필드 명 타입 설명
deviceId string 디바이스 ID
playerId int(0~4중의 랜덤한 수) 플레이어 ID
latency bytes 레이턴시
  • 바이트 배열의 구조
필드 명 타입 설명 크기
totalLength int 메세지의 전체 길이 4 Byte
packetType int 패킷의 타입 1 Byte
protobuf protobuf 프로토콜 버퍼 구조체 가변

 

보통 클라이언트가 payload에 데이터를 담아서 서버로 전송하면 서버는 이를 적절한 방식으로 처리하기 마련이다. 이때 적절한 방식이란 대개 핸들러 함수의 인자로 payload를 넘겨서 특정한 로직을 수행하는 것이다.

 

물론 그렇다고 해서 payload를 있는 그대로 넘기지는 않는다. payload를 포함한 패킷을 주고 받기 편리한 형태로 가공하고 전송하는데, 이때 클라이언트와 서버 간 데이터 교환에서 사용하는 기본 단위를 메시지 블록이라고 한다. 함수 매개변수(payload)를 메시지 블록으로 만드는 과정을 직렬화(serialize)라고 하며, 반대로 메시지 블록에서 함수 매개변수를 추출하는 것을 역직렬화(deserialize)라고 한다.

프로토콜 버퍼

그렇다면 게임 개발자의 과제는 메시지 블록을 빠르고 안전하고 정확하게 전달하는 것 아닐까?

바로 이런 필요성에 의해 만들어진 데이터 직렬화 시스템이 바로 프로토콜 버퍼(Protobuf)이다. 구글에서 개발하였고, 데이터를 효율적으로 저장하고 네트워크를 통해 빠르게 전달하기 위해 사용된다. 프로토콜 버퍼는 데이터를 직렬화하는 데 있어 JSON이나 XML 같은 방식보다 더 작고, 더 빠르며, 더 효율적이다.

프로토콜 버퍼의 동작 원리

프로토 파일 작성

.proto확장자를 가진 파일에 데이터 구조(편의상 스키마라고 표현하겠다.)를 정의한다.

syntax = "proto3";

message Example {
  string deviceId = 1;
  int32 timestamp = 2;
}

메시지는 데이터의 구조를 정의하며, 데이터 타입, 필드 이름, 필드 번호로 이루어져 있다.

Namespace = proto3
type = Example
typeName = proto3.Example

 

이렇게 정의한 스키마는 각기 다른 프로그래밍 언어로 변환되기 마련이다. 나는 Node.js에서 서버를 만들고 있기 때문에 Protobuf.js라는 모듈을 통해 프로토버프를 사용하였다. 해당 모듈에서는 위 스키마를 다음과 같이 변환한다.

const ExampleType = {
  fields: {
    deviceId: {
      type: "string",  // 필드 타입
      id: 1            // 필드 번호
    },
    timestamp: {
      type: "int32",   // 필드 타입
      id: 2            // 필드 번호
    }
  }
};

 

Protobuf.js는 .proto 파일을 읽어 각 메시지의 정의를 객체로 변환할 때, 메시지에 정의된 모든 필드를 관리하기 위한 내부 데이터 구조fields를 생성한다. fields는 스키마에 정의된 모든 필드의 이름과 정보를 관리하므로, 사용자가 스키마에 정의한 모든 필드가 여기에 포함된다.

따라서 아래와 같은 코드로 에러를 처리할 수도 있다.

// 필드가 비어 있거나, 필수 필드가 누락된 경우 처리
const expectedFields = Object.keys(PayloadType.fields);
const actualFields = Object.keys(payload);
const missingFields = expectedFields.filter(
    (field) => !actualFields.includes(field),
);
if (missingFields.length > 0) {
    throw new CustomError(
        ErrorCodes.MISSING_FIELDS,
        `필수 필드가 누락되었습니다: ${missingFields.join(', ')}`,
    );
}

프로토콜 버퍼로 데이터 직렬화하기

(1) 먼저, 적당한 프로토 파일을 불러온다. 편의상 getProtoMessages()가 반환하는 객체가 그런 기능을 가지고 있다고 가정하겠다.

syntax = "proto3";

package gameNotification;

// 위치 정보 메시지 구조
message LocationUpdate {
  repeated UserLocation users = 1;

  message UserLocation {
    string id = 1;
    float x = 2;
    float y = 3;
  }
}

// 게임 시작 알림
message Start {
  string gameId = 1;
  int64 timestamp = 2;
}
const protoMessages = getProtoMessages();
const Start = protoMessages.gameNotification.Start;

(2) 메시지가 가질 속성을 payload 변수를 통해 정의한다. 이 payload와 스키마를 바탕으로 메시지 객체를 생성한다.(create 메서드는 payload의 속성을 Start 메시지 정의에 따라 매핑하고, 누락된 필드에 기본값을 설정한다.)

const payload = { gameId, timestamp };
const message = Start.create(payload);

(3) 생성된 메시지 객체를 프로토콜 버퍼로 직렬화하려면 별도의 encode 메서드를 사용해야 한다.

const startPacket = Start.encode(message).finish();

(4) 이런 방식을 응용하여 사전에 정의해 두었던 패킷 구조(이 또한 프로토버프 구조로 정의되어 있다.)로 데이터를 가공한다.

const createPacket = (
  handlerId,
  payload,
  clientVersion = '1.0.0',
  type,
  name,
) => {
  const protoMessages = getProtoMessages();
  const PayloadType = protoMessages[type][name];

  if (!PayloadType) {
    throw new Error('PayloadType을 찾을 수 없습니다.');
  }

  const payloadMessage = PayloadType.create(payload);
  const payloadBuffer = PayloadType.encode(payloadMessage).finish();

  return {
    handlerId,
    userId,
    clientVersion,
    sequence,
    payload: payloadBuffer,
  };
};

프로토콜 버퍼로 데이터 역직렬화하기

직렬화하는 방법을 이해했다면 역직렬화는 어렵지 않다. 왜냐하면 직렬화 때 해주었던 일을 반대로 해주면 되기 때문이다. 다만, 프로토버프에 대한 정보가 없으면 알아보기 어려운 단락이 있기에, 이해를 돕기 위해 꼼꼼히 주석을 달아보았다.

export const packetParser = (data) => {
  const protoMessages = getProtoMessages();

  // 공통 패킷 구조를 디코딩
  const Packet = protoMessages.common.Packet;
  let packet;
  try {
    packet = Packet.decode(data);
    // Packet 스키마를 바탕으로 바이너리 데이터(data)를 디코딩하겠다.
    // 디코딩된 데이터는 Protobuf 스키마(Packet)에 정의된 필드와 값을 가진 JavaScript 객체로 변환된다.
  } catch (err) {
    console.error(err);
  }

  const handlerId = packet.handlerId;
  const userId = packet.userId;
  const clientVersion = packet.clientVersion;
  const sequence = packet.sequence;

  console.log('clientVersion:', clientVersion);

  // 여기서 문제가 있다. Protobuf의 bytes 타입은 디코딩되지 않는다.(int, string처럼 명확히 정의된 데이터만 디코딩이 된다. bytes는 어떤 데이터 형식인지 알 수 없는 필드. 주로 이미지, json 등이 이에 해당한다.)
  // 외부 모듈에서 실제 payload를 참조하려면 여기서 추가적인 디코딩 작업을 수행해줘야 한다.

  // clientVersion 검증
  if (clientVersion !== config.client.version) {
    throw new CustomError(
      ErrorCodes.CLIENT_VERSION_MISMATCH,
      '클라이언트 버전이 일치하지 않습니다.',
    );
  }

  // 핸들러 ID에 따라 적절한 payload 구조(스키마)를 반환받는다.
  const protoTypeName = getProtoTypeNameByHandlerId(handlerId);
  if (!protoTypeName) {
    throw new CustomError(
      ErrorCodes.UNKNOWN_HANDLER_ID,
      `알 수 없는 핸들러 ID: ${handlerId}`,
    );
  }

  // 스키마의 풀네임을 .으로 split해서 namespace와 type으로 각각 나누어 준다.
  const [namespace, type] = protoTypeName.split('.');
  const PayloadType = protoMessages[namespace][type];
  let payload;
  try {
    // payload의 스키마를 바탕으로 packet.payload의 데이터를 디코딩 해주겠다!
    payload = PayloadType.decode(packet.payload);
  } catch (error) {
    throw new CustomError(
      ErrorCodes.PACKET_STRUCTURE_MISMATCH,
      '패킷 구조가 일치하지 않습니다.',
    );
  }

  // 필드가 비어 있거나, 필수 필드가 누락된 경우 처리
  const expectedFields = Object.keys(PayloadType.fields);
  const actualFields = Object.keys(payload);
  const missingFields = expectedFields.filter(
    (field) => !actualFields.includes(field),
  );
  if (missingFields.length > 0) {
    throw new CustomError(
      ErrorCodes.MISSING_FIELDS,
      `필수 필드가 누락되었습니다: ${missingFields.join(', ')}`,
    );
  }

  return { handlerId, userId, payload, sequence };
};

댓글