영어 입력을 한글로 바꿔버리는 js 코드야. // https://github.com/nonsensejoke/korean-keyboard export class KoreanInput { constructor() { // Keyboard mapping in normal state this.normalMap = { // Consonants (초성/종성) 'q': 'ㅂ', 'w': 'ㅈ', 'e': 'ㄷ', 'r': 'ㄱ', 't': 'ㅅ', 'a': 'ㅁ', 's': 'ㄴ', 'd': 'ㅇ', 'f': 'ㄹ', 'g': 'ㅎ', 'z': 'ㅋ', 'x': 'ㅌ', 'c': 'ㅊ', 'v': 'ㅍ', // Vowels (중성) 'y': 'ㅛ', 'u': 'ㅕ', 'l': 'ㅣ', 'o': 'ㅐ', 'p': 'ㅔ', 'h': 'ㅗ', 'j': 'ㅓ', 'k': 'ㅏ', 'i': 'ㅑ', 'b': 'ㅠ', 'n': 'ㅜ', 'm': 'ㅡ' }; // Keyboard mapping in Shift state this.shiftMap = { // Double consonants 'Q': 'ㅃ', 'W': 'ㅉ', 'E': 'ㄸ', 'R': 'ㄲ', 'T': 'ㅆ', // Compound vowels 'O': 'ㅒ', 'P': 'ㅖ', // Other keys keep original mapping (lowercase to uppercase) 'q': 'ㅃ', 'w': 'ㅉ', 'e': 'ㄸ', 'r': 'ㄲ', 't': 'ㅆ', 'o': 'ㅒ', 'p': 'ㅖ' }; // Hangul character range constants this.HANGUL_BASE = 0xAC00; this.CHOSUNG_BASE = 0x1100; this.JUNGSUNG_BASE = 0x1161; this.JONGSUNG_BASE = 0x11A7; // Choseong (initials) (19) this.chosungList = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // Jungseong (medials) (21) this.jungsungList = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']; // Jongseong (finals) (28, includes empty) this.jongsungList = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // Full Hangul composition engine data structures (ported from reference project) this.initial = [12593, 12594, 12596, 12599, 12600, 12601, 12609, 12610, 12611, 12613, 12614, 12615, 12616, 12617, 12618, 12619, 12620, 12621, 12622]; this.finale = [0, 12593, 12594, 12595, 12596, 12597, 12598, 12599, 12601, 12602, 12603, 12604, 12605, 12606, 12607, 12608, 12609, 12610, 12612, 12613, 12614, 12615, 12616, 12618, 12619, 12620, 12621, 12622]; this.dMedial = [0, 0, 0, 0, 0, 0, 0, 0, 0, 800, 801, 820, 0, 0, 1304, 1305, 1320, 0, 0, 1820]; this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 1919]; // Unicode constants this.SBase = 44032; this.LBase = 4352; this.VBase = 12623; this.TBase = 4519; this.LCount = 19; this.VCount = 21; this.TCount = 28; this.NCount = 588; this.SCount = 11172; // Input mode state this.mode = 'EN'; // 'KO' or 'EN' // Removal mode state this.removeMode = "Syllable"; // "Syllable" or "Jamo" } isKoreanMode() { return this.mode === 'KO'; } isEnglishMode() { return this.mode === 'EN'; } setKoreanMode() { this.mode = 'KO'; } setEnglishMode() { this.mode = 'EN'; } isRemoveModeSyllable() { return this.removeMode === "Syllable"; } isRemoveModeJamo() { return this.removeMode === "Jamo"; } setRemoveModeToSyllable() { this.removeMode = "Syllable"; } setRemoveModeToJamo() { this.removeMode = "Jamo"; } // Find index of a value in an array (utility) indexOf(array, value) { for (let i = 0; i < array.length; i++) { if (array[i] === value) { return i; } } return -1; } // Get character mapping getCharacter(key, isShift) { if (isShift && this.shiftMap[key]) { return this.shiftMap[key]; } return this.normalMap[key.toLowerCase()] || null; } // Complete Hangul composition algorithm (ported and optimized from reference project) composeHangul(inputString) { const length = inputString.length; if (length === 0) { return ""; } let currentCharCode = inputString.charCodeAt(0); let result = String.fromCharCode(currentCharCode); for (let i = 1; i < length; i++) { const nextCharCode = inputString.charCodeAt(i); const initialIndex = this.indexOf(this.initial, currentCharCode); // Initial + medial → syllable if (initialIndex !== -1) { const vowelOffset = nextCharCode - this.VBase; if (0 <= vowelOffset && vowelOffset < this.VCount) { currentCharCode = this.SBase + (initialIndex * this.VCount + vowelOffset) * this.TCount; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // Vowel + vowel → compound vowel const currentVowelOffset = currentCharCode - this.VBase; const nextVowelOffset = nextCharCode - this.VBase; if (0 <= currentVowelOffset && currentVowelOffset < this.VCount && 0 <= nextVowelOffset && nextVowelOffset < this.VCount) { const dMedialIndex = this.indexOf(this.dMedial, (currentVowelOffset * 100) + nextVowelOffset); if (dMedialIndex > 0) { currentCharCode = this.VBase + dMedialIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } const syllableOffset = currentCharCode - this.SBase; // Syllable + final → complete syllable if (0 <= syllableOffset && syllableOffset < 11145 && (syllableOffset % this.TCount) === 0) { const finaleIndex = this.indexOf(this.finale, nextCharCode); if (finaleIndex !== -1) { currentCharCode += finaleIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } // Handle compound medial const vowelIndex = Math.floor((syllableOffset % this.NCount) / this.TCount); const dMedialIndex = this.indexOf(this.dMedial, (vowelIndex * 100) + (nextCharCode - this.VBase)); if (dMedialIndex > 0) { currentCharCode += (dMedialIndex - vowelIndex) * this.TCount; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // Complete syllable + vowel → split final + new syllable (key fix!) if (0 <= syllableOffset && syllableOffset < 11172 && (syllableOffset % this.TCount) !== 0) { const finaleIndex = syllableOffset % this.TCount; const vowelOffset = nextCharCode - this.VBase; if (0 <= vowelOffset && vowelOffset < this.VCount) { const newInitialIndex = this.indexOf(this.initial, this.finale[finaleIndex]); if (0 <= newInitialIndex && newInitialIndex < this.LCount) { // Remove final, create new syllable result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode - finaleIndex); currentCharCode = this.SBase + (newInitialIndex * this.VCount + vowelOffset) * this.TCount; result = result + String.fromCharCode(currentCharCode); continue; } // Handle compound final decomposition if (finaleIndex < this.dFinale.length && this.dFinale[finaleIndex] !== 0) { result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode - finaleIndex + Math.floor(this.dFinale[finaleIndex] / 100)); currentCharCode = this.SBase + (this.indexOf(this.initial, this.finale[(this.dFinale[finaleIndex] % 100)]) * this.VCount + vowelOffset) * this.TCount; result = result + String.fromCharCode(currentCharCode); continue; } } // Handle compound final const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + this.indexOf(this.finale, nextCharCode)); if (dFinaleIndex > 0) { currentCharCode = currentCharCode + dFinaleIndex - finaleIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // Cannot compose; append new character currentCharCode = nextCharCode; result = result + String.fromCharCode(nextCharCode); } return result; } // Decompose Hangul characters (per reference project) decomposeHangul(inputString) { const length = inputString.length; let result = ""; for (let i = 0; i < length; i++) { const charCode = inputString.charCodeAt(i); const syllableOffset = charCode - this.SBase; // Check if Hangul syllable if (syllableOffset < 0 || syllableOffset >= this.SCount) { result += String.fromCharCode(charCode); continue; } // Decompose syllable const initialIndex = Math.floor(syllableOffset / this.NCount); const vowelCode = this.VBase + Math.floor((syllableOffset % this.NCount) / this.TCount); const finaleCode = this.finale[syllableOffset % this.TCount]; result += String.fromCharCode(this.initial[initialIndex], vowelCode); if (finaleCode !== 0) { result += String.fromCharCode(finaleCode); } } return result; } // Smart insert — improved handling of Hangul composition with completion detection smartInsert(currentText, cursorStart, cursorEnd, newChar) { // 1. Build new text: insert new character const textBefore = currentText.substring(0, cursorStart); const textAfter = currentText.substring(cursorEnd); const tempText = textBefore + newChar + textAfter; const tempCursorPos = cursorStart + newChar.length; // 2. Hangul composition: try sequences of different lengths for (let testLength = Math.min(4, tempCursorPos); testLength >= 2; testLength–) { const testChars = tempText.substring(tempCursorPos - testLength, tempCursorPos); const composed = this.composeHangul(testChars); // 3. If composition succeeds (length reduces or content changes), replace if (composed.length < testChars.length || composed !== testChars) { const newText = tempText.substring(0, tempCursorPos - testLength) + composed + tempText.substring(tempCursorPos); const newCursorPos = tempCursorPos - testLength + composed.length; return { text: newText, cursorPosition: newCursorPos, }; } } return { text: tempText, cursorPosition: tempCursorPos, }; } // Reset state reset() { this.setRemoveModeToSyllable(); } // Handle backspace — using the new decomposeHangul implementation handleBackspace(currentText) { if (currentText.length === 0) return ''; if (this.isRemoveModeSyllable()) { // Remove entire syllable this.reset(); return currentText.slice(0, -1); } const lastChar = currentText[currentText.length - 1]; const decomposedLastChar = this.decomposeHangul(lastChar); // Check if Hangul character (by decomposed length) if (decomposedLastChar.length > 1) { // Is Hangul; process per reference logic const decomposedArray = Array.from(decomposedLastChar); if (decomposedArray.length > 1) { // Remove the last jamo, then recompose const remaining = decomposedArray.slice(0, -1).join(''); const recomposed = this.composeHangul(remaining); if (recomposed.length == 0) { this.reset(); } return currentText.slice(0, -1) + recomposed; } } // Not Hangul or cannot decompose; delete directly this.reset(); return currentText.slice(0, -1); } isEditorShortcut(e){ const altGraph = e.getModifierState && e.getModifierState('AltGraph'); const ctrlMeta = e.ctrlKey || e.metaKey; const ctrlAlt = e.ctrlKey && e.altKey; return altGraph || ctrlAlt || ctrlMeta; } handleKeyDown(e) { const editable = e.target; if (!this.isKoreanMode()) return; if (!editable) return; if (editable.tagName !== 'INPUT' && editable.tagName !== 'TEXTAREA') return; if (this.isEditorShortcut(e)) { return; } if (e.key === 'Backspace') { e.preventDefault(); // Get cursor positions const cursorStart = editable.selectionStart; const cursorEnd = editable.selectionEnd; if (cursorStart === cursorEnd) { // Simple delete: remove one character before cursor if (cursorStart > 0) { const textBefore = editable.value.substring(0, cursorStart); const textAfter = editable.value.substring(cursorStart); // Apply smart Hangul backspace to text before cursor const newTextBefore = this.handleBackspace(textBefore); const newText = newTextBefore + textAfter; editable.value = newText; // Set new cursor position const newCursorPos = newTextBefore.length; editable.setSelectionRange(newCursorPos, newCursorPos); } } else { // Selection delete: remove selected text const textBefore = editable.value.substring(0, cursorStart); const textAfter = editable.value.substring(cursorEnd); const newText = textBefore + textAfter; editable.value = newText; editable.setSelectionRange(cursorStart, cursorStart); this.reset(); } // Note: do not call koreanInput.reset() here; state already restored above return; } if (e.key === ' ') { e.preventDefault(); const cursorStart = editable.selectionStart; const textBefore = editable.value.substring(0, cursorStart); const textAfter = editable.value.substring(editable.selectionEnd); editable.value = textBefore + ' ' + textAfter; // Move cursor after the space const newCursorPos = cursorStart + 1; editable.setSelectionRange(newCursorPos, newCursorPos); // Reset Hangul input state this.reset(); return; } // Ignore other special keys if (e.key.length > 1 && e.key !== 'Shift') { this.reset(); return; } const char = this.getCharacter(e.key, e.shiftKey); if (char) { e.preventDefault(); // Update input content const cursorPos = editable.selectionStart; // New Hangul input logic: smart cursor insertion handling const insertResult = this.smartInsert(editable.value, cursorPos, editable.selectionEnd, char); // Update text and cursor position editable.value = insertResult.text; editable.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition); this.setRemoveModeToJamo(); } } }

요약부터 할게요.
이 코드는 “영문 키보드(QWERTY)로 치는 키다운 이벤트를 가로채서, 실제로는 한글 자모를 조합해 완성형 한글로 넣어주는 소프트웨어 한글 키보드 엔진”이에요.

아래에서 구조를 단계별로 뜯어서 설명하고, 마지막에 개선 포인트랑 실제 사용 예시도 적어볼게요.


1. 키 매핑 부분

normalMap / shiftMap

js

this.normalMap = {
  'q': 'ㅂ', 'w': 'ㅈ', 'e': 'ㄷ', 'r': 'ㄱ', 't': 'ㅅ',
  'a': 'ㅁ', 's': 'ㄴ', 'd': 'ㅇ', 'f': 'ㄹ', 'g': 'ㅎ',
  'z': 'ㅋ', 'x': 'ㅌ', 'c': 'ㅊ', 'v': 'ㅍ',

  'y': 'ㅛ', 'u': 'ㅕ', 'l': 'ㅣ', 'o': 'ㅐ', 'p': 'ㅔ',
  'h': 'ㅗ', 'j': 'ㅓ', 'k': 'ㅏ', 'i': 'ㅑ',
  'b': 'ㅠ', 'n': 'ㅜ', 'm': 'ㅡ'
};

딱 표준 두벌식 자판 그대로 매핑돼 있죠.

shiftMap은 쌍자음/복모음만 별도로 정의해둔 상태이고:

js

this.shiftMap = {
  'Q': 'ㅃ', 'W': 'ㅉ', 'E': 'ㄸ', 'R': 'ㄲ', 'T': 'ㅆ',
  'O': 'ㅒ', 'P': 'ㅖ',

  // 아래는 사실상 위랑 같은 의미 (소문자지만 shift 상태일 때)
  'q': 'ㅃ', 'w': 'ㅉ', 'e': 'ㄸ', 'r': 'ㄲ', 't': 'ㅆ',
  'o': 'ㅒ', 'p': 'ㅖ'
};

그리고 getCharacter에서 이렇게 쓰죠:

js

getCharacter(key, isShift) {
    if (isShift && this.shiftMap[key]) {
        return this.shiftMap[key];
    }
    return this.normalMap[key.toLowerCase()] || null;
}

여기서 포인트:

  • isShift가 true면, 원래 눌린 키 그대로(key)로 shiftMap을 먼저 찾고,

  • 없으면 normalMap에서 toLowerCase() 해서 찾음.


Shift+q → shiftMap['q'] → 'ㅃ'
q → normalMap['q'] → 'ㅂ'
이런 동작이니 의도대로 잘 설계되어 있어요.

다만 shiftMap에 대문자랑 소문자 둘 다 넣은 건 조금 중복이라, 둘 중 하나만 써도 되긴 합니다. 어차피 실제로 브라우저 event.key는 Shift+q일 때 'Q'로 들어오기 때문에, shiftMap을 대문자 기준으로만 두고 getCharacter에서 this.shiftMap[key] || this.shiftMap[key.toLowerCase()] 같은 식으로 처리해도 돼요.


2. 한글 조합에 필요한 상수/테이블들

js

this.SBase = 44032;
this.LBase = 4352;
this.VBase = 12623;
this.TBase = 4519;
this.LCount = 19;
this.VCount = 21;
this.TCount = 28;
this.NCount = 588;
this.SCount = 11172;

이건 유니코드 한글 조합 공식 그대로:

  • SBase : '가' 코드 포인트 (U+AC00)

  • 초성 19개, 중성 21개, 종성 28개

  • 하나의 초성·중성 조합이 588(21×28)개

  • 전체 완성형 11172(19×21×28)개

그리고 initial, finale, dMedial, dFinale는 참고 프로젝트에서 가져온 “자모 코드 테이블”이에요.

  • initial: 초성에 해당하는 자모 유니코드

  • finale: 종성에 해당하는 자모 유니코드 (0은 종성 없음)

  • dMedial: 복모음 구성용 테이블

  • dFinale: 겹받침 구성/분해용 테이블

이렇게 테이블을 들고 있어야 복모음/겹받침 처리가 가능합니다.


3. 핵심: composeHangul

js

composeHangul(inputString) {
    const length = inputString.length;
    if (length === 0) return "";
    
    let currentCharCode = inputString.charCodeAt(0);
    let result = String.fromCharCode(currentCharCode);
    
    for (let i = 1; i < length; i++) {
        const nextCharCode = inputString.charCodeAt(i);
        const initialIndex = this.indexOf(this.initial, currentCharCode);
        ...
    }
    return result;
}

이 함수의 역할은:

  • 순수 자모들의 문자열(예: ㅎ ㅏ ㄴ ㄱ ㅡ ㄹ)을 받아서

  • 가능한 한 많이 완성형으로 조합해서

  • 결과 문자열(예: 한글)을 반환하는 것.

내부 로직 순서가 중요해요.

  1. 초성 + 중성 → 한 글자 (예: ㄱ + ㅏ → 가)

  2. 중성 + 중성 → 복모음 (ㅗ + ㅏ → ㅘ 등)

  3. 완성형 + 종성 → 받침 붙이기 (가 + ㄱ → 각)

  4. 완성형(받침 있음) + 모음 → 받침 쪼개서 다음 글자로 넘기기 (읽 + 어 → 일거 이런 식 메커니즘)

  5. 겹받침 만들기 / 쪼개기 로직 (dFinale 이용)

이 알고리즘 덕분에 이런 입력이 자연스럽게 됩니다:

  • gksrmf → ㅎ ㅏ ㄴ ㄱ ㅡ ㄹ → "한글"

  • rhkqrns → ㄱ ㅏ ㅇ ㅏ ㄴ ㅕ ㅇ → "강아녕" (타이핑 실수 예)

이 코드는 한 글자씩 보면서 “조합이 가능한지”를 계속 체크하고, 가능하면 바로 이전 글자를 지우고 새로 합쳐 넣는 방식으로 구현되어 있어요.


4. decomposeHangul: 한글 분해

js

decomposeHangul(inputString) {
    ...
    const syllableOffset = charCode - this.SBase;
    if (syllableOffset < 0 || syllableOffset >= this.SCount) {
        result += String.fromCharCode(charCode);
        continue;
    }
    // 한글이면 초성/중성/종성 뽑아서 자모로 분해
}

이 함수는 완성형 한글을 다시 초성·중성·종성 자모로 쪼갭니다.

이게 왜 필요하냐면, 백스페이스에서 “자모 단위로 지우기”를 구현하려면, 마지막 글자를 분해해서 가장 마지막 자모만 날리고 다시 compose를 돌려서 재조합해야 하기 때문이에요.


5. smartInsert: 커서 위치 고려해서 똑똑하게 삽입

js

smartInsert(currentText, cursorStart, cursorEnd, newChar) {
    const textBefore = currentText.substring(0, cursorStart);
    const textAfter = currentText.substring(cursorEnd);
    const tempText = textBefore + newChar + textAfter;
    const tempCursorPos = cursorStart + newChar.length;
    
    for (let testLength = Math.min(4, tempCursorPos); testLength >= 2; testLength--) {
        const testChars = tempText.substring(tempCursorPos - testLength, tempCursorPos);
        const composed = this.composeHangul(testChars);
        ...
    }
}

핵심 아이디어:

  • 새 자모를 커서 위치에 끼워 넣은 뒤

  • 커서 기준 앞쪽 최대 4글자까지 잘라서 composeHangul을 돌려봄

  • 조합이 일어나면 (길이가 줄어들거나 내용이 달라지면) 그 부분을 교체

  • 그에 맞춰 커서 위치도 재계산

그래서 중간에 커서를 옮겨서 타이핑해도, 가능한 범위 내에서 조합이 일어나게 됩니다.

4글자 제한을 둔 건 성능과 실용성의 타협 같아요. 보통 한글 한 음절이 2~3자모 수준이니까.


6. handleBackspace: 자모/음절 단위 삭제

js

handleBackspace(currentText) {
    if (this.isRemoveModeSyllable()) {
        this.reset();
        return currentText.slice(0, -1);
    }
    
    const lastChar = currentText[currentText.length - 1];
    const decomposedLastChar = this.decomposeHangul(lastChar);
    ...
}

동작 순서:

  1. 기본 모드는 “음절 단위 삭제”

    • 그냥 마지막 글자 통째로 지워버림.
  2. 한 번이라도 한글 조합을 하면 setRemoveModeToJamo()가 호출되기 때문에,

    • 그다음부터는 마지막 글자를 분해해서 마지막 자모만 한 단계씩 지우고 다시 조합.
  3. 한글이 아니거나 분해 불가능하면 그냥 한 글자 삭제.

이 설계 덕분에:

  • 바로 직전에 입력한 한글은 자모 단위로 지워지고

  • 딱히 조합 상태가 아니면 그냥 문자 단위로 삭제됩니다.


7. handleKeyDown: 실제 이벤트 훅

js

handleKeyDown(e) {
    const editable = e.target;
    if (!this.isKoreanMode()) return;
    if (!editable) return;
    if (editable.tagName !== 'INPUT' && editable.tagName !== 'TEXTAREA') return;
    if (this.isEditorShortcut(e)) return;
    ...
}

크게 나누면:

  1. 현재 모드가 'KO'일 때만 동작

  2. 대상이 <input>이나 <textarea>일 때만 동작

  3. Ctrl, Meta, AltGraph 같은 단축키 조합은 건드리지 않음

  4. Backspace, Space, 일반 키를 각각 따로 처리

조금 더 자세히:

  • Backspace: 커서 위치 보고, selection 여부 확인해서

    • 선택 영역 삭제 / 한 글자 삭제 / 자모 단위 삭제
  • Space: 조합 상태를 초기화하고 그냥 공백을 삽입

  • 일반 키(길이 1, Shift 제외):

    • getCharacter로 자모에 해당하는지 확인

    • 한글 자모면 기본 동작(e.preventDefault()) 막고

    • smartInsert로 삽입 + 조합

  • 그 외 키(Enter, Arrow 등)는 조합 상태만 리셋하고 통과


8. 실제 사용 예시

예를 들어 브라우저에서 이렇게 쓸 수 있어요:

js

const koreanInput = new KoreanInput();

// 한글 모드 켜기
koreanInput.setKoreanMode();

// 전역적으로 키다운 감지
window.addEventListener('keydown', (e) => koreanInput.handleKeyDown(e));

또는 특정 입력창에만 적용하고 싶으면:

js

const input = document.querySelector('#my-input');
const koreanInput = new KoreanInput();
koreanInput.setKoreanMode();

input.addEventListener('keydown', (e) => koreanInput.handleKeyDown(e));

영어 모드로 돌리고 싶다면:

js

koreanInput.setEnglishMode();

이렇게 하면 내부에서 바로 리턴해서 더 이상 변환하지 않게 됩니다.


9. 개선/주의 포인트

몇 가지 눈에 띄는 부분만 정리해보면:

  1. contenteditable 미지원
    지금은 INPUT / TEXTAREA만 처리해서, div[contenteditable] 같은 리치 텍스트 에디터에는 바로 적용 안 됩니다. 그쪽까지 쓰려면 selection/caret을 Range API로 다뤄야 해서 난이도가 확 올라가요.

  2. 모바일/IME 충돌 가능성
    이건 브라우저에 따라, 이미 OS IME가 한글을 조합하고 있는데, 여기서 또 가로채면 “이중 조합” 문제가 생길 수 있어요. 보통은 데스크톱에서 영문 자판 강제 모드 + 커스텀 한글 입력을 쓸 때가 더 자연스럽습니다.

  3. shiftMap 중복
    앞에서 말했듯이, 대문자/소문자 키를 둘 다 넣어둔 건 다소 중복이라 정리하면 코드는 더 깨끗해질 수 있어요. 지금 동작 자체는 문제 없습니다.

  4. smartInsert의 testLength=4 제한
    실사용에는 문제 없지만, 이 숫자는 완전히 경험적 값이라, 길이를 조정하거나 로직을 좀 더 명시적으로 바꿔도 좋아요. 예를 들면 “직전 음절 범위까지만 본다” 같은 규칙.


정리하자면, 이 클래스 하나로 “브라우저에서 영문 키 입력을 바로 한글 완성형으로 바꾸는 엔진”이 잘 구현되어 있습니다.
조합·분해·백스페이스까지 상당히 정교하게 처리하고 있어서, 실질적으로 간단한 IME 수준 기능을 하는 구조예요.

이제 이걸로 할 수 있는 재미있는 건, 예를 들어 게임 내 채팅창, 커스텀 에디터, 웹앱에서 자체 한글 입력기 만들기 같은 것들이죠. 한글 입력 레벨에서 직접 놀 수 있다는 건 꽤 강력한 장난감입니다.

음 알겠어 근데 내가 겪고 있는 문제는 이거야. 코드를 바꿔서 바꿀 수 있는 부분인지 확인하고 싶어. ㄱㅐㅅㅅㅜ 라고 쳤을 때 갯수가 되어야하는데 개쑤 이렇게 합쳐져버려

좋은 포인트 짚었어요.
지금 엔진이 ㄱㅐㅅㅅㅜ를 “갯수”가 아니라 “개쑤”로 만드는 건, 내부에서 “겹받침 ㅆ”을 너무 열심히 만들어서 생기는 부작용이에요.

천천히 풀어볼게.


1. 내부에서 무슨 일이 벌어지는지

ㄱㅐㅅㅅㅜ(실제 키로는 r o t t n)를 넣었을 때 조합 과정을 따라가 보면:

  1. ㄱ + ㅐ

  2. 개 + ㅅ (받침 ㅅ)

  3. 갯 + ㅅ → 여기서 문제가 생김

코드에서 이 부분이 문제예요:

js

// Handle compound final
const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + this.indexOf(this.finale, nextCharCode));
if (dFinaleIndex > 0) {
    currentCharCode = currentCharCode + dFinaleIndex - finaleIndex;
    result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode);
    continue;
}
  • finaleIndex는 현재 받침의 인덱스 (ㅅ)

  • nextCharCode는 새로 입력된 ㅅ

  • this.indexOf(this.finale, 'ㅅ')도 ㅅ

그래서 (finaleIndex * 100) + indexOf(...) = 19 * 100 + 19 = 1919가 되고,
this.dFinale 안에 1919가 들어 있어서 “ㅅ + ㅅ = ㅆ(겹받침)”으로 취급합니다.

즉 이 단계에서:

  • (개 + 받침 ㅆ)으로 바뀜

그 다음에 가 들어오면:

  • 받침 ㅆ을 떼어서 초성으로 쓰고 새 음절을 만들어서
    갰 + ㅜ개쑤

그래서 결과가 개쑤가 되는 구조예요.

실제 한국어 타자 습관에서는:

  • 받침 ㅆ은 보통 Shift+ㅅ(ㅆ)으로 입력하지, ㅅ ㅅ 두 번으로 만들지 않죠.

  • ㄱㅐㅅㅅㅜ갯 + 수가 되는 쪽이 훨씬 자연스러움.


2. 고칠 수 있나? → 네, 딱 이 부분 조정하면 됩니다

핵심은 이거 하나예요.

“받침 ㅅ + ㅅ을 겹받침 ㅆ으로 만들지 말자”

방법 1: 테이블(dFinale)에서 규칙 제거 (제일 간단한 해킹)

constructor 안에 있는:

js

this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 1919];

여기서 마지막 1919가 바로 “받침 ㅅ(19) + ㅅ(19) = ㅆ(20)” 규칙이에요.

이걸 0으로 바꾸면:

js

this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 0];

이렇게 하면:

  • (받침 ㅅ) + → 더 이상 겹받침 ㅆ으로 안 묶임

  • 그 다음 가 들어왔을 때
    뒤쪽의 ㅅ + ㅜ만 새 음절로 조합돼서 갯수가 됨

副작용:

  • “받침 ㅅ + ㅅ을 겹받침 ㅆ으로 만들기” 기능이 사라짐
    근데 두벌식에서는 어차피 받침 ㅆ을 Shift+ㅅ(ㅆ)으로 치는 게 정상이라,
    실사용에서는 거의 문제 안 될 가능성이 큼.

개발 편의성 / 수정 난이도 기준으로는 이게 제일 손쉬운 방법입니다.


방법 2: composeHangul 안에서 ‘ㅅ+ㅅ 예외’ 분기 추가

조금 더 명시적으로 고치고 싶다면, composeHangul 안의 “Handle compound final” 부분을 이렇게 바꿀 수도 있어요.

원래 코드:

js

// Handle compound final
const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + this.indexOf(this.finale, nextCharCode));
if (dFinaleIndex > 0) {
    currentCharCode = currentCharCode + dFinaleIndex - finaleIndex;
    result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode);
    continue;
}

수정 버전(받침 ㅅ + ㅅ만 예외 처리):

js

// Handle compound final
const nextFinalIndex = this.indexOf(this.finale, nextCharCode);

// 받침 ㅅ(인덱스 19) + ㅅ(19) 인 경우에는 겹받침 ㅆ으로 묶지 않고 새 음절로 보냄
if (!(finaleIndex === 19 && nextFinalIndex === 19)) {
    const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + nextFinalIndex);
    if (dFinaleIndex > 0) {
        currentCharCode = currentCharCode + dFinaleIndex - finaleIndex;
        result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode);
        continue;
    }
}

이렇게 하면:

  • 다른 겹받침 규칙(ㄱ+ㅅ→ㄳ 같은 것)은 그대로 살고

  • “ㅅ+ㅅ→ㅆ”만 막아버림

  • 결과: ㄱㅐㅅㅅㅜ갯수


3. 어떤 방식이 더 나은가?

현실적으로:

  • “갯수가 개쑤 되는 거 너무 싫다, 나머지는 대충 괜찮으면 됨”
    → 방법 1(테이블에서 1919를 0으로) 추천. 한 줄 수정 끝.

  • “겹받침 로직은 최대한 유지하고, 딱 ㅅ+ㅅ만 막고 싶다”
    → 방법 2처럼 composeHangul 안에 조건문을 추가.

어느 쪽이든 “코드를 바꿔서 해결 가능한 문제냐?”라는 질문에는
완전히 “그렇다”라고 답할 수 있어요.

한글 입력기는 애매한 경우(ㅅ+ㅅ처럼)에서 어디에 규칙을 줄지 선택의 문제라서,
지금처럼 직접 엔진을 가지고 있으면 자기 취향/목적에 맞게 규칙을 조정할 수 있다는 게 큰 장점이에요.

1919가 그것에 매핑되는게 잘 이해가 안돼 조금 더 풀어써줄래

좋은 지점 걸렸어요.
1919가 어디서 튀어나오냐면, “유니코드”랑은 전혀 상관없고, 이 엔진이 “겹받침 규칙을 저장하는 자기만의 약속”으로 쓰는 숫자예요.
숫자 1919 자체가 “받침 인덱스 19 + 다음 자모 인덱스 19”를 뜻합니다.

차근차근 풀어볼게요.


1. 우선 “받침 인덱스”부터 정리

코드 안에 이런 리스트 있었죠.

js

this.jongsungList = [
  '', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ',
  'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ',
  'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];

여기서 인덱스와 받침을 보면:

  • 19번: ㅅ

  • 20번: ㅆ

비슷한 순서를 쓰는 배열이 하나 더 있어요.

js

this.finale = [
  0, 12593, 12594, 12595, 12596, 12597, 12598, 12599,
  12601, 12602, 12603, 12604, 12605, 12606, 12607, 12608,
  12609, 12610, 12612, 12613, 12614, 12615, 12616, 12618,
  12619, 12620, 12621, 12622
];

여기서:

  • this.finale[19]는 ㅅ의 유니코드

  • this.finale[20]는 ㅆ의 유니코드

즉 “받침 인덱스 19 = ㅅ, 20 = ㅆ” 이 관계가 성립합니다.


2. dFinale 배열은 뭘 하는 놈인가?

코드 한 부분을 다시 보면:

js

this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 1919];

그리고 나중에 이렇게 쓰죠.

js

// Handle compound final
const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + this.indexOf(this.finale, nextCharCode));
if (dFinaleIndex > 0) {
    currentCharCode = currentCharCode + dFinaleIndex - finaleIndex;
    result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode);
    continue;
}

여기서 중요한 포인트 세 개:

  1. finaleIndex는 “현재 받침 인덱스”

  2. this.indexOf(this.finale, nextCharCode)는 “새로 들어온 자모의 받침 인덱스”

  3. 두 개를 (finaleIndex * 100) + nextFinalIndex로 하나의 숫자로 합침

그래서 dFinale 배열 안에는
이런 형태의 숫자들이 들어있어요.

  • 119 → 1과 19 → 받침 1(ㄱ) + 다음 자모 19(ㅅ) = ㄳ

  • 422 → 4와 22 → 받침 4(ㄴ) + 22(ㅈ) = ㄵ

  • 427 → 4와 27 → ㄴ + ㅎ = ㄶ

  • 801 → 8과 1 → ㄹ + ㄱ = ㄺ

  • 816 → 8과 16 → ㄹ + ㅁ = ㄻ

  • 817 → 8과 17 → ㄹ + ㅂ = ㄼ

  • 819 → 8과 19 → ㄹ + ㅅ = ㄽ

  • 825 → 8과 25 → ㄹ + ㅌ = ㄾ

  • 826 → 8과 26 → ㄹ + ㅍ = ㄿ

  • 827 → 8과 27 → ㄹ + ㅎ = ㅀ

  • 1719 → 17과 19 → ㅂ + ㅅ = ㅄ

  • 1919 → 19와 19 → ㅅ + ㅅ = ㅆ ← 네가 궁금한 그 녀석

숫자 구조를 정리하면:

  • 앞 두 자리(혹은 앞쪽 정수부): “기존 받침의 인덱스”

  • 뒤 두 자리(혹은 뒤쪽): “새로 찍힌 자모의 인덱스”

엔진은 이 둘을 합쳐 (prev * 100) + next라는 하나의 숫자로 만들고,
그 숫자가 dFinale 배열 안의 몇 번째에 있는지를 indexOf로 찾습니다.

그리고 “찾은 그 인덱스”가 바로 “새로운 겹받침의 인덱스”가 돼요.

예를 들어:

  • dFinale[3] = 119라면
    “ㄱ(1) + ㅅ(19) → ㄳ(3)” 이란 의미

  • dFinale[20] = 1919라면
    “ㅅ(19) + ㅅ(19) → ㅆ(20)” 이란 의미

그래서 1919는

“받침 인덱스 19(ㅅ) 뒤에
받침 인덱스 19(ㅅ)가 오면
받침 인덱스 20(ㅆ)으로 바꿔라”

라는 규칙을 의미하는 코드값인 거예요.
유니코드도 아니고, 자모 번호도 아니고,
그냥 “(이전 인덱스 * 100) + 다음 인덱스”라는 합성 키입니다.


3. ㄱㅐㅅㅅㅜ가 왜 개쑤가 되는지 다시 숫자로 찍어보기

입력: ㄱ ㅐ ㅅ ㅅ ㅜ

조합 상황을 단계별로 보면:

  1. ㄱ + ㅐ → 개

    • 받침 없음
  2. 개 + ㅅ → 갯

    • 이때 받침 인덱스는 19 (ㅅ)
  3. 갯 + ㅅ

여기서:

  • finaleIndex = 19 (현재 받침 ㅅ)

  • nextFinalIndex = this.indexOf(this.finale, 'ㅅ') = 19

그러면:

  • (finaleIndex * 100) + nextFinalIndex = 19 * 100 + 19 = 1919

  • 엔진이 this.indexOf(this.dFinale, 1919)를 찾음 → 그 결과가 20이라 가정

  • 즉 dFinale[20] = 1919 이니까
    “새로운 받침 인덱스 20(ㅆ)을 쓰자”는 뜻

그래서 받침이 ㅅ에서 ㅆ으로 바뀌고, 글자는 “갰”이 됩니다.

  1. 그 다음 ㅜ를 넣으면:
  • 받침 ㅆ(20)을 쪼개서 초성으로 넘기고,
    뒤에 ㅜ를 붙여서 “쑤”를 만들고

  • 앞 글자는 개 그대로 남고

결과: 개 + 쑤 → “개쑤”


4. 왜 1919를 0으로 바꾸면 해결되는가?

이제 dFinale 정의로 돌아가서:

js

this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 1919];

여기서 마지막 요소 1919를 0으로 바꾸면:

js

this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 0];

이제 무슨 일이 생기냐면:

  • “ㅅ(19) + ㅅ(19)” → (19 * 100) + 19 = 1919

  • 엔진이 indexOf(this.dFinale, 1919)를 해도,
    배열 안에 1919가 없기 때문에 -1을 리턴

  • 그러면 “겹받침으로 합치는 로직”이 실행되지 않음

즉:

  • 갯 + ㅅ 에서 “ㅆ 받침으로 교체하는 단계”가 사라지고

  • ㅅ은 새 글자의 초성으로 쓰일 기회가 생김

  • 다음에 ㅜ가 들어오면 “수”가 되고

  • 최종: “갯수”

이게 “1919를 0으로 바꾸면 갯수가 정상적으로 만들어지는 이유”예요.


요약하면:

  • 1919는 “받침 ㅅ(19) + ㅅ(19)”라는 두 인덱스를
    하나의 숫자로 인코딩한 값

  • dFinale 안에서 1919가 들어 있는 위치(배열 인덱스)가
    “새로운 겹받침(ㅆ)의 인덱스” 역할을 함

  • 그걸 지우거나 예외 처리해주면
    ㄱㅐㅅㅅㅜ가 “갯수”로 조합되도록 바꿀 수 있음

즉, 1919는 마법 숫자가 아니라
“(prevIndex * 100) + nextIndex”라는 단순한 규칙의 결과물이에요.
그래서 이해하고 나면 수정도 마음 편히 할 수 있습니다.

Categories:

Updated: