한영 오타 교정기를 만들어보자.
개요
최근에 글쓰기라는 취미가 생겨서 ‘글쓰기 수업’이란 것을 받고 있습니다.
그러다보니 에디터에 텍스트를 입력하는 일이 잦아졌는데요, 저는 InputSource Pro라는 유틸을 쓰고 있어서 다른 탭으로 전환했다가 에디터로 돌아오면 영어로 입력되는 일이 많았습니다.
나름 거슬리는 포인트라서 ‘영어가 입력되었을 때, 알아서 감지해서 한글로 변환하면 어떨까?’ 해서 유틸리티 프로그램을 만들어보기로 했습니다.
저희에겐 LLM이 있으니까요.
Koing
이름은 Koing이라고 지었고, 한글로 적고 있었다(Korean + doing)는 뉘앙스를 담고 싶었습니다.
언어는 Rust를 선택했으며 이유는 크게 두 가지인데:
먼저 새로운 언어에 도전해보고 싶었습니다. 흔히 말하는 ‘찍먹’의 성격이 강했고..
LLM과 문답을 해가면서 얻은 결과로 키 입력 단위의 실시간 처리에서 ‘성능’이라는 이슈를 고려하게 되었습니다.
자주 사용되지 않는 한글을 어떻게 구분할까?
가장 먼저 든 생각은 ‘자주 사용되지 않는 한글을 어떻게 판단할 수 있을까?’ 였습니다.
“뢓”, “긝” 같은 문자는 유니코드 상(0xAC00~0xD7A3) 유효한 한글이지만, 실제 한국어에서 이런 글자를 보는 것은 인코딩이 깨질 때 말고는 없지 않을런지.
낱자모 검사
가장 먼저 완성된 한글 음절이 아니라, 자음이나 모음이 홀로 남아있는 경우를 걸러냅니다.
두벌식 자판에서 한글을 입력하면 자음 → 모음 → 자음 → 모음 순서가 자연스럽게 반복되면서 완성형 음절이 만들어집니다.
그런데 영어 단어를 두벌식 매핑으로 변환하면, 자음끼리 연속하거나 모음끼리 연속하는 경우가 많아서 음절이 완성되지 못하고 낱자모가 남게 됩니다.
"hello" → h(ㅗ) e(ㄷ) l(ㅣ) l(ㅣ) o(ㅐ)
모 자 모 모 모
→ 결과: ㅗ디ㅣㅐ
^ ^ ^
ㅗ, ㅣ, ㅐ가 홀로 남은 낱자모 (U+3131~U+318E 범위)
반면 한글 입력은 자음-모음이 교대로 오기 때문에 거의 전부 완성형 음절이 됩니다:
"dkssud" → d(ㅇ) k(ㅏ) s(ㄴ) s(ㄴ) u(ㅕ) d(ㅇ)
자 모 자 자 모 자
→ 결과: 안녕 (U+AC00~U+D7A3 범위, 완성형 음절)
이 차이를 이용하면 간단한 검사 하나로 대부분의 영어 단어를 걸러낼 수 있습니다:
pub fn has_incomplete_jamo(text: &str) -> bool {
text.chars().any(|c| ('\u{3131}'..='\u{318E}').contains(&c))
}
표로 예시를 들어보면 다음과 같습니다.
| 입력 | 변환 결과 | 낱자모 | 판정 |
|---|---|---|---|
| hello | ㅗ디ㅣㅐ | ㅗ, ㅣ, ㅐ | 탈락 |
| name | ㅜ믇 | ㅜ | 탈락 |
| test | ㅅㄷㅅㅅ | 전부 낱자모 | 탈락 |
| dkssud | 안녕 | 없음 | 통과 |
텍스트에 낱자모가 하나라도 포함되어 있으면, 그건 영어였을 가능성이 높으므로 변환하지 않습니다.
음절 구조 검사
‘낱자모 검사’를 통과하고나면, 완성형 음절로만 이루어진 결과를 다뤄야합니다.
예를 들어 "virus" → "퍄견"에서 “퍄”나 “견” 모두 유니코드상 유효한 한글 음절이지만, 실제 한국어에서 “퍄”라는 글자가 쓰이는 빈도는 극히 낮습니다.
여기서 초성+중성 조합의 희귀도를 따져봅니다.
한국어에서 실제로 사용되는 초성+중성 조합은 전체 가능한 조합(19 x 21 = 399개) 중 일부에 집중되어 있는데요:
- “가”, “나”, “다”, “한”, “안” → 매우 흔함
- “왜”, “걔”, “얘” → 사용되지만 제한적
- “퍄”, “먀”, “뱌”, “챠” → 거의 안 쓰이지 않나..
이 차이를 5가지 규칙으로 정의하면:
- Rule 1: ㅒ 모음 — 걔/얘/쟤만 허용, 나머지는 희귀
- Rule 2: ㅙ 모음 — 왜만 허용
- Rule 3: ㅞ 모음 — 웨만 허용
- Rule 4: 쌍자음(ㄲ/ㄸ/ㅃ/ㅉ) + y계열 모음(ㅑ/ㅖ/ㅛ/ㅠ/ㅢ) → 항상 희귀
- Rule 5: ㅁ/ㅂ/ㅊ/ㅋ/ㅌ/ㅍ + ㅑ → 먀/뱌/챠/캬/탸/퍄 → 희귀
판정 기준은 연속 희귀 음절 2개 이상 또는 전체 음절 중 희귀 비율 50% 이상이면 비자연스러운 한글로 판정하도록 했습니다.
"퍄견" → 퍄(ㅍ+ㅑ, Rule 5 → 희귀), 견(ㄱ+ㅕ → 정상)
희귀 1/2 = 50% → 탈락
"먀나다" → 먀(희귀), 나(정상), 다(정상)
희귀 1/3 = 33% → 통과 (관대하게 허용)
N-gram 스코어링
음절 구조 검사까지 통과하는 교묘(?)한 케이스가 있을 수 있습니다.
음절 하나하나는 자연스러운데, 그 조합이 부자연스러운 경우입니다. 이걸 잡기 위해 통계적 접근을 도입했습니다.
LLM이 제시한 방법은 N-gram인데요, 이는 텍스트에서 연속된 N개의 항목을 묶은 것입니다.
1개면 유니그램(unigram), 2개면 바이그램(bigram)이라 부릅니다.
"안녕하세요"의 바이그램:
→ (안,녕), (녕,하), (하,세), (세,요)
이렇게 추출한 바이그램이 실제 한국어 텍스트에서 얼마나 자주 등장하는지를 미리 세어놓으면, 새로운 텍스트가 한국어다운지(?)를 수치로 판단할 수 있습니다.
코퍼스(corpus)
N-gram 모델에서 “이 음절 조합이 자연스러운지”를 판단하려면, 먼저 실제 한국어 텍스트에서 통계를 수집해야 하는데, 이 학습용 텍스트 데이터를 코퍼스라고 합니다.
Koing에서는 Python 스크립트로 코퍼스를 학습하도록 했는데요:
- 코퍼스에서 완성형 한글만 추출 (숫자, 영문, 특수문자 제외)
- 추출된 텍스트를 순회하며 유니그램(글자 1개)과 바이그램(연속 2글자)의 등장 횟수를 셈
- 등장 횟수가
min-freq미만인 항목은 제외 (노이즈 제거) - 결과를 JSON으로 저장
def count_ngrams(text, min_freq=1):
hangul_only = extract_hangul(text)
unigrams = Counter(hangul_only) # 각 글자의 등장 횟수
bigrams = Counter()
for i in range(len(hangul_only) - 1):
bigrams[hangul_only[i:i+2]] += 1 # 연속 2글자의 등장 횟수
return unigrams, bigrams
자세한건 코드에서 확인할 수 있습니다.
코퍼스가 크고 다양할수록 판별력이 좋아질텐데, 그정도까지 필요한가 싶기도하고..
그 이후에는 일종의 점수를 부여하는 작업이 필요합니다.
쉽게 말해 “안” 다음에 “녕”이 올 확률은 얼마인가? 에 대해 점수를 매깁니다.
이를 조건부 확률로 표현하는데:
C(안녕) + k
P(녕|안) = ─────────────────
C(안) + k × V
- C(안녕): 코퍼스에서 “안녕”이 연속으로 등장한 횟수 (바이그램 빈도)
- C(안): 코퍼스에서 “안”이 등장한 횟수 (유니그램 빈도)
- k = 0.001: 스무딩 상수. 코퍼스에 한 번도 안 나온 조합이라도 확률을 0으로 만들지 않기 위한 장치입니다
- V = 11,172: 가능한 어휘 크기. 한글 완성형 음절 전체 수 (19초성 x 21중성 x 28종성)
이렇게 구한 확률에 로그를 취하고, 모든 바이그램에 대해 평균을 냅니다.
score("안녕하세요") = [ln P(녕|안) + ln P(하|녕) + ln P(세|하) + ln P(요|세)] / 4
예시
코퍼스에 다음 빈도가 있다고 가정해봅시다:
유니그램: 안=100, 녕=80, 퍄=0, 견=20
바이그램: (안,녕)=50, (퍄,견)=0
“안녕”의 스코어:
P(녕|안) = (50 + 0.001) / (100 + 0.001 × 11172)
= 50.001 / 111.172
≈ 0.4498
score = ln(0.4498) ≈ -0.80
“퍄견”의 스코어:
P(견|퍄) = (0 + 0.001) / (0 + 0.001 × 11172)
= 0.001 / 11.172
≈ 0.0000895
score = ln(0.0000895) ≈ -9.32
임계값이 -10.0이라면:
- “안녕”: -0.80 ≥ -10.0 → 자연스러운 한국어 → 변환 허용을 허용하고
- “퍄견”: -9.32 ≥ -10.0 → 이것도 통과하긴 하지만, 2단계 음절 구조 검사에서 이미 걸러짐
각 단계가 서로 보완하는 구조입니다.
pub fn score_with_config(&self, text: &str, config: &NgramConfig) -> f64 {
let chars: Vec<char> = text.chars().collect();
let mut log_prob_sum = 0.0;
let mut count = 0;
for window in chars.windows(2) {
let bigram_count = self.bigram_count(window[0], window[1]) as f64;
let context_count = self.unigram_count(window[0]) as f64;
let prob = (bigram_count + k) / (context_count + k * v);
log_prob_sum += prob.ln();
count += 1;
}
log_prob_sum / count as f64 // 바이그램 로그 확률의 평균
}
사실 LLM과 문답하면서 N-gram 검사는 과하다고 생각했지만, 정확해서 나쁠건 없다고 생각해서 추가했습니다.
영어에서 한글 변환을 어떻게 할까?
다음은 한글 변환 파트입니다.
크게 세 단계로 구분했는데:
자모 매핑
먼저 영문 키 하나를 한글 자모 하나로 대응시킵니다.
d → ㅇ (자음, 초성 인덱스 11, 종성 인덱스 21)
k → ㅏ (모음, 중성 인덱스 0)
s → ㄴ (자음, 초성 인덱스 2, 종성 인덱스 4)
여기서 같은 ㄱ이라도 초성일 때와 종성일 때 유니코드 인덱스가 다릅니다:
'r' => Jamo::Consonant {
cho_index: 0, // 초성 ㄱ → 인덱스 0
jong_index: Some(1), // 종성 ㄱ → 인덱스 1
}
초성은 19개(ㄱ~ㅎ), 종성은 28개(없음 포함, 복합 종성 ㄳ/ㄵ 등 포함)로 목록 자체가 다르기 때문입니다.
또한 ㄸ/ㅃ/ㅉ같은 자음은 종성으로 올 수 없으므로 jong_index: None으로 표현합니다.
총 33개 키가 매핑되고, 나머지(숫자, 특수문자, X 등)는 None을 반환하여 그대로 통과합니다.
FSM(유한 상태 기계) 한글 조합
한글의 특수성은 하나의 글자가 여러 키 입력으로 조합된다는 것입니다.
“한” 하나를 만들려면 ㅎ+ㅏ+ㄴ 세 번의 입력이 필요합니다. 그리고 다음에 오는 입력에 따라 ㄴ이 현재 글자의 종성이 될 수도, 다음 글자의 초성이 될 수도 있습니다.
g(ㅎ) → k(ㅏ) → s(ㄴ) → r(ㄱ) → m(ㅡ) → f(ㄹ)
│
├── 다음이 모음(ㅡ)이면? → "한" + ㄴ은 다음 초성으로 분리
└── 다음이 자음(ㄱ)이면? → ㄴ은 종성 확정, ㄱ은 새 글자 시작
이런 “미래 입력(?)에 의존하는 조합”을 **유한 상태 기계(FSM)**로 모델링했습니다. 4개의 상태가 있는데 살펴보면:
┌─────────┐ 자음 ┌──────────┐ 모음 ┌────────────────────┐
│ Empty │──────▶│ Choseong │──────▶│ Choseong+Jungseong │
└─────────┘ └──────────┘ └────────────────────┘
│
│ 자음 (종성 가능)
▼
┌──────────────────────────┐
│ Choseong+Jungseong │
│ +Jongseong │
└──────────────────────────┘
“gksrmf” (한글) 조합 과정:
g(ㅎ) → State: Choseong [ㅎ]
k(ㅏ) → State: Choseong+Jungseong [ㅎ+ㅏ] = "하" 조합 중
s(ㄴ) → State: CJ+Jongseong [ㅎ+ㅏ+ㄴ] = "한" 조합 중
r(ㄱ) → ㄴ+ㄱ 복합 종성? → 불가! → "한" 확정 출력, ㄱ을 새 초성으로
State: Choseong [ㄱ]
m(ㅡ) → State: Choseong+Jungseong [ㄱ+ㅡ] = "그" 조합 중
f(ㄹ) → State: CJ+Jongseong [ㄱ+ㅡ+ㄹ] = "글" 조합 중
(끝) → "글" 확정 출력
결과: "한글"
가장 복잡한 전이는 복합 종성 분리입니다:
"dlfr" (읽) 조합 과정:
d(ㅇ) → Choseong [ㅇ]
l(ㅣ) → Choseong+Jungseong [ㅇ+ㅣ]
f(ㄹ) → CJ+Jongseong [ㅇ+ㅣ+ㄹ] = "일" 조합 중
r(ㄱ) → ㄹ+ㄱ = ㄺ(복합 종성 가능!) → [ㅇ+ㅣ+ㄺ] = "읽" 조합 중
만약 여기서 모음 k(ㅏ)가 오면?
→ ㄺ를 분리: ㄹ은 종성에 남기고, ㄱ은 다음 초성으로
→ "읽"이 아니라 "일" 확정 + "가" 시작 = "일가"
복합 모음:
"dhksfy" (완료) 조합 과정:
d(ㅇ) → Choseong [ㅇ]
h(ㅗ) → Choseong+Jungseong [ㅇ+ㅗ]
k(ㅏ) → ㅗ+ㅏ = ㅘ(복합 모음!) → [ㅇ+ㅘ] = "와" 조합 중
s(ㄴ) → CJ+Jongseong [ㅇ+ㅘ+ㄴ] = "완" 조합 중
f(ㄹ) → ㄴ+ㄹ 복합 종성? → 불가! → "완" 확정, ㄹ 새 초성
y(ㅛ) → Choseong+Jungseong [ㄹ+ㅛ] = "료" 조합 중
(끝) → "료" 확정
결과: "완료"
유니코드 합성
FSM이 초성/중성/종성 인덱스를 확정하면, 한 줄의 공식으로 유니코드 문자를 생성합니다.
code = 0xAC00 + (초성 × 21 + 중성) × 28 + 종성
예를 들어
"한" = 0xAC00 + (18 × 21 + 0) × 28 + 4
= 0xAC00 + (378) × 28 + 4
= 0xAC00 + 10588
= 0xD55C
= '한'
0xAC00은 유니코드 한글 음절 블록의 시작점(‘가’)이고, 초성 19 x 중성 21 x 종성 28 = 11,172개의 한글 완성형 음절을 생성할 수 있습니다.
키 입력을 어떻게 감지할까?
순수 Rust로 변환 엔진을 만들었으니, 이제 macOS에서 키보드 입력을 가로채서 변환을 적용해야 합니다.
CGEventTap: 시스템 전역 키보드 감지
macOS는 CGEventTap이라는 API를 제공합니다.
시스템 전역에서 키보드/마우스 이벤트를 가로채는 저수준 API로, 어떤 앱이 포커스되어 있든 모든 키 입력을 볼 수 있습니다.
let tap = CGEventTap::new(
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
vec![CGEventType::KeyDown, CGEventType::FlagsChanged],
move |_proxy, event_type, event| {
handle_event(&state, event_type, event)
},
)
HID 레벨에서 이벤트를 잡으면, IME(한글 입력기)가 처리하기 전 단계의 raw 키 이벤트를 볼 수 있습니다.
이벤트를 그대로 통과시킬 수도 있고(Some(event)), 가로채서 삼킬 수도 있습니다(None 반환).
참고로 이 API를 사용하려면 반드시 macOS 접근성(Accessibility) 권한이 필요해서 허용을 꼭 해줘야합니다..
키 버퍼: 입력을 누적하기
이벤트 탭의 콜백에서는 키 입력을 하나씩 받습니다. d, k, s, s, u, d — 이것들을 모아야 dkssud라는 문자열이 되고, 비로소 안녕으로 변환할 수 있습니다.
이를 위해 **키 버퍼(KeyBuffer)**를 사용합니다:
[사용자 키 입력]
│
▼
이벤트 탭 콜백 (handle_event)
│
├── 영문 입력 모드? → 버퍼에 문자 추가
│ "d" → "dk" → "dks" → "dkss" → "dkssu" → "dkssud"
│
├── Space / Enter / Tab / 방향키? → 버퍼 초기화
│
└── 한글 입력 모드? → 버퍼 초기화 (변환 대상 아님)
버퍼에 쌓인 문자열을 언제 변환할지는 두 가지 경로를 만들어놨는데요:
수동 변환 — 사용자가 Option + Space를 누르면 즉시 변환합니다. 버퍼에 있는 내용을 그대로 변환 엔진에 넘기고, 3단계 검사 없이 변환합니다.
자동 변환 — 사용자가 타이핑하는 동안 백그라운드에서 자동 감지합니다. 키 입력이 멈추면(debounce 300ms) 버퍼 내용을 3단계 검사에 넘겨서, 한글로 판별되면 자동으로 변환합니다.
처음 만들때는 수동 변환을 넣었는데, 이렇게 수동변환 할거면 이 유틸리티를 왜 써야하나 싶어서, 추후 업데이트에서 없어질 기능이 될 것 같습니다.
변환 타이밍 조절
사용자가 d, k, s를 연속으로 누르는 중에 매번 변환을 시도하면 비효율적입니다.
키 입력이 잠시 멈춘 시점, 즉 한 단어를 다 쳤을 가능성이 높은 시점에 변환을 시도해야 합니다.
이를 위해 Condvar 기반 debounce 타이머를 사용합니다:
키 입력 → 타이머 리셋 (300ms)
키 입력 → 타이머 리셋 (300ms)
키 입력 → 타이머 리셋 (300ms)
... 300ms 경과 ...
→ 1단계: 높은 confidence 변환 시도
→ 실패 시 → 2단계: 1500ms 더 대기 후 구조적 변환 시도
N-gram 점수가 높은 확실한 한글(예: “안녕하세요”)은 빠르게 변환하고, 점수가 낮지만 구조적으로는 유효한 한글(예: 고유명사)은 좀 더 기다렸다가 변환하기 위해서입니다.
텍스트 교체
한글로 변환해야 한다고 판단되면, 실제로 화면에 보이는 영문 텍스트를 지우고 한글로 바꿔야 합니다.
macOS에는 “임의 앱의 텍스트를 직접 수정하는” API가 없으므로, 사용자가 직접 조작하는 것처럼 키 이벤트를 시뮬레이션합니다:
"dkssud" → "안녕" 변환 시:
1. 클립보드 현재 내용 백업 (사용자의 복사 내용 보존)
2. Backspace × 6회 (영문 "dkssud" 삭제)
3. "안녕"을 클립보드에 복사
4. Cmd+V 시뮬레이션 (붙여넣기)
5. 1.5초 후 클립보드 원래 내용 복원
pub fn replace_text(backspace_count: usize, new_text: &str) -> Result<(), String> {
// 클립보드 백업
let backup = ClipboardBackup::save();
// Backspace로 기존 텍스트 삭제
for _ in 0..backspace_count {
simulate_backspace()?;
}
// 한글을 클립보드에 복사 후 붙여넣기
set_clipboard_string(new_text);
simulate_paste()?;
// 지연 복원 (대상 앱이 paste를 처리할 시간 확보)
schedule_deferred_restore(backup.content);
Ok(())
}
여기서 몇 가지 주의할 점이 있습니다:
시뮬레이션된 키 이벤트가 다시 감지되는 문제는 Backspace와 Cmd+V도 CGEventTap에 잡힙니다.
자기가 생성한 이벤트를 다시 처리하면 무한 루프에 빠지므로, 합성 이벤트에 고유 마커를 달아서 필터링합니다:
// 합성 이벤트 생성 시 마커 설정
event.set_integer_value_field(
EventField::EVENT_SOURCE_USER_DATA,
0x4B4F494E47 // "KOING" in ASCII
);
// 이벤트 수신 시 마커 확인 → 통과
if user_data == KOING_SYNTHETIC_EVENT_MARKER {
return Some(event.clone()); // 처리하지 않고 그대로 통과
}
또한 콜백이 블로킹되면 안 되는 문제 가 있는데요, CGEventTap 콜백이 약 500ms 이상 블로킹되면 macOS가 이벤트 탭을 자동으로 비활성화합니다.
Backspace 6회 + 클립보드 조작 + 붙여넣기를 하면 수백 밀리초가 걸려서, 이걸 콜백 안에서 직접 하면 안 됩니다.
해결책으로, 콜백에서는 mpsc 채널로 작업만 전송하고 즉시 반환하고, 별도의 워커 스레드가 채널에서 작업을 꺼내 실제 텍스트 교체를 수행합니다:
이벤트 탭 콜백 (메인 RunLoop 스레드) 워커 스레드
───────────────────────────────── ──────────────
│ │
│ WorkItem::Convert("dkssud") │
│ ──────────────────────────────────▶ │
│ (즉시 반환) │
│ ├── validator.analyze("dkssud")
│ ├── "안녕" 변환 결정
│ ├── replace_text(6, "안녕")
│ └── switch_to_korean()
전체 흐름 요약
지금까지 설명한 모든 과정을 하나로 연결하면 다음과 같습니다.
사용자: d → k → s → s → u → d 입력
│
▼
[CGEventTap 콜백]
├── 영문 입력 소스 확인 (TIS API)
├── 키코드 → 문자 변환 (keycode 2 → 'd')
├── 버퍼에 추가: "dkssud"
└── debounce 타이머 리셋 (300ms)
│
▼ (300ms 경과, 추가 입력 없음)
[Debounce 타이머 스레드]
├── 3단계 검사:
│ ├── 1단계: "안녕" → 낱자모 없음 ✓
│ ├── 2단계: 음절 구조 정상 ✓
│ └── 3단계: N-gram 점수 -0.80 ≥ -10.0 ✓
└── 변환 결정 → 워커 스레드에 전송
│
▼
[워커 스레드]
├── Backspace × 6 (영문 삭제)
├── 클립보드에 "안녕" 복사
├── Cmd+V 시뮬레이션
└── 입력 소스를 한글로 전환
│
▼
화면: "dkssud" → "안녕"
맺음
수동 변환은 쓰다보니 필요가 없는거 같고, 아직 잔 버그가 많아서 fix할 것이 계속 보입니다. (심지어 간혹 변환이 안될때가 있어서..)
무엇보다 사용자별로 텍스트 입력 속도가 달라서 이를 어떻게 개선해야할지에 대한 고민이 가장 큽니다.
제가 쓰다가 쓸만하다 싶으면 그 때 공식적으로 홍보해봐야겠습니다.
Koing은 GitHub에서 소스 코드를 확인할 수 있고, 랜딩 페이지도 만들어놨으니 관심이 있으신 분들은 한 번 써보시길.
