한글로 읽은 숫자를 아라비아 숫자로 변환하기 — '이백팔십'을 280으로 (STT 후처리)
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
이 변환은 LLM에게 맡기지 않기로 처음부터 정했습니다. "이백팔십"이 280이라는 건 규칙이 정확히 알려진 문제입니다. 답이 하나로 딱 떨어지고, 같은 입력에 항상 같은 결과가 나와야 하며, 굳이 모델을 부를 이유가 없습니다. LLM을 쓰면 느리고, 비용이 들고, 아주 가끔이라도 엉뚱한 숫자를 내놓을 여지가 생깁니다. 반대로 코드로 짠 변환은 빠르고 공짜이고 언제나 같습니다. 그래서 이 편은 규칙으로 푼 결정론적 변환기 이야기입니다.
시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.
- 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
- 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버)
- 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인
- 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 — 지금 보는 글
- 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli)
- 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에게 넘기는 이야기입니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기