음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다
시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다.
- 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법
- 2. "말이 끝났다"는 판단 — VAD 엔드포인터
- 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계
- 4. 로컬 LLM 대화 두뇌와 도구 호출 루프
- 5. 매 턴 지연시간을 재는 이유
- 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜
- 7. 로컬 Whisper STT 연결하기
- 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글
돌아보며 시작하는 마지막 편
이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다.
실제 모델을 기다리지 않기로 한 이유
음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미리 피하고 싶었습니다. 그래서 오케스트레이터와 상태 흐름을 먼저 확정하기로 하고, 모델 자리에는 정해진 텍스트를 그대로 돌려주는 가짜 인식기와 비프음을 내는 가짜 합성기를 끼워 넣었습니다.
계약부터 그린다: 이벤트 스트림으로 정의한 엔진 인터페이스
가짜 엔진을 만들기 전에 먼저 한 일은 인식기와 합성기가 지켜야 할 규약을 추상 클래스로 못박는 것이었습니다. 인식기는 오디오 프레임 스트림을 받아 이벤트를 비동기로 흘려보내고, 합성기는 텍스트를 받아 오디오 청크를 비동기로 흘려보냅니다. 요청 하나를 던지고 결과 문자열 하나를 기다리는 방식이 아니라, 제어 요청과 이벤트 스트림을 분리한 형태입니다.
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:
...
이런 요청-이벤트 분리 구조는 실시간 음성 시스템에서 흔히 쓰는 표준 프로토콜의 형태를 단순화해서 본뜬 것입니다. 이벤트는 타입 문자열 하나에 텍스트·오디오·부가정보를 선택적으로 담는 단순한 데이터 클래스로 정의했고, 오디오 프레임은 항상 20밀리초 단위의 고정 크기로 맞춘다는 조건도 인터페이스 문서에 못박아 두었습니다. 이렇게 규약을 문서와 타입으로 먼저 그려 두면, 오케스트레이터는 이 추상 클래스에만 의존하도록 설계할 수 있고 구체적인 엔진이 무엇인지는 전혀 알 필요가 없어집니다.
가짜 목소리, 진짜 규약: Echo STT와 Beep TTS
이 계약 위에서 만든 첫 구현체가 에코 인식기와 비프 합성기입니다. 에코 인식기는 들어오는 오디오 프레임을 실제로 끝까지 소비하지만 내용은 전혀 분석하지 않고, 미리 정해 둔 문자열을 그대로 돌려줍니다. 대신 발화 시작 이벤트를 먼저 내보내고 인식 완료 이벤트를 나중에 내보내는 순서만큼은 정확히 지킵니다. 입력이 동기 이터러블이든 비동기 스트림이든 똑같이 받아들이도록 만들어서, 실제 상황에서 마이크 프레임이 실시간으로 들어오는 경우까지 미리 대비했습니다.
비프 합성기도 마찬가지입니다. 실제 음성을 만들지는 않지만 440헤르츠 사인파를 텍스트 길이에 비례하는 길이로 만들어서, 20밀리초 단위 프레임으로 쪼개 스트리밍합니다. 마지막 프레임이 딱 떨어지지 않으면 남는 자리를 0으로 채워서라도 프레임 크기를 항상 동일하게 유지하는데, 이는 나중에 진짜 합성 엔진이 들어와도 다운스트림 코드가 프레임 크기를 재조립할 필요가 없게 하기 위한 장치입니다. 끼어들기가 들어오면 중단 플래그를 세워 다음 청크부터 즉시 스트림을 멈추고 완료 이벤트도 내보내지 않으며, 이 플래그는 매번 새로운 발화가 시작될 때 초기화되어 이전 발화의 중단 신호가 다음 발화까지 새어 들어가지 않도록 했습니다. 겉모습은 가짜지만 프레임 크기, 스트리밍 방식, 중단 시점까지 실제 엔진이 지켜야 할 조건을 그대로 재현한 셈입니다.
테스트가 지켜준 여덟 편 — 시리즈를 마치며
이번 시리즈에서 다룬 오케스트레이터, 엔드포인터, 프로토콜, 엔진 인터페이스는 전부 유닛 테스트로 뒷받침돼 있습니다. 오케스트레이터 동작을 검증하는 테스트, 턴 전환을 검증하는 테스트, 프로토콜 규격을 검증하는 테스트, 엔진 인터페이스 계약을 검증하는 테스트, 세션 흐름을 검증하는 테스트가 각자 맡은 영역의 규칙을 하나씩 못박아 두는 방식으로 쌓였습니다. 이 테스트들이 실제로 붙잡고 돌린 대상은 처음부터 끝까지 에코 인식기와 비프 합성기, 즉 가짜 엔진이었습니다.
돌이켜보면 스텁을 먼저 만든 선택이 이 시리즈 전체를 가능하게 했습니다. 무거운 모델이 준비되지 않은 상태에서도 상태머신과 이벤트 흐름, 끼어들기 같은 어려운 동시성 로직을 빠르게 돌려보고 테스트로 굳힐 수 있었기 때문입니다. 그리고 인터페이스를 먼저 확정해 둔 덕분에, 나중에 진짜 엔진을 그 자리에 끼워 넣을 때 오케스트레이터 쪽 코드는 손댈 필요가 없었습니다. 계약이 먼저 있었기에 구현은 언제든 교체 가능한 부품이 될 수 있었던 것입니다. 음성봇을 처음부터 끝까지 직접 만들어보며 얻은 가장 큰 소득은 결국 이것이었습니다. 어려운 문제는 실제 모델이 아니라 그 모델을 둘러싼 설계에 있었다는 사실을, 여덟 편을 거치며 몸으로 확인했습니다. 이 시리즈는 여기서 마무리합니다.
댓글
댓글 쓰기