실시간 장비 대시보드 — WebSocket으로 인큐베이터 상태를 스트리밍
이 토픽을 마치면
교과서에서 배운 WebSocket, React state, 시계열 버퍼 를 엮어서, 세포 배양 인큐베이터가 매초 뿜어내는 온도·CO2·pH 값을 브라우저에서 실시간으로 흘려보는 대시보드를 직접 만들 수 있습니다. HTTP 요청/응답만 알던 여러분이 서버가 먼저 밀어주는 데이터 를 처리하는 세계로 넘어가게 됩니다.
이 글은 교육용 일반 예제 입니다. 세포 배양 모니터링은 실험실에서 자주 하는 관리 업무라 소재로 삼았습니다.
"다음날 아침에야 알았어" — 폴링의 한계
여러분이 세포 배양 인큐베이터에 소중한 세포주를 두었다고 합시다. 인큐베이터의 CO2 농도는 5% 로 유지되어야 합니다. 그런데 어느 날 밤 사이 누군가 문을 살짝 열어두어 CO2가 급락합니다. 여러분은 다음날 아침 세포가 다 죽은 것을 발견합니다.
이런 사고를 막으려면 실시간 모니터링 이 필요합니다. "값이 매초 새로고침되고, 이상하면 즉시 알림" 시스템.
여러분의 첫 시도는 이럴 수 있습니다.
// 순진한 폴링 접근
setInterval(async () => {
const res = await fetch("/api/incubator");
const data = await res.json();
updateChart(data);
}, 1000);1초마다 서버에 물어봅니다. 이 방법은 작동은 하지만 문제가 큽니다.
- 네트워크 부담: 1초마다 HTTP 요청 + 응답 헤더 왕복. 대부분 요청이 "새 데이터 없음"으로 낭비됩니다.
- 지연: 인큐베이터가 0.1초 전에 CO2 급락을 감지했어도, 여러분의 다음 polling 시점(최대 1초 뒤)까지 화면은 정상값을 표시합니다.
- 여러 장비 확장 어려움: 5대의 인큐베이터를 감시하면 초당 5회 요청. 스케일 확대 시 서버가 폴링 부하로 무너집니다.
이 문제들을 해결하는 게 WebSocket 입니다. 요청/응답 왕복 없이, 서버가 데이터가 생길 때마다 클라이언트에게 밀어주는 통신. 이 글에서는 그 원리를 밑바닥부터 만듭니다.
먼저 완성품을 봅시다 (블랙박스 먼저 돌리기)
우리가 만들 시스템은 이런 모양입니다.
┌────────────────┐ WebSocket ┌──────────────────┐
│ Python 서버 │ ═══════════════════════════ │ React 브라우저 │
│ (모의 인큐베이터) │ {temp, co2, pH, timestamp} │ (대시보드) │
│ │ ← 1초에 여러 번 밀어보냄 │ │
└────────────────┘ └──────────────────┘서버는 초당 여러 번 이런 메시지를 밀어보냅니다.
{"temp": 37.02, "co2": 4.98, "pH": 7.35, "timestamp": 1720000000.5}
{"temp": 37.03, "co2": 4.97, "pH": 7.35, "timestamp": 1720000000.7}
{"temp": 37.01, "co2": 4.98, "pH": 7.36, "timestamp": 1720000000.9}브라우저는 이 값들을 받아 최근 60초 창 을 유지하고, 실시간으로 세 개의 라인 그래프를 그립니다. CO2가 위험 범위(4.5~5.5) 밖으로 벗어나면 화면 상단에 경고 배너가 뜹니다.
폴링 없이, 지연 없이, 서버가 데이터를 만들어내자마자 화면이 반응합니다.
이 도구는 어떤 부품으로 조립되는가 (부품 분해도)
실시간 장비 대시보드 (풀스택)
┌──────────────────────────────────────────────────────┐
│ │
│ [서버 사이드] [클라이언트 사이드] │
│ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ 인큐베이터 모의값 │ │ WebSocket 연결 │ │
│ │ (initial full) │ │ (수신 → React) │ │
│ └─────────┬───────┘ └────────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ WebSocket 서버 │ ────────► │ 시계열 버퍼 │ │
│ │ 부품: websocket │ │ (최근 60초) │ │
│ └─────────────────┘ └────────┬───────┘ │
│ ★ 직접 만든다 │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ React 그래프 │ │
│ │ + 경고 배너 │ │
│ └────────────────┘ │
│ ★ 직접 만든다 │
└──────────────────────────────────────────────────────┘| 부품 | 어디서 배웠나 | 이 도구에서 하는 일 |
|---|---|---|
| HTML/CSS/React 기초 | html-css-react-basics | 페이지 구조와 컴포넌트 렌더링 |
| WebSocket | websocket-basics | 서버 → 클라이언트 push 통신 |
| React state | react-state | 밀어온 데이터를 화면과 연결 |
| 시계열 버퍼 | time-series-buffer | 최근 창만 유지, 오래된 값 폐기 |
📌 이 개념들 처음 본다면 (상단 진입 링크)
직접 만드는 새 개념은 WebSocket 서버 · React state · 원형 시계열 버퍼 3개. HTML/CSS 기본 구조는 도구라 완성본으로 드립니다. 딱 3개죠 — 인지 한계선 안입니다.
만들기 1단계 — 모의 인큐베이터 서버 ★ (WebSocket 서버)
✍️ 직접 채우는 구간. 부품 = WebSocket 서버 (Python). 모의 인큐베이터가 초당 여러 번 상태 데이터를 밀어보냄.
Python의 websockets 라이브러리를 씁니다. 로컬에서 실행할 수 있는 완결된 서버입니다.
# server.pyimport asyncioimport jsonimport timeimport random
import websockets # pip install websockets
class MockIncubator: """모의 인큐베이터 — 온도/CO2/pH를 노이즈와 함께 시뮬레이션.""" def __init__(self, target_temp=37.0, target_co2=5.0, target_ph=7.35): self.target_temp = target_temp self.target_co2 = target_co2 self.target_ph = target_ph # 살짝 문 열림 시뮬레이션 상태 self.door_open = False
def sample(self) -> dict: # 정상 상태: 목표값 ± 작은 노이즈 temp_noise = 0.15 co2_noise = 0.10 if not self.door_open else 0.8 ph_noise = 0.02 return { "temp": round(self.target_temp + random.gauss(0, temp_noise), 3), "co2": round(self.target_co2 + random.gauss(0, co2_noise), 3), "pH": round(self.target_ph + random.gauss(0, ph_noise), 3), "timestamp": round(time.time(), 3), "door_open": self.door_open, }
incubator = MockIncubator()
async def stream_handler(websocket): """새 클라이언트 연결마다 이 함수가 호출됨.""" print(f"[+] 클라이언트 연결됨: {websocket.remote_address}") try: while True: data = incubator.sample() await websocket.send(json.dumps(data)) await asyncio.sleep(0.5) # 초당 2회 push except websockets.ConnectionClosed: print("[-] 클라이언트 연결 종료")
async def main(): async with websockets.serve(stream_handler, "localhost", 8765): print("서버 시작: ws://localhost:8765") await asyncio.Future() # forever
if __name__ == "__main__": asyncio.run(main())이 코드에서 결정적 지점은 while True 안의 send와 sleep 조합입니다. 매 반복마다 새 샘플을 만들어 클라이언트에게 밀어보냅니다. HTTP처럼 요청을 기다리지 않습니다.
로컬 테스트로 검증:
# test_server.py — 서버가 잘 밀어보내는지 클라이언트 흉내로 검증import asyncioimport jsonimport websockets
async def sanity_check(): async with websockets.connect("ws://localhost:8765") as ws: # 5개 메시지 받기 messages = [] for _ in range(5): raw = await ws.recv() messages.append(json.loads(raw)) # 검증 assert len(messages) == 5 for m in messages: assert set(m.keys()) >= {"temp", "co2", "pH", "timestamp"} assert 35 < m["temp"] < 39 # 정상 범위 assert 3 < m["co2"] < 7 assert 6.8 < m["pH"] < 8.0 # 타임스탬프가 시간순으로 증가 times = [m["timestamp"] for m in messages] assert all(times[i] <= times[i+1] for i in range(len(times)-1))
# asyncio.run(sanity_check()) # 서버가 켜져 있을 때 실행🔎 WebSocket이 폴링과 뭐가 다른가 (드로어 — websocket-basics) HTTP는 요청 → 응답 → 연결 종료 를 매번 반복합니다. WebSocket은 한 번 연결하면 계속 유지되는 양방향 채널 입니다. 서버가 원할 때 언제든 데이터를 밀어보낼 수 있고, 클라이언트도 언제든 보낼 수 있습니다. 채팅, 게임, 실시간 모니터링에 표준입니다.
🤔 자기설명 프롬프트
while True안에await asyncio.sleep(0.5)를 굳이 넣었습니다. 이걸 빼면 어떻게 될까요? 서버의 CPU 사용률은? 클라이언트가 초당 얼마나 많은 메시지를 받게 되나요? (힌트: sleep이 없으면 CPU 100% 사용 + 밀리초에 수천 메시지)
만들기 2단계 — 시계열 버퍼 ★ (time-series-buffer)
✍️ 직접 채우는 구간. 부품 = 원형(circular) 시계열 버퍼. 최근 N개 포인트만 유지, 오래된 값은 자동 폐기.
실시간 대시보드는 모든 과거 데이터를 보관할 필요가 없습니다. 최근 60초 창만 보면 됩니다. 데이터가 초당 2개씩 오면 60초 창 = 최대 120개 포인트.
가장 단순한 접근: JavaScript 배열에 계속 push하고, 가끔 shift로 앞을 자른다.
// 순진한 버퍼 - 문제 있음
let buffer = [];
function push(point) {
buffer.push(point);
if (buffer.length > 120) buffer.shift();
}이 방식이 작동은 하지만 문제가 있습니다. Array.shift() 는 O(n) — 배열 앞을 지우면 뒤 모든 요소가 한 칸씩 밀립니다. 데이터가 오래 흐르면 매 push마다 O(n) 밀림 비용이 쌓입니다.
원형 버퍼(circular buffer) 로 O(1) push/pop을 얻습니다.
🔎 원형 버퍼란 (드로어 — time-series-buffer) 고정 크기 배열을 원처럼 사용합니다. write index 하나로 어디에 다음 값을 쓸지 추적합니다. 배열 끝에 도달하면 처음으로 돌아가 덮어씁니다. 이 구조로 push/pop 모두 O(1) 입니다. 오디오 처리, 로그 링, 실시간 그래프에 자주 씁니다.
// buffer.ts
export class TimeSeriesBuffer<T> {
private data: (T | null)[];
private writeIdx = 0;
private size = 0;
constructor(public capacity: number) {
this.data = new Array(capacity).fill(null);
}
push(value: T): void {
this.data[this.writeIdx] = value;
this.writeIdx = (this.writeIdx + 1) % this.capacity;
this.size = Math.min(this.size + 1, this.capacity);
}
toArray(): T[] {
// 시간 순으로 정렬된 배열 반환
if (this.size < this.capacity) {
// 아직 다 안 찼음 — 앞에서부터 size 개
return this.data.slice(0, this.size) as T[];
}
// 다 찼음 — writeIdx 이후부터 감싸서 반환
return [
...this.data.slice(this.writeIdx),
...this.data.slice(0, this.writeIdx),
] as T[];
}
}검증:
// buffer.test.ts
import { TimeSeriesBuffer } from "./buffer";
const buf = new TimeSeriesBuffer<number>(5);
[1, 2, 3].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3]");
[4, 5].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3,4,5]");
// 용량 초과 - 오래된 것 덮어쓰기
[6, 7].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[3,4,5,6,7]");O(n) shift() 를 O(1) writeIdx 증가 로 바꿨습니다. 데이터가 몇 시간 흘러도 push 비용이 일정합니다.
🤔 자기설명 프롬프트
toArray()가 O(n) 인 이유가 뭘까요? push는 O(1)인데 왜 반환은 O(n)일까요? 실시간 그래프 렌더링에서 이게 문제될까요? (힌트: 60Hz 렌더링이라 매 프레임 O(120)은 무의미합니다.)
만들기 3단계 — React state 훅 ★ (react-state)
✍️ 직접 채우는 구간. 부품 = React state + useEffect. WebSocket에서 밀어온 값을 버퍼에 담고, 화면에 반영.
React에서 실시간 데이터를 다룰 때 핵심 원리는 useState + useEffect 조합입니다.
- useState: 화면에 그릴 "현재 창"의 스냅샷
- useEffect: WebSocket 연결·해제 라이프사이클 관리
// useIncubatorStream.ts
import { useEffect, useState, useRef } from "react";
import { TimeSeriesBuffer } from "./buffer";
interface Sample {
temp: number;
co2: number;
pH: number;
timestamp: number;
door_open?: boolean;
}
export function useIncubatorStream(wsUrl: string, windowSize: number = 120) {
const [samples, setSamples] = useState<Sample[]>([]);
const [connected, setConnected] = useState(false);
const bufferRef = useRef(new TimeSeriesBuffer<Sample>(windowSize));
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket 연결됨");
setConnected(true);
};
ws.onmessage = (event) => {
const data: Sample = JSON.parse(event.data);
bufferRef.current.push(data);
// React state에 새 스냅샷 전달
setSamples(bufferRef.current.toArray());
};
ws.onclose = () => {
console.log("WebSocket 종료");
setConnected(false);
};
// Cleanup: 컴포넌트가 언마운트되면 연결 종료
return () => {
ws.close();
};
}, [wsUrl, windowSize]);
return { samples, connected };
}이 훅의 결정적 특징은 다음과 같습니다.
bufferRef(useRef): 원형 버퍼는 컴포넌트 리렌더링과 무관하게 유지되어야 합니다.useRef로 감쌉니다.setSamples: 매 메시지마다 새 배열을 반환해 React가 리렌더링하게 합니다. 배열 참조가 바뀌어야 리액트가 변화를 감지합니다.- cleanup 함수: 컴포넌트가 사라지면 반드시
ws.close(). 안 하면 좀비 연결이 쌓입니다.
🔎 useRef vs useState는 언제 어느 것 (드로어 — react-state) 화면에 영향을 주면 useState (변화 시 리렌더). 내부 계산이나 참조 유지만 필요하면 useRef (변화해도 리렌더 안 함). 원형 버퍼 자체는 useRef 로, 그래프 렌더 대상인 스냅샷은 useState 로 — 이 분리가 성능의 핵심입니다.
🤔 자기설명 프롬프트 만약
bufferRef를 그냥let buffer = new TimeSeriesBuffer(...)로 컴포넌트 안에 놓으면 어떻게 될까요? (힌트: 매 렌더링마다 새 버퍼가 만들어져서 과거 데이터가 다 날아갑니다.)
만들기 4단계 — 대시보드 UI 조립하기 ★
이제 마지막 층. 훅에서 나온 samples를 실제 그래프와 경고 배너로 그립니다.
// IncubatorDashboard.tsx
import { useIncubatorStream } from "./useIncubatorStream";
function isCritical(latest: any): string | null {
if (!latest) return null;
if (latest.co2 < 4.5 || latest.co2 > 5.5) return `CO2 이상: ${latest.co2}%`;
if (latest.temp < 36.5 || latest.temp > 37.5) return `온도 이상: ${latest.temp}°C`;
if (latest.pH < 7.2 || latest.pH > 7.5) return `pH 이상: ${latest.pH}`;
return null;
}
export function IncubatorDashboard() {
const { samples, connected } = useIncubatorStream("ws://localhost:8765");
const latest = samples[samples.length - 1];
const warning = isCritical(latest);
return (
<div className="dashboard">
<header>
<h1>인큐베이터 A</h1>
<span className={connected ? "connected" : "disconnected"}>
{connected ? "실시간 연결됨" : "연결 끊김"}
</span>
</header>
{warning && (
<div className="warning-banner">
⚠️ 위험: {warning}
</div>
)}
<section className="metrics">
<MetricCard label="온도" value={latest?.temp} unit="°C" target={37.0} />
<MetricCard label="CO2" value={latest?.co2} unit="%" target={5.0} />
<MetricCard label="pH" value={latest?.pH} unit="" target={7.35} />
</section>
<section className="charts">
<LineChart data={samples} field="temp" color="#ff6b6b" />
<LineChart data={samples} field="co2" color="#4ecdc4" />
<LineChart data={samples} field="pH" color="#95e1d3" />
</section>
</div>
);
}MetricCard 와 LineChart 는 표준 컴포넌트라 상세 구현은 생략합니다. 핵심은 위 훅 하나가 대시보드 전체의 데이터 소스 라는 점입니다.
검증 (통합 테스트 개요):
// integration.test.ts (Playwright/Cypress 통합 테스트 개념)
async function test_dashboard_flow() {
// 1. 서버 시작 (test fixture)
// 2. 브라우저에서 대시보드 로드
// 3. 5초 안에 그래프에 최소 5개 포인트가 나타나야 함
await waitFor(() => expect(chart.pointCount()).toBeGreaterThan(5));
// 4. WebSocket 연결이 살아있음
expect(document.querySelector(".connected")).toBeInTheDocument();
// 5. 인위적으로 door_open=true 상태로 바꾸면 warning 배너 나타남
await triggerDoorOpen(server);
await waitFor(() => expect(document.querySelector(".warning-banner")).toBeInTheDocument());
}부품을 하나로 — 완성된 대시보드 구조
전체 시스템을 정리하면 이렇습니다.
서버 사이드 (Python):
MockIncubator → sample() 반복 → WebSocket.send()
파일: server.py
클라이언트 사이드 (React/TypeScript):
useIncubatorStream(wsUrl)
├── WebSocket 연결 (useEffect)
├── TimeSeriesBuffer (useRef, O(1) push)
└── samples 상태 (useState, 렌더 트리거)
IncubatorDashboard
├── 연결 상태 표시
├── 경고 배너 (isCritical 함수)
├── MetricCard × 3 (현재 값)
└── LineChart × 3 (60초 창)
파일들: useIncubatorStream.ts, buffer.ts, IncubatorDashboard.tsx이 도구가 오늘 여러분이 산업용 SCADA(Supervisory Control and Data Acquisition), 실험실 LIMS(Laboratory Information Management System) 등에서 보는 실시간 대시보드의 축소판입니다. 실무 도구는 여기에 인증, 다중 장비 라우팅, 시계열 DB 저장(InfluxDB), 알림 라우팅(Slack/이메일) 등이 추가된 것뿐입니다.
성능 딥다이브 — 왜 이 아키텍처인가
폴링 방식 (매 1초 fetch):
- 서버 부담: 클라이언트 N개 × 초당 1 요청 = N req/sec
- 지연: 평균 0.5초 (polling 간격의 절반)
- 대역폭: 매 요청에 HTTP 헤더 오버헤드 (~500 bytes)
WebSocket 방식 (연속 push):
- 서버 부담: 연결 하나당 상시 유지 (소켓 1개, TCP 유지)
- 지연: ~밀리초 (네트워크 왕복만)
- 대역폭: 헤더 없이 payload만 (~80 bytes)숫자로 비교하면:
| 시나리오 | 폴링 (1초 간격) | WebSocket |
|---|---|---|
| 클라이언트 100대 · 초당 요청 | 100 req/sec | 0 req/sec |
| 이상 감지 지연 (평균) | 500ms | ~10ms |
| 페이로드 대역폭 (초당) | 100 × 500B = 50KB/s | 100 × 80B × 2 = 16KB/s |
감지 지연이 50배, 대역폭이 1/3. 여러 장비를 확장할수록 격차가 커집니다.
다른 길도 있다 (멀티패스 성찰)
- Server-Sent Events (SSE): WebSocket보다 단순한 대안. 서버 → 클라이언트 단방향 만 지원하지만, HTTP 위에 올려서 프록시·방화벽 통과가 쉽습니다. 클라이언트가 서버에 명령을 보낼 필요가 없다면 (단순 모니터링) SSE로 충분합니다.
- MQTT 브로커: IoT 표준 프로토콜. 여러 장비를 브로커에 연결하고, 대시보드도 브로커에 구독합니다. 장비 대수가 수십~수백이면 이게 낫습니다. Mosquitto, HiveMQ 등 오픈소스가 있습니다.
- 시계열 DB 병용: 실시간 대시보드만으로는 과거 로그가 없습니다. InfluxDB, TimescaleDB, Prometheus 등에 동시에 기록하면, 실시간 뷰 + 과거 분석이 함께 가능합니다.
- 재접속 로직: 우리 훅은 서버가 끊어지면 재연결 안 합니다. 실무에서는 exponential backoff 로 재연결 하는 로직이 필수입니다.
ws.onclose안에 재시도 스케줄을 넣습니다. - 경고 전달의 확장: 화면 배너만으로는 밤 사이 사고 대응이 불가합니다. Slack webhook, SMS, PagerDuty 등 외부 채널로 경고를 밀어야 실제로 유용합니다.
핵심: "서버가 밀어주고, 버퍼가 창을 유지하고, state가 화면을 잇는다." 이 세 층이 각자 자기 몫을 알고 있으면 실시간 시스템의 아키텍처가 보입니다. 여러분이 방금 만든 것이 그 아키텍처의 실체입니다.
다음 단계로 (하단 출구 링크)
- WebSocket 프로토콜의 상세 → WebSocket 원리
- 원형 버퍼의 다양한 변형 → 시계열 버퍼
- 앞에서 만든 시각화와 조합 → RNA-seq 히트맵
직접 해보기 (독립 문제)
- 재접속 로직: WebSocket이 끊어지면 1초, 2초, 4초, 8초 (exponential backoff)로 재시도하는 로직을 훅에 추가하세요.
- 다중 장비: 5대의 인큐베이터를 감시하는 대시보드로 확장하세요. 각 장비마다 별도 WebSocket 연결. 성능 부담이 어디에서 오는지 관찰하세요.
- 다운샘플링: 초당 20개 메시지가 오는 경우, 화면 렌더링은 초당 5회로 제한하고 싶습니다. 훅에 debounce/throttle 을 추가하세요.
- 도전 — SSE 이식: 같은 시스템을 WebSocket 대신 Server-Sent Events로 재구현하세요. 코드 복잡도가 어떻게 변하나요?
정리
우리는 "장비가 뿜어내는 실시간 데이터를 즉시 화면에 반영하는" 문제를, 세 개의 부품으로 정복했습니다.
- WebSocket 이 서버가 밀어주는 통신 채널을 열었습니다 (폴링 없음).
- 원형 시계열 버퍼 가 O(1) push로 최근 창을 유지했습니다.
- React state (+ useRef) 가 데이터 흐름과 렌더링을 정확히 잇는 다리가 되었습니다.
인큐베이터가 매초 뿜어내는 값이 이제 여러분의 화면에 밀리초 지연으로 흘러갑니다. 다음날 아침에야 사고를 발견하는 시대는 지나갔습니다. 문 열린 5초 안에 화면에 경고가 뜹니다.
이 글은 일반 교육 예제 입니다. 실무 실험실 대시보드는 여기에 인증, 다중 장비 라우팅, 시계열 DB, 알림 시스템 등이 추가됩니다. 그 상세한 버전은 이 뼈대 위에 여러분이 붙이거나, 검증된 SCADA/LIMS 도구에 맡기면 됩니다.