맥미니를 AI 정제 서버로 만들다 — claude -p headless 워커
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
JustBlurt 다시 짓다 — 클라우드 STT 전환기 · 전체 5편 중 3편
- 1편 내가 만든 앱을 내가 안 쓴다 — 온디바이스 STT의 한계발행
- 2편 ElevenLabs Scribe v2 Realtime으로 갈아탄 이유발행
- 3편 맥미니를 AI 정제 서버로 만들다현재 편
- 4편 무료 사용자 3회 제한을 Supabase Edge Function으로 구현하다발행
- 5편 앱 출시 블로커가 사업자등록이라는 것발행
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_URL | Tailscale Funnel URL | Supabase 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 -p | MAC_REFINER_URL 응답 OK | 0원 (구독 포함) |
| 2순위 | Anthropic API Haiku 4.5 | 맥미니 타임아웃/에러 | 매우 낮음 |
| 3순위 | Gemini Flash | Anthropic 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 게이트 이야기로 이어집니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기