8.6 라이브니스(Liveliness) 토큰과 실시간 노드 상태 감지
Slack이나 Discord 우측 패널을 보면 친구들의 접속 상태가 실시간 초록불로 빛난다. 브라우저 게임에서는 방(Room)에 누군가 들어오고 나갈 때마다 “사용자가 입장했습니다“라는 알림이 뜬다.
이것을 기존 웹 백엔드 개발자들은 어떻게 구현했을까? 클라이언트가 3초마다 Ping 을 보내고, 서버의 Redis DB에 Last_Seen 타임스탬프를 밀어 넣은 다음, 10초가 지나도록 핑이 안 오면 배치 프로세스가 “얘 죽었음” 이라고 판단해 다른 사용자들에게 소켓으로 브로드캐스트 하는 끔찍하고 무거운 과정의 연속이었다.
이 장에서는 수만 대의 로봇과 수천 명의 웹 브라우저 클라이언트가 맞물린 Zenoh 생태계에서, 단 5줄의 코드만으로 이 거대한 프레즌스(Presence) 관리망 을 0 ms 딜레이로 구축해 내는 마법 같은 ‘Liveliness’ 아키텍처를 전개한다. Redis도, 핑(Ping) 서버도 필요 없다.
1. 분산 네트워크에서의 프레즌스(Presence) 관리 개념
기존 중앙 집중형 서버에서 “누가 살아있나?“를 판별하는 주체는 무조건 하나(서버)였다. 하지만 엣지 컴퓨팅망에서는 서버 자체가 증발할 수 있다.
1.0.1 [인스펙션] Liveliness의 중앙 탈피(Decentralized) 철학
Zenoh의 Liveliness는 특정 장비가 살아있다는 것을 증명하기 위해 ‘생명 토큰(Liveliness Token)’ 이라는 추상적 개념을 발급한다.
- 토큰 발급 (Declaration): Node.js나 브라우저가 특정 공간(
ui/client/123/alive)에 자신의 생명을 증명하는 깃발을 꽂는다. - 자동 심장박동 (Under-the-hood Heartbeat): TypeScript 개발자가
setInterval을 짤 필요가 없다. 밑바닥의 Zenoh Rust 코어가 알아서 라우터망에 미세한 핑을 찔러넣어 자신이 켜져 있음을 동네방네 알린다. - 토큰 소멸 (Drop/Undeclare): 브라우저 탭이 강제로 닫히거나 서버 앱이 크래시가 나면 네트워크 커넥션이 끊긴다. 이 순간, 주변 라우터들이 즉각 “어? 123번이 심정지 했다!“를 인지하고 망 내의 모든 구독자들에게 [DELETE] 이벤트를 멀티캐스트로 쏴버린다.
이 철학적 전환 덕분에 TS 백엔드 엔지니어들은 상태 관리를 위한 DB(Redis 등) 유지 보수 지옥에서 완전히 해방된다. “로봇이 지금 살아 있습니까?” 라고 물을 필요가 없다. 죽으면 즉시 라우터가 장례를 치러서 부고장을 보내주기 때문이다.
2. TypeScript 클라이언트에서 Liveliness 토큰 발행
당신이 만든 대시보드 웹 앱(React) 혹은 백엔드 워커(Worker)가 전원을 켜는 순간, 자신이 스웜에 합류했음을 만천하에 선포하는 단계다.
2.0.1 [Runbook] 불멸의 징표 발급 전술
import * as zenoh from "@eclipse-zenoh/zenoh";
import { v4 as uuidv4 } from "uuid";
async function bootClientMachine() {
// 1. 세션 체결 (WASM 이든 네이티브든 상관없다)
const session = await zenoh.open();
// 고유 장비 ID (보통 크롬 LocalStorage에서 꺼내쓰거나 UUID를 딴다)
const myId = uuidv4();
// 2. 생명 토큰 경로 설계 (규칙: 역할/id/liveliness)
const myLifeKey = `dashboard/client/${myId}/alive`;
// 3. 토큰 발급!
// -> 이 순간 망 전체에 "dashboard/client/xxx 이 켜졌다"는 [PUT] 이벤트가 폭풍처럼 퍼진다.
const token = await session.declareLiveliness(myLifeKey);
console.log(`[SYS] 생명 징표 획득. 현재 내 상태: ONLINE (${myLifeKey})`);
// 4. 로직 실행 (사용자가 이 버튼을 누르면 정상적으로 로그아웃)
process.on('SIGINT', async () => {
console.log("정상 종료 감지. 징표를 자진 반납(Undeclare)합니다.");
// 정상 수거. 이 경우 우아하게 [DELETE] 이벤트가 퍼진다.
token.undeclare();
await session.close();
process.exit();
});
}
이 토큰은 단순히 문자열 경로만 잡고 있는 것이 아니다. 선언하는 순간, Zenoh 엔진 객체의 라이프사이클과 100% 동기화된다.
정상 종료(undeclare) 시에는 “스스로 나갔다“고 알려주고, 컴퓨터 코드를 뽑아버려 비정상 종료 시에는 라우터가 타임아웃을 판별한 뒤 “얘 죽었다“고 타살 판정을 내린다.
3. 네트워크 파티션 및 노드 비정상 이탈(Drop) 감지 로직
8.6.2 장에서 전국의 수천 개 노드들이 모두 살았다고 깃발을 꽂았으니, 이제 중앙 관제 서버(Node.js)나 전체 모니터링 화면 쪽에서 이 깃발들이 꽂히고 부러지는 현상을 실시간으로 감시해야 한다.
3.0.1 [Runbook] 생사(生死) 판별 구독 전술
이것은 단순한 declareSubscriber가 아니라, 특수한 declareLivelinessSubscriber 를 쓴다.
import * as zenoh from "@eclipse-zenoh/zenoh";
async function startGlobalPresenceMonitor() {
const session = await zenoh.open();
console.log(">> 글로벌 프레즌스 감시소(Watchtower) 가동");
// "dashboard/client/**" 영역에서 깃발이 꽂히거나 뽑히는 모든 현상을 감시하라!
await session.declareLivelinessSubscriber(
"dashboard/client/**",
(sample: zenoh.Sample) => {
// sample.kind 는 이 이벤트의 종류를 알려준다. (PUT: 생성, DELETE: 삭제)
const nodeKey = sample.keyExpr;
if (sample.kind === zenoh.SampleKind.PUT) {
// 누군가 새롭게 망에 들어왔거나, 끊겼다가 재접속(Reconnect)했다!
console.log(`[🔔 알림] 노드 합류 감지 - ${nodeKey} 님 환영합니다.`);
} else if (sample.kind === zenoh.SampleKind.DELETE) {
// 누군가 정상적으로 로그아웃 했거나, 랜선이 뽑혀 사망했다!
console.error(`[💀 부고] 노드 단절 위험 - ${nodeKey} 연결 소실.`);
// 프론트엔드라면 여기서 Redux의 userStatus 를 'OFFLINE' 으로 바꾼다.
// 백엔드라면 여기서 메일/슬랙 알림 큐를 트리거한다.
}
}
);
}
이 전술의 가장 무서운 점은 중앙 서버가 단 한 방울의 CPU/메모리(Redis 조회, 핑 루프)를 허비하지 않는다는 점이다. 죽으면 라우터가 “이 녀석 연결이 끊겼소“라고 알아서 메일을 배달해 준다. 이게 바로 분산형 시스템(Decentralized System) 진정한 이벤트 기반 관제(Event-driven Monitoring)의 정점이다.
4. 프론트엔드 UI에 분산 시스템 토폴로지 상태 실시간 반영
React의 Context API 나 Zustand(혹은 Redux)의 전역 상태 저장소에 Zenoh Liveliness 이벤트 콜백을 직결(Direct Mapping)시키는 프론트엔드 최종 테크닉이다.
로봇 관리 지도(Google Map) 위에 로봇들의 위치가 찍혀있는데, 특정 로봇이 맵 밖으로 나가거나 죽었을 때 마커 아이콘을 “회색“으로 변경하는 완벽한 리액트 훅스(React Hooks) 패턴이다.
4.0.1 [Runbook] React 상태(State) - Zenoh 동기화 아키텍처
import React, { useEffect, useState } from "react";
import * as zenoh from "@eclipse-zenoh/zenoh";
// 상태 인터페이스
interface NodeStatusMap {
[robotId: string]: "ONLINE" | "OFFLINE";
}
export const RobotTopologyMonitor: React.FC = () => {
// UI 와 직결된 상태 저장소
const [robotStatuses, setRobotStatuses] = useState<NodeStatusMap>({});
useEffect(() => {
let mounted = true;
let session: zenoh.Session;
let livenessSub: zenoh.Subscriber;
const boot = async () => {
session = await zenoh.open();
// 1. 와일드카드로 전 세계 모든 로봇의 맥박(Heartbeat) 감시
livenessSub = await session.declareLivelinessSubscriber(
"robot/*/alive",
(sample) => {
if (!mounted) return;
// 경로명 파싱: "robot/RBT-01/alive" -> "RBT-01" 추출
const parts = sample.keyExpr.split('/');
const rId = parts[1];
const isAlive = (sample.kind === zenoh.SampleKind.PUT);
// 2. React 상태 불변성(Immutability) 업데이트
setRobotStatuses(prev => ({
...prev,
[rId]: isAlive ? "ONLINE" : "OFFLINE"
}));
}
);
};
boot();
return () => {
mounted = false;
if (livenessSub) livenessSub.undeclare();
if (session) session.close();
};
}, []);
return (
<div style={{ padding: 20 }}>
<h2>🌐 로봇 관제 토폴로지 서버</h2>
<div style={{ display: 'flex', gap: '10px' }}>
{Object.entries(robotStatuses).map(([id, status]) => (
<div key={id} style={{
padding: '10px',
border: '1px solid black',
backgroundColor: status === "ONLINE" ? "var(--green)" : "var(--gray)",
color: "white"
}}>
{id} : {status}
</div>
))}
</div>
{Object.keys(robotStatuses).length === 0 && <p>현재 활성화된 기체가 없습니다.</p>}
</div>
);
};
이 패턴이 적용된 화면은, 당신이 로봇 기체의 전원 코드를 뽑는 순간 1밀리초도 안 되어 브라우저 화면의 로봇 박스가 회색(OFFLINE)으로 변하는 극도의 퍼포먼스를 보여준다. 이는 브라우저의 DOM 렌더링 엔진과 C++ 로봇 기체를 Zenoh 인프라가 완전히 하나로(One-body) 결속시켰기 때문에 가능한 일이다.