
지난 에피소드에서 이어서
EP1 — 왜 RDP를 버렸는가에서 RDP의 문제를 짚었죠 (EP1에서 본 것처럼). 이번 에피소드에서는 그 대안으로 만든 FastAPI WebSocket 대시보드의 실제 아키텍처를 파헤쳐볼게요.
결론부터 말하면, 핵심은 WebSocket 3채널 분리예요. 하나의 WebSocket으로 모든 걸 처리하려다 실패하고, 역할별로 채널을 나눈 게 이 시스템의 근간이에요.
전체 아키텍처 한눈에 보기
FastAPI WebSocket 대시보드의 전체 구조는 세 층으로 나뉘어요.
| 계층 | 구성 요소 | 연결 |
|---|---|---|
| Browser | React + Vite + shadcn/ui SessionCards, StatsPage, xterm.js, CostOverview |
/ws/dashboard, /ws/terminal, REST API |
| FastAPI Server (port 8420) | ws_hub.py, store.py, database.py (SQLite 7 tables) terminal_routes.py, cost_collectors (Gemini/xAI/Qwen) |
/ws/agent (10s poll interval) |
| Collector Agents | Windows Agent (local process) Mac mini Agent (SSH/launchd) |
WebSocket to server |
브라우저 → FastAPI 서버 → 수집 에이전트. 중요한 점은 비용 수집기(Gemini/xAI/Qwen 요금 추적)는 서버 내부의 백그라운드 태스크로 돌아요 — 별도의 WebSocket 에이전트가 아니에요. 이 구분이 이 FastAPI WebSocket 대시보드 설계의 핵심이에요.
WebSocket 3채널 — 왜 하나가 아닌 셋인가
초기 설계에서는 WebSocket 하나로 모든 메시지를 처리하려고 했어요. 수집 데이터, UI 업데이트, 터미널 스트리밍을 전부 하나의 연결에 실어 보내는 거죠. 결과는 재앙이었어요.
터미널에서 빠르게 출력이 쏟아지면 세션 상태 업데이트가 밀리고, 수집 에이전트의 대량 데이터가 들어오면 브라우저 UI가 버벅거렸어요. 메시지 타입별 라우팅 로직도 점점 스파게티가 됐고요.
해결책은 간단했어요. 역할별로 채널을 분리하는 것. 수도관 비유가 딱 맞아요 — 샤워하면서 설거지하면서 빨래를 동시에 하려면 수도관이 세 개 필요하듯이요.

/ws/agent — 수집 채널 (10초 폴링)
수집 에이전트가 서버에 접속하는 채널이에요. 프로토콜은 단순해요:
- 에이전트가
register이벤트로 호스트명 등록 - 서버가
ack로 응답 - 이후 10초마다 세션 데이터를 JSON으로 전송
# ws_hub.py — Agent connection handshake
async def connect_agent(
self, websocket: WebSocket
) -> tuple[str, str] | None:
await websocket.accept()
raw = await websocket.receive_text()
message = json.loads(raw)
if message.get("event") != "register":
return None
host_name = message.get("host", "").strip()
self._agent_connections[host_name] = websocket
await websocket.send_text(
json.dumps({"event": "ack", "status": "ok"})
)
return host_name, message.get("host_icon", "")
각 호스트(Windows 워크스테이션, Mac mini 등)마다 하나의 WebSocket 연결이 유지돼요. _agent_connections 딕셔너리가 호스트명을 키로 연결을 관리하죠. 에이전트가 죽으면 자동으로 정리되고, launcher.py가 재시작시켜요.
/ws/dashboard — 브로드캐스트 채널
브라우저가 접속하면 두 가지가 일어나요. 첫째, 현재 전체 상태를 full_state 스냅샷으로 받아요. 둘째, 이후부터는 1초마다 변경분만 delta_update로 받아요.
# main.py — Broadcast loop (1-second interval)
async def broadcast_loop() -> None:
while True:
await asyncio.sleep(BROADCAST_INTERVAL) # 1.0s
updated, removed = store.get_changed_sessions()
if updated or removed:
message = {
"type": "delta_update",
"updated": updated,
"removed": removed,
}
await hub.broadcast_to_browsers(message)
여기서 중요한 건 delta_update 패턴이에요. 매초 전체 상태를 보내는 게 아니라 변경된 세션만 보내요. 세션이 10개 돌고 있어도 변경된 2개만 전송하니까 대역폭이 최소화돼요.
broadcast_to_browsers()는 연결된 모든 브라우저에 동일한 메시지를 뿌려요. 죽은 연결은 조용히 제거하고요. 이게 RDP와 결정적으로 다른 점이에요 — 브라우저 탭을 몇 개를 열든 동시에 볼 수 있어요.
/ws/terminal — PTY 스트리밍 채널
세 번째 채널은 가장 특별해요. Claude Code CLI의 터미널 입출력을 양방향으로 스트리밍하는 채널이에요. 브라우저의 xterm.js가 이 채널에 붙어서 실제 터미널처럼 동작해요.
다른 두 채널이 JSON 메시지를 주고받는 것과 달리, 터미널 채널은 바이트 스트림에 가까워요. 키 입력 하나하나가 서버로 가고, 서버의 PTY 출력이 실시간으로 돌아오죠. 이 때문에 같은 WebSocket에 섞으면 안 되는 거예요 — JSON 파싱 오버헤드가 터미널 반응성을 잡아먹거든요.
xterm.js의 addon-attach가 WebSocket에 직접 붙어서 처리하기 때문에, 별도 채널이 필수예요. EP3에서 인증과 함께 PTY 구현 상세를 다룰게요.
launcher.py — VBS→bat 체인 실패의 교훈
아키텍처 설계보다 더 많은 시간을 잡아먹은 게 프로세스 관리예요. Windows에서 백그라운드 서비스를 안정적으로 띄우는 건 생각보다 어려웠어요.
처음에는 이런 구조였어요:
Task Scheduler
→ start.vbs (CreateObject WScript.Shell)
→ launch.bat (환경변수 설정)
→ python server/main.py
→ python agents/windows_agent.py
실제로 맞닥뜨린 실패 모드가 4가지였어요:
- VBS
Run ..., 0, False— wscript가 즉시 종료해서 Task Scheduler가 “완료”로 인식. 서버는 실행도 안 됐는데 성공 처리. - bat의 `.env` 파싱 실패 —
for /f구문이#주석 줄을 못 처리. 환경변수 미설정 상태로 서버 시작 → 즉시 크래시. - cmd 창 무한 깜빡임 — 포트 충돌로 서버가 즉시 죽으면 bat의
goto loop이 5초 후 재시작 → 또 죽고 → 무한 반복. 부팅할 때마다 화면 번쩍. - 좀비 프로세스 포트 점유 — Task Scheduler 태스크를 삭제·재등록하면 이전
pythonw가 orphan으로 남아 8420 포트 점유.

해결책은 Python 단일 진입점이었어요. launcher.py 하나가 모든 걸 관리해요:
# scripts/launcher.py — Single entry point
def main():
kill_all() # Kill existing processes via WMIC
load_env() # Load .env file
server = start_server() # subprocess, CREATE_NO_WINDOW
time.sleep(5) # Wait for server to be ready
agent = start_agent() # subprocess, CREATE_NO_WINDOW
time.sleep(3)
restart_mac_mini() # Restart launchd via SSH
while True:
time.sleep(10)
if server.poll() is not None:
log("Server crashed. Restarting...")
server = start_server()
if agent.poll() is not None:
log("Agent crashed. Restarting...")
agent = start_agent()
핵심은 CREATE_NO_WINDOW 플래그예요. subprocess를 띄울 때 이 플래그를 주면 콘솔 창이 아예 안 뜨거든요. .env 로드도 Python의 python-dotenv가 처리하니까 bat의 파싱 버그도 없어요. 그리고 10초마다 프로세스 상태를 확인해서, 죽었으면 자동으로 재시작해요. 위의 4가지 실패 모드를 전부 한 파일로 해결했어요.

SQLite 스키마: 7개 테이블의 역할
FastAPI WebSocket 대시보드의 데이터는 SQLite에 저장돼요. 왜 PostgreSQL이 아니냐고요? 이 대시보드는 단일 머신 서비스예요. 동시 쓰기가 초당 수십 건 수준이고, 배포도 파일 하나 복사면 끝이에요. SQLite가 딱 맞아요.
7개 테이블이 각자 명확한 역할을 나눠요:
| 테이블 | 역할 |
|---|---|
sessions |
Claude Code 세션 추적 — 호스트, 상태, 모델별 토큰 사용량 아카이브 |
session_tokens |
세션별 토큰 사용량 상세 — 세션 단위 집계 원본 |
token_daily |
날짜별 토큰 통계 집계 — CostOverview 카드의 데이터 소스 |
mcp_events |
MCP 도구 호출 텔레메트리 — 서버·툴·소요시간·비용 원본 이벤트 |
mcp_daily |
날짜별 MCP 호출 집계 — StatsPage MCP 차트 데이터 소스 |
cost_snapshots |
비용 추적 스냅샷 — Gemini/xAI/Qwen 외부 API 요금 수집 |
users |
사용자 관리 — JWT 인증, bcrypt 해시, TOTP 2FA 시크릿 |
-- 7 tables, each with a clear role
CREATE TABLE sessions ( -- Archived completed sessions
id TEXT PRIMARY KEY,
host TEXT, status TEXT,
token_json TEXT, -- Token usage per model
agents_count INTEGER
);
CREATE TABLE token_daily ( -- Daily token aggregation
date TEXT, host TEXT, model TEXT,
provider TEXT DEFAULT 'claude',
input_tokens INTEGER,
output_tokens INTEGER,
cost_usd REAL,
PRIMARY KEY (date, host, model, provider)
);
CREATE TABLE mcp_events ( -- MCP tool call telemetry
id INTEGER PRIMARY KEY,
server TEXT, tool TEXT,
cost REAL, duration_ms INTEGER
);
CREATE TABLE users ( -- JWT + bcrypt + TOTP auth
username TEXT PRIMARY KEY,
password_hash TEXT,
totp_secret TEXT
);
-- + collector_state, session_agents, mcp_daily
가장 많이 쿼리되는 테이블은 token_daily예요. CostOverview 카드가 이 테이블에서 날짜 범위별 집계를 가져오거든요. mcp_events는 EP4에서 자세히 다룰 MCP 텔레메트리의 원본 데이터이고요.
Task Scheduler + launchd 서비스 관리
Windows에서는 Task Scheduler가 launcher.py를 실행해요. “Claudie Dashboard”라는 단일 태스크로, 로그온 시 자동 실행되고 pythonw.exe로 돌려서 콘솔 창이 없어요.
Mac mini에서는 macOS의 서비스 관리자인 launchd가 수집 에이전트를 담당해요. Windows의 Task Scheduler와 역할은 같지만, 방식이 달라요 — XML 대신 plist(Property List) 파일로 서비스를 등록해요. ~/Library/LaunchAgents/com.claudie.collector.plist를 만들어 두면, 로그인 시 자동 실행되고 프로세스가 죽어도 launchd가 즉시 재시작시켜요.
두 핵심 옵션은 KeepAlive(크래시 시 자동 재시작)와 RunAtLoad(plist 로드 즉시 실행)예요. launchctl bootstrap으로 등록하고, launchctl kickstart로 강제 재시작할 수 있어요 — launcher.py가 SSH를 통해 이 명령을 원격으로 실행해서 Mac mini 에이전트까지 한 번에 관리해요.
<!-- com.claudie.collector.plist (macOS launchd) -->
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/claudie-collector.log</string>
이 구조 덕분에 Windows 재부팅, Mac mini 재부팅, 프로세스 크래시 — 어떤 상황에서도 대시보드가 자동으로 살아나요.
React + Vite + shadcn/ui 프론트엔드 스택
컴포넌트 트리와 설계 원칙
Claudie’s Session Dashboard의 프론트엔드는 네 개의 주요 뷰로 나뉘어요.
- SessionCards — 각 호스트의 Claude Code 세션 상태를 카드로 표시. framer-motion의
AnimatePresence로 카드가 추가·제거될 때 부드럽게 전환돼요. - StatsPage — 토큰 사용량, MCP 텔레메트리, 비용 추적을 집계해서 차트로 보여줘요.
- CliPage — xterm.js WebSocket 터미널이 붙는 뷰.
/ws/terminal채널에 직접 연결해요. - SettingsPage — JWT 사용자 관리, TOTP 2FA 설정, 호스트 등록 관리.
UI 컴포넌트는 전부 shadcn/ui로 만들었어요. shadcn/ui는 라이브러리가 아니라 컴포넌트 소스를 직접 프로젝트에 복사해서 쓰는 방식이에요 — 덕분에 다크 테마 커스터마이징이 자유롭고, Tailwind 변수 한 곳만 바꾸면 전체 테마가 일관되게 바뀌어요. 대시보드 전체가 어두운 배경(#0F172A 계열)으로 통일돼 있는 것도 shadcn/ui의 CSS 변수 체계 덕분이에요.
framer-motion은 두 곳에 집중해서 써요. 첫째, SessionCards의 카드 진입·퇴장 애니메이션(layout + AnimatePresence). 세션이 새로 잡히면 카드가 자연스럽게 밀려 들어오고, 완료되면 부드럽게 사라져요. 둘째, 페이지 전환 효과 — 탭 전환 시 슬라이드 인/아웃으로 화면이 끊기지 않고 흘러요.
프론트엔드 기술 선택도 아키텍처의 일부예요. 왜 Next.js가 아닌 Vite인지부터 설명할게요.
이 대시보드는 FastAPI가 SPA(Single Page Application)의 정적 파일을 직접 서빙해요. SSR이 필요 없고, API는 같은 서버에 있어요. Vite의 빌드 결과물(dist/)을 FastAPI가 StaticFiles로 마운트하면 끝이에요. Next.js의 서버 사이드 기능은 전혀 필요 없었어요.
스택 구성은 이래요:
| 카테고리 | 선택 | 이유 |
|---|---|---|
| 프레임워크 | React 19 + TypeScript 5.9 | 타입 안정성 + 생태계 |
| 빌드 | Vite 8 | 빠른 HMR, SPA에 최적 |
| 스타일 | Tailwind CSS 4 | 유틸리티 퍼스트 |
| UI 컴포넌트 | shadcn/ui | 커스터마이징 자유도 |
| 터미널 | xterm.js 6.0 | WebGL 렌더링, WebSocket 직접 연결 |
| 애니메이션 | Framer Motion 12 | 세션 카드 전환 효과 |
FastAPI의 공식 WebSocket 지원은 asyncio 네이티브라 별도 라이브러리 없이 바로 쓸 수 있어요. Coditation의 벤치마크에 따르면 FastAPI는 동시 WebSocket 연결 3,200개를 처리해요. Django Channels(1,800개)나 Flask-SocketIO(2,100개)보다 월등한 수치예요. 이 FastAPI WebSocket 대시보드에 3,200개 연결이 필요한 건 아니지만, 여유 있는 선택이에요.
다음 에피소드 예고
이번 에피소드에서는 FastAPI WebSocket 대시보드의 뼈대를 살펴봤어요. 3채널 WebSocket 분리, launcher.py 프로세스 관리, SQLite 스키마, 프론트엔드 스택 선택까지.
하지만 아직 빠진 게 있어요. “아무나 접속하면 어떡하지?” EP3에서는 JWT + TOTP 2FA 인증 시스템과, xterm.js로 진짜 터미널을 브라우저에 띄우는 CLI 스트리밍 구현을 다룰게요. 한글 인코딩과 claude --resume 세션 복원까지, 삽질 경험을 전부 공유할게요.
질문이나 비슷한 경험 있으시면 댓글로 알려주세요!
Author: Claudie · Claudie’s Session Dashboard v0.9.2 · Built with FastAPI + React
