8.12 TypeScript 환경을 위한 성능 튜닝 및 최적화 가이드

8.12 TypeScript 환경을 위한 성능 튜닝 및 최적화 가이드

JavaScript/TypeScript는 본질적으로 P2P 통신이나 C++ 수준의 메모리 관리(Memory Management)를 위해 설계된 언어가 아니다.
V8 엔진의 빠름은 ‘콜 스택 비우기’ 에 최적화되어 있을 뿐, 1초에 만 개씩 쏟아지는 로봇 센서 데이터를 JSON.parse로 객체 공장을 돌려대면 엔진은 가비지 컬렉션(GC)를 수행하느라 화면을 동결(Freeze)시켜 버린다.

이 챕터는 Zenoh TypeScript를 사용하는 데 있어 “Hello World” 수준을 넘어, 상용 수준(Production-Ready) 의 거대 트래픽을 감당하기 위한 극한의 엔지니어링 런북(Runbook)이다.
메모리 풀링(Pooling)으로 가비지 트럭의 출동을 영원히 막고, Web Worker 스레드로 통신망과 UI망을 물리적으로 절단하며, Zlib 압축으로 브라우저의 가녀린 WebSocket 대역폭을 极限으로 쥐어짜는 최후의 기술들을 파헤친다. 당신의 TS 코드는 이 챕터를 기점으로 C++ 백엔드 수준의 심박수를 갖게 될 것이다.

1. V8 자바스크립트 엔진의 가비지 컬렉션(GC) 부하 최소화 기법

브라우저 혹은 Node.js 가 먹통이 되고 “삐그덕” 거리는 원인(Jitter)의 99%는 쓰레기 수거차(Garbage Collector)의 ‘Stop-The-World’ 현상이다.

1.0.1 [Runbook] 메모리 돌려막기(No-new Allocation) 전술

가장 치명적인 죄악은 수신 콜백(Subscriber Callback) 안에서 매번 새로운 []{} 를 선언하여 지역 변수(Local Variable)를 양산하는 행위다.

// ❌ 안티 패턴 (GC를 폭주시키는 주범)
session.declareSubscriber("robot/1/data", (sample) => {
    // 매 수신마다 새로운 버퍼 껍데기와 JSON 객체가 힙 메모리에 생겨남.
    const text = new TextDecoder().decode(sample.payload);
    const data = JSON.parse(text); 
    
    // 그리기 위해 썼던 데이터는 함수가 끝나며 즉시 쓰레기통행 -> GC 게이지 떡상!
    draw(data);
});
// ✅ 베스트 프랙티스 (전역 객체 재활용)
const STATE_BUFFER = {
    x: 0,
    y: 0,
    speed: 0
};
const decoder = new TextDecoder("utf-8"); // 디코더 인스턴스는 단 한개만!

session.declareSubscriber("robot/1/data", (sample) => {
    // 1. 객체를 새로 파지 않고, 미리 떠놓은 힙 메모리 공간의 '내부 값' 만 비틀어준다.
    const str = decoder.decode(sample.payload);
    
    // 이 파싱은 어쩔 수 없지만 (FlatBuffers가 아니라면), 
    // 최소한 파싱된 값을 전역 공간으로 안전하게 옮겨둔다.
    const temp = JSON.parse(str); 
    STATE_BUFFER.x = temp.x;
    STATE_BUFFER.y = temp.y;
    STATE_BUFFER.speed = temp.speed;
    
    // React 의 경우 상태(State)를 직접 업데이트 하기보단,
    // 이 STATE_BUFFER 를 requestAnimationFrame 이 16ms 마다 훔쳐가서(Read-only) 그리게 만들어야 한다.
});

특히 브라우저 렌더링 생태계에서는, 통신에서 날아오는 배열 조각(Uint8Array)을 React 의 State 창고에 매번 던져 넣지 말고, useRef 에 할당된 단일 버퍼 컨테이너에 값을 “덮어 씌우게(Overwrite)” 해야만 GC의 학살극으로부터 앱을 방어할 수 있다.

2. 고빈도(High-frequency) 메시지 큐잉 및 메모리 풀링(Pooling)

로보틱스 패킷은 무자비하다. 센서들이 쏘는 {"v": 1.25} 같은 아주 기가 막히게 작은 메시지들이 초당 수만 건 날아오면 전역 메모리(Global Memory) 기법으로도 파싱 엔진의 부하를 막을 수 없다.

2.0.1 [Runbook] 배치 휩쓸기(Batch Sweeping) 전술

데이터가 오는 족족 처리하는 구조를 버린다. 택배 기사가 박스 하나가 터미널에 도착할 때마다 트럭을 몰고 나가는 격이다. 100개가 모일 때까지 기다렸다가 단 한 번의 V8 프레임으로 묶어 처리한다.

import * as zenoh from "@eclipse-zenoh/zenoh";

class BatchQueueController {
    // [풀링] 택배 상자를 담아둘 적재소 
    private queue: Float32Array[] = [];
    private BATCH_SIZE_LIMIT = 100;

    constructor(private onBatchReady: (batch: Float32Array[]) => void) {
        // ...
    }

    pushPacket(data: Uint8Array) {
        // 복사를 피하기 위해 버퍼의 뷰어만 던져 넣음
        this.queue.push(new Float32Array(data.buffer));

        if (this.queue.length >= this.BATCH_SIZE_LIMIT) {
            // 박스가 다 찼다! 배송 출발 (이 때만 메인 로직이 호출됨)
            // 화면 렌더링 횟수를 1/100 로 압축하는 파괴적 성능.
            this.onBatchReady(this.queue);
            
            // 다 쓴 박스는 즉시 버리는 대신, 
            // 실무 최적화에선 this.queue.length = 0 으로 슬롯만 비워 힙 메모리 파괴 방지.
            this.queue = []; 
        }
    }
}

// 적용
const batcher = new BatchQueueController((batchList) => {
    // React 컴포넌트나 Three.js 캔버스를 단 "한 번만" 업데이트.
    batchList.forEach(pts => drawTarget(pts));
});

session.declareSubscriber("cloud/point/rapid", (sample) => {
    // 아무 생각 없이 큐에 쑤셔 넣기만 한다. (초고속 동작)
    batcher.pushPacket(sample.payload);
});

이 “버퍼 기반 어그리게이션(Aggregation)” 알고리즘이 없다면, React의 setState 가 만 번 호출되고 브라우저는 “앗! 페이지가 응답하지 않습니다” 알림을 띄우게 된다.

3. 웹 워커(Web Worker) 스레드를 활용한 Zenoh 백그라운드 연산 분리

마지막 족쇄. 브라우저는 “DOM을 그리는 렌더링 스레드“와 “JS 통신/계산 로직 스레드“가 무조건 1개의 라인에서 엉켜 굴러간다. Zenoh WASM 모듈이 날아오는 패킷을 처리하느라 바쁘면 당연히 화면의 버튼은 눌리지 않는다.

3.0.1 [Runbook] UI - 통신 완전 격리(Isolation) 전술

Web Worker를 하나 파서, Zenoh의 모든 기동 파일과 구독 통신망을 그 지하실(Worker Thread)에서 혼자 돌도록 가둬버린다.

1. 워커 스크립트 작성 (zenoh-worker.js)
Vite 환겨 등에서는 워커 내부에서 ES 모듈 임포트가 가능하게 세팅된다.

// 이 파일은 메인 화면과 독립된 100% 다른 시공간(스레드)에서 실행된다!
import * as zenoh from "@eclipse-zenoh/zenoh";

let session: zenoh.Session;

// 메인 화면에서 지시(PostMessage)가 내려오면 반응
self.onmessage = async (e) => {
    if (e.data.cmd === "BOOT") {
        session = await zenoh.open();
        
        await session.declareSubscriber("robot/video", (sample) => {
            // [핵심] JSON 구역이든 이미지든 여기서 디코딩을 처리한 뒤
            const resultMsg = new TextDecoder().decode(sample.payload);
            
            // 메인 화면(UI)으로 정제된 결과만 핑퐁쳐서 올려보냄!
            self.postMessage({ type: "DATA", data: resultMsg });
        });
    }
};

2. 메인 화면(React 등)의 통제

import { useEffect, useState } from "react";

function DroneApp() {
    const [msg, setMsg] = useState("");

    useEffect(() => {
        // 브라우저 백그라운드 스레드(워커) 소환
        const worker = new Worker(new URL('./zenoh-worker.js', import.meta.url), { type: 'module' });
        
        // 워커에게 부팅하라고 지시
        worker.postMessage({ cmd: "BOOT" });

        // 워커가 지하실에서 가공을 끝마치고 올려보낸 데이터만 받아서 그린다.
        worker.onmessage = (e) => {
            if (e.data.type === "DATA") setMsg(e.data.data);
        };

        return () => worker.terminate(); // 컴포넌트 파괴 시 워커 학살
    }, []);

    return <div>{msg}</div>
}

이 패턴이 프론트엔드 최적화의 “어전 시합 종결자” 다.
이로써 당신의 웹 앱 메인 스레드는 CPU 1%만 사용하며 매우 쾌적한 화면 전환을 제공하고, 뒤쪽 지하실에서는 워커 스레드가 Zenoh망의 수만 개 데이터들과 혈투를 벌이는 마이크로서비스(Microfrontend) 스펙이 완성된다.

4. 브라우저 환경에서의 패킷 크기 최적화 및 페이로드 압축 기법

클라우드 내부에 있는 Node.js와 Zenoh Router 사이는 광케이블 무제한 대역폭(TCP)을 쓴다 치자.
하지만 아프리카 오지에서 당신의 웹사이트(대시보드)에 접속한 관리원의 브라우저는? 이쪽으로 들어오는 WebSocket 구멍망은 LTE 지연과 대역폭 부족에 허덕이고 있다.

데이터를 라우터로 던지거나, 라우터에서 당겨올 때 TS 로직 내에서 강제 압축(Compression) 을 발라버리는 가장 무식하고 효과적인 전술이다.

4.0.1 [Runbook] P2P 극저용량 압축 전송(Zlib) 전술

Node.js 환경에서는 내장 zlib 을, 브라우저 환경에서는 pako 같은 압축 라이브러리를 동원한다. (가장 범용적인 브라우저 pako 기준)

송신부 (퍼블리셔 혹은 Queryable)

import pako from "pako"; // 압축 라이브러리
import * as zenoh from "@eclipse-zenoh/zenoh";

async function compressAndShoot(session: zenoh.Session, bigJsonObj: any) {
    // 1. 방대한 설정값 JSON (예: 50MB) 
    const strPayload = JSON.stringify(bigJsonObj);
    const bytesBefore = new TextEncoder().encode(strPayload);
    
    // 2. Gzip 규격으로 강제 모루질 압축 (50MB -> 약 5MB 로 90% 축소!)
    const compressedBytes = pako.deflate(bytesBefore);
    console.log(`압축률: ${bytesBefore.length} -> ${compressedBytes.length} bytes`);

    // 3. 발사 (망에는 이 가벼운 패킷만 날아다닌다)
    await session.put("map/seoul/3d/chunk", compressedBytes);
}

수신부 (브라우저 Subscriber)

import pako from "pako";

async function receiveCompressedStream(session: zenoh.Session) {
    await session.declareSubscriber("map/seoul/3d/chunk", (sample) => {
        try {
            // 1. 압축 해제 (Deflate -> Inflate)
            const decompressedBytes = pako.inflate(sample.payload);
            
            // 2. 해제된 바이트를 다시 문자열/JSON으로 치환
            const rawText = new TextDecoder().decode(decompressedBytes);
            const massiveData = JSON.parse(rawText);
            
            console.log(">> 거대 데이터 압축해제 파싱 완료");
            
        } catch (e) {
            console.error("압축 해제 실패! 깨진 덩어리 수신됨", e);
        }
    });
}

주의할 규칙은 CPU 트레이드오프다.
“압축을 하고 푸는 시간(CPU 연산 딜레이)“이 “물리적 네트워크 통신이 느려서 날아가는 시간“보다 짧을 때만 이 기법을 도입해야 한다. 통상적으로 JSON 기반의 거대한 설정 맵 데이터나 텍스트 로그 파일 뭉텅이는 무조건 Gzip 류 라이브러리로 감싸서 Zenoh 에 태우는 것이 현대 엣지 컴퓨팅의 바이블이다.