Node.js용 바이너리 컴파일러 구축

발행: (2026년 1월 6일 오후 09:08 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크만으로는 번역할 본문이 없습니다. 번역을 원하는 전체 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.

Source:

개요

바이너리 컴파일러는 데이터 형식을 받아 순수하고 평평한 바이너리 데이터를 생성합니다.

data → buffer

이러한 것들(프로토버프, 플랫버퍼 등)이 존재하는 이유는 간단합니다. 현재 인터넷 통신의 표준은 부피가 크기 때문입니다. 맞아요, JSON은 엄청 비효율적이지만 디버깅 가능성과 가독성을 위해 우리가 치르는 대가죠.

바로 이 숫자 → 1JSON에서는 순수 바이너리보다 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);

작동합니다.

진짜 질문은: 적절한 조사를 한 뒤, 얼마나 더 개선될 수 있을까?

Back to Blog

관련 글

더 보기 »

Socket.IO 서버 벤치마킹

Socket.IO 서버 벤치마크 !Sahaj Bhatthttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads....

고트래픽 Node.js API 최적화 전략

Node.js의 이벤트‑드리븐 아키텍처를 활용하세요. I/O 작업을 논블로킹으로 유지하고, 블로킹 코드 대신 async/await 또는 promises를 사용합니다. javascript // Use async…