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

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

STT·TTS 엔진을 코드 한 줄 안 고치고 바꾸는 구조 — 음성봇 인터페이스 설계

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 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(음성인식)와 TTS(음성합성)를 실제로 처리하는 부품을 오케스트레이터가 직접 알지 못해도 되게 만드는 추상 계층을 말합니다. 지난 편에서 끼어들기(barge-in) 처리를 다뤘다면, 이번 편은 그 끼어들기를 가능하게 만든 더 아래 층, 즉 엔진을 통째로 갈아 끼울 수 있게 짜둔 구조에 관한 기록입니다.

혼자 음성봇을 만들면서 가장 먼저 부딪힌 문제는 이것이었습니다. STT 엔진 하나를 정해서 오케스트레이터 코드 곳곳에 그 엔진의 함수를 직접 호출해버리면, 나중에 다른 엔진으로 바꾸고 싶을 때 오케스트레이터 코드를 전부 뜯어고쳐야 합니다. 테스트용 스텁 엔진과 실제 엔진을 나란히 두고 검증하고 싶었던 저에게는 이 방식이 애초에 성립하지 않았습니다. 그래서 오케스트레이터가 의존할 대상을 구체 엔진이 아니라 추상 인터페이스 하나로 좁히는 쪽을 택했습니다.

왜 blocking transcribe()가 아니라 요청과 이벤트를 분리했는가

가장 단순한 설계는 transcribe(audio)를 호출하면 텍스트가 바로 리턴되는 blocking 함수였을 겁니다. 하지만 실제 음성 인식은 그렇게 동작하지 않습니다. 사용자가 말을 시작한 순간, 말이 끝나 최종 텍스트가 확정된 순간, 아무 말도 하지 않고 시간이 초과된 순간이 각각 다른 시점에 발생합니다. 하나의 리턴값으로는 이 시간의 흐름을 표현할 수 없습니다.

그래서 저는 STT·TTS 인터페이스를 "요청을 던지고 비동기 이벤트 스트림을 받는" 형태로 설계했습니다. recognize()나 speak()를 호출하면 즉시 결과가 오는 게 아니라, 진행 상황을 알리는 이벤트가 하나씩 흘러나오는 async generator를 돌려받습니다. 오케스트레이터는 이 스트림을 async for로 순회하면서 지금 무슨 일이 일어나고 있는지 그때그때 판단합니다. 이렇게 하면 발화 시작 감지 같은 중간 신호도, 최종 결과도, 타임아웃도 같은 통로로 자연스럽게 흘려보낼 수 있습니다.

abc.ABC로 오케스트레이터와 엔진 사이에 벽을 세우다

이 구조를 코드로 강제하기 위해 STTEngine과 TTSEngine을 파이썬 abc.ABC로 선언했습니다. 추상 메서드로 계약만 정의하고 실제 구현은 전부 하위 클래스에 맡기는 방식입니다.

import abc
from dataclasses import dataclass
from typing import AsyncIterator, Iterable

@dataclass
class EngineEvent:
    type: str
    text: str | None = None
    audio: bytes | None = None
    meta: dict | None = None

AudioSource = Iterable[bytes] | AsyncIterator[bytes]

class STTEngine(abc.ABC):
    @abc.abstractmethod
    async def recognize(self, audio_iter: AudioSource) -> AsyncIterator[EngineEvent]:
        ...

class TTSEngine(abc.ABC):
    @abc.abstractmethod
    async def speak(self, text: str) -> AsyncIterator[EngineEvent]:
        ...

    @abc.abstractmethod
    async def stop(self) -> None:
        ...

recognize와 speak의 본문이 ...로만 되어 있는 이유가 있습니다. 여기에 yield를 넣어버리면 추상 메서드 자체가 async generator가 되어버려서, 계약을 선언하는 역할과 실제로 값을 흘려보내는 역할이 뒤섞입니다. yield는 오직 하위 구현체 안에만 있어야 합니다. 오케스트레이터는 이 두 추상 클래스만 보고 코드를 짜고, 그 뒤에 어떤 엔진이 붙는지는 신경 쓰지 않습니다. 그 덕분에 테스트용 Echo·Beep 스텁 엔진과, faster-whisper나 MeloTTS 같은 실제 엔진을 인터페이스만 지키면 코드 수정 없이 서로 바꿔 끼울 수 있습니다.

이벤트 하나에 상태를 다 실어 보내는 법

EngineEvent는 필드 네 개짜리 단순한 dataclass입니다. type이 이벤트 이름이고, text·audio·meta는 그 이벤트 종류에 따라 채워지거나 비워집니다. STT 쪽 이벤트 어휘는 세 가지로 정리했습니다. START_OF_INPUT은 사용자가 말을 시작했다는 신호이고, RECOGNITION_COMPLETE는 최종 텍스트가 확정됐다는 신호로 이때 text 필드가 채워집니다. NO_INPUT_TIMEOUT은 정해진 시간 동안 아무 입력도 없었다는 신호입니다.

이 세 가지만으로도 오케스트레이터가 대화의 진행을 추적하는 데 충분합니다. 말을 시작했는지, 끝난 텍스트가 무엇인지, 아니면 아예 말이 없었는지를 구분해서 처리 흐름을 분기하면 되기 때문입니다. 이벤트 타입을 문자열로 넓게 열어둔 이유도 여기 있습니다. 나중에 이벤트 종류가 늘어나도 EngineEvent의 구조 자체는 그대로 두고 새 type 값만 추가하면 됩니다.

오디오 규격을 한 줄로 통일해야 스왑이 실제로 된다

인터페이스를 아무리 잘 짜도 오디오 포맷이 엔진마다 제각각이면 교체는 말뿐인 구조가 됩니다. 그래서 16kHz, mono, PCM16(16비트 리틀엔디안)이라는 오디오 규격을 인터페이스 전체에서 하나로 고정했습니다. recognize에 넘기는 입력 프레임도, speak가 흘려보내는 출력 청크도 전부 같은 규격을 따릅니다. 이 부분에서 실시간 음성 시스템이 오래전부터 써온 MRCP(Media Resource Control Protocol, IETF RFC 6787) 같은 표준의 발상을 참고했습니다. 클라이언트가 리소스에 요청을 보내고 리소스가 비동기로 이벤트를 돌려주는 구조 자체가 이미 그쪽 세계에서 검증된 방식이었습니다.

자주 묻는 질문

Q. 이벤트 스트림 방식이 blocking 함수보다 코드가 복잡해지지 않나요. 호출부만 보면 await transcribe() 한 줄이 async for 루프보다 짧은 건 사실입니다. 하지만 발화 시작 감지, 최종 확정, 타임아웃을 blocking 함수 하나로 표현하려면 콜백이나 폴링을 따로 얹어야 해서 결국 복잡도가 사라지는 게 아니라 다른 곳으로 옮겨갈 뿐입니다. 이벤트 스트림은 그 복잡도를 인터페이스 안으로 끌어와 한곳에서 관리하게 해줍니다.

Q. 스텁 엔진과 실제 엔진을 같은 인터페이스로 묶으면 테스트가 실제 동작을 보장하나요. 아닙니다. 인터페이스가 같다는 건 오케스트레이터 코드가 수정 없이 돌아간다는 뜻이지, 스텁 테스트를 통과했다고 실제 엔진의 인식 정확도나 지연 시간까지 검증됐다는 뜻은 아닙니다. 스텁은 구조 검증용이고, 실제 엔진 검증은 별도로 필요합니다.

이번 편에서는 오케스트레이터가 엔진의 구체적인 구현을 몰라도 되게 만든 추상 인터페이스 설계를 정리했습니다. 다음 편에서는 이 인터페이스 뒤에서 실제로 스텁 엔진이 어떻게 이벤트를 만들어내는지, Echo·Beep 스텁의 구체적인 동작을 다루겠습니다.

음성봇 직접 만들기 시리즈 (전체 6편)

댓글

이 블로그의 인기 게시물

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

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

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