음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

맥미니를 AI 정제 서버로 만들다 — claude -p headless 워커

STT는 해결됐습니다. ElevenLabs Scribe v2 Realtime이 음성을 텍스트로 바꿔주는 속도와 정확도는 충분합니다. 그런데 다음 문제가 기다리고 있었습니다. 변환된 텍스트를 그대로 쓸 수 없다는 점이었습니다.

음성 인식 결과는 구어체 그대로입니다. "그러니까 어 이걸 뭐 오늘까지 해야 하는데요"가 텍스트로 남으면 쓸모가 없습니다. 이걸 "오늘까지 완료해야 하는 작업"으로 다듬는 단계가 필요합니다. 거기서 LLM이 등장합니다.

1. 정제 비용이 문제였다

처음엔 단순하게 생각했습니다. Anthropic API를 직접 호출하면 되지 않냐고. 실제로 Haiku 4.5는 짧은 텍스트 정제에 충분히 빠르고, 토큰 단가도 낮습니다. 문제는 "매 발화마다"라는 조건입니다.

JustBlurt는 음성을 실시간으로 받아서 바로 정제합니다. 사용자가 앱을 5분 쓰면 수십 건의 정제 요청이 발생합니다. 클라우드 LLM API를 매번 호출하면, 단가가 낮아도 누적 비용이 쌓입니다. 무료 플랜 사용자에게는 무제한으로 제공하기 어렵습니다.

정리하면: 정제 품질을 높이려면 그만한 모델을 써야 하는데, 그러면 무료 사용자에게 감당하기 어려운 비용이 붙습니다. 그렇다고 비용을 아끼려고 모델을 낮추면 정제 결과가 다시 구어체 티를 냅니다.

그러다가 단순한 사실을 깨달았습니다. 저는 Claude Max 구독을 사용하고 있습니다. 이미 내는 구독료 범위 안에서 Claude를 쓸 수 있습니다. Claude CLI, 즉 claude 커맨드가 그 도구입니다.

문제는 CLI를 서버에서 programmatic하게 호출하는 방법이었습니다. Claude CLI에는 -p 옵션이 있습니다. headless pipe 모드입니다. 프롬프트를 stdin으로 넘기면 결과를 stdout으로 돌려줍니다. 대화형 UI 없이 스크립트에서 쓸 수 있습니다.

echo "다음 구어체 텍스트를 간결한 문어체로 정제하세요: 그러니까 어 이걸 오늘까지 해야 하는데요" | claude -p

이게 동작한다는 걸 확인하는 순간, 구조가 바로 그려졌습니다. 맥미니에 Flask 서버를 올리고, subprocess로 claude -p를 호출하면 됩니다.

2. claude -p headless 패턴

claude -p는 Claude CLI의 pipe 모드입니다. --print의 단축 옵션으로, 인터랙티브 세션을 열지 않고 단발 요청을 처리합니다. stdin으로 프롬프트를 받고, stdout으로 응답을 출력합니다.

claude -p "프롬프트 텍스트"
# 또는
echo "프롬프트 텍스트" | claude -p

Python에서 subprocess로 감싸면 응답 텍스트를 바로 받아올 수 있습니다.

import subprocess

def refine_with_claude(raw_text: str) -> str:
    prompt = f"""다음 음성 인식 텍스트를 간결한 문어체로 정제하세요.
군더더기 표현(어, 그러니까, 뭐 등)을 제거하고,
의미를 유지한 채 명료하게 다듬으세요.

원문: {raw_text}

정제 결과:"""

    result = subprocess.run(
        ["claude", "-p", prompt],
        capture_output=True,
        text=True,
        timeout=30
    )

    if result.returncode != 0:
        raise RuntimeError(f"claude -p failed: {result.stderr}")

    return result.stdout.strip()

주의할 점이 하나 있습니다. claude 바이너리의 경로를 확실히 잡아야 합니다. launchd에서 실행할 때는 PATH가 인터랙티브 셸과 다릅니다. which claude로 절대 경로를 확인하고, subprocess 호출 시 절대 경로를 쓰거나 env 딕셔너리로 PATH를 명시합니다.

import os

CLAUDE_BIN = "/Users/hh/.nvm/versions/node/v22.0.0/bin/claude"  # which claude 결과

result = subprocess.run(
    [CLAUDE_BIN, "-p", prompt],
    capture_output=True,
    text=True,
    timeout=30,
    env={**os.environ, "PATH": "/usr/local/bin:/usr/bin:/bin:/Users/hh/.nvm/versions/node/v22.0.0/bin"}
)

3. Python 서버 + Tailscale Funnel

Flask로 간단한 HTTP 서버를 만들었습니다. Supabase Edge Function이 정제 요청을 POST로 보내면, 서버가 claude -p를 호출하고 결과를 반환합니다.

from flask import Flask, request, jsonify
import subprocess, os, hmac, hashlib

app = Flask(__name__)

SECRET = os.environ["MAC_REFINER_SECRET"]

def verify_request(req):
    provided = req.headers.get("X-Refiner-Secret", "")
    return hmac.compare_digest(provided, SECRET)

@app.route("/refine", methods=["POST"])
def refine():
    if not verify_request(request):
        return jsonify({"error": "unauthorized"}), 401

    body = request.json
    raw_text = body.get("text", "").strip()
    if not raw_text:
        return jsonify({"error": "empty text"}), 400

    prompt = f"다음 음성 인식 텍스트를 간결한 문어체로 정제하세요. 군더더기 표현을 제거하고 의미를 유지하세요.\n\n원문: {raw_text}\n\n정제 결과:"

    try:
        result = subprocess.run(
            [CLAUDE_BIN, "-p", prompt],
            capture_output=True, text=True, timeout=30,
            env={**os.environ, "PATH": CLAUDE_PATH}
        )
        if result.returncode != 0:
            raise RuntimeError(result.stderr)
        return jsonify({"refined": result.stdout.strip()})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8787)

서버는 localhost:8787에만 바인딩합니다. 외부 노출은 Tailscale Funnel이 담당합니다.

Tailscale Funnel은 Tailscale 네트워크 밖에서도 접근할 수 있도록 로컬 포트를 HTTPS로 공개하는 기능입니다. 포트 포워딩 설정이 필요 없고, TLS 인증서도 자동입니다.

# Tailscale Funnel 활성화
tailscale funnel --bg 8787

# 생성된 공개 URL 확인
tailscale funnel status
# 예: https://your-mac-mini.tail12345.ts.net -> 127.0.0.1:8787

생성된 URL이 MAC_REFINER_URL입니다. Supabase Edge Function의 환경 변수로 등록하면 연결이 완성됩니다.

환경 변수값위치
MAC_REFINER_URLTailscale Funnel URLSupabase Edge Function
MAC_REFINER_SECRET랜덤 시크릿 문자열Supabase + 맥미니 서버

4. launchd로 상시 실행

맥미니가 재부팅되어도 서버가 자동으로 올라와야 합니다. macOS에서는 launchd plist가 그 역할을 합니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.user.mac-refiner</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/hh/.venvs/mac-refiner/bin/python</string>
    <string>/Users/hh/.mac-refiner/server.py</string>
  </array>

  <key>EnvironmentVariables</key>
  <dict>
    <key>MAC_REFINER_SECRET</key>
    <string>YOUR_SECRET_HERE</string>
    <key>PATH</key>
    <string>/usr/local/bin:/usr/bin:/bin:/Users/hh/.nvm/versions/node/v22.0.0/bin</string>
  </dict>

  <key>RunAtLoad</key>
  <true/>

  <key>KeepAlive</key>
  <true/>

  <key>StandardOutPath</key>
  <string>/tmp/mac-refiner.log</string>

  <key>StandardErrorPath</key>
  <string>/tmp/mac-refiner.err</string>
</dict>
</plist>

plist 파일을 ~/Library/LaunchAgents/com.user.mac-refiner.plist에 저장하고 로드합니다.

# 로드
launchctl load ~/Library/LaunchAgents/com.user.mac-refiner.plist

# 상태 확인
launchctl list | grep mac-refiner

# 로그 확인
tail -f /tmp/mac-refiner.log

KeepAlive: true로 설정하면 서버가 예기치 않게 종료되어도 launchd가 자동으로 재시작합니다. 실질적으로 상시 실행이 보장됩니다.

5. 3단 폴백 정제 체인

맥미니가 꺼져 있거나 Tailscale 연결이 끊길 수 있습니다. 서버가 응답하지 않을 때 앱이 멈추면 안 됩니다. 그래서 폴백 체인을 구성했습니다.

정제 요청은 Supabase Edge Function에서 조율합니다. 우선순위는 다음 순서입니다.

순서엔진조건비용
1순위맥미니 claude -pMAC_REFINER_URL 응답 OK0원 (구독 포함)
2순위Anthropic API Haiku 4.5맥미니 타임아웃/에러매우 낮음
3순위Gemini FlashAnthropic API도 실패낮음

Edge Function 코드 구조는 다음과 같습니다.

async function refineText(rawText: string): Promise<string> {
  // 1순위: 맥미니 claude -p
  if (Deno.env.get("MAC_REFINER_URL")) {
    try {
      const res = await fetch(`${Deno.env.get("MAC_REFINER_URL")}/refine`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Refiner-Secret": Deno.env.get("MAC_REFINER_SECRET") ?? "",
        },
        body: JSON.stringify({ text: rawText }),
        signal: AbortSignal.timeout(8000),  // 8초 타임아웃
      });
      if (res.ok) {
        const data = await res.json();
        return data.refined;
      }
    } catch (e) {
      console.warn("mac-refiner failed, falling back:", e.message);
    }
  }

  // 2순위: Anthropic Haiku 4.5
  try {
    return await refineWithAnthropic(rawText);
  } catch (e) {
    console.warn("anthropic failed, falling back:", e.message);
  }

  // 3순위: Gemini Flash
  return await refineWithGemini(rawText);
}

1순위가 성공하는 한 API 비용은 0입니다. 맥미니가 정상 가동 중이면 대부분의 요청이 여기서 처리됩니다. 맥미니를 꺼놓는 시간에만 폴백이 작동합니다.

실제 운영해보니 8초 타임아웃이 핵심입니다. claude -p는 가끔 느릴 때가 있습니다. 타임아웃이 너무 짧으면 멀쩡한 요청도 폴백으로 넘어갑니다. 8초가 실험적으로 찾은 적정값입니다.

이 구조가 성립하는 이유는 이미 매달 내고 있는 Claude Max 구독을 정제에 그대로 쓰기 때문입니다. 구독료는 어차피 나가는 돈이고, claude -p는 그 구독을 HTTP 엔드포인트로 노출하는 얇은 레이어일 뿐입니다.

결국 놀고 있던 맥미니 한 대가 정제 서버가 됐습니다. 외부에서 접근하는 부분은 Tailscale이, 재부팅 후에도 서버가 살아 있게 하는 부분은 launchd가 맡습니다. 이렇게 붙여 놓으니 월 추가 비용 0원짜리 정제 인프라가 됐습니다. 혼자 운영하는 사이드 프로젝트 JustBlurt에서 실제로 벌어진 일이고, 다음 편은 무료·유료 사용자를 가르는 Supabase Edge Function 게이트 이야기로 이어집니다.

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기