목록으로 돌아가기

블로그

단일 의존성: ws 패키지만으로 프로덕션 서버 구축

Node.js 서버의 런타임 의존성이 하나입니다. 하나. 이 선택의 이유와 신뢰성, 보안, 유지보수에 미치는 영향을 설명합니다.

게시일 태그: 엔지니어링 / 아키텍처 / node.js / 보안
블로그

이 카테고리의 제품 및 엔지니어링 업데이트를 확인하세요.

카테고리 보기

Tactic Remote 서버의 package.json을 열어보세요. dependencies 아래에 정확히 하나의 항목만 있습니다:

{
  "dependencies": {
    "ws": "^8.18.0"
  }
}

이게 전부입니다. ws WebSocket 라이브러리 하나. 그 외 모든 것 — HTTP 서버, 인증, 속도 제한, 터미널 관리, 파일 탐색, 입력 유효성 검사, 프로세스 실행 — 은 Node.js 내장 기능으로 구축했습니다.

우연한 미니멀리즘이 아닙니다. 신뢰성, 보안, 사용자에게 구체적인 영향을 미치는 의도적인 엔지니어링 결정입니다.

왜 이것이 중요한가

서버는 Mac에서 실행되며 Claude Code 세션을 관리합니다. 터미널, 파일시스템, 실행 중인 프로세스에 접근합니다. 이런 시스템에 추가하는 모든 의존성은:

  1. 공격 면입니다. 각 npm 패키지는 여러분의 권한으로 실행되는 코드이며, 모르는 사람이 작성했고, 감사하지 않은 전이 의존성을 가집니다.
  2. 유지보수 부담입니다. 의존성은 업데이트가 필요합니다. 업데이트는 호환성 문제나 취약점을 도입할 수 있습니다. 많을수록 기능 개발 대신 npm audit fix를 더 자주 하게 됩니다.
  3. 신뢰성 위험입니다. 의존성이 깨지면(leftpad, colors, node-ipc) 서버가 깨집니다. 사용자의 개발 워크플로가 중단됩니다.

사람과 AI 사이에서 기기 위의 코드 작성을 중개하는 서버에서 의존성 침해 비용은 심각합니다. 외부 의존성의 올바른 수는 가능한 한 0에 가까운 것이라고 결정했습니다.

의존성 없이 구축한 것

HTTP 서버

Node.js에 http.createServer()가 내장되어 있습니다. Express는 아니지만 정확히 두 개의 HTTP 엔드포인트만 제공합니다:

// POST /hook — Claude Code 라이프사이클 이벤트 수신
// GET /status — 마지막 이벤트 정보가 포함된 상태 확인

두 개의 라우트. 미들웨어 체인, 바디 파서, 라우터 라이브러리 없음. 요청 핸들러는 URL 경로를 ===로 확인하고, req.on('data')로 요청 본문을 읽고, 적절한 핸들러를 호출하는 단일 함수입니다.

"웹스케일"인가요? 아닙니다. 그래야 하나요? 역시 아닙니다. 이 서버는 하나의 iPhone에서 하나의 연결을 처리합니다. 라우팅 라이브러리는 엔지니어링 극장일 뿐입니다.

인증

인증 시스템은 API 키 검증을 위해 두 가지 헤더 형식을 지원합니다:

// HTTP 엔드포인트
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '')
 
// WebSocket 연결
const apiKey =
  req.headers['authorization']?.replace('Bearer ', '') || req.headers['sec-websocket-protocol']

Sec-WebSocket-Protocol 폴백은 iOS의 URLSessionWebSocketTask가 업그레이드 요청에 임의의 헤더를 설정할 수 없기 때문에 존재합니다. 이는 문서화된 iOS 제한 — 프로토콜 헤더는 설정할 수 있지만 커스텀 헤더는 안 됩니다. 이를 우회하는 WebSocket 라이브러리를 가져오는 대신 프로토콜 헤더를 인증 채널로 사용합니다.

인증은 연결 시 한 번 확인됩니다. 실패하면 WebSocket이 코드 1008(정책 위반)로 닫힙니다. 세션 토큰, 갱신 흐름, JWT 라이브러리 없음. 정적 API 키, ===로 비교.

속도 제한

인메모리 슬라이딩 윈도우, 40줄의 코드:

class RateLimiter {
  constructor(maxAttempts = 30, windowMs = 60000) {
    this.attempts = new Map() // IP -> { count, resetTime }
    this.maxAttempts = maxAttempts
    this.windowMs = windowMs
 
    // 5분마다 오래된 항목 정리
    setInterval(() => this.cleanup(), 300000)
  }
 
  isRateLimited(ip) {
    const now = Date.now()
    const record = this.attempts.get(ip)
 
    if (!record || now > record.resetTime) {
      this.attempts.set(ip, { count: 1, resetTime: now + this.windowMs })
      return false
    }
 
    record.count++
    return record.count > this.maxAttempts
  }
}

IP당 분당 30회의 인증 실패 시도. 초과하면 서버가 retryAfter 필드와 함께 429 Too Many Requests를 반환합니다. 윈도우가 만료되면 카운터가 리셋됩니다.

Redis 기반 속도 제한기가 더 정교할 것입니다. Redis 의존성도 추가될 것입니다. 하나의 클라이언트를 처리하는 서버에는 주기적 정리가 있는 인메모리 Map이 적절합니다.

입력 유효성 검사

모든 사용자 입력은 시스템에 닿기 전에 검증됩니다. 전체 유효성 검사 면은 다음과 같습니다:

입력유효성 검사이유
세션 이름/^[a-zA-Z0-9_-]{1,64}$/셸 메타문자를 포함할 수 있는 tmux 세션 이름
Claude 명령/^claude(\s+--?[a-zA-Z0-9-]+)*$/claude만 허용, 옵션 플래그 — 파이프, 리다이렉트, 체인 없음
tmux 키 이름18개 키의 명시적 허용 목록임의 키 입력 주입 방지
파일 경로path.resolve() + startsWith()디렉토리 순회 방지
단일 문자/^[a-zA-Z0-9!@#$%^&*()...]$/인쇄 가능한 ASCII만

유효성 검사 라이브러리 없음. 인라인으로 읽기 쉬운 정규 표현식과 문자열 비교.

프로세스 실행

보안상 가장 중요한 부분입니다. 서버가 사용자를 대신하여 tmux 명령을 실행합니다. 이 계층에 취약점이 있으면 공격자가 기기에서 임의의 명령을 실행할 수 있습니다.

답: child_process.spawn()에 shell: false, 모든 외부 명령이 통과하는 단일 함수로 감싸기:

function execSafe(cmd, args = [], opts = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false, // 셸 보간 없음. 마침표.
      ...opts,
    })
 
    let stdout = ''
    let stderr = ''
 
    child.stdout?.on('data', (data) => {
      stdout += data
    })
    child.stderr?.on('data', (data) => {
      stderr += data
    })
 
    child.on('close', (code) => {
      if (code === 0) resolve({ stdout, stderr, code })
      else reject(new Error(`Exit code ${code}: ${stderr}`))
    })
  })
}

shell: false는 인수가 /bin/sh를 거치지 않고 프로세스에 직접 전달된다는 의미입니다. 문자열 보간, 템플릿 리터럴 확장, 셸 주입의 기회가 없습니다. ["send-keys", "-t", sessionName, "-l", content]를 args 배열로 전달하면 그것이 정확히 tmux가 argv로 받는 것입니다. 세미콜론, 파이프, 백틱, $() — 어느 것도 특별한 의미를 갖지 않습니다.

모든 tmux 작업은 이 함수의 얇은 래퍼입니다:

async function tmuxSendKeys(sessionName, keys) {
  return execSafe('tmux', ['send-keys', '-t', sessionName, ...keys])
}
 
async function tmuxCapture(sessionName, lines = 500) {
  const result = await execSafe('tmux', [
    'capture-pane',
    '-t',
    sessionName,
    '-p',
    '-S',
    `-${lines}`,
  ])
  return result.stdout
}
 
async function tmuxNewSession(name, dir) {
  await execSafe('tmux', ['new-session', '-d', '-s', name, '-c', dir])
  await execSafe('tmux', ['set-option', '-t', name, 'history-limit', '50000'])
}

tmux 래퍼 라이브러리 없음. 터미널 세션 ORM 없음. 명시적 인수 배열을 가진 spawn만.

경로 순회 보호

파일 탐색은 홈 디렉토리 트리로 접근을 제한합니다. 민감한 디렉토리는 항상 차단됩니다:

const SENSITIVE_DIRS = [
  '.ssh',
  '.gnupg',
  '.aws',
  '.kube',
  '.config/gcloud',
  'Library/Keychains',
  'Library/Cookies',
  'Library/Application Support/1Password',
]
 
function containsSensitiveDir(targetPath) {
  const segments = targetPath.split(path.sep)
  return SENSITIVE_DIRS.some((sensitive) => {
    const sensitiveSegments = sensitive.split(path.sep)
    return segments.some((_, i) => sensitiveSegments.every((seg, j) => segments[i + j] === seg))
  })
}

세그먼트별 매칭. 경로 문자열에 includes()가 아닙니다. 이는 my-.ssh-backup이라는 디렉토리에서 오탐을 방지합니다. 확인은 민감한 디렉토리 패턴과 정확히 일치하는 연속 경로 세그먼트를 찾습니다.

모든 경로는 확인 전에 path.resolve()를 통해 절대 경로로 변환됩니다. 이는 .. 순회, 심볼릭 링크 공격, 상대 경로 트릭을 무력화합니다.

WebSocket 프로토콜

ws 패키지가 WebSocket 서버를 제공합니다. 그 위에 원래의 플랫 메시지 형식과 새로운 구조화된 형식을 동시에 처리하는 이중 프로토콜 시스템을 구축했습니다.

두 프로토콜인 이유

플랫 프로토콜을 먼저 출시했습니다:

{ "type": "send_to_claude", "content": "fix the bug", "session": "webapp" }

작동하지만 요청 상관 관계가 없습니다. 클라이언트가 두 요청을 연속으로 보내면 응답이 별도 메시지로 도착하고 어떤 응답이 어떤 요청과 일치하는지 알 수 없습니다.

새 프로토콜은 요청 ID를 추가합니다:

{"type": "req", "id": "abc-123", "method": "send_to_claude", "params": {"content": "fix the bug"}}
{"type": "res", "id": "abc-123", "ok": true, "payload": {"status": "sent"}}

플랫 형식을 제거하여 기존 클라이언트를 깨뜨릴 수 없었습니다. 그래서 서버가 둘 다 처리합니다:

function handleMessage(ws, data) {
    const msg = JSON.parse(data);
 
    if (msg.type === 'req') {
        // 새 프로토콜: 메서드와 파라미터 추출, 핸들러 호출,
        // 매칭되는 id로 응답
        const result = await handlers[msg.method](msg.params);
        sendToClient(ws, { type: 'res', id: msg.id, ok: true, payload: result });
    } else {
        // 레거시 프로토콜: type이 곧 메서드
        const result = await handlers[msg.type](msg);
        sendToClient(ws, { type: 'status', ...result });
    }
}

iOS 앱의 WebSocketClient도 클라이언트 측에서 동일하게 합니다 — 수신 메시지를 새 Frame 프로토콜로 먼저 디코딩하고, 실패하면 레거시 ServerMessage로 폴백합니다.

하트비트

모바일 WebSocket 연결은 불안정합니다. 휴대폰이 WiFi와 셀룰러를 오가고, 슬립에 들어가고, 데드존에 진입합니다. 죽은 연결을 빠르게 감지하되 일시적으로 중단된 연결을 끊지 않아야 합니다.

setInterval(() => {
  this.wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      ws.missedHeartbeats = (ws.missedHeartbeats || 0) + 1
      if (ws.missedHeartbeats >= 2) {
        return ws.terminate()
      }
    } else {
      ws.missedHeartbeats = 0
    }
    ws.isAlive = false
    ws.ping()
  })
}, 45000)

45초 간격, 2회 허용. 클라이언트가 최대 90초까지 응답하지 않아도 종료되지 않습니다. 이것은 모바일에 맞게 조정되었습니다 — 일반적인 WebSocket 하트비트는 15-30초이지만, 모바일 클라이언트는 타워 전환이나 WiFi 재연결 시 30-60초의 네트워크 전환을 자주 겪습니다.

WebSocket 레벨의 ping 프레임(ws 라이브러리가 자동으로 처리)과 애플리케이션 레벨의 {"type": "ping"} 메시지를 모두 보냅니다. 애플리케이션 레벨 ping은 저수준 WebSocket 프레임에 직접 접근할 수 없는 클라이언트를 위한 것입니다(또 다른 iOS URLSessionWebSocketTask 제약).

없는 것

이 아키텍처에 없는 것을 솔직하게:

클러스터링 없음. 서버는 단일 프로세스, 단일 머신입니다. 수평 확장이 불가합니다. 필요하지도 않습니다 — 하나의 Mac에서 한 사용자를 서비스합니다.

영속성 없음. 서버가 재시작되면 속도 제한기 상태와 클라이언트 레지스트리가 사라집니다. tmux 세션은 살아남습니다(독립 프로세스이므로), 하지만 서버의 인메모리 상태는 그렇지 않습니다. 사용 사례에서 이는 괜찮습니다 — 클라이언트가 재연결하고 몇 초 내에 상태를 다시 수립합니다.

그레이스풀 셧다운 오케스트레이션 없음. 서버가 중단되면 WebSocket 연결이 드레인되지 않고 종료됩니다. 단일 사용자 서버에서 역시 괜찮습니다 — 클라이언트가 재연결을 처리합니다.

요청 로깅이나 APM 없음. stdout에 로그합니다. 더 필요하면 파일로 파이프하세요. 구조화된 로깅 라이브러리, OpenTelemetry 통합, 로그 집계가 없습니다.

이것들은 모두 멀티테넌트 클라우드 서비스에서 의미가 있는 기능입니다. 책상 위에서 실행되어 휴대폰을 서비스하는 서버에서는 의미가 없습니다.

유지보수 현실

출시 이후 의존성 유지보수:

  • 보안 패치가 있을 때 ws 업데이트
  • 끝

전이 의존성의 npm audit 경고 없음. 직접 사용하지 않는 라이브러리에 대한 Dependabot PR 없음. 미들웨어 프레임워크의 메이저 버전 범프로 인한 호환성 변경 없음.

ws 라이브러리에 취약점이 있을 때(시작 이후 두 번 있었음) 하나의 패키지를 업데이트합니다. 변경이 작고, 위험이 제한되며, 5분 안에 diff를 감사할 수 있습니다.

이것을 전형적인 Express + passport + helmet + compression + cors + body-parser 스택과 비교해보세요. 각각 자체 의존성 트리를 끌어옵니다. 단일 npm audit이 수십 개의 전이 취약점을 표시할 수 있으며, 대부분 이론적이지만 모두 조사가 필요합니다.

이 접근 방식이 작동하지 않을 때

명확히 하면: 이것은 범용 권장 사항이 아닙니다. 단일 의존성 서버가 작동하는 조건:

  • 단일 사용자 또는 작고 알려진 클라이언트 세트를 서비스
  • HTTP 면이 작음(1-5개 라우트)
  • 데이터베이스 접근, 템플릿, 복잡한 라우팅이 불필요
  • 보안이 라이브러리 적층보다 단순성으로 더 잘 제공됨
  • 팀이 Node.js 내장 API를 읽는 데 익숙

사용자 계정, OAuth, 데이터베이스 모델, REST API가 있는 SaaS를 만든다면 프레임워크를 사용하세요. 적절한 ORM과 함께 Express 또는 Fastify가 수개월의 작업을 절약합니다. "단일 의존성" 접근 방식은 의존성 비용이 구현 비용을 초과하는 시스템을 위한 것입니다.

Tactic Remote 서버 — tmux와 iPhone 사이의 단일 사용자, 단일 목적 WebSocket 릴레이 — 에서는 하나가 올바른 의존성 수입니다.


이 서버가 전체 아키텍처에 어떻게 맞는지는 스마트폰에서 Claude Code 원격 실행하기를 참조하세요. 전체 WebSocket 프로토콜 레퍼런스는 시작하기 가이드를 참조하세요.

Tactic Remote 사용해 보기

휴대전화로 코딩 에이전트를 원격 제어하세요

Mac, Windows, Linux 컴퓨터의 Claude Code, Codex 등에 연결하세요. iPhone이나 iPad에서 진행 상황을 확인하고 다음 요청을 보낼 수 있습니다.