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

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

ElevenLabs Scribe v2 Realtime으로 갈아탄 이유 — 스트리밍 STT 구현기

JustBlurt 다시 짓다 — 클라우드 STT 전환기 · 전체 5편 중 2편

  1. 1편 내가 만든 앱을 내가 안 쓴다 — 온디바이스 STT의 한계발행
  2. 2편 ElevenLabs Scribe v2 Realtime으로 갈아탄 이유현재 편
  3. 3편 맥미니를 AI 정제 서버로 만들다발행
  4. 4편 무료 사용자 3회 제한을 Supabase Edge Function으로 구현하다발행
  5. 5편 앱 출시 블로커가 사업자등록이라는 것발행

지난 편에서 온디바이스 STT의 구체적인 한계를 확인했습니다. 짧은 발화는 잘 끊기고, 한국어 인식률은 기대에 못 미쳤으며, 무엇보다 "인식 중..." 표시만 보여주는 UX가 앱 사용 빈도를 낮추는 직접적인 원인이었습니다. 이번 편에서는 클라우드 STT로 전환을 결정한 기준과, ElevenLabs Scribe v2 Realtime을 선택한 이유, 그리고 실제 WebSocket 스트리밍 구현 과정을 정리합니다.


1. 클라우드 STT를 선택한 기준

클라우드 STT 전환은 처음부터 확정된 방향이 아니었습니다. 온디바이스 방식이 네트워크 없이 동작한다는 장점이 있기 때문에, 전환을 결정하기 전에 세 가지 기준을 먼저 정했습니다.

기준 설명 온디바이스 클라우드
한국어 인식률 혼합 발화, 축약어, 구어체 포함 미흡 우수
스트리밍 지원 발화 중 partial transcript 반환 미지원 지원
언어 자동 감지 한/영 전환 없이 자동 처리 미지원 지원

세 가지 기준 모두 충족해야 전환을 진행하기로 했습니다. 그중 스트리밍 지원이 핵심이었습니다. 발화가 끝난 후 결과를 받는 방식으로는 체감 속도 문제가 해결되지 않기 때문입니다. 사용자가 말을 멈추기 전에 이미 텍스트가 채워지기 시작해야 UX가 달라집니다.

후보군으로 검토한 서비스는 Google Cloud Speech-to-Text v2, OpenAI Whisper API, Deepgram Nova-2, ElevenLabs Scribe v2 Realtime 네 가지였습니다. Google은 한국어 스트리밍 지원은 되지만 가격 구조가 복잡하고, Whisper API는 스트리밍을 공식 지원하지 않습니다. Deepgram은 한국어 지원이 아직 베타 수준입니다. ElevenLabs Scribe v2 Realtime은 세 가지 기준을 모두 충족하면서 WebSocket API 문서가 명확하고 React Native 연동 레퍼런스도 있었습니다.


2. ElevenLabs Scribe v2 Realtime의 구조

ElevenLabs Scribe v2 Realtime은 WebSocket 기반의 스트리밍 STT 서비스입니다. 클라이언트가 오디오 청크를 Base64로 인코딩해 전송하면, 서버가 두 종류의 이벤트를 반환합니다.

이벤트 타입 설명 처리 방식
TranscriptPartial 발화 중 중간 결과, 계속 변경됨 UI에 임시 표시, 덮어쓰기
TranscriptFinal 문장 단위 확정 결과 상태에 누적 저장

연결 흐름은 다음과 같습니다.

1. WebSocket 연결 (wss://api.elevenlabs.io/v1/speech-to-text/stream)
2. 연결 시 API 키 + 설정 전송 (language_code: "auto", model: "scribe_v2")
3. 녹음 시작 → 오디오 청크를 16ms 간격으로 Base64 인코딩 후 전송
4. TranscriptPartial 수신 → partial 상태 업데이트
5. TranscriptFinal 수신 → confirmed 배열에 추가
6. 녹음 종료 신호 전송 → 연결 종료

language_code: "auto" 설정이 중요합니다. 한국어와 영어가 혼합된 발화에서 언어를 자동으로 감지하기 때문에, 사용자가 언어를 수동으로 선택하지 않아도 됩니다. JustBlurt의 주요 사용 시나리오인 업무 관련 메모에서 영어 단어가 섞이는 경우를 자연스럽게 처리할 수 있습니다.


3. use-cloud-transcriber.ts 설계

React Native에서 WebSocket 스트리밍 STT를 다루는 커스텀 훅을 별도로 분리했습니다. 훅의 책임은 세 가지입니다: WebSocket 연결 수명 관리, 오디오 청크 전달, transcript 상태 노출.

// hooks/use-cloud-transcriber.ts
import { useRef, useState, useCallback } from 'react';
import { ELEVENLABS_API_KEY } from '@env';

type TranscriberState = {
  partialText: string;
  confirmedText: string;
  isConnected: boolean;
};

export function useCloudTranscriber() {
  const wsRef = useRef<WebSocket | null>(null);
  const [state, setState] = useState<TranscriberState>({
    partialText: '',
    confirmedText: '',
    isConnected: false,
  });

  const connect = useCallback(() => {
    const ws = new WebSocket(
      'wss://api.elevenlabs.io/v1/speech-to-text/stream'
    );

    ws.onopen = () => {
      ws.send(JSON.stringify({
        type: 'SessionConfig',
        api_key: ELEVENLABS_API_KEY,
        language_code: 'auto',
        model: 'scribe_v2',
      }));
      setState(prev => ({ ...prev, isConnected: true }));
    };

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      if (msg.type === 'TranscriptPartial') {
        setState(prev => ({ ...prev, partialText: msg.text }));
      } else if (msg.type === 'TranscriptFinal') {
        setState(prev => ({
          ...prev,
          confirmedText: prev.confirmedText + msg.text + ' ',
          partialText: '',
        }));
      }
    };

    ws.onclose = () => {
      setState(prev => ({ ...prev, isConnected: false }));
    };

    wsRef.current = ws;
  }, []);

  const sendChunk = useCallback((base64Audio: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({
        type: 'AudioChunk',
        audio: base64Audio,
      }));
    }
  }, []);

  const disconnect = useCallback(() => {
    wsRef.current?.send(JSON.stringify({ type: 'EndStream' }));
    wsRef.current?.close();
  }, []);

  return { ...state, connect, sendChunk, disconnect };
}

설계 시 주의한 점은 confirmedText와 partialText를 분리한 것입니다. 확정된 텍스트는 누적되고, partial은 매번 덮어씁니다. UI에서는 두 값을 합쳐서 표시합니다. 이렇게 하면 partial이 바뀌어도 이전에 확정된 텍스트가 사라지지 않습니다.

오디오 청크는 @siteed/audio-studio 라이브러리의 PCM 스트리밍 기능으로 공급합니다. 이 라이브러리는 네이티브 모듈을 포함하고 있어서 의존성 추가 후 npx expo prebuild를 다시 실행해야 했습니다. Expo Go에서는 동작하지 않고 개발 빌드가 필요합니다.


4. partial transcript가 UX를 바꾼다

기술적으로는 단순한 변경이지만, 실제로 앱을 사용하는 경험이 크게 달라졌습니다. 이전 방식에서는 녹음 버튼을 누르고 말하는 동안 화면에 "인식 중..."이라는 텍스트만 표시됐습니다. 발화가 끝난 후에야 결과가 나타나기 때문에 제대로 인식되고 있는지 알 수 없었습니다.

변경 전: 말하는 중 → "인식 중..." → (침묵 감지) → 결과 표시
변경 후: 말하는 중 → 텍스트가 실시간으로 채워짐 → 발화 종료 시 확정

partial transcript 표시가 가져오는 효과는 두 가지입니다. 첫째, 사용자가 인식 상태를 실시간으로 확인할 수 있어서 오인식이 발생하면 발화 중에 알아챌 수 있습니다. 둘째, 텍스트가 채워지는 시각적 피드백이 "지금 잘 되고 있다"는 신뢰를 줍니다. 앱이 반응하고 있다는 느낌이 사용 빈도에 직접적인 영향을 미칩니다.

구현 측면에서는 UI 컴포넌트에서 confirmedText + partialText를 합쳐서 렌더링하는 것만으로 충분합니다.

// RecordingView.tsx (일부)
const displayText = confirmedText + (partialText ? partialText : '');

<Text style={styles.transcript}>
  {displayText || '말씀해 주세요...'}
</Text>

partial 텍스트는 글자 색을 약간 연하게 처리해서 아직 확정되지 않은 상태임을 시각적으로 구분했습니다. 이렇게 하면 확정된 부분과 변경될 수 있는 부분을 사용자가 자연스럽게 구분합니다.


5. 오프라인 폴백 처리

클라우드 STT로 전환하면서 가장 우려한 부분이 네트워크 의존성이었습니다. JustBlurt의 핵심 사용 시나리오 중 하나가 이동 중 빠른 메모이기 때문에, 네트워크가 불안정한 상황에서도 기본 기능은 동작해야 했습니다.

이를 위해 use-smart-transcriber.ts라는 상위 훅을 만들었습니다. 클라우드를 먼저 시도하고, 연결 실패 시 온디바이스로 자동 전환하는 역할을 합니다.

// hooks/use-smart-transcriber.ts
import NetInfo from '@react-native-community/netinfo';
import { useCloudTranscriber } from './use-cloud-transcriber';
import { useOnDeviceTranscriber } from './use-on-device-transcriber';

export function useSmartTranscriber() {
  const cloud = useCloudTranscriber();
  const onDevice = useOnDeviceTranscriber();
  const [mode, setMode] = useState<'cloud' | 'ondevice'>('cloud');

  const start = useCallback(async () => {
    const netState = await NetInfo.fetch();
    const isOnline = netState.isConnected && netState.isInternetReachable;

    if (isOnline) {
      try {
        await cloud.connect();
        setMode('cloud');
        return;
      } catch {
        // 연결 실패 시 온디바이스로 전환
      }
    }

    setMode('ondevice');
    onDevice.start();
  }, [cloud, onDevice]);

  const active = mode === 'cloud' ? cloud : onDevice;

  return {
    ...active,
    mode,
    start,
  };
}

폴백 전환은 사용자에게 명시적으로 알립니다. 온디바이스 모드로 전환되면 입력창 하단에 "오프라인 모드 — 인식 정확도가 낮을 수 있습니다"라는 문구를 표시합니다. 사용자가 상황을 인식하고 중요한 내용은 다시 녹음할 수 있도록 하기 위해서입니다.

온디바이스 폴백을 유지하는 것은 기술적 보험이기도 하지만, 앱 스토어 심사 대응 측면에서도 중요합니다. 인터넷 연결 없이 핵심 기능이 전혀 동작하지 않는 앱은 심사 과정에서 문제가 될 수 있습니다. 폴백이 있으면 오프라인 환경에서도 기본 동작을 보장할 수 있습니다.

이로써 STT 계층의 구조가 확정됐습니다. use-smart-transcriber → 온라인이면 use-cloud-transcriber(ElevenLabs WebSocket), 오프라인이면 use-on-device-transcriber(네이티브 SFSpeechRecognizer)로 위임하는 구조입니다. 다음 편에서는 인식된 텍스트를 AI로 정제하는 단계, 즉 맥미니를 AI 정제 서버로 만드는 과정을 다룹니다.

댓글

이 블로그의 인기 게시물

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

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

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