ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함
    IT 2026. 5. 8. 23:00
    로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함

    들어가며 — "마이크 버튼 하나만 더해주세요"의 진짜 비용

    손에 음식 묻었을 때, 또는 한 손은 아이를 안고 있을 때 — 키보드를 못 친다. ChatGPT 모바일이 마이크 버튼 하나로 음성을 받기 시작한 순간 사용자가 "그래, 이거지" 한다. 로컬 챗봇에도 같은 게 필요했다.

    음성 입출력은 두 단계 — 마이크 → 텍스트(STT, Speech-To-Text)와 텍스트 → 스피커(TTS, Text-To-Speech). STT는 Whisper, TTS는 Piper 같은 표준이 있다. 그런데 막상 챗봇 코드베이스에 "pip install faster-whisper 한 줄만 더하자"라는 결정이 의외로 비싸다. 이번 글은 그 의존성 추가를 거부하고 이미 있는 다른 프로젝트의 venv를 subprocess로 빌려쓴 영리한 회피를 다룬다.


    1. 왜 챗봇 venv에 PyTorch를 또 깔지 않으려 했나

    음성 STT의 표준은 OpenAI Whisper다. Python에서는 faster-whisper 패키지가 가장 널리 쓰인다. 이걸 챗봇 venv에 넣으면 따라오는 의존성이 다음과 같다.

    diagram

    이 그림이 보여주는 것은 "한 줄 pip install이 사실은 조용히 4-5GB 의존성을 끌어온다"는 사실이다. faster-whisper 자체는 작은 라이브러리지만, ctranslate2(C++ CUDA 바인딩), torch + torchaudio(딥러닝 프레임워크), numpy/soundfile/av(오디오 처리) 등 백엔드 의존성이 따라온다. 아래 박스에 정리한 다섯 가지 비용 중 가장 신경 쓰이는 건 두 번째 — torch 버전 충돌. 같은 머신에 voice-pipeline 프로젝트가 이미 torch 2.x를 쓰는데, 챗봇이 torch 2.y를 깔면 두 venv가 다른 버전을 갖게 된다. 이건 venv 격리로 막히지만, 만약 챗봇이 voice-pipeline의 코드를 import하려는 시나리오가 생기면 즉시 문제. 의존성 표면적이 늘면 미래의 통합 시나리오가 모두 위험해진다.

    음성 입력은 가끔 쓰는 기능이다. "한 번 쓸까 말까"인 기능을 위해 챗봇의 핵심 의존 트리를 5GB 부풀리는 건 손해. 그렇다고 아예 빼면 사용자 UX가 답답해진다.


    2. 답 — voice-pipeline 프로젝트의 venv를 빌려쓰기

    같은 머신에 이미 voice-pipeline 프로젝트가 있다. 그 프로젝트의 .venv에는 faster-whisper, torch, soundfile이 다 깔려있다 — "이미 있는 인프라". 챗봇이 그것을 다시 깔지 않고 그냥 그 venv의 python을 subprocess로 호출한다.

    diagram

    이 그림이 보여주는 디자인의 핵심 — 두 venv가 서로의 의존성을 모른다. 왼쪽 챗봇 venv는 가벼운 의존성만 가지고 있고 torch가 없다. 오른쪽 voice-pipeline venv는 torch + faster-whisper를 가지고 있다. 두 venv는 서로 import 관계가 없다 — 챗봇이 import faster_whisper를 시도하면 ImportError가 나는 게 정상. 두 venv가 통신하는 인터페이스는 가운데 화살표 — subprocess의 stdin/stdout 한 줄짜리 JSON. 이게 Unix 파이프라인의 모듈성 그대로다.

    코드는 거짓말처럼 단순하다.

    # speech.py
    VOICE_PYTHON = str(Path.home() / "projects" / "voice-pipeline" /
                       ".venv" / "bin" / "python")
    WHISPER_MODEL = "small"     # 한국어 chat 음성에 충분
    WHISPER_LANGUAGE = "ko"
    
    _TRANSCRIBE_SCRIPT = """
    import json, sys
    from faster_whisper import WhisperModel
    audio_path, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3]
    model = WhisperModel(model_name, device="cpu", compute_type="int8")
    segments, info = model.transcribe(audio_path, language=lang,
                                      beam_size=1, vad_filter=True)
    text = " ".join(s.text.strip() for s in segments).strip()
    print(json.dumps({"text": text}, ensure_ascii=False))
    """
    
    def transcribe_audio(audio_path: str) -> str:
        completed = subprocess.run(
            [VOICE_PYTHON, "-c", _TRANSCRIBE_SCRIPT,
             audio_path, WHISPER_MODEL, WHISPER_LANGUAGE],
            capture_output=True, text=True, timeout=120,
        )
        data = json.loads(completed.stdout.strip().splitlines()[-1])
        return (data.get("text") or "").strip()
    

    이 코드의 두 가지 디자인 결정이 핵심이다. 첫째, VOICE_PYTHON다른 프로젝트의 절대 경로를 가리킨다 — 챗봇이 그 프로젝트의 venv가 어디 있는지 알아야 동작. 이게 두 프로젝트 간의 암묵적 계약이다 (시리즈에서는 트레이드오프 섹션에서 이 약점을 자세히 다룬다). 둘째, _TRANSCRIBE_SCRIPT인라인 문자열로 만들어 python -c "<code>"로 실행한다. 별도 .py 파일을 만들 필요 없고, 챗봇 코드만 보면 무엇이 실행되는지 그 자리에서 알 수 있다. 출력은 stdout으로 JSON 한 줄. 챗봇은 그것을 읽어 파싱만 한다 — 두 venv가 통신하는 인터페이스가 곧 "JSON 한 줄"이라는 가장 단순한 형태로 결정된다.


    3. 같은 패턴이 어디까지 적용되는가 — Unix 철학과의 만남

    "하나의 프로그램은 하나의 일을 잘하고, 프로그램들은 서로 인터페이스(stdout/stdin)로 연결된다"는 Unix 철학과 같은 디자인이다. 같은 머신에 이미 잘 작동하는 인프라가 있다면 그것을 import 하지 말고 호출하라.

    diagram

    이 그림에서 같은 패턴 세 가지가 챗봇에 적용된 걸 보여준다 — RAG 검색은 knowledge-vault-rag 프로젝트의 search_json.py를 subprocess로 호출, 음성 STT는 위에서 다룬 voice-pipeline, 캘린더 도구는 ~/scripts의 calendar_tool.sh 쉘 스크립트. 세 케이스 모두 챗봇 코드에는 그 도구의 의존성이 0개다. RAG가 Qdrant 클라이언트나 임베딩 모델을 import하지 않고, 음성이 torch를 import하지 않고, 캘린더가 Google API SDK를 import하지 않는다. 모두 호출만 한다. 이 디자인의 누적 효과로 챗봇 venv가 매우 가볍게 유지되고, 각 도구의 업데이트가 챗봇에 영향을 안 준다.


    4. 트레이드오프 — 빌려쓰기의 비용

    4-1. subprocess 부팅 비용 — 매 호출 1.5~2초의 사용자 체감 지연

    매 전사마다 voice-pipeline의 python을 새로 띄운다. 인터프리터 시동(0.3초) + faster-whisper import(0.5초) + small 모델 로드(0.5-1초)로 합산 1.5~2초. 사용자 체감으로는 "10초짜리 음성을 녹음하니 4-5초 후 텍스트가 떠요"라는 정도. 그중 부팅 비용이 절반 가까이 차지한다.

    이 비용을 없애려면 voice-pipeline에 daemon 모드를 만들어야 한다. faster-whisper를 메모리에 keep해두는 작은 HTTP 서버를 띄우고, 챗봇이 HTTP 요청으로 전사를 요청. 그러면 매 호출이 ~10ms로 떨어지지만 — 따라오는 비용이 (a) 새 데몬 프로세스 관리(systemd 유닛 추가), (b) 데몬이 메모리에 ~1GB를 항상 차지(small 모델 + 라이브러리), (c) 데몬-챗봇 간 HTTP 인터페이스 정의(JSON schema), (d) 두 프로젝트의 배포 동기화. 인프라 복잡도가 한 단계 올라간다.

    음성 사용 빈도가 낮으니 이 인프라 비용을 정당화하기 어렵다 — 하루에 음성 입력 1-2번이라면 매번 1.5초 추가가 누적되어도 합산 3초 미만. 사용자가 매번 음성을 쓰기 시작하면 그때 daemon 모드로 옮긴다. 현재는 단순함을 우선.

    4-2. CPU 추론 — GPU 자원 충돌을 회피한 의도적 선택

    위 코드를 보면 device="cpu", compute_type="int8". small 모델 기준 10초 클립이 CPU 코어 한 개로 ~3-5초로 처리된다. GPU에 비하면 10배 느리지만 충분히 인터랙티브.

    왜 GPU를 안 쓰는가? 챗봇이 동작 중일 때 텍스트 vLLM이 GPU 메모리 80GB를 잡고 있다. 그 GPU에 faster-whisper(~1GB)를 추가로 띄우려면 GPU 스케줄러를 통해 lock을 acquire해야 하는데, 그건 vLLM을 잠시 중단시킨다. 시리즈 #4에서 다룬 vision 모델 스왑 비용(~3분)이 음성 전사에서도 발생한다. 10초짜리 음성 한 번을 위해 텍스트 채팅을 3분 멈추는 건 명백히 안 좋은 트레이드오프.

    그래서 CPU. 음성 클립이 30초 미만의 짧은 인터랙티브 발화라는 가정 하에 CPU가 충분. 만약 사용자가 30분짜리 녹음을 던진다면? 그건 챗봇의 인터랙티브 사용 시나리오를 벗어난 것. 그런 케이스는 voice-pipeline의 본격 GPU 파이프라인(별도 systemd 워커가 GPU를 적시 사용)으로 따로 처리하는 게 맞다 — 챗봇과 voice-pipeline의 역할 분리.

    4-3. 두 venv 간 인터페이스가 깨질 가능성 — 명시적 계약의 부재

    이 디자인의 가장 큰 약점이다. 챗봇은 voice-pipeline의 .venv 경로와 faster_whisper.WhisperModel API에 암묵적으로 의존한다. 두 프로젝트 사이에 명시적 인터페이스 계약이 없다 — Unix subprocess의 일반적인 약점.

    구체적인 깨짐 시나리오를 상상해 보면. 어느 날 voice-pipeline 메인테이너가 "faster-whisper에서 더 좋은 라이브러리(예: WhisperX)로 옮기자"라고 결정한다. voice-pipeline 자체는 자기 코드를 새 라이브러리로 마이그레이션. 그 결과 voice-pipeline의 .venv에서 faster_whisper 패키지가 제거된다. 챗봇의 인라인 스크립트는 from faster_whisper import WhisperModel로 시작하니 그 시점부터 import error. 사용자가 마이크 버튼을 누르면 "[전사 오류] ImportError"가 응답으로 온다. 발견은 사용자가 음성 입력을 시도할 때, 즉 변경 후 사용자 시도 사이의 시간차만큼 늦어진다.

    완화책은 두 가지. 첫째 — voice-pipeline에 명시적인 "transcribe entry script"(예: transcribe_for_chatbot.py)를 두고 챗봇이 그것을 호출. 그러면 voice-pipeline 메인테이너가 그 entry script를 깨지 않게 신경 쓴다. 둘째 — 두 프로젝트의 변경을 함께 테스트하는 CI(예: 챗봇 테스트가 voice-pipeline의 entry script를 dry-run). 현재는 둘 다 미적용 — 1인 운영자라 단일 메인테이너의 머릿속에 계약이 있는 셈. 다중 메인테이너 환경으로 옮기면 첫 번째 완화책이 거의 의무가 된다.


    5. 마무리

    "이 기능 하나만 추가해주세요"라는 평범한 요구가 의존성 트리를 부풀게 만드는 일이 흔하다. "같은 머신에 이미 잘 도는 인프라가 있다면 import 하지 말고 호출하라"는 게 이번 묶음의 한 줄 교훈. Unix 철학의 모듈성을 Python venv 단위로 끌어올린 패턴이다.

    다음 편은 메모리. /remember로 LLM을 우회해 즉시 저장하는 슬래시 커맨드.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.