한글 입력을 문자열로 비교하면 안 되는 이유 — IME 조합 중간 상태 다루기
2026.08.03
입력창에 한글을 치는 동안 값이 어떻게 흐르는지 직접 찍어 보면 놀란다. "사과"를 칠 때 onChange로 들어오는 값을 순서대로 나열하면 이렇다.
ㅅ → 사 → 사ㄱ → 사고 → 사과
다섯 번 중 목표 문자열의 앞부분인 것은 사와 사과 둘뿐이다. ㅅ, 사ㄱ, 사고는 모두 "사과".startsWith(value)를 통과하지 못한다. 그런데 이 셋은 오타가 아니라 정상적으로 치고 있는 중간 상태다.
한글 IME는 조합이 끝나지 않은 글자를 그대로 값에 흘린다. 이걸 모르고 문자열 비교로 입력을 검사하면, 제대로 치는 사람에게 오타 경고가 쏟아진다.
왜 이런 값이 나오는가
두벌식은 자모를 순서대로 눌러 글자를 조립한다. ㅅ을 누른 순간에는 아직 초성만 있고, 다음 키가 모음일지 자음일지 IME도 모른다. 그래서 조립 중인 상태를 그대로 보여준다.
더 까다로운 건 사ㄱ → 사고 부분이다. ㄱ을 눌렀을 때 IME는 이게 사의 받침인지 다음 글자의 초성인지 결정을 미룬다. 그 다음 ㅗ가 들어오면 앞 글자에서 떼어내 고로 만든다. 이 재배치가 값에 그대로 반영된다.
자모로 풀어서 비교한다
해결 방향은 하나뿐이다. 글자 단위로 비교하지 않고 자모 단위로 풀어서 비교한다.
유니코드 한글 음절(가~힣)은 규칙적으로 배열되어 있어서 계산으로 분해된다.
const BASE = 0xac00; // '가'
function decompose(ch: string): string[] {
const code = ch.codePointAt(0)!;
if (code < BASE || code > 0xd7a3) return [ch]; // 낱자는 그대로
const index = code - BASE;
const jong = index % 28; // 종성 28가지 (없음 포함)
const jung = Math.floor(index / 28) % 21; // 중성 21가지
const cho = Math.floor(index / (28 * 21)); // 초성 19가지
const parts = [CHO[cho], JUNG[jung]];
if (jong > 0) parts.push(JONG[jong]);
return parts;
}음절 코드가 ((초성 × 21) + 중성) × 28 + 종성 순서로 배열되어 있기 때문에, 나눗셈과 나머지만으로 셋을 되찾을 수 있다. 라이브러리 없이 열 줄이면 끝난다.
이제 사는 [ㅅ, ㅏ], 사과는 [ㅅ, ㅏ, ㄱ, ㅘ]가 된다. 그리고 ㅅ은 [ㅅ]이니 앞부분으로 통과한다.
겹자모라는 함정
여기서 끝나면 좋겠지만 하나가 남는다. 사고를 풀면 [ㅅ, ㅏ, ㄱ, ㅗ]이고 목표인 사과는 [ㅅ, ㅏ, ㄱ, ㅘ]다. 마지막이 ㅗ와 ㅘ로 다르니 여전히 오타로 잡힌다.
ㅘ는 ㅗ와 ㅏ를 이어 눌러 만드는 겹모음이다. 조합이 절반 진행된 상태가 ㅗ인 것이다. 같은 일이 겹받침에서도 일어난다.
그래서 "이 자모의 첫 조각은 무엇인가"를 알려주는 표를 하나 두고, 마지막 자모에 대해서만 그 조각을 허용했다.
const FIRST_PART: Record<string, string> = {
ㅘ: "ㅗ", ㅙ: "ㅗ", ㅚ: "ㅗ", ㅝ: "ㅜ", ㅞ: "ㅜ", ㅟ: "ㅜ", ㅢ: "ㅡ",
ㄳ: "ㄱ", ㄵ: "ㄴ", ㄺ: "ㄹ", ㄻ: "ㄹ", ㅄ: "ㅂ", ㄲ: "ㄱ", ㅆ: "ㅅ",
// …
};
function isOnTrack(typed: string, word: string): boolean {
const a = toJamo(typed);
const b = toJamo(word);
if (a.length > b.length) return false;
// 마지막 자모 앞은 정확히 같아야 한다
for (let i = 0; i < a.length - 1; i++) {
if (a[i] !== b[i]) return false;
}
if (a.length === 0) return true;
// 마지막 자모만 "덜 조합된 상태"를 허용한다
const last = a[a.length - 1];
const target = b[a.length - 1];
return last === target || FIRST_PART[target] === last;
}핵심은 마지막 하나만 관용을 베푸는 것이다. 전체에 관용을 베풀면 각을 치고 있는데 갈도 통과해 버린다. 조합이 진행 중인 글자는 언제나 맨 끝 하나뿐이므로, 앞부분은 엄격하게 봐도 된다.
덤: 두벌식 타수 세기
같은 분해로 타수도 셀 수 있다. 여기서 주의할 게 두 가지다.
- 쌍자음(ㄲㄸㅃㅆㅉ)은 한 타다. 시프트를 같이 누르는 것이라 키 누름이 한 번이다.
- 겹받침과 이중모음은 두 타다.
ㄺ은ㄹ과ㄱ을 따로 누른다.
이걸 구분하지 않으면 낱말마다 타수가 어긋난다. 실제로 세 글자짜리만 모아 놓고 재 봐도 차이가 크다.
글자 수로 속도를 재면 어떤 낱말이 걸렸느냐가 실력을 덮어버린다. 타자 속도를 다루는 기능이라면 음절이 아니라 타수를 세야 공평하다.
React에서: controlled input이 조합을 깨뜨린다
로직을 다 맞춰도 리액트에서 한 번 더 걸린다. 입력값을 상태로 묶는 흔한 방식이 한글 조합을 깨뜨린다.
// 이렇게 하면 조합 중에 글자가 사라진다
<input value={typed} onChange={(e) => setTyped(e.target.value)} />조합 중인 글자는 아직 확정되지 않은 상태로 DOM에 있는데, 리액트가 리렌더에서 value를 도로 써넣으면 IME가 들고 있던 조합 버퍼와 어긋난다. 브라우저와 조합 단계에 따라 글자가 사라지거나 자모가 튄다.
그래서 입력창은 비제어(uncontrolled)로 두고 값은 읽기만 했다.
<input ref={inputRef} onChange={(e) => handle(e.target.value)} />비우는 것도 리액트에 맡기지 않고 직접 한다.
useEffect(() => {
const el = inputRef.current;
if (el) el.value = "";
}, [wordIndex]); // 새 낱말이 뜰 때만화면에 "지금까지 뭘 쳤는지"를 보여줘야 한다면, 입력창 자체를 제어하지 말고 별도의 표시 영역을 만들어 상태로 그리면 된다. 입력과 표시를 분리하는 것이 IME 위에서 안전하다.
테스트로 고정해두기
이 로직은 눈으로 확인하기 어렵다. 브라우저 자동화로도 IME 조합을 재현하기 힘들다. 대신 중간 상태가 어떤 값인지 알고 나면, 그 값을 그대로 넣는 테스트를 쓸 수 있다.
it("조합 중인 낱자를 오타로 보지 않는다", () => {
expect(isOnTrack("ㅅ", "사과잼")).toBe(true);
expect(isOnTrack("사ㄱ", "사과잼")).toBe(true);
});
it("이중모음이 덜 조합된 중간 상태를 통과시킨다", () => {
expect(isOnTrack("사고", "사과잼")).toBe(true);
});
it("다른 글자를 치면 벗어난 것이다", () => {
expect(isOnTrack("바", "사과잼")).toBe(false);
expect(isOnTrack("사자", "사과잼")).toBe(false);
});한 번 써두면 자모 표를 건드릴 때마다 그물이 되어 준다. 나는 과와 밖 케이스를 이 테스트로 잡았다.
직접 겪은 일: 타자 게임을 만들다 부딪혔다
이 문제를 문서로 배운 게 아니라, 타자 농구라는 게임을 만들다 만났다. 화면에 뜬 세 글자 낱말을 치면 그 속도가 슛 파워가 되는 게임이다.
처음 판정을 startsWith로 짰더니, 제대로 치는 사람이 계속 오타 판정을 받았다. 위에 적은 그대로다. 자모로 풀어 비교하도록 바꾸고 나서야 정상으로 돌았다.
그런데 한 번 더 걸리는 데가 있었다. 파워를 재는 기준이다.
속도를 음절 수로 재면 어떤 낱말이 걸렸느냐가 실력을 덮어버린다. 같은 세 글자라도 실제로 눌러야 하는 타수가 다르기 때문이다.
아이들→ 7타닭갈비→ 9타
아이들이 걸린 사람은 같은 손 속도로도 2타를 덜 친다. 음절로 세면 그냥 운이 좋은 것이 실력으로 기록된다. 그래서 파워를 음절이 아니라 두벌식 실제 타수로 계산한다.
같은 코드가 두 가지 일을 한다는 점이 여기서 편했다. 조합 중간 상태를 판정하려고 어차피 자모로 풀어야 했고, 풀어놓은 자모의 개수가 곧 타수였다. 두 값이 서로 다른 규칙을 쓰지 않도록 한 군데서 뽑아 쓰고, 낱말별 타수는 테스트로 고정해뒀다.
정리하며
한글 입력을 다루는 규칙은 결국 하나로 줄었다. 글자로 비교하지 말고 자모로 비교하고, 마지막 하나에만 관용을 베푼다.
검색창의 실시간 필터, 채팅의 입력 검증, 타자 연습, 폼의 즉시 유효성 검사 — 입력 중간값을 검사하는 곳이면 어디든 같은 문제를 만난다. 영어로 개발하고 테스트하면 절대 보이지 않고, 한국어 사용자만 겪는다는 점이 특히 고약하다.
라이브러리를 찾기 전에 유니코드 배열 규칙을 한 번 들여다보는 것을 권한다. 열 줄이면 분해가 되고, 무엇을 관용할지는 결국 내 화면의 요구사항이라 직접 정하는 편이 낫다.