Node.js용 바이너리 컴파일러 구축
Source: Dev.to
위에 제공된 소스 링크만으로는 번역할 본문이 없습니다. 번역을 원하는 전체 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.
Source: …
개요
바이너리 컴파일러는 데이터 형식을 받아 순수하고 평평한 바이너리 데이터를 생성합니다.
data → buffer
이러한 것들(프로토버프, 플랫버퍼 등)이 존재하는 이유는 간단합니다. 현재 인터넷 통신의 표준은 부피가 크기 때문입니다. 맞아요, JSON은 엄청 비효율적이지만 디버깅 가능성과 가독성을 위해 우리가 치르는 대가죠.
바로 이 숫자 →
1은 JSON에서는 순수 바이너리보다 200 % 더 큽니다.
'1' = 8 bits
'"' = 8 bits
'"' = 8 bits
----------------
Total: 24 bits
순수 바이너리에서는 8 bits에 불과합니다.
하지만 이게 전부가 아닙니다. JSON의 모든 값은 키도 필요합니다:
{
"key": "1"
}
잠깐 생각해 보세요. 이제 몇 비트가 될까요?
제 말만 믿지 마세요—증거가 있습니다.
예시 객체
const obj = {
age: 26,
randNum16: 256,
randNum16: 16000,
randNum32: 33000,
hello: "world",
thisisastr: "a long string lowkey",
}
크기 비교
obj.json 98 kb
obj.bin 86 kb # ← 프로토콜 없음 (키 + 값 직렬화)
obj2.bin 41 kb # ← 프로토콜 사용 (값만, 프로토콜이 키를 관리)
키가 인코딩된 경우에도 순수 바이너리는 여전히 훨씬 가볍고, 페이로드가 커질수록 절감 효과가 급격히 누적됩니다.
성능
Execution time comparison (milliseconds):
Name | Count | Min(ms) | Max(ms) | Mean(ms) | Median(ms) | StdDev(ms)
-----+-------+---------+---------+----------+------------+-----------
JSON | 100 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000
TEMU | 100 | 0.000 | 1.000 | 0.010 | 0.000 | 0.099
가장 오래 걸린 실행은 1 ms였는데, 이는 오해의 소지가 있습니다—100개의 샘플 중 단 하나의 이상치일 뿐이죠. 제 추측은? Node가 초기 버퍼를 할당하는 과정 때문입니다.
왜 바이너리 컴파일러인가?
최근에 Node.js용 실시간 시스템과 도구를 많이 만들면서 JSON이 대역폭을 잡아먹는 문제를 겪고 있습니다. 기본 전송 수단으로 JSON을 사용하면서 저지연 시스템을 구축한다는 것은 거의 불가능에 가깝습니다.
그래서 서버‑간 통신에 protobuf 같은 것이 존재합니다. 이들은 엄청나게 빠르고 훨씬 가볍습니다.
범용 솔루션을 쓰는 대신, 저는 직접 구현해 보는 실험을 하고 있습니다. 이는 주로 Node.js에 도입하고 싶은 여러 프로젝트의 기반 작업이기도 합니다.
- tessera.js – 원시 바이트(
SharedArrayBuffers)로 구동되는 C++ N‑API 렌더러
How I built a renderer for Node.js - shard – 100 ns 미만 지연을 기록하는 프로파일러 (네이티브 C/C++는 보통 5–40 ns 정도)
- nexus – Node.js용 Godot‑같은 게임 엔진
이 실험은 해당 프로젝트들을 위한 견고한 바이너리 인코더/디코더를 준비하는 것이 핵심 목표입니다.
컴파일러 만들기
Note: this is experimental. I literally opened VS Code and just started coding—no research, no paper‑reading.
Note: 이것은 실험적인 시도입니다. 저는 VS Code를 열고 바로 코딩을 시작했을 뿐, 어떤 조사도, 논문도 읽지 않았습니다.
Honestly, this is the best way to learn anything: build it badly from intuition first, then see how experts do it. You’ll notice there’s zero thought put into naming, just pure flow. That’s intentional; it’s how I prototype.
솔직히 말해서, 어떤 것을 배우는 가장 좋은 방법은 직관에 따라 먼저 대충 만들어 보고, 그 다음 전문가들이 어떻게 하는지 보는 것입니다. 이름 짓기에 전혀 신경 쓰지 않은 것을 보게 될 텐데, 순수하게 흐름에 맡긴 것이죠. 이는 의도된 것이며, 제가 프로토타입을 만들 때 사용하는 방식입니다.
Utils and Setup
import { writeFileSync, fstatSync, openSync, closeSync } from "fs";
const obj = {
age: 26,
randNum16: 256,
randNum16: 16000,
randNum32: 33000,
hello: "world",
thisisastr: "a long string lowkey",
// stack: ['c++', "js", "golang"],
// hobbies: ["competitive gaming", "hacking node.js", "football"]
};
const TYPES = {
numF: 1, // float
numI8: 2, // int8
numI16: 3, // int16
numI32: 4, // int32
string: 5,
array: 6,
};
function getObjectKeysAndValues(obj) {
// JS preserves property order per spec
const keys = Object.keys(obj);
const values = Object.values(obj);
return [keys, values];
}
function isFloat(num) {
return !Number.isInteger(num);
}
Serializing Keys
Simple protocol:
[allKeysLen | keyLen | key] → buffer
function serKeys(keys) {
let len = 0;
for (let i = 0; i 255)
throw new Error(`Key too long: "${k}" (${k.length} bytes)`);
buf.writeUInt8(k.length, writer++);
const written = buf.write(k, writer, "utf8");
writer += written;
}
return buf;
}
Deserializing is just the reverse: read length → read key → move pointer.
function deserKeys(buf) {
let reader = 2;
const keys = [];
while (reader = -128 && num = -32768 && num = -128 && num = -32768 && num 8 bits
i16 -> 16 bits
i32 -> 32 bits
컴파일러
직렬화
function gen(obj, protocol = false) {
if (typeof obj !== "object")
throw new Error("Must be Object");
let cache = new Map();
const [keys] = getObjectKeysAndValues(obj);
let serk;
if (!protocol) {
serk = serKeys(keys);
}
let length = 0;
for (const key of keys) {
let buf;
switch (typeof obj[key]) {
case "number":
buf = seNumber(obj[key]);
break;
case "string":
buf = seString(obj[key]);
break;
default:
continue;
}
length += buf.length;
cache.set(key, buf);
}
const dataBuf = Buffer.allocUnsafe(length);
let writer = 0;
for (const key of keys) {
const b = cache.get(key);
if (b) {
b.copy(dataBuf, writer);
writer += b.length;
}
}
return protocol ? dataBuf : Buffer.concat([serk, dataBuf]);
}
역직렬화
function unserData(buf) {
let reader = 0;
let data = [];
while (reader < buf.length) {
const t = buf.readInt8(reader++);
switch (t) {
case 1:
data.push(buf.readFloatBE(reader));
reader += 4;
break;
case 2:
data.push(buf.readInt8(reader++));
break;
case 3:
data.push(buf.readInt16BE(reader));
reader += 2;
break;
case 4:
data.push(buf.readInt32BE(reader));
reader += 4;
break;
case 5:
const len = buf.readInt16BE(reader);
reader += 2;
data.push(buf.subarray(reader, reader + len).toString("utf8"));
reader += len;
break;
}
}
return data;
}
통합 파서
function ungen(buf, protocol = false) {
if (!protocol) {
const keysLen = buf.readInt16BE(0);
const keysBuf = buf.subarray(0, keysLen);
deserKeys(keysBuf);
return unserData(buf.subarray(keysLen));
}
return unserData(buf);
}
실행하기
정상 확인
let samples = { JSON: [], TEMU: [] };
function J() {
const start = process.hrtime.bigint();
JSON.parse(JSON.stringify(obj));
const end = process.hrtime.bigint();
samples.JSON.push((end - start) / 1_000_000n);
}
function T() {
const start = process.hrtime.bigint();
const b = gen(obj, true);
ungen(b);
const end = process.hrtime.bigint();
samples.TEMU.push((end - start) / 1_000_000n);
}
샘플링
const WARMUP = 100_000;
const SAMPLE = 100;
for (let i = 0; i < WARMUP; i++) {}
for (let i = 0; i < SAMPLE; i++) J();
for (let i = 0; i < WARMUP; i++) {}
for (let i = 0; i < SAMPLE; i++) T();
console.dir(samples.TEMU);
작동합니다.
진짜 질문은: 적절한 조사를 한 뒤, 얼마나 더 개선될 수 있을까?