한글 서비스를 만들면 반드시 만나는 골칫거리가 있어요. 바로 한글 입력이에요. 영어권에서 만든 에디터는 글자 하나를 키 하나로 딱 찍지만, 우리 한글은 자음과 모음을 조합해서 한 글자를 완성하잖아요. 이 조합이 진행되는 중간 과정을 다루는 게 IME(Input Method Editor, 입력기)인데, 이 중간 과정을 모르고 짠 코드가 한글을 만나면 온갖 버그가 터져요. 오늘은 제가 밤새워 잡았던 한글 입력 버그를, 상황과 코드와 결과로 하나씩 풀어 볼게요.
23.1 조합 중이라는 상태가 왜 중요해요?
한글을 칠 때 기역을 누르면 화면에 기역이 뜨고, 아를 누르면 가로 합쳐지고, 니은을 누르면 간이 돼요. 이 합쳐지는 동안은 아직 글자가 확정되지 않은 조합 중 상태예요. 영어엔 이런 미확정 상태가 없어서, 영어 기준으로 만든 에디터는 입력이 들어오는 족족 바로 처리해도 멀쩡하죠. 그런데 그 코드가 한글을 만나면, 아직 조합 중인 글자를 성급하게 건드려서 글자가 깨져요.
그래서 저는 한글을 다룰 때 가장 먼저 지금 조합 중인지 아닌지를 에디터가 알게 만들어요. 조합 중엔 함부로 안 건드리고 조용히 기다리게 하는 거죠. 이 상태 하나만 잘 챙겨도 한글 버그의 절반은 사라져요.
23.2 조합 상태를 어떻게 알아채요?
다행히 브라우저가 조합의 시작과 끝을 알려 줘요. 조합이 시작되면 compositionstart(조합 시작), 끝나면 compositionend(조합 끝) 이벤트가 오거든요. 조합이 바뀌는 중간중간엔 compositionupdate(조합 갱신)가 오고요. 저는 이 신호를 잡아서 조합 중인지를 표시하는 플래그(flag, 상태 깃발) 하나를 둬요.
let composing = false;
el.addEventListener("compositionstart", () => {
composing = true;
});
el.addEventListener("compositionend", () => {
composing = false;
});
이러면 composing 값만 확인해도 지금 사용자가 글자를 조합하는 중인지 알 수 있어요. 조합을 시작하면 참이 되고, 확정하면 거짓으로 돌아와요. 이 깃발이 오늘 이야기의 열쇠라, 앞으로 나오는 코드가 전부 이 값을 들여다봐요.
23.3 글자가 두 번 찍히는 건 왜 그래요?
가장 흔한 한글 버그가 글자 중복이에요. 안녕이라고 쳤는데 안안녕녕처럼 되는 거죠. 원인은 input(입력) 이벤트예요. 이 이벤트는 조합이 진행되는 중에도 계속 발생하는데, 여기서 편집 내용을 코드로 동기화하면 조합 중인 글자를 확정해 버리고 조합이 실제로 끝날 때 또 넣어서 두 번 찍혀요.
해법은 isComposing이에요. input 이벤트 객체에는 지금 조합 중인지를 알려 주는 isComposing 속성이 있거든요. 조합 중이면 참이라, 이때는 처리를 건너뛰면 돼요.
el.addEventListener("input", (e) => {
if (e.isComposing) return;
syncValue(el.textContent);
});
결과는 명확해요. 조합 중에 오는 input은 전부 return으로 흘려보내고, 조합이 끝난 뒤 딱 한 번만 syncValue가 돌아요. 안안녕녕이 안녕으로 딱 떨어지죠. 앞서 만든 composing 깃발 대신 이벤트가 직접 주는 isComposing을 써도 되는데, 저는 둘을 함께 확인해서 브라우저 편차를 메워요.
23.4 keydown의 229는 대체 뭔가요?
단축키를 붙이다 보면 이상한 걸 만나요. 한글 조합 중에 keydown(키 누름) 이벤트의 keyCode(키 코드)를 찍어 보면 엉뚱하게 229가 나오거든요. 이건 오류가 아니라, 지금 이 키는 입력기가 조합에 쓰는 중이라 실제 키를 알려 줄 수 없다고 브라우저가 말하는 약속된 값이에요. 그래서 조합 중에 keydown의 키 값을 믿고 단축키를 처리하면 오작동해요.
그래서 keydown에서도 조합 여부를 먼저 걸러요.
el.addEventListener("keydown", (e) => {
if (e.isComposing || e.keyCode === 229) return;
handleShortcut(e);
});
keydown 이벤트에도 isComposing이 있어서 조합 중이면 참이에요. 여기에 keyCode 229까지 함께 걸러 주면, 오래된 브라우저에서 isComposing이 없거나 부정확해도 안전해요. 조합 중이 아닐 때만 handleShortcut이 돌아서, 조합에 쓰인 키가 단축키로 새는 일이 없어져요.
23.5 엔터가 두 번 먹는 채팅, 어떻게 막아요?
이게 제일 악명 높아요. 채팅창에서 한글을 조합하다가 엔터를 누르면, 그 엔터는 조합을 확정하는 엔터인데 에디터는 메시지를 보내는 엔터로 처리해서 같은 말이 두 번 전송돼요. 사용자는 조합을 끝내려고 엔터를 눌렀을 뿐인데 말이죠. 저는 이 문의를 정말 많이 받았어요.
해법은 엔터를 처리하기 전에 조합 중이었는지 확인하는 거예요.
textarea.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.isComposing) {
e.preventDefault();
sendMessage(textarea.value);
}
});
e.key가 엔터이면서 조합 중이 아닐 때만 메시지를 보내요. 조합을 확정하는 엔터는 isComposing이 참이라 이 조건에 안 걸리고, 그냥 글자만 확정되죠. 이 !e.isComposing 한 조각이 두 번 전송을 깔끔하게 막아요. 매일 수없이 눌리는 엔터라, 이 한 줄이 주는 체감이 정말 커요.
23.6 화면을 다시 그리는 도구는 왜 조합을 깨요?
상태가 바뀔 때마다 편집 영역을 새로 그리는 프레임워크를 쓰면 또 다른 함정이 있어요. 조합 중에도 인정사정없이 다시 그려서 조합을 깨뜨리거든요. 그려지는 순간 조합 중이던 글자가 날아가거나 자음만 덩그러니 남아요. 그래서 조합 중엔 다시 그리기를 잠시 막아야 해요.
el.addEventListener("input", (e) => {
if (composing) return;
render(el.textContent);
});
el.addEventListener("compositionend", () => {
composing = false;
render(el.textContent);
});
조합 중일 땐 render를 건너뛰고, compositionend가 온 뒤에 딱 한 번 그려요. 조합이 끝난 완성된 글자를 반영하니 화면이 안 깨지죠. 여기서 순서를 조심해야 해요. 일부 브라우저는 compositionend가 마지막 input보다 늦게 오기도 해서, 저는 두 이벤트 어느 쪽에서 그리든 결과가 같도록 맞춰 두고 실제 기기에서 확인해요.
23.7 오늘 정리
한글 입력의 모든 문제는 조합이라는 중간 과정에서 나왔어요. compositionstart와 compositionend로 조합 상태를 잡고, input과 keydown에서는 isComposing으로 조합 중을 걸러 냈죠. 글자 중복은 조합 중 input을 건너뛰어 막고, keydown의 229는 조합 중이라는 신호로 이해했고요. 채팅의 두 번 전송은 !e.isComposing으로, 화면 깨짐은 조합 중 다시 그리기를 미뤄서 잡았어요. 마지막으로 꼭 당부하고 싶은 건, 반드시 한글로 직접 길게 쳐 보라는 거예요. 영어로만 테스트하면 이 버그들은 하나도 안 보이거든요. 한글을 존중하는 에디터가 좋은 에디터예요.