한글 입력기 구현
상태 머신, 키 입력 처리, 완전한 JavaScript 구현 코드
이 문서는 한글 입력기를 직접 구현하기 위한 완전한 가이드다. 상태 머신 설계, 키 입력 처리 흐름, 그리고 실제 동작하는 JavaScript 코드를 포함한다.
1. 구현 개요
Section titled “1. 구현 개요”한글 입력기는 다음 세 가지를 수행한다:
- 키 → 자모 변환: 물리 키를 초성(L)/중성(V)/종성(T) 인덱스로 변환
- 상태 전이: 현재 조합 상태에 따라 자모를 해석하고 상태 변경
- 완성형 계산:
S = 0xAC00 + (L×588) + (V×28) + T로 한 글자 생성
2. 전체 흐름
Section titled “2. 전체 흐름”flowchart TD
K[키 입력] --> KTJ{키 → 자모 변환}
KTJ --> |자음| C[자음 처리]
KTJ --> |모음| V[모음 처리]
KTJ --> |기타| O[commit/취소]
C --> SM[상태 머신]
V --> SM
SM --> |상태 변경| CALC[완성형 계산]
SM --> |commit 필요| COMMIT[commit 실행]
CALC --> PREEDIT[preedit 갱신]
COMMIT --> PREEDIT
PREEDIT --> DISPLAY[화면 표시]
3. 상태 머신 상세
Section titled “3. 상태 머신 상세”3.1 상태 정의
Section titled “3.1 상태 정의”| 상태 | 설명 | L | V | T | preedit 예 |
|---|---|---|---|---|---|
| EMPTY | 초기 상태 | -1 | -1 | 0 | "" |
| L_ONLY | 초성만 | 0~18 | -1 | 0 | ”ㄱ” |
| LV | 초성+중성 | 0~18 | 0~20 | 0 | ”가” |
| LVT | 초성+중성+종성 | 0~18 | 0~20 | 1~27 | ”각” |
3.2 상태 전이 다이어그램
Section titled “3.2 상태 전이 다이어그램”stateDiagram-v2
[*] --> EMPTY
EMPTY --> L_ONLY: 자음 입력
EMPTY --> LV: 모음 입력
L_ONLY --> L_ONLY: 쌍자음 또는 교체
L_ONLY --> LV: 모음 입력
LV --> LV: 겹모음 조합
LV --> LVT: 종성 추가
LV --> COMMIT_LV: commit 후 새 음절
LVT --> LVT: 겹받침 조합
LVT --> COMMIT_LVT: commit 후 새 초성
LVT --> COMMIT_SPLIT: 종성 분리
COMMIT_LV --> L_ONLY
COMMIT_LV --> LV
COMMIT_LVT --> L_ONLY
COMMIT_SPLIT --> LV
상태별 preedit:
- EMPTY: ""
- L_ONLY: “ㄱ” (초성만)
- LV: “가” (초성+중성)
- LVT: “각” (초성+중성+종성)
- COMMIT_SPLIT: “각” + ㅏ → commit “가” + preedit “가”
3.3 자음 입력 시 결정 흐름
Section titled “3.3 자음 입력 시 결정 흐름”flowchart TD
INPUT[자음 입력] --> CHECK_STATE{현재 상태?}
CHECK_STATE --> |EMPTY| SET_L[L = 입력 자음]
SET_L --> TO_L_ONLY[상태 → L_ONLY]
CHECK_STATE --> |L_ONLY| CHECK_DOUBLE{쌍자음 가능?}
CHECK_DOUBLE --> |예| MAKE_DOUBLE[L = 쌍자음]
CHECK_DOUBLE --> |아니오| REPLACE_L[L = 입력 자음]
MAKE_DOUBLE --> STAY_L[상태 유지]
REPLACE_L --> STAY_L
CHECK_STATE --> |LV| CHECK_JONG{종성 가능?}
CHECK_JONG --> |예| SET_T[T = 종성 인덱스]
SET_T --> TO_LVT[상태 → LVT]
CHECK_JONG --> |아니오| COMMIT1[현재 음절 commit]
COMMIT1 --> NEW_L1[L = 입력 자음]
NEW_L1 --> TO_L_ONLY2[상태 → L_ONLY]
CHECK_STATE --> |LVT| CHECK_DOUBLE_JONG{겹받침 가능?}
CHECK_DOUBLE_JONG --> |예| MAKE_DOUBLE_JONG[T = 겹받침]
MAKE_DOUBLE_JONG --> STAY_LVT[상태 유지]
CHECK_DOUBLE_JONG --> |아니오| COMMIT2[현재 음절 commit]
COMMIT2 --> NEW_L2[L = 입력 자음]
NEW_L2 --> TO_L_ONLY3[상태 → L_ONLY]
3.4 모음 입력 시 결정 흐름
Section titled “3.4 모음 입력 시 결정 흐름”flowchart TD
INPUT[모음 입력] --> CHECK_STATE{현재 상태?}
CHECK_STATE --> |EMPTY| SET_DEFAULT["L = 11 ㅇ"]
SET_DEFAULT --> SET_V1[V = 입력 모음]
SET_V1 --> TO_LV1[상태: LV]
CHECK_STATE --> |L_ONLY| SET_V2[V = 입력 모음]
SET_V2 --> TO_LV2[상태: LV]
CHECK_STATE --> |LV| CHECK_DOUBLE_V{겹모음 가능?}
CHECK_DOUBLE_V --> |예| MAKE_DOUBLE_V[V = 겹모음]
MAKE_DOUBLE_V --> STAY_LV[상태 유지]
CHECK_DOUBLE_V --> |아니오| COMMIT1[현재 음절 commit]
COMMIT1 --> SET_DEFAULT2["L = 11 ㅇ"]
SET_DEFAULT2 --> SET_V3[V = 입력 모음]
SET_V3 --> TO_LV3[상태: LV]
CHECK_STATE --> |LVT| SPLIT[종성 분리]
SPLIT --> |단받침| COMMIT2["commit T=0"]
SPLIT --> |겹받침| COMMIT3["commit 첫째만 T"]
COMMIT2 --> NEXT_L1[다음 L = T의 초성]
COMMIT3 --> NEXT_L2[다음 L = 둘째 초성]
NEXT_L1 --> SET_V4[V = 입력 모음]
NEXT_L2 --> SET_V4
SET_V4 --> TO_LV4[상태: LV]
4. 완전한 JavaScript 구현
Section titled “4. 완전한 JavaScript 구현”4.1 상수 정의
Section titled “4.1 상수 정의”// 초성 19개 (L: 0~18)const CHOSEONG = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
// 중성 21개 (V: 0~20)const JUNGSEONG = ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
// 종성 28개 (T: 0~27, 0=없음)const JONGSEONG = ['','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
const HANGUL_BASE = 0xAC00;const JUNGSEONG_COUNT = 21;const JONGSEONG_COUNT = 28;4.2 키 → 자모 매핑 (2벌식)
Section titled “4.2 키 → 자모 매핑 (2벌식)”// 키 → 초성 인덱스const KEY_TO_CHOSEONG = { 'r': 0, 'R': 1, // ㄱ, ㄲ 's': 2, // ㄴ 'e': 3, 'E': 4, // ㄷ, ㄸ 'f': 5, // ㄹ 'a': 6, // ㅁ 'q': 7, 'Q': 8, // ㅂ, ㅃ 't': 9, 'T': 10, // ㅅ, ㅆ 'd': 11, // ㅇ 'w': 12, 'W': 13, // ㅈ, ㅉ 'c': 14, // ㅊ 'z': 15, // ㅋ 'x': 16, // ㅌ 'v': 17, // ㅍ 'g': 18, // ㅎ};
// 키 → 중성 인덱스const KEY_TO_JUNGSEONG = { 'k': 0, 'o': 1, // ㅏ, ㅐ 'i': 2, 'O': 3, // ㅑ, ㅒ 'j': 4, 'p': 5, // ㅓ, ㅔ 'u': 6, 'P': 7, // ㅕ, ㅖ 'h': 8, // ㅗ 'y': 12, // ㅛ 'n': 13, // ㅜ 'b': 17, // ㅠ 'm': 18, // ㅡ 'l': 20, // ㅣ};
// 초성 인덱스 → 종성 인덱스 (받침 가능한 것만)const CHOSEONG_TO_JONGSEONG = { 0: 1, // ㄱ → T=1 1: 2, // ㄲ → T=2 2: 4, // ㄴ → T=4 3: 7, // ㄷ → T=7 5: 8, // ㄹ → T=8 6: 16, // ㅁ → T=16 7: 17, // ㅂ → T=17 9: 19, // ㅅ → T=19 10: 20, // ㅆ → T=20 11: 21, // ㅇ → T=21 12: 22, // ㅈ → T=22 14: 23, // ㅊ → T=23 15: 24, // ㅋ → T=24 16: 25, // ㅌ → T=25 17: 26, // ㅍ → T=26 18: 27, // ㅎ → T=27};
// 종성 인덱스 → 초성 인덱스 (다음 글자 초성으로)const JONGSEONG_TO_CHOSEONG = { 1: 0, // ㄱ 2: 1, // ㄲ 4: 2, // ㄴ 7: 3, // ㄷ 8: 5, // ㄹ 16: 6, // ㅁ 17: 7, // ㅂ 19: 9, // ㅅ 20: 10, // ㅆ 21: 11, // ㅇ 22: 12, // ㅈ 23: 14, // ㅊ 24: 15, // ㅋ 25: 16, // ㅌ 26: 17, // ㅍ 27: 18, // ㅎ};4.3 겹모음/겹받침 조합 테이블
Section titled “4.3 겹모음/겹받침 조합 테이블”// 겹모음: [현재 V, 입력 V] → 결과 Vconst DOUBLE_JUNGSEONG = { '8,0': 9, // ㅗ + ㅏ → ㅘ '8,1': 10, // ㅗ + ㅐ → ㅙ '8,20': 11, // ㅗ + ㅣ → ㅚ '13,4': 14, // ㅜ + ㅓ → ㅝ '13,5': 15, // ㅜ + ㅔ → ㅞ '13,20': 16, // ㅜ + ㅣ → ㅟ '18,20': 19, // ㅡ + ㅣ → ㅢ};
// 겹받침: [현재 T, 입력 L] → 결과 Tconst DOUBLE_JONGSEONG = { '1,9': 3, // ㄱ + ㅅ → ㄳ '4,12': 5, // ㄴ + ㅈ → ㄵ '4,18': 6, // ㄴ + ㅎ → ㄶ '8,0': 9, // ㄹ + ㄱ → ㄺ '8,6': 10, // ㄹ + ㅁ → ㄻ '8,7': 11, // ㄹ + ㅂ → ㄼ '8,9': 12, // ㄹ + ㅅ → ㄽ '8,16': 13, // ㄹ + ㅌ → ㄾ '8,17': 14, // ㄹ + ㅍ → ㄿ '8,18': 15, // ㄹ + ㅎ → ㅀ '17,9': 18, // ㅂ + ㅅ → ㅄ};
// 겹받침 분리: T → [남는 T, 다음 L]const SPLIT_JONGSEONG = { 3: [1, 9], // ㄳ → ㄱ + ㅅ 5: [4, 12], // ㄵ → ㄴ + ㅈ 6: [4, 18], // ㄶ → ㄴ + ㅎ 9: [8, 0], // ㄺ → ㄹ + ㄱ 10: [8, 6], // ㄻ → ㄹ + ㅁ 11: [8, 7], // ㄼ → ㄹ + ㅂ 12: [8, 9], // ㄽ → ㄹ + ㅅ 13: [8, 16], // ㄾ → ㄹ + ㅌ 14: [8, 17], // ㄿ → ㄹ + ㅍ 15: [8, 18], // ㅀ → ㄹ + ㅎ 18: [17, 9], // ㅄ → ㅂ + ㅅ};4.4 HangulIME 클래스
Section titled “4.4 HangulIME 클래스”class HangulIME { constructor() { this.L = -1; // 초성 인덱스 (-1 = 없음) this.V = -1; // 중성 인덱스 (-1 = 없음) this.T = 0; // 종성 인덱스 (0 = 없음) this.commitBuffer = ''; }
// 완성형 한 글자 계산 getSyllable() { if (this.L < 0 || this.V < 0) return ''; const code = HANGUL_BASE + (this.L * JUNGSEONG_COUNT * JONGSEONG_COUNT) + (this.V * JONGSEONG_COUNT) + this.T; return String.fromCodePoint(code); }
// 현재 preedit 문자열 getPreedit() { if (this.L < 0) return ''; if (this.V < 0) return CHOSEONG[this.L]; return this.getSyllable(); }
// commit 버퍼 비우고 반환 flushCommit() { const result = this.commitBuffer; this.commitBuffer = ''; return result; }
// 상태 초기화 reset() { this.L = -1; this.V = -1; this.T = 0; }
// 현재 음절을 commit 버퍼에 추가 commitCurrent() { const syllable = this.getSyllable(); if (syllable) { this.commitBuffer += syllable; } this.reset(); }
// 키 입력 처리 (메인 로직) process(key) { // 자음 키인가? if (key in KEY_TO_CHOSEONG) { return this.processConsonant(KEY_TO_CHOSEONG[key]); }
// 모음 키인가? if (key in KEY_TO_JUNGSEONG) { return this.processVowel(KEY_TO_JUNGSEONG[key]); }
// 기타 키 (스페이스, 엔터 등) this.commitCurrent(); return { commit: this.flushCommit(), preedit: '' }; }
// 자음 처리 processConsonant(choseong) { // 상태 1: 비어있음 → L 설정 if (this.L < 0) { this.L = choseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 2: 초성만 있음 → 쌍자음 시도 또는 교체 if (this.V < 0) { // 쌍자음 시도 (같은 자음 두 번) if (this.L === choseong) { const doubled = this.tryDoubleChoseong(choseong); if (doubled !== null) { this.L = doubled; return { commit: this.flushCommit(), preedit: this.getPreedit() }; } } // 다른 자음이면 교체 this.L = choseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 3: 초성+중성 있음 → 종성 시도 if (this.T === 0) { const jongseong = CHOSEONG_TO_JONGSEONG[choseong]; if (jongseong !== undefined) { this.T = jongseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; } // 종성 불가 → commit 후 새 초성 this.commitCurrent(); this.L = choseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 4: 초성+중성+종성 있음 → 겹받침 시도 const doubleJong = DOUBLE_JONGSEONG[`${this.T},${choseong}`]; if (doubleJong !== undefined) { this.T = doubleJong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 겹받침 불가 → commit 후 새 초성 this.commitCurrent(); this.L = choseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 모음 처리 processVowel(jungseong) { // 상태 1: 비어있음 → ㅇ + 모음 if (this.L < 0) { this.L = 11; // ㅇ this.V = jungseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 2: 초성만 있음 → 중성 추가 if (this.V < 0) { this.V = jungseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 3: 초성+중성 있음 → 겹모음 시도 if (this.T === 0) { const doubleJung = DOUBLE_JUNGSEONG[`${this.V},${jungseong}`]; if (doubleJung !== undefined) { this.V = doubleJung; return { commit: this.flushCommit(), preedit: this.getPreedit() }; } // 겹모음 불가 → commit 후 새 (ㅇ, V) this.commitCurrent(); this.L = 11; // ㅇ this.V = jungseong; return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 상태 4: 초성+중성+종성 있음 → 종성 분리 const split = SPLIT_JONGSEONG[this.T]; if (split) { // 겹받침 분리 const [remainT, nextL] = split; this.T = remainT; this.commitCurrent(); this.L = nextL; this.V = jungseong; } else { // 단받침 → 다음 초성으로 const nextL = JONGSEONG_TO_CHOSEONG[this.T]; this.T = 0; this.commitCurrent(); this.L = nextL; this.V = jungseong; } return { commit: this.flushCommit(), preedit: this.getPreedit() }; }
// 쌍자음 변환 시도 tryDoubleChoseong(choseong) { const doubles = { 0: 1, 3: 4, 7: 8, 9: 10, 12: 13 }; return doubles[choseong] ?? null; }
// 취소 (Esc) cancel() { this.reset(); return { commit: '', preedit: '' }; }
// 강제 확정 flush() { this.commitCurrent(); return { commit: this.flushCommit(), preedit: '' }; }}4.5 사용 예시
Section titled “4.5 사용 예시”const ime = new HangulIME();
// "가나다" 입력 시뮬레이션const inputs = ['r', 'k', 's', 'k', 'e', 'k', ' '];let document = '';
for (const key of inputs) { const result = ime.process(key); document += result.commit; console.log(`키: ${key}, commit: "${result.commit}", preedit: "${result.preedit}"`);}
console.log(`최종 문서: "${document}"`);// 출력:// 키: r, commit: "", preedit: "ㄱ"// 키: k, commit: "", preedit: "가"// 키: s, commit: "가", preedit: "ㄴ"// 키: k, commit: "", preedit: "나"// 키: e, commit: "나", preedit: "ㄷ"// 키: k, commit: "", preedit: "다"// 키: , commit: "다", preedit: ""// 최종 문서: "가나다"5. 종성 분리 상세
Section titled “5. 종성 분리 상세”모음 입력 시 종성이 다음 음절의 초성으로 이동하는 과정:
sequenceDiagram
participant U as 사용자
participant IME as HangulIME
participant D as 문서
U->>IME: r ㄱ
Note over IME: L=0 preedit ㄱ
U->>IME: k ㅏ
Note over IME: V=0 preedit 가
U->>IME: r ㄱ 종성
Note over IME: T=1 preedit 각
U->>IME: k ㅏ 종성분리
Note over IME: T를 다음 L로
IME->>D: commit 가
Note over IME: preedit 가
6. 겹받침 분리 예시
Section titled “6. 겹받침 분리 예시”“삶” + ㅏ → “살” + “마” 입력 과정:
sequenceDiagram
participant U as 사용자
participant IME as HangulIME
participant D as 문서
Note over IME: preedit 삶 T=10 ㄻ
U->>IME: k ㅏ
Note over IME: 겹받침 분리 ㄻ to ㄹ+ㅁ
Note over IME: T=8 ㄹ 유지
IME->>D: commit 살
Note over IME: preedit 마
7. 에디터 통합
Section titled “7. 에디터 통합”class HangulEditor { constructor(element) { this.el = element; this.ime = new HangulIME(); this.content = '';
this.el.addEventListener('keydown', this.onKeyDown.bind(this)); }
onKeyDown(e) { // 한글 키가 아니면 IME 확정 if (!this.isHangulKey(e.key)) { const result = this.ime.flush(); this.content += result.commit; this.render(); return; // 기본 동작 허용 }
e.preventDefault();
const result = this.ime.process(e.key); this.content += result.commit; this.render(result.preedit); }
isHangulKey(key) { return key in KEY_TO_CHOSEONG || key in KEY_TO_JUNGSEONG; }
render(preedit = '') { // content + preedit(밑줄) 표시 this.el.textContent = this.content + preedit; }}8. 참고 문서
Section titled “8. 참고 문서”- 한글 조합 원리 - L/V/T 인덱스, 완성형 공식
- 한글 조합 규칙 - 겹모음, 겹받침 테이블
- 2벌식 알고리즘 - KS X 5002 키 매핑
- 3벌식 알고리즘 - 세벌식 키 매핑
- libhangul API - C 라이브러리 사용법