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

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

한글 자모 분해와 레벤슈타인으로 유사 단어 매칭하기 — 오타·발음 오류 잡는 법

음성으로 받은 주소에서 "역산동"이라는 문자열이 나왔다고 해 보겠습니다. 실제로는 "역삼동"인데 STT가 받침을 살짝 바꿔 들은 것입니다. 이제 실존 동네 목록에서 "역산동"과 가장 가까운 걸 찾아야 하는데, 여기서 흔한 방법인 문자열 비교가 이상하게 무력해집니다.

컴퓨터 입장에서 "역삼동"과 "역산동"은 가운데 한 글자가 통째로 다른 단어입니다. 삼과 산은 서로 다른 문자 코드를 가진, 아무 관계 없는 두 글자니까요. 사람 눈에는 받침 하나 차이지만, 글자 단위로 비교하면 "역삼동"과 "역삼구"만큼이나 멀게 나옵니다. 발음이 가까운 정도를 글자 비교로는 잴 수 없다는 뜻입니다.

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

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

1. 왜 음절이 아니라 자모 단위인가

문제의 뿌리는 한글 한 글자가 여러 소리를 뭉쳐 하나의 코드로 저장된다는 데 있습니다. 삼은 ㅅ+ㅏ+ㅁ이 합쳐진 한 음절이고, 산은 ㅅ+ㅏ+ㄴ입니다. 둘은 초성 ㅅ과 중성 ㅏ가 같고 종성만 ㅁ↔ㄴ으로 다릅니다. 세 소리 중 두 개가 같은데도, 음절 단위로 비교하면 이 겹침이 전혀 보이지 않습니다. 완성된 글자끼리는 "같다/다르다" 두 값만 나오기 때문입니다.

그래서 비교 단위를 음절에서 자모(초성·중성·종성) 로 내려야 합니다. 삼을 [ㅅ, ㅏ, ㅁ]으로, 산을 [ㅅ, ㅏ, ㄴ]으로 펼쳐 놓으면 비로소 "세 자리 중 두 자리가 같다"가 드러납니다. 받침 하나, 모음 하나 차이를 잡아내려면 소리를 이 최소 단위까지 분해해야 합니다.

한글은 유니코드에서 이 분해가 규칙적으로 정의돼 있어서 계산으로 풀립니다. 완성형 음절 코드에서 기준값(0xAC00)을 빼면, 초성·중성·종성 인덱스가 정해진 자리수(중성 21개, 종성 28개)로 딱 떨어집니다.

const CHOSUNG  = [..."ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"];
const JUNGSUNG = [..."ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"];
const JONGSUNG_LIST = ["", ..."ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"];

export function decomposeSyllable(ch) {
  const code = ch.charCodeAt(0);
  if (code < 0xac00 || code > 0xd7a3) return [ch, "", ""];
  const offset = code - 0xac00;
  const cho  = Math.floor(offset / (21 * 28));
  const jung = Math.floor((offset % (21 * 28)) / 28);
  const jong = offset % 28;
  return [CHOSUNG[cho], JUNGSUNG[jung], JONGSUNG_LIST[jong]];
}

한글이 아닌 글자(공백·영문·숫자)는 그대로 돌려보냅니다. 이 함수 하나가 이후 모든 비교의 출발점입니다.

2. 문자열을 자모 시퀀스로 펼치기

음절을 하나씩 분해할 수 있으면, 문자열 전체를 자모가 죽 늘어선 시퀀스로 바꿀 수 있습니다. 종성이 없는 글자(예: 가)는 종성 자리를 비우고, 있는 글자만 종성을 붙입니다.

export function toJamoSeq(text) {
  let out = "";
  for (const ch of text) {
    const [cho, jung, jong] = decomposeSyllable(ch);
    out += cho;
    if (jung) out += jung;
    if (jong) out += jong;
  }
  return out;
}

이렇게 하면 "역삼동"은 ㅇㅕㄱㅅㅏㅁㄷㅗㅇ, "역산동"은 ㅇㅕㄱㅅㅏㄴㄷㅗㅇ이 됩니다. 아홉 자리 중 여덟 자리가 같고 한 자리(ㅁ↔ㄴ)만 다른, 눈으로도 가까움이 보이는 문자열이 됐습니다. 이제 남은 건 "몇 자리가 다른가"를 숫자로 재는 일입니다.

3. 레벤슈타인 편집거리로 다름을 세다

두 문자열이 얼마나 다른지를 재는 고전적인 방법이 레벤슈타인 편집거리입니다. 한 문자열을 다른 문자열로 바꾸는 데 필요한 최소 편집(문자 삽입·삭제·교체) 횟수를 셉니다. 앞서 만든 자모 시퀀스에 이걸 적용하면, ㅁ을 ㄴ으로 한 번 교체하면 되니 거리는 1이 됩니다.

export function levenshtein(a, b) {
  if (a === b) return 0;
  const la = a.length, lb = b.length;
  if (la === 0) return lb;
  if (lb === 0) return la;
  let prev = Array.from({ length: lb + 1 }, (_, i) => i);
  for (let i = 1; i <= la; i++) {
    const cur = [i];
    for (let j = 1; j <= lb; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
    }
    prev = cur;
  }
  return prev[lb];
}

거리는 "몇 번 고쳐야 하나"라 값이 클수록 다릅니다. 사람이 보기 편하게 이걸 0~100 유사도 점수로 뒤집습니다. 편집거리를 두 시퀀스 중 긴 쪽 길이로 나눠 비율을 구하고, 1에서 빼서 백분율로 만듭니다.

export function jamoSimilarity(a, b) {
  const ja = toJamoSeq(a);
  const jb = toJamoSeq(b);
  const dist = levenshtein(ja, jb);
  const maxLen = Math.max(ja.length, jb.length, 1);
  return Math.round((1 - dist / maxLen) * 1000) / 10;
}

"역삼동"과 "역산동"은 자모 아홉 자리에서 거리 1이니 약 88.9점, 무관한 "역삼동"과 "삼성동"은 훨씬 낮게 나옵니다. 음절 비교였다면 앞엣것도 뒤엣것도 뭉뚱그려 "다르다"였을 텐데, 자모로 내려오니 가까움의 정도가 숫자로 갈라집니다.

4. 후보를 유사도 순으로 정렬하기

국토부 API가 실존 후보를 여럿 돌려주면, 각 후보에 대해 이 점수를 매겨 높은 순으로 세우기만 하면 됩니다. 그게 사실상 "가장 그럴듯한 주소" 순위입니다.

export function rankCandidates(query, candidates) {
  return candidates
    .map((c) => [c, jamoSimilarity(query, c)])
    .sort((x, y) => y[1] - x[1]);
}

STT가 뱉은 "역산동"으로 후보 목록을 정렬하면, "역삼동"이 가장 높은 점수로 맨 앞에 옵니다. 사람이 "아, 역삼동을 말한 거구나" 하고 알아채는 판단을, 점수 하나로 근사한 셈입니다.

한 가지 더 있습니다. 국토부 검색 API는 정확 일치에 가까워서, 아예 표기가 어긋난 검색어로는 후보 자체가 안 나오기도 합니다. 현대 국어에서 사실상 같은 소리인 모음(ㅐ↔ㅔ, ㅒ↔ㅖ, ㅚ↔ㅙ↔ㅞ)이 특히 그렇습니다. "앨리엇"으로는 "엘리엇"이 안 잡히는 식입니다. 그래서 검색 전에 이 동음 모음을 바꾼 표기 변형을 미리 만들어 함께 조회합니다. 조합이 폭발하지 않도록 상한(cap, 기본 4개)을 둬 잘라 냅니다.

// ㅐ↔ㅔ 같은 동음 모음을 바꾼 표기 변형을 생성(원문 포함), 최대 cap개
homophoneVariants("앨리엇");  // → ["앨리엇", "엘리엇", ...]

이렇게 변형을 만들어 후보 풀을 넓혀 놓고, 돌아온 실존 후보들을 자모 유사도로 다시 줄 세우면, 표기가 어긋난 입력에서도 맞는 주소가 위로 올라옵니다.

자주 묻는 질문

Q. 자모로 비교하면 언제나 음절 비교보다 나은가요? "가까운 정도"를 재야 할 때 유리합니다. 받침·모음 한 끗 차이를 점수 차로 드러내 주니까요. 반대로 완전히 같은지 아닌지만 보면 되는 경우라면 음절 비교로 충분하고 더 빠릅니다. 여기서 자모를 쓰는 건 STT 오류가 대개 소리의 미세한 어긋남이라, 그 어긋남의 크기를 재는 게 목적이기 때문입니다.

Q. 이 유사도 계산에 외부 라이브러리나 API가 필요한가요? 아니요. 한글 분해는 유니코드 조합 규칙이라 코드로 결정론적으로 풀리고, 레벤슈타인도 짧은 함수 하나입니다. 네트워크 호출이 없으니 빠르고, 같은 입력이면 항상 같은 점수가 나오고, 비용도 들지 않습니다. 실존 여부 확인만 국토부 API에 맡기고, "누가 더 가까운가"의 판단은 이 로컬 계산이 담당합니다.

이번 편에서 얻은 건 결국 "발음이 가까운 정도를 숫자로 잰다"는 한 가지입니다. 한글을 소리의 최소 단위까지 분해하고 나니, 문자열 비교로는 안 보이던 받침 하나 차이가 점수 차로 또렷하게 드러났습니다. 처음엔 별생각 없이 문자열을 그대로 비교하다가 "역삼동"을 놓치고서야, 비교하기 전에 먼저 소리를 펼쳐 놓아야 한다는 걸 깨달았던 편이기도 합니다. 다음 편은 이 시리즈에서 미뤄 뒀던 숙제, "이백팔십"을 280으로 되돌리는 한글 숫자 변환입니다. 규칙만으로 어디까지 결정론적으로 풀 수 있는지를 다룹니다.

댓글

이 블로그의 인기 게시물

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

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

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