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

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

로컬 Whisper STT를 음성봇에 연결하기 — faster-whisper 연동기

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다.

  1. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법
  2. 2. "말이 끝났다"는 판단 — VAD 엔드포인터
  3. 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계
  4. 4. 로컬 LLM 대화 두뇌와 도구 호출 루프
  5. 5. 매 턴 지연시간을 재는 이유
  6. 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜
  7. 7. 로컬 Whisper STT 연결하기 — 지금 보는 글
  8. 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결)

지난 편에서 음성봇의 STT 자리에 스텁을 꽂아 넣었습니다. 스텁은 정해진 문장을 그대로 돌려주는 가짜 귀였습니다. 오케스트레이터가 이벤트를 제대로 받아 다음 단계로 넘어가는지, 끼어들기가 타이밍대로 처리되는지를 확인하기엔 충분했습니다. 그런데 스텁만으로는 절대 알 수 없는 것이 하나 있었습니다. 진짜 사람 목소리를 실제로 텍스트로 바꿀 수 있느냐는 질문입니다. 이번 편은 그 자리에 faster-whisper 기반의 진짜 STT 엔진을 연결한 과정을 정리합니다.

인터페이스 계약만 지키면 엔진 교체는 무수정이다

50편에서 STT 엔진이 지켜야 할 추상 인터페이스를 정의했습니다. 핵심은 recognize 함수가 EngineEvent 스트림을 내보낸다는 계약 하나였습니다. 오디오가 없으면 NO_INPUT_TIMEOUT, 음성이 들어오기 시작하면 START_OF_INPUT, 인식이 끝나면 텍스트를 담은 RECOGNITION_COMPLETE를 순서대로 방출합니다.

WhisperSTT는 이 계약을 그대로 구현했습니다. 오케스트레이터와 웹소켓 계층은 STT가 스텁인지 실제 모델인지 전혀 모릅니다. 그저 이 세 가지 이벤트 이름과 순서만 믿고 동작합니다. 그래서 스텁을 걷어내고 WhisperSTT를 꽂는 작업은 오케스트레이터 쪽 코드를 단 한 줄도 건드리지 않고 끝났습니다. 인터페이스를 먼저 설계해두는 이유가 바로 이것이었습니다. 구현체는 갈아 끼워도 계약을 지키는 한 나머지 시스템은 안전합니다.

PCM 프레임에서 텍스트가 나오기까지

recognize는 먼저 오디오 소스에서 PCM16 프레임을 전부 모읍니다. 동기 이터러블과 비동기 이터레이터를 둘 다 받을 수 있게 __aiter__ 유무로 분기해서 처리합니다. 프레임을 다 모았는데 바이트가 하나도 없으면 모델을 아예 부르지 않고 NO_INPUT_TIMEOUT만 내보내고 끝냅니다. 음성이 없는 턴에 모델을 돌리지 않는 이 분기가 리소스 낭비를 막는 첫 번째 방어선입니다.

오디오가 있으면 START_OF_INPUT을 먼저 내보낸 다음, 리틀엔디안 int16 바이트를 float32로 변환합니다. 32768로 나누는 이유는 가장 작은 음수 샘플이 정확히 -1.0에 맞도록 하기 위해서입니다. 변환이 끝나면 asyncio.to_thread로 실제 transcribe 호출을 별도 스레드에 던집니다. faster-whisper의 트랜스크라이브 자체가 블로킹, CPU 바운드 연산이라 이벤트 루프에서 그대로 돌리면 동시에 처리 중인 다른 세션이 멈춰버립니다. 스레드로 빼는 한 줄이 없었다면 세션이 하나만 있어도 서버 전체가 버벅였을 것입니다. 결과 텍스트가 나오면 RECOGNITION_COMPLETE에 실어 내보내는 것으로 흐름이 끝납니다.

모델 로딩을 뒤로 미루는 설계

WhisperModel을 생성자에서 바로 만들지 않은 것도 의도적인 선택입니다. 모델은 model이나 transcribe_fn 인자로 주입받을 수 있고, 둘 다 안 주면 첫 recognize 호출 시점까지 로딩을 미룹니다. 실제 faster-whisper import 문도 이 지연 로딩 경로 안에 숨어 있어서, 패키지가 없는 환경에서 이 클래스를 그냥 생성만 하는 코드는 아무 문제 없이 돌아갑니다.

이 구조 덕분에 테스트에서는 진짜 모델 대신 가짜 transcribe_fn을 주입해서 오디오 수집, 변환, 이벤트 순서 로직만 따로 검증할 수 있습니다. 모델 다운로드도, GPU도, 수백 메가바이트짜리 가중치도 필요 없습니다. 의존성 주입 하나로 무거운 부분과 가벼운 부분을 완전히 분리해둔 셈입니다.

빠른 테스트와 느린 테스트를 가르는 기준

여기서 테스트를 두 층으로 나눴습니다. 하나는 faster-whisper 패키지 자체가 없을 수도 있다는 문제입니다. pytest.importorskip("faster_whisper")를 테스트 상단에 넣어두면 패키지가 설치돼 있지 않은 환경에서는 해당 테스트를 건너뜁니다. 실패로 처리하지 않고 스킵으로 처리하는 것이 핵심입니다. 설치 여부가 코드 품질과는 무관한 별개의 문제이기 때문입니다.

다른 하나는 패키지는 있어도 실제 모델을 로드하는 테스트는 느리다는 문제입니다. 모델을 내려받고 메모리에 올리는 것만으로도 수 초에서 수십 초가 걸립니다. 이런 테스트에는 @pytest.mark.slow 마커를 붙여서 평소 돌리는 빠른 테스트 스위트에서는 제외했습니다. 커밋할 때마다 매번 무거운 모델을 로드하고 있으면 개발 루프 자체가 느려져서 테스트를 점점 안 돌리게 됩니다. 인터페이스 계약, 즉 이벤트 어휘와 순서는 가짜 함수로 매번 빠르게 검증하고, 모델 정확도처럼 실제 무게가 실리는 부분은 필요할 때만 별도로 확인하는 전략입니다. 두 층을 분리해두니 평소 개발 루프는 가볍게 유지되고, 정확도 검증이 필요한 순간에만 느린 테스트를 골라서 돌리면 됩니다.

마무리

async def recognize(self, audio_iter: AudioSource):
    pcm = await self._collect_pcm(audio_iter)

    if len(pcm) == 0:
        yield EngineEvent("NO_INPUT_TIMEOUT")
        return

    yield EngineEvent("START_OF_INPUT")

    audio = self._pcm16_to_float32(pcm)
    transcribe_fn = self._ensure_transcribe_fn()
    text = await asyncio.to_thread(transcribe_fn, audio)

    yield EngineEvent("RECOGNITION_COMPLETE", text=text)

스텁이 오케스트레이터 로직을 검증하는 자리였다면, WhisperSTT는 그 로직 위에 실제 인식 능력을 얹는 자리였습니다. 인터페이스를 먼저 굳혀놓은 덕분에 이 교체가 조용히 끝났다는 것 자체가 이번 작업의 소득입니다. 다음 편에서는 STT가 내놓은 텍스트를 받아 응답을 만드는 두뇌 플러그인 쪽을 다룹니다.

FAQ

Q1. faster-whisper 패키지가 설치돼 있지 않으면 테스트가 실패하나요? 아닙니다. 테스트 상단에서 pytest.importorskip("faster_whisper")를 호출해두면 패키지가 없는 환경에서는 해당 테스트가 실패가 아니라 스킵으로 처리됩니다. 패키지 설치 여부와 로직 정확성은 별개의 문제이기 때문에 이렇게 구분해뒀습니다.

Q2. 느린 테스트는 아예 안 돌리는 건가요? 아닙니다. @pytest.mark.slow로 표시만 해두고 평소 빠른 스위트에서 제외했을 뿐, 실제 모델 정확도를 확인해야 하는 시점에는 그 마커가 붙은 테스트만 골라서 따로 실행합니다. 자주 도는 검증과 가끔 도는 검증을 마커 하나로 나눠둔 것입니다.

댓글

이 블로그의 인기 게시물

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

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

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