8.9 프론트엔드 프레임워크 통합 (React, Vue 중심)

8.9 프론트엔드 프레임워크 통합 (React, Vue 중심)

백엔드용 node.js 터미널에서 Zenoh 로그가 예쁘게 찍히는 걸 구경하는 것과, 브라우저의 React 컴포넌트 안에 Zenoh를 부어 넣어 ‘화면이 살아서 숨 쉬게’ 만드는 것은 완전히 다른 차원의 공학이다.

React나 Vue 같은 모던 프론트엔드는 자신만의 복잡한 Virtual DOM 렌더링 생명주기(Lifecycle) 를 갖는다. C++의 로보틱스 통신 시스템인 Zenoh를 이 생태계에 억지로 구겨 넣다가는 무한 렌더링 루프(Infinite Rendering)에 빠져 크롬 탭이 폭발하거나, 메모리 누수로 인해 웹 앱이 10분 만에 멈춰버리는 끔찍한 결말을 맞는다.

이 챕터는 Zenoh가 React/Vue 프레임워크와 결합할 때 지켜야 할 철칙(Rule)들을 런북 형태로 다룬다. 컴포넌트 마운트 시 소켓을 열고, 상태를 Redux/Zustand 에 동기화하며, 언마운트 시 안전하게 라우팅을 폭파하는 ’프론트엔드 통신 아키텍처의 정수’다.

1. SPA(Single Page Application) 아키텍처 내에서의 Zenoh 포지셔닝

SPA(React, Vue)의 특징은 브라우저 탭을 한 번 열면 사용자가 창을 끌 때까지 “웹 페이지 이동(새로고침)“이 발생하지 않는다는 것이다.

1.0.1 [아키텍처 인스펙션] 전역 세션(Global Session) 유지 전술

SPA에서 Zenoh 세션(zenoh.Session) 인스턴스는 무조건 앱 최상단의 불변하는 1개의 싱글톤(Singleton) 으로 관리되어야 한다.

안티 패턴 (최악의 구조):
컴포넌트 단위(예: RobotDetail.tsx) 마다 렌더링될 때 zenoh.open() 을 호출하는 짓이다. 사용자가 로봇 목록 창과 상세 창을 여러 번 왔다 갔다 하면 백그라운드에 수십 개의 WebSocket 통로가 증식하여 라우터 서버를 DdoS 공격하게 된다.

베스트 프랙티스 (App-Level Bootstrapping):

  1. React의 가장 바깥쪽, 즉 <App /> 컴포넌트 밖이거나 index.tsx 진입점에서 가장 먼저 zenoh.open() 을 실행하여 글로벌 터널을 뚫는다.
  2. 이 단일 세션(Session) 객체를 React Context API 나 전역 상태 툴(Zustand)의 Store 안에 처넣어 둔다.
  3. 이후 화면 구석구석의 하위 컴포넌트들은 이 저장소에서 세션 포인터를 꺼내서 구독(declareSubscriber) 과 발사(put) 용도로만 재활용(Reuse)한다.

이 구조를 뼈대에 박아야만 SPA 환경에서 “단 1개의 경량 통로“를 타고 수천 개의 통신이 오가는 진정한 멀티플렉싱(Multiplexing) 앱이 완성된다.

2. React 상태 관리 라이브러리(Zustand, Redux)와 Zenoh 구독 상태 연동

Zenoh로 데이터를 받았다고 끝이 아니다. React는 상태(State)가 변해야 화면(DOM)을 다시 그린다.
“통신 스레드“와 “UI 렌더링 스레드“를 완벽하게 붙여주는 용접 기술이 필요하다. 요즘 가장 빠르고 직관적인 통제 도구인 Zustand 를 활용한다.

2.0.1 [Runbook] 데이터 투사(Data Mesh)의 Zustand 주입 전술

Zustand Store 바깥쪽에서 Zenoh 구독을 켜고, 데이터가 떨어질 때마다 Store 자체를 찔러 화면을 반응시킨다.

// --- store.ts (전역 상태) ---
import { create } from "zustand";

interface RobotStore {
    robots: Record<string, number>; // { "robot1": 95, "robot2": 15 }
    updateBattery: (id: string, bat: number) => void;
}

export const useRobotStore = create<RobotStore>((set) => ({
    robots: {},
    updateBattery: (id, bat) => set((state) => ({
        robots: { ...state.robots, [id]: bat }
    }))
}));

// --- zenohManager.ts (통신 계층) ---
import * as zenoh from "@eclipse-zenoh/zenoh";
import { useRobotStore } from "./store";

let session: zenoh.Session | null = null;

export async function initGlobalZenoh() {
    session = await zenoh.open();

    // Redux/Zustand 스토어 바깥에서 Zenoh 구독기를 설치하고, 
    // 변화 감지 시 Store 의 Action(updateBattery) 을 직접 타격한다!
    await session.declareSubscriber("fleet/*/battery", (sample) => {
        // 경로에서 로봇 식별자 뽑기
        const parts = sample.keyExpr.split("/");
        const robotId = parts[1];
        
        // 페이로드 해독
        const batteryVal = parseFloat(new TextDecoder().decode(sample.payload));
        
        // UI를 그리는 React에게 데이터 전달 (Hook 직접 호출)
        useRobotStore.getState().updateBattery(robotId, batteryVal);
    });
}

이제 React의 컴포넌트(RobotList.tsx)는 오로지 Zustand의 robots 객체만 바라보고 렌더링하게 놔둔다. 컴포넌트는 자신이 그리는 데이터가 구글 서버에서 온 건지 5m 옆의 로봇에서 Zenoh 망을 타고 날아온 건지 영영 모르게(Dependency Inversion) 아키텍처가 고립(Isolation)된다.

3. React Custom Hook 설계 (useZenohSession, useZenohSubscribe)

전역(Global) 세션은 유지하되, 각 UI 패널이 모니터 화면에 등장(Mount)할 때만 특정 데이터를 땡겨오고, 창을 닫으면 알아서 구독을 끊게 만드는 현대 React의 궁극기 “Custom Hook” 이다.

3.0.1 [Runbook] 마법의 Zenoh 훅스 팩토리

session 객체는 React의 useContext 를 통해 이미 컴포넌트 트리 깊숙이 공급되고 있다고 가정한다.

import { useEffect, useState } from "react";
import * as zenoh from "@eclipse-zenoh/zenoh";
// (글로벌 컨텍스트에서 가져오는 함수라 가정)
import { useZenohSession } from "./ZenohProvider"; 

/**
 * 컴포넌트가 살아있는 동안만 특정 경로를 구독하고 상태로 꺼내어주는 훅
 */
export function useZenohSubscribe(keyExpr: string) {
    const session = useZenohSession();
    const [data, setData] = useState<string | null>(null);

    useEffect(() => {
        if (!session) return;
        
        let subObject: zenoh.Subscriber;
        
        // 1. 컴포넌트 마운트 시 야전 통신선 개통
        const init = async () => {
            subObject = await session.declareSubscriber(keyExpr, (sample) => {
                const text = new TextDecoder().decode(sample.payload);
                setData(text);
            });
        }
        init();

        // 2. 컴포넌트 파괴 시 통신망 자동 절단! (메모리 릭 방지)
        return () => {
            if (subObject) {
                subObject.undeclare();
            }
        };
    // keyExpr이나 session 객체가 바뀌면 Hook이 자동으로 리롤링(Rerolling)된다.
    }, [session, keyExpr]); 

    return data;
}

이제 주니어 프론트엔드 개발자는 복잡한 C++ WASM 바인딩이나 Session 생명 주기를 몰라도 된다.
그저 컴포넌트 최상단에서 const temp = useZenohSubscribe("room/1/temp"); 1줄만 선언하면, 로봇 기체 온도계와 프론트엔드 브라우저 화면이 생명공학적으로 일체화된다.

4. 실시간 IoT 센서 대시보드 및 로봇 원격 측정(Telemetry) 차트 구현

React의 상태 훅에 데이터를 물려놨으면, 이제 차트 라이브러리(Chart.js, Recharts 등)에 데이터를 흘려보낸다.
주의할 점은 앞서 8.7.4 장에서 배운 “렌더링 스로틀링(Throttling)“이다. 초당 100건 날아온다고 차트를 100번 다시 그리면 React 렌더 트리가 마비된다.

4.0.1 [Runbook] Throttle 렌더링 차트 병합술

import React, { useRef, useEffect } from "react";
// 8.9.3에서 만든 훅
import { useZenohSession } from "./ZenohProvider"; 

// 캔버스를 직접 조작하여 React 트렌더링(setState) 오버헤드를 아예 없애버리는 전술
export const LiDARCanvas: React.FC = () => {
    const session = useZenohSession();
    const canvasRef = useRef<HTMLCanvasElement>(null);

    useEffect(() => {
        if (!session || !canvasRef.current) return;
        
        const ctx = canvasRef.current.getContext("2d");
        let sub: any;
        let lastDraw = 0;

        session.declareSubscriber("robot/1/lidar/points", (sample) => {
            const now = Date.now();
            // 30 FPS 방어막 (33ms 마다만 렌더링을 허용)
            if (now - lastDraw < 33) return; 
            lastDraw = now;

            // Float32Array로 파싱 (8.4.4.3 장의 제로 카피 기법)
            const pts = new Float32Array(sample.payload.buffer);
            
            // React 라이프사이클을 배제하고 즉각 DOM(Canvas)에 브러쉬를 칠한다!
            ctx!.clearRect(0,0, 400, 400);
            for(let i=0; i<pts.length; i+=2) {
                const x = pts[i] + 200;
                const y = pts[i+1] + 200;
                ctx!.fillRect(x, y, 2, 2);
            }
        }).then(s => sub = s);

        return () => sub && sub.undeclare();
    }, [session]);

    // React는 껍데기 캔버스만 그릴 뿐, 데이터 변화로 인한 리렌더링 폭풍에서 완벽히 고립된다.
    return <canvas ref={canvasRef} width={400} height={400} style={{border: "1px solid red"}}/>;
};

React 의 철학(선언적 렌더링)과 정면으로 배치되는 useRef + Vanilla JS Canvas 조작이지만, 이것이야말로 초당 메가바이트 단위로 흩뿌려지는 레이저 포인트 데이터를 브라우저가 버틸 수 있는 유일한 ‘오버클럭 렌더링’ 아키텍처다. “프레임워크의 규칙을 깨야 할 때를 아는 자가 진정한 팩토리 엔지니어다.”

5. 컴포넌트 생명주기(Unmount)에 따른 안전한 구독 해제 방안

이것은 단순히 “코드를 예쁘게 짠다“의 문제가 아니라, 뒷단(Backend) Zenoh Router 의 “트래픽 요금“과 “라우팅 테이블 부하“를 물리적으로 부숴버리는 최중요 이슈다.

5.0.1 [아키텍처 인스펙션] 구독 해제(undeclare) 결락 시 발생하는 일

브라우저에서 useZenohSubscribe("camera/HD") 를 띄운 <CameraView> 컴포넌트가 화면에 마운트(등장)되면, 뒷단의 라우터는 “아 클라이언트가 데이터를 원하니까 미국에 있는 로봇한테서 영상을 땡겨와야겠군” 하고 통로를 연다.

그런데 사용자가 [뒤로 가기] 버튼을 눌렀다. 컴포넌트는 React 트리에서 사라진다.
이때 sub.undeclare() 를 던져주지 않으면?
라우터는 브라우저 탭(Session)이 아직 살아있기 때문에 사용자가 안 보는 화면인데도 불구하고 계속해서 미국 로봇으로부터 초당 6MB 씩 영상을 끌어와서 브라우저 뒷구멍(WebSocket)에 계속 붓고 있게 된다. 사용자가 상세 창을 100번 클릭했다면 초당 600MB 의 유령 트래픽이 발생해 라우터와 네트워크가 불타버린다.

5.0.2 [Runbook] 무결점 Unmount 체인 강제 적용 전술

useEffect 의 정리(Cleanup) 함수 안에서, 단순 선언뿐만 아니라 비동기로라도 반드시 해제 코드가 불리게 쇠사슬을 감아야 한다.

useEffect(() => {
    let mounted = true;
    let mySub: zenoh.Subscriber | null = null;

    // 1. 비동기 구독 생성부
    const setup = async () => {
        try {
            const sub = await session.declareSubscriber("heavy/video/feed", callback);
            
            // 만약 내가 sub을 얻어낸 0.5초 찰나에 이미 사용자가 뒤로가기를 눌러서 
            // mounted가 false가 되었다면?
            if (!mounted) {
                // 얻자마자 그 즉시 부숴버려서 트래픽이 흐르는 걸 바로 철벽 방어한다.
                console.warn("[방어선 전개] 마운트 해제 후 생성된 구독 객체는 즉시 파기함.");
                sub.undeclare();
                return;
            }
            
            // 정상 상태면 저장
            mySub = sub;
            
        } catch(e) { /* 에러 방어 */ }
    }
    setup();

    // 2. 컴포넌트 소멸 파괴 훅
    return () => {
        mounted = false; // "사용자 떠남" 플래그 꽂음
        if (mySub) {
            console.log(">> 굿바이. 화면 전원 끔 (Undeclare)");
            // 이 신호가 라우터에 닿는 순간, 그 뒤에 있던 중계망의 경로 탐색도 일제히 중단된다.
            mySub.undeclare(); 
        }
    };
}, [session]);

비동기 프라미스(Promise)의 타이밍 이슈로 인해, “구독이 다 만들어지기 전에 사용자가 창을 닫아버리는” 찰나의 엣지 케이스를 막아내야 한다. 위 코드의 if(!mounted) 스나이핑 샷이야말로 기업형 프론트엔드 모듈이 가져야 할 최고 수준의 결함 방어책이다.