
지난 에피소드에서 이어서
EP1에서 RDP를 버린 이유를 이야기했고, EP2에서 FastAPI + WebSocket + React 아키텍처를 설계했어요. 서버가 돌아가고 WebSocket 파이프라인이 연결됐으니, 이제 진짜 문제를 풀 차례예요 — 누가 접속하는지 어떻게 확인하고, Claude Code 브라우저 터미널을 어떻게 안전하게 열어줄 것인가?
이번 에피소드에서는 JWT + bcrypt 인증부터 TOTP 2FA, Tailscale 자동 로그인, 그리고 xterm.js + PTY 기반 Claude Code 브라우저 터미널 스트리밍까지 전부 다뤄볼게요.
인증이 왜 필요한가 — Tailscale만으로는 부족할까?
Tailscale VPN 안에 있으니까 인증 없이 열어도 되지 않냐고요? 짧게 답하면 — 안 돼요.
Tailscale은 네트워크 레벨 접근 제어예요. 누가 이 네트워크에 들어올 수 있는지를 관리하지, 누가 이 대시보드에서 뭘 할 수 있는지는 모릅니다. VPN 안에 여러 사람이 있을 수 있고, 디바이스가 분실될 수도 있어요. 네트워크 접근 ≠ 애플리케이션 인증이에요.
그래서 Claudie’s Session Dashboard는 3단계 인증을 구현했어요:
- bcrypt 해시 비밀번호 — 기본 로그인
- JWT 토큰 — 세션 유지 (WebSocket 포함)
- TOTP 2FA — 선택적 2단계 인증
여기에 Tailscale CGNAT IP 감지를 통한 자동 로그인까지 얹으면, 보안은 철벽이면서 사용성도 챙길 수 있어요.
JWT + bcrypt — 기본 인증 플로우
비밀번호 저장은 bcrypt를 사용해요. OWASP 2025 가이드라인 기준으로 cost factor 12 이상이면 여전히 안전합니다. 새 프로젝트라면 Argon2id가 더 나은 선택이지만, bcrypt도 현역이에요.
# server/auth.py — Password hash generation and verification
import bcrypt
# Registration: password → bcrypt hash
hashed = bcrypt.hashpw(
password.encode(),
bcrypt.gensalt()
).decode()
# Login: compare input against stored hash
bcrypt.checkpw(
password.encode(),
user["password_hash"].encode()
)
로그인 성공하면 JWT 토큰을 발급해요. 토큰 페이로드는 sub(사용자명), role(admin/viewer), exp(만료 시각)를 담고 있어요. 이 토큰이 이후 모든 REST API 호출과 WebSocket 연결의 인증 수단이 됩니다.
토큰 만료 시 재발급은 별도 refresh endpoint 없이 재로그인으로 처리해요. 대시보드 세션은 기본 24시간이고, Tailscale 자동 로그인 환경에서는 만료돼도 재로그인 마찰이 없어서 이 방식으로 충분해요.

핵심은 WebSocket 인증이에요. HTTP 엔드포인트는 헤더에 Bearer 토큰을 넣으면 되지만, WebSocket은 첫 메시지로 토큰을 보내야 해요:
# server/terminal_routes.py — WebSocket authentication
@router.websocket("/ws/terminal")
async def terminal_ws(websocket: WebSocket):
await websocket.accept()
# First message: JWT token authentication
auth_msg = await websocket.receive_json()
token = auth_msg.get("token")
user = verify_jwt(token)
if not user:
await websocket.close(code=4001)
return
# Auth success → start terminal protocol
await websocket.send_json(
{"type": "auth_ok"}
)
TOTP 2FA — pyotp 30초 윈도우 구현
비밀번호만으로는 불안하다면 TOTP 2FA를 켤 수 있어요. Google Authenticator, Authy 같은 앱과 호환됩니다.
# server/auth.py — TOTP setup and verification
import pyotp
# Enable 2FA: generate secret key
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# Generate provisioning URI for QR code
uri = totp.provisioning_uri(
name=username,
issuer_name="Claudie Dashboard"
)
# Login: verify 6-digit code
# valid_window=1 → allow ±30 seconds clock skew
totp.verify(code, valid_window=1)
pyotp가 RFC 6238을 구현하고 있어서 설정은 코드 몇 줄로 끝나요. valid_window=1로 기기 시계 오차 ±30초를 허용합니다.
Tailscale 자동 로그인 — CGNAT 범위 감지 트릭
보안은 철벽인데, 집에서 매번 비밀번호 + TOTP 치는 건 귀찮잖아요. Tailscale VPN 안에서 접속하면 자동 로그인되게 만들었어요.
핵심은 Tailscale이 할당하는 IP 대역이에요. RFC 6598에 정의된 CGNAT(Carrier-Grade NAT) 범위 100.64.0.0/10을 사용해요. 일반 인터넷에서는 이 대역 IP가 나올 수 없으므로, 이 IP에서 접속하면 = Tailscale VPN 안에 있다는 뜻이에요.
# server/auth.py — Tailscale CGNAT detection
import ipaddress
_TAILSCALE_NETWORK = ipaddress.ip_network(
"100.64.0.0/10"
)
def is_tailscale_ip(self, ip_str: str) -> bool:
"""Returns True if the IP is within the Tailscale VPN range"""
return (
ipaddress.ip_address(ip_str)
in _TAILSCALE_NETWORK
)
Python 표준 라이브러리 ipaddress만으로 구현 가능해요. 외부 의존성 제로. 이 함수가 True를 반환하면 비밀번호 입력 없이 자동으로 JWT를 발급합니다.
실제 흐름은 이래요 — 브라우저가 /api/auth/tailscale-login을 GET 요청하면, 서버는 클라이언트 IP를 추출해서 is_tailscale_ip()로 검사해요. Tailscale 대역이면 관리자 권한 JWT를 즉시 반환, 아니면 401을 돌려보내요. 프론트엔드는 페이지 로드 시 이 엔드포인트를 먼저 시도하고, 성공하면 로그인 화면 없이 바로 대시보드로 진입합니다.
Setup Wizard — 첫 실행 무설정 자동화
인증 시스템이 있으면 첫 관리자 계정은 어떻게 만들까요? 대시보드를 처음 실행하면 Setup Wizard가 자동으로 뜹니다.
로직은 단순해요 — active_admin_count() == 0이면 Setup Wizard 모드로 진입합니다. 관리자 계정을 만들면 기본 admin 계정이 자동 비활성화되고, 이후에는 정상 로그인 화면이 나와요.
별도의 설정 파일이나 환경 변수 없이, SQLite DB에 활성 관리자가 있는지만 확인하는 거예요. Zero-config 철학이에요.
Setup Wizard 이후에는 Settings 페이지에서 사용자 관리를 할 수 있어요. 관리자는 새 사용자를 추가하고, 역할(admin/viewer)을 변경하고, 비밀번호를 초기화할 수 있어요. users 테이블에 is_active와 is_default 컬럼을 추가해서, 기본 admin 계정은 Setup 완료 후 자동으로 is_default=True, is_active=False 상태가 됩니다. 잊어버린 계정이 백도어로 남지 않아요.

Claude Code 브라우저 터미널 — xterm.js + PTY 연결
이제 이번 에피소드의 하이라이트예요. 브라우저에서 Claude Code를 직접 조작하는 Claude Code 브라우저 터미널입니다.
구조를 먼저 볼게요:
| 레이어 | 컴포넌트 | 역할 |
|---|---|---|
| 프론트엔드 | xterm.js | 브라우저 터미널 에뮬레이터 |
| 서버 | terminal_proxy | WebSocket 릴레이 + 인증 |
| 에이전트 | terminal_handler | PTY 프로세스 관리 |
| 프로세스 | claude CLI | 실제 Claude Code 실행 |
데이터 흐름은 이래요: xterm.js → WebSocket → terminal_proxy → WebSocket → terminal_handler → PTY → claude. 양방향이에요. Claude의 출력도 같은 경로를 역순으로 타고 브라우저에 렌더링됩니다.

WebSocket 프로토콜은 세 가지 메시지 타입을 사용해요:
start— 새 터미널 세션 시작 (또는--resume세션 ID 포함)input— 키보드 입력 전달 (base64 인코딩 — 한글 지원 핵심)resize— 터미널 크기 변경 (cols, rows → PTY resize syscall)
한 가지 더 — xterm.js를 React SPA에서 쓸 때 흔히 겪는 문제가 있어요. 다른 페이지로 이동하면 컴포넌트가 unmount되면서 터미널 세션이 끊기거든요. Claudie Dashboard는 이걸 CSS display 토글로 해결했어요. CliPage를 항상 마운트 상태로 유지하고, 화면에 보이지 않을 때는 display: none으로만 숨겨요. React 라우팅 중에도 PTY 프로세스가 계속 실행되므로, 대시보드 → CLI 페이지로 돌아와도 실행 중인 Claude Code 세션이 그대로 살아 있어요.
pywinpty vs Unix pty — 플랫폼 분기 구현
PTY(Pseudo-Terminal)는 운영체제마다 API가 달라요. Claudie’s Session Dashboard는 Windows와 macOS/Linux를 모두 지원해야 하므로 플랫폼 분기가 필수입니다.
Windows에서는 pywinpty를 사용해요:
# agent/terminal_handler.py — Windows PTY
from winpty import PtyProcess
proc = PtyProcess.spawn(
cmd,
cwd=self._cwd,
env=self._spawn_env
)
# Read output: proc.read()
# Write input: proc.write(data)
Unix/macOS에서는 표준 라이브러리 pty를 사용합니다:
# agent/terminal_handler.py — Unix PTY
import pty as pty_module
import asyncio, os
master_fd, slave_fd = pty_module.openpty()
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=self._cwd,
env=self._spawn_env,
preexec_fn=os.setsid
)
Windows PTY는 동기 API(PtyProcess), Unix는 비동기 API(asyncio.create_subprocess_exec)예요. 이 차이를 terminal_handler.py 내부에서 추상화해서, 상위 레이어는 플랫폼을 신경 쓸 필요가 없어요.
한글 입력 문제 — InvalidCharacterError와 UTF-8 base64
Claude Code 브라우저 터미널이 잘 동작하는데… 한글을 입력하면 터져요. InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
원인은 JavaScript의 btoa() 함수예요. 이 함수는 Latin-1(ISO 8859-1) 범위(0x00-0xFF)만 처리할 수 있어요. 한글, 일본어, CJK 문자는 이 범위를 벗어나므로 btoa()가 예외를 던집니다.
해결은 3줄이에요:
// frontend/src/components/CliPage.tsx
// Korean/CJK-safe base64 encoding
const bytes = new TextEncoder().encode(data)
let binary = ""
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
sendInput(btoa(binary))
TextEncoder().encode()가 문자열을 UTF-8 바이트 배열로 변환하고, 각 바이트를 Latin-1 문자로 매핑한 뒤 btoa()에 넣는 거예요. 바이트 값은 항상 0x00-0xFF 범위이므로 절대 에러가 안 나요.
보안 인증 3단계를 쌓는 데 수백 줄이 들었는데, 한글 버그 수정은 딱 3줄이라는 게 개발의 아이러니죠.
claude –resume — 세션 연속성 30초 캐시
Claude Code 브라우저 터미널에서 세션이 끊겨도 이어서 작업할 수 있어요. claude --resume <session-id> 명령으로 세션을 복원합니다.
세션 목록을 가져오려면 Claude Code의 JSONL 세션 파일들을 스캔해야 해요. 파일이 수십~수백 개일 수 있으므로 성능이 중요합니다:
# agent/terminal_handler.py — Session scan (summary)
import asyncio
from functools import partial
async def list_sessions(self):
# Check 30-second TTL cache
if self._session_cache_valid():
return self._session_cache
loop = asyncio.get_event_loop()
# Run file I/O in thread pool (avoid blocking)
sessions = await loop.run_in_executor(
self._executor,
partial(self._scan_jsonl_files)
)
self._update_cache(sessions)
return sessions # Top 20, most recent first
각 JSONL 파일은 전체를 읽지 않아요. 앞 8KB(세션 ID, 시작 시각)와 뒤 2KB(마지막 메시지)만 읽어요. JSONL 파일이 수십 MB여도 탐색 시간이 거의 없어요. 30초 TTL 캐시를 더하면, UI에서 세션 목록을 연속으로 요청해도 파일 시스템 I/O는 30초에 한 번뿐이에요. 에이전트 시작 시 pre-warming도 하므로 첫 요청도 빨라요.
사용자 입장에서는 대시보드에서 이전 세션 목록을 보고, 클릭 한 번으로 claude --resume <session-id>가 실행돼요. 며칠 전 작업하던 컨텍스트가 그대로 복원됩니다.
다음 에피소드 예고
인증과 Claude Code 브라우저 터미널이 완성됐어요. 보안은 3단계로 철벽이고, PTY 스트리밍으로 실시간 제어가 가능해요. 실제로 이 시스템이 완성된 지금, 소파에서 폰으로 대시보드 URL을 열면 — Tailscale VPN 연결 확인, CGNAT 범위 감지, 자동 로그인, 브라우저 터미널 열기까지 5초 안에 끝나요. Windows 워크스테이션에서 Claude Code가 풀 MCP 접근으로 돌아가는데, 제 손에는 폰 하나만 있어요.
그런데 한 가지 빠진 게 있어요 — “이 대시보드를 운영하는 데 얼마가 드는 거지?”
Claude Code MAX 구독은 월 $200 고정이에요. 그런데 xAI 이미지 생성, Gemini API, Qwen은 별도 과금이에요. 이 모든 비용을 한 화면에서 실시간으로 보지 않으면 실제 AI 운영 비용을 알 수 없어요.
EP4에서는 MCP 텔레메트리와 비용 추적을 구현해요. 7개 MCP 서버를 계측하고, Claude 토큰 사용량을 수집해서 “MAX 없이 API만 쓰면 얼마였을까”를 계산하고, Gemini·xAI·Qwen 프로바이더별 실제 지출을 일별로 집계합니다. Real Spend / If All API / MAX Savings 세 숫자가 대시보드에 실시간으로 표시돼요.
SERIES — RDP 없이 Claude Code 원격 제어하기
| EP1 문제 인식 — 왜 RDP를 버렸는가 |
| EP2 아키텍처 — FastAPI + WebSocket + React |
| EP3 인증과 CLI 스트리밍 — 보안 원격 터미널 ◀ 지금 읽고 있는 글 |
| EP4 비용 추적과 MCP 텔레메트리 — 내가 얼마를 쓰고 있는지 알기 (coming soon) |
