Skip to content

한글 입력기 구현

상태 머신, 키 입력 처리, 완전한 JavaScript 구현 코드

이 문서는 한글 입력기를 직접 구현하기 위한 완전한 가이드다. 상태 머신 설계, 키 입력 처리 흐름, 그리고 실제 동작하는 JavaScript 코드를 포함한다.


한글 입력기는 다음 세 가지를 수행한다:

  1. 키 → 자모 변환: 물리 키를 초성(L)/중성(V)/종성(T) 인덱스로 변환
  2. 상태 전이: 현재 조합 상태에 따라 자모를 해석하고 상태 변경
  3. 완성형 계산: S = 0xAC00 + (L×588) + (V×28) + T로 한 글자 생성

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[화면 표시]

상태설명LVTpreedit 예
EMPTY초기 상태-1-10""
L_ONLY초성만0~18-10”ㄱ”
LV초성+중성0~180~200”가”
LVT초성+중성+종성0~180~201~27”각”
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 “가”
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]
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]

// 초성 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;
// 키 → 초성 인덱스
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, // ㅎ
};
// 겹모음: [현재 V, 입력 V] → 결과 V
const 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] → 결과 T
const 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], // ㅄ → ㅂ + ㅅ
};
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: '' };
}
}
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: ""
// 최종 문서: "가나다"

모음 입력 시 종성이 다음 음절의 초성으로 이동하는 과정:

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 가

“삶” + ㅏ → “살” + “마” 입력 과정:

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 마

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;
}
}