한글 자모 분해와 레벤슈타인으로 유사 단어 매칭하기 — 오타·발음 오류 잡는 법
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
음성으로 받은 주소에서 "역산동"이라는 문자열이 나왔다고 해 보겠습니다. 실제로는 "역삼동"인데 STT가 받침을 살짝 바꿔 들은 것입니다. 이제 실존 동네 목록에서 "역산동"과 가장 가까운 걸 찾아야 하는데, 여기서 흔한 방법인 문자열 비교가 이상하게 무력해집니다.
컴퓨터 입장에서 "역삼동"과 "역산동"은 가운데 한 글자가 통째로 다른 단어입니다. 삼과 산은 서로 다른 문자 코드를 가진, 아무 관계 없는 두 글자니까요. 사람 눈에는 받침 하나 차이지만, 글자 단위로 비교하면 "역삼동"과 "역삼구"만큼이나 멀게 나옵니다. 발음이 가까운 정도를 글자 비교로는 잴 수 없다는 뜻입니다.
시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.
- 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
- 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버)
- 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 — 지금 보는 글
- 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기
- 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli)
- 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으로 되돌리는 한글 숫자 변환입니다. 규칙만으로 어디까지 결정론적으로 풀 수 있는지를 다룹니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기