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

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

한글로 읽은 숫자를 아라비아 숫자로 변환하기 — '이백팔십'을 280으로 (STT 후처리)

이 변환은 LLM에게 맡기지 않기로 처음부터 정했습니다. "이백팔십"이 280이라는 건 규칙이 정확히 알려진 문제입니다. 답이 하나로 딱 떨어지고, 같은 입력에 항상 같은 결과가 나와야 하며, 굳이 모델을 부를 이유가 없습니다. LLM을 쓰면 느리고, 비용이 들고, 아주 가끔이라도 엉뚱한 숫자를 내놓을 여지가 생깁니다. 반대로 코드로 짠 변환은 빠르고 공짜이고 언제나 같습니다. 그래서 이 편은 규칙으로 푼 결정론적 변환기 이야기입니다.

시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.

  1. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
  2. 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버)
  3. 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인
  4. 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 — 지금 보는 글
  5. 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli)
  6. 6. 브라우저 프로토타입 → Vercel 서버리스

1. 단위가 있으면 정석 계산: 이백팔십 → 280

한글 숫자에는 자리를 나타내는 단위가 있습니다. 십·백·천·만입니다. "이백팔십"에는 백과 십이 들어 있으니, 사람이 계산하듯 자리마다 값을 더해 주면 됩니다. 이(2)×백(100) + 팔(8)×십(10) = 280.

코드도 딱 그 순서로 읽습니다. 숫자 글자를 만나면 그 값을 기억해 두고, 단위 글자를 만나면 앞의 숫자에 단위를 곱해 누적합니다. 만(10000)은 자리 단위가 크니 그때까지 쌓인 값을 한 덩어리로 묶고 다시 시작합니다.

let result = 0, section = 0, num = 0;
for (const ch of str) {
  if (DIGITS[ch] !== undefined) {
    num = DIGITS[ch];              // 이, 팔 ... 숫자 글자
  } else if (UNITS[ch] !== undefined) {
    const unit = UNITS[ch];        // 십, 백, 천, 만
    if (unit === 10000) {
      section = (section + (num || 1)) * unit;
      result += section; section = 0; num = 0;
    } else {
      section += (num || 1) * unit; num = 0;
    }
  } else {
    return null;                   // 숫자가 아닌 글자 섞이면 변환 포기
  }
}
result += section + num;

num || 1이 포인트입니다. "백"만 단독으로 오면 앞에 숫자가 없으니 1×100=100으로 봐야 합니다. 숫자가 아닌 글자가 하나라도 섞이면 그냥 null을 돌려주고 변환을 포기합니다. 억지로 끼워 맞추지 않는 게 오히려 안전합니다. 뒤 단계에서 "이건 숫자가 아니구나" 하고 지명이나 도로명으로 넘길 수 있으니까요.

2. 단위가 없으면 한 자리씩 이어 붙인다: 오팔구 → 589

문제는 사람들이 번지를 늘 정석으로 부르지 않는다는 겁니다. "589번지"를 "오백팔십구"라고 하는 사람도 있지만, 그냥 "오, 팔, 구"라고 한 자리씩 부르는 사람이 더 많습니다. 이때 STT는 "오팔구"라고 붙여서 뱉습니다. 여기엔 단위(십·백·천)가 하나도 없습니다.

그래서 변환기는 먼저 단위가 있는지부터 봅니다. 단위가 없고 숫자 글자만 두 개 이상이면, 계산하지 않고 그대로 이어 붙입니다. 오(5)·팔(8)·구(9)를 문자열로 붙이면 "589"입니다.

const hasUnit = chars.some((ch) => UNITS[ch] !== undefined);
if (!hasUnit && chars.length >= 2) {
  let digits = "";
  for (const ch of chars) {
    if (DIGITS[ch] === undefined) return null;
    digits += DIGITS[ch];        // 오·팔·구 -> "5"+"8"+"9"
  }
  return parseInt(digits, 10);   // 589
}

같은 소리라도 부르는 방식에 따라 두 갈래로 갈립니다. "이백팔십"은 단위가 있어 계산해서 280, "오팔구"는 단위가 없어 이어 붙여 589. 사람이 자연스럽게 구분하는 걸 코드도 단위의 유무 하나로 가릅니다. 둘 다 답이 하나로 정해져 있어 모델의 판단이 낄 자리가 없습니다.

3. 도로명 앞 숫자는 아라비아로 되돌려 붙인다

숫자는 번지에만 있는 게 아닙니다. 도로명 자체에 들어가기도 합니다. "고산로 539번길"의 539가 그렇습니다. STT는 이걸 "오백삼십구번길"이라고 인식해서, 앞의 "고산로"와 떨어뜨려 뱉는 경우가 있습니다.

토큰화 단계에는 도로 조각을 만나면 앞의 한글 숫자만 아라비아로 바꾸는 처리가 있습니다. "오백삼십구번길"에서 숫자 부분 "오백삼십구"를 539로 바꾸고 나머지 "번길"은 그대로 두어 "539번길"을 만듭니다.

function normalizeRoadNumber(tok) {
  const m = tok.match(/^([일이삼사오육륙칠팔구십백천만]+)(.+)$/);
  if (m) {
    const n = sinoKoreanToNumber(m[1]);        // 오백삼십구 -> 539
    if (n !== null) return String(n) + m[2];   // "539" + "번길"
  }
  return tok;
}

이렇게 정규화한 조각을 앞 도로명에 이어 붙이면 "고산로"+"539번길" = "고산로539번길"이 됩니다. 이건 실제로 존재하는 도로명입니다. 떨어져 있던 두 조각이 붙어야 비로소 정답지에서 조회되는 실존 주소가 되는 거죠. 여기서도 첫 도로명 토큰 자체에는 숫자 변환을 하지 않습니다. "구부랑길" 같은 이름의 "구"를 9로 바꾸면 안 되니까요. 변환은 이어지는 번길 조각에만 적용합니다.

4. 본번-부번은 대시로 이어 지번을 만든다

지번 주소에는 "445-2" 같은 형태가 있습니다. 앞이 본번, 뒤가 부번입니다. 사람은 이걸 "사사오에 이"처럼 부르거나 중간에 "다시"("대시")를 넣어 "사사오 다시 이"라고 부릅니다.

변환기는 숫자를 쌓아 두다가, 대시 구분자를 만나면 다음 숫자를 앞 번지에 -로 이어 붙입니다. 445를 담아 두고, 대시 뒤의 2를 만나면 "445-2"로 합쳐 하나의 지번으로 완성합니다.

const pushNumber = (n) => {
  if (dashPending && detailNumbers.length) {
    detailNumbers[detailNumbers.length - 1] += "-" + n;  // 445 -> 445-2
  } else {
    detailNumbers.push(n);
  }
  dashPending = false;
};

번지의 한 자리씩 부르기, 도로명 속 숫자, 본번-부번까지 — 숫자가 나타나는 자리마다 형태는 다르지만, 되돌리는 규칙은 전부 명확하게 정해져 있습니다. 정답이 하나로 떨어지는 문제는 규칙으로 처리하는 게 가장 빠르고 안전합니다. LLM은 이런 곳에 아껴 뒀다가, 규칙으로도 정답지 조회로도 안 풀리는 마지막 순간에만 씁니다. 다음 편은 바로 그 마지막 폴백, 규칙이 손드는 건물명과 애매한 검색어를 LLM에게 넘기는 이야기입니다.

댓글

이 블로그의 인기 게시물

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

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

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