-
[TIL] 프로토콜 버퍼 (protobuf) - 2TIL-sparta 2024. 6. 30. 23:30
> 1부: https://donkim0122.tistory.com/95
학습 키워드: Node.js, net, socket, protobuf
2. protobuf 사용
1) What is it?:
이번에는 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'TIL-sparta' 카테고리의 다른 글
[TIL] 스파르타) Chapter 5 주특기 플러스 개인 과제 진행 (TCP 게임 서버, D-6) (0) 2024.07.02 [TIL] 스파르타) Chapter 5 주특기 플러스 개인 과제 진행 (TCP 게임 서버, D-7) (0) 2024.07.01 [TIL] 프로토콜 버퍼 (protobuf) - 1 (0) 2024.06.29 [TIL] 스파르타) Node.js 게임서버 주특기 플러스 3주차 강의 수강 (DB, connection pool, migration) (0) 2024.06.28 강의 과제) 삼각 함수, 역 삼각 함수 (0) 2024.06.27