ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TIL] 프로토콜 버퍼 (protobuf) - 2
    TIL-sparta 2024. 6. 30. 23:30

     

     > 1부: https://donkim0122.tistory.com/95

     

    학습 키워드: Node.js, net, socket, protobuf

     

    2. protobuf 사용

    1) What is it?:

    Figure 1. protobufjs flow diagram

     

     이번에는 protobuf의 root에 저장해둔 message들이 어떤 식으로 사용되는지를 알아보았다. Figure 1에서 푸른색 상자 안에 있는 것들이 proto message 객체에 속한 method들의 모음이다. 다음과 같은 모양으로 사용하게 된다.

     

    // common.proto
    
    syntax = 'proto3';
    
    package common;
    
    message Packet {
        uint32 handlerId = 1;
        string userId = 2;
        string clientVersion = 3;
        uint32 sequence = 4;
        bytes payload = 5;
    }
    import protobuf from 'protobufjs'
    
    const root = protobuf.Root();
    /* load proto files to root */
    const Packet = root.lookupType('common.Packet');
    // const packet = Packet.decode(data);
    // Packet.encode(), Packet.verify() ...

     기본적으로 위와 같은 구조로 사용한다. 강의에서 작성된 코드는 message 타입들을 모두 매핑해서 사용했다.

     

     

    2) How does it work?:

    // client.js
    
    ...
    
    const sendPacket = (socket, packet) => {
      const protoMessages = getProtoMessages();
      const Packet = protoMessages.common.Packet;
      if (!Packet) {
        console.error("Packet 메시지를 찾을 수 없습니다.");
        return;
      }
    
      const buffer = Packet.encode(packet).finish();
    
      // 패킷 길이 정보를 포함한 버퍼 생성
      const packetLength = Buffer.alloc(TOTAL_LENGTH);
      packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0); // 패킷 길이에 타입 바이트 포함
    
      // 패킷 타입 정보를 포함한 버퍼 생성
      const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
      packetType.writeUInt8(1, 0); // NORMAL TYPE
    
      // 길이 정보와 메시지를 함께 전송
      const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);
    
      socket.write(packetWithLength);
    };
    
    // 서버에 연결할 호스트와 포트
    const HOST = "127.0.0.1";
    const PORT = 5555;
    
    const client = new net.Socket();
    
    client.connect(PORT, HOST, async () => {
      console.log("Connected to server");
      await loadProtos();
    
      const message = {
        handlerId: 2,
        userId: "xyz",
        payload: {},
        clientVersion: "1.0.0",
        sequence: 0,
      };
    
      sendPacket(client, message);
    });
    
    ...

     지난 TIL에서 loadProtos.js 파일 내 loadProtos 함수를 통해 initialize가 완료된 proto message들은 getProtoMessages 함수를 이용하여 가져올 수 있도록 했었다. 앞서 언급했듯이 저장된 proto message에 있는 함수들을 이용하여 몇 가지 작업을 해줄 수 있는데, encode() 가 그 중 하나다.

     

    ...
    
    const sendPacket = (socket, packet) => {
      const buffer = Packet.encode(packet).finish();
    
    ...
    
    const message = {
      handlerId: 2,
      userId: "xyz",
      payload: {},
      clientVersion: "1.0.0",
      sequence: 0,
    };
    
    sendPacket(client, message);
    
    ...

     이해를 돕기 위해 앞의 client.js 코드에서 일부분을 추려내 보았다. sendPacket 함수는 두 번째 인자로 packet이라는 객체를 넘겨받는데, 이는 key-value 페어 객체이며, 각 key의 명칭이 proto 파일에 정의된 message의 key 이름과 동일하게 구성되어 있다. 유의할 점은 encode() 의 경우 뒤에 .finish()를 붙여줘야 제대로 동작한다는 것과, encode가 자체적으로 verify를 하지 않기 때문에 encode할 데이터가 정상인지를 개발자가 직접 관리해야한다는 것이다. 물론 verify 또한 protobuf message 객체의 method로 존재한다.

     

    // packetParser.js
    
    import { getProtoTypeNameByHandlerId } from "../../../handlers/index.js";
    import { getProtoMessages } from "../../../init/loadProtos.js";
    import { ErrorCodes } from "../error/errorCodes.js";
    
    export const packetParser = (data) => {
      const protoMessages = getProtoMessages();
    
      // 공통 패킷 구조 디코딩
      const Packet = protoMessages.common.Packet;
      let packet;
      try {
        packet = Packet.decode(data);
      } catch (err) {
        throw new CustomError(ErrorCodes.PACKET_DECODE_ERROR, "패킷 디코딩 중 오류가 발생했습니다.");
      }
    
      const handlerId = packet.handlerId;
      const userId = packet.userId;
      const clientVersion = packet.clientVersion;
      // const payload = packet.payload;
      const sequence = packet.sequence;
    
      // client version check
    
      if (clientVersion !== config.client.clientVersion) {
        throw new CustomError(ErrorCodes.CLIENT_VERSION_MISMATCH, "클라이언트 버전이 일치하지 않습니다.");
      }
    
      const protoTypeName = getProtoTypeNameByHandlerId(handlerId);
      // 이미 내부에서 handle 되는 에러
      // if (!protoTypeName) {
      //   throw new CustomError(ErrorCodes.UNKNOWN_HANDLER_ID, `알 수 없는 핸들러 ID: ${handlerId}`);
      // }
    
      const [namespace, typeName] = protoTypeName.split(".");
      const PayloadType = protoMessages[namespace][typeName];
      let payload;
      try {
        payload = PayloadType.decode(packet.payload);
      } catch (err) {
        throw new CustomError(ErrorCodes.PACKET_DECODE_ERROR, "패킷 디코딩 중 오류가 발생했습니다.");
      }
    
      // 이미 decode에서 한 번 검증하기 때문에 안해도 됨
      // const errorMessage = PayloadType.verify(payload);
      // if (errorMessage) ...
      // throw new CustomError(ErrorCodes.INVALID_PACKET, `패킷 구조가 일치하지 않습니다: ${errorMessage}`);
    
      // 필드가 비어있는 경우 = 필수 필드 누락
      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 };
    };

     decode의 경우는 내부에서 verify가 진행된다고 한다. 검증 실패 시 에러가 발생하므로 try/catch로 묶어주고, 성공한다면 proto에 정의된대로 key-value로 묶인 객체가 반환된다. 

     

     

    3) Why use it?:

     강의와 공식 documentation에서 protobuf가 빠르다고 강조했는데, 찾아보니 몇 가지 이유를 더 있었다. 첫 째는 backward compatibility로, 버전을 체크하기 위해 if문을 여러개 작성하는 지저분한 코드를 작성하지 않을 수 있다는 점이 있다. 또 다른 이유로는 앞서 예제 코드에서 처럼 schema의 형태로 패킷의 구조를 정의하고 검증할 수 있다는 점이 있다. 그리고 빠뜨릴 수 없는 가장 큰 이유는 여러 언어를 지원하기 때문에 기술 스택 전환에도 부담이 적다는 장점이 있다.

     

     

    --

     

    REFERENCES:

     

    https://teamsparta.notion.site/2-7b03e94d6bd346cfa23d5514d9d15139#df5c8c7791a94ae4bd1661967d22ddfe

     > 강의 노트, "[게임서버개발 플러스] 실전 게임 서버만들기 (2)" by 조호영 튜터님, accessed: 2024-06-29

    https://www.npmjs.com/package/protobufjs

     > protobufjs (NPM official page), accessed: 2024-06-30, Figure 1

    https://codeclimate.com/blog/choose-protocol-buffers

     > CodeClimate, "5 Reasons to Use Protocol Buffers Instead of JSON for Your Next Service", accessed: 2024-06-30

    728x90
Designed by Tistory.