[TIL] 프로토콜 버퍼 (protobuf) - 2
> 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