
증상부터 보시죠
직접 만든 웹 채팅 UI에서 이런 일이 있었는데요. 한글로 문장을 입력하고 Enter를 눌렀더니, 메시지가 정상적으로 전송된 다음에 마지막 글자 한 개짜리 메시지가 하나 더 전송되는 현상이 생겼습니다.
사용자: 다음 주 회의 자료 초안
사용자: 안
영문으로 입력할 때는 멀쩡하고, 한글로 칠 때만 그렇습니다. 처음엔 이벤트 리스너를 두 번 등록했나 싶어서 코드를 뒤졌는데 그게 아니었구요. 원인은 훨씬 더 근본적인 곳에 있었습니다. 바로 IME(Input Method Editor, 입력기) 입니다.
한글 입력은 "조합"이라는 중간 상태를 거칩니다
영문은 키를 누르면 그 글자가 곧바로 확정됩니다. a를 누르면 a가 입력되고 끝이구요.
그런데 한글은 다릅니다. ㅇ → 아 → 안 처럼 자모가 모여서 하나의 음절이 만들어지는데, 이 과정 동안 글자는 아직 확정되지 않은 상태로 입력창에 떠 있습니다. 이걸 조합 중(composing) 상태라고 부르고, 브라우저는 이 구간을 이렇게 알려줍니다.
compositionstart → compositionupdate ... → compositionend
compositionend가 발생해야 비로소 "이 글자 확정됐습니다"가 되는 거구요. 일본어, 중국어 입력기도 똑같은 구조입니다.
문제는 Enter 키가 이 조합을 확정하는 키이기도 하다는 점입니다. 한글 입력 도중 Enter를 누르면 브라우저 입장에서는 두 가지 의미가 겹칩니다.
- 조합 중인 글자를 확정해라
- 줄바꿈 / 폼 제출을 해라
이 둘이 같은 키에 물려 있으니 사고가 나는 거죠.
왜 하필 마지막 글자만 남을까요
제 코드는 이렇게 생겼었습니다.
$('chatinput').addEventListener('keydown', ev => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
$('chatform').requestSubmit();
}
});
한 줄씩 따라가 보면 이렇게 됩니다.
- "다음 주 회의 자료 초안"까지 입력합니다. 마지막 안은 아직 조합 중입니다.
- Enter를 누릅니다 → keydown이 발생하고, 핸들러가 폼을 제출합니다.
- 전송 로직이 input.value = ''로 입력창을 비웁니다.
- 그런데 IME는 아직 자기 일이 안 끝났습니다. 조합 중이던 글자를 확정하면서 비어 있는 입력창에 다시 써 넣습니다.
- 이어서 두 번째 keydown이 올라옵니다. 핸들러가 또 폼을 제출합니다.
- 그 결과, 입력창에 홀로 남아 있던 한 글자가 별도 메시지로 전송됩니다.
핵심은 입력창을 비우는 시점과 IME가 조합을 확정하는 시점이 엇갈린다는 것입니다. 그래서 "마지막 한 글자"라는 독특한 증상이 나오는 거구요. 브라우저와 IME 조합에 따라 남는 글자가 마지막 글자가 아닐 수도 있고, 아예 재현이 안 되는 환경도 있습니다.
해결: 조합 중인 Enter는 무시합니다
고치는 방법 자체는 간단합니다. 조합이 끝난 진짜 Enter만 처리하면 됩니다.
$('chatinput').addEventListener('keydown', ev => {
if (ev.isComposing || ev.keyCode === 229) return; // ← 이 한 줄
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
$('chatform').requestSubmit();
}
});
여기서 조건이 두 개인 게 포인트인데요, 하나씩 보겠습니다.
ev.isComposing
KeyboardEvent.isComposing은 해당 이벤트가 compositionstart와 compositionend 사이에 발생했는지를 알려주는 표준 속성입니다. 조합 중이면 true구요. 원래는 이것만으로 충분해야 합니다.
ev.keyCode === 229
그런데 이것만으로는 부족한 경우가 있습니다. 브라우저와 버전, OS 입력기 조합에 따라 compositionend가 keydown보다 먼저 발생하는 케이스가 있는데요, 이때는 정작 문제가 되는 그 keydown에서 isComposing이 이미 false가 되어 버립니다. 방어가 뚫리는 거죠.
keyCode 229는 그래서 필요합니다. 브라우저는 "이 키 입력은 IME가 처리 중입니다"라는 의미로 keyCode에 229를 실어 보내는데, IME 관련 처리에서는 아직도 가장 신뢰할 만한 신호입니다.
KeyboardEvent.keyCode는 공식적으로 deprecated된 속성입니다. 평소라면 쓰지 말아야 할 물건인데요, IME 판별만큼은 이걸 대체할 표준 방법이 마땅치 않아서 실무에서 여전히 쓰이고 있습니다. 주요 라이브러리들도 같은 조합을 씁니다. 저는 주석을 달아 두는 쪽을 택했습니다.
if (ev.isComposing || ev.keyCode === 229) return; // 한글 IME 조합 중 Enter 무시
안전망 하나 더: 중복 제출 차단
키 이벤트 쪽을 막았어도, 저는 제출 핸들러에 가드를 하나 더 넣었습니다.
$('chatform').addEventListener('submit', async ev => {
ev.preventDefault();
if ($('sendbtn').disabled) return; // 전송 중이면 무시
const t = $('chatinput').value.trim();
if (!t) return;
$('chatinput').value = '';
$('sendbtn').disabled = true;
// ... 전송 로직
$('sendbtn').disabled = false;
});
이건 IME 문제만을 위한 건 아니구요. 사용자가 Enter를 연타하거나, 버튼을 더블클릭하거나, 네트워크가 느려서 응답을 기다리는 동안 또 누르는 상황까지 한 번에 막아 줍니다. 비동기 전송이 걸린 폼에는 사실상 필수라고 생각합니다.
원인을 정확히 짚어서 고치는 게 1번이고, 이런 가드는 2번입니다. 순서를 바꾸면 안 되는데요, 가드만 넣고 넘어가면 "가끔 마지막 글자가 씹힌다" 같은 다른 증상으로 튀어나올 수 있기 때문입니다.
React에서는 이렇게 합니다
React를 쓰신다면 합성 이벤트(SyntheticEvent)를 한 겹 거치기 때문에 조금 다릅니다.
function ChatInput({ onSend }) {
const [text, setText] = useState('');
const composingRef = useRef(false);
const handleKeyDown = (e) => {
// 세 겹 방어
if (composingRef.current) return;
if (e.nativeEvent.isComposing) return;
if (e.nativeEvent.keyCode === 229) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend(text);
setText('');
}
};
return (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onKeyDown={handleKeyDown}
/>
);
}
useState가 아니라 useRef로 조합 상태를 관리하는 게 포인트입니다. 상태 업데이트는 비동기라서, setState로 관리하면 keyDown 시점에 아직 갱신 전 값을 읽을 수 있거든요. 조합 플래그처럼 즉시 읽어야 하는 값은 ref가 맞습니다.
이런 대안도 있긴 합니다
keyup으로 옮기기 — keyup 시점에는 조합이 이미 끝나 있어서 문제가 잘 안 생깁니다. 다만 키를 뗄 때까지 반응이 없어서 미묘하게 굼떠 보이고, Enter를 누른 채로 다른 곳에 포커스가 옮겨가면 keyup을 놓치는 등 다른 엣지 케이스가 생깁니다. 저는 권하지 않습니다.
beforeinput 이벤트 활용 — inputType이 insertLineBreak인지 보는 방식입니다. 더 표준적이긴 한데 브라우저 지원과 동작 편차를 따로 확인하셔야 합니다.
전송 버튼만 두기 — Enter 전송을 아예 포기하는 거죠. 확실하긴 한데 채팅 UI에서는 UX 손해가 큽니다.
결론적으로는 isComposing || keyCode === 229 조합이 가장 무난합니다.
테스트할 때 확인하실 것들
이런 버그는 개발자가 영문으로 테스트하다 보면 배포까지 그대로 통과합니다. 그래서 체크리스트를 만들어 두시면 좋은데요.
- [ ] 한글 문장 입력 후 Enter — 메시지가 한 번만 전송되나요
- [ ] 한글 입력 후 스페이스로 조합을 끊고 Enter — 정상인가요
- [ ] 영문/숫자만 입력 후 Enter — 여전히 잘 되나요
- [ ] Shift+Enter — 줄바꿈이 되나요
- [ ] 마지막 글자가 받침 있는 글자(안)와 없는 글자(아) 양쪽 다
- [ ] Chrome, Safari, Firefox 각각 — 브라우저마다 IME 이벤트 순서가 다릅니다
- [ ] macOS 기본 입력기와 Windows 기본 입력기 양쪽
- [ ] 모바일(iOS/Android) 가상 키보드 — 여기는 또 다른 세계입니다
특히 Safari + macOS 한글 입력기 조합이 가장 잘 터지는 편이니 우선 확인해 보시길 권합니다.
정리하면
한글 IME의 Enter 중복 전송은 십수 년째 반복되는 웹 개발의 고전인데요, 원인만 알면 한 줄로 끝납니다.
if (ev.isComposing || ev.keyCode === 229) return;
기억하실 세 가지는 이렇습니다.
- 한글 입력에는 "조합 중"이라는 중간 상태가 있고, Enter는 그 조합을 확정하는 키이기도 합니다.
- isComposing 하나만으로는 브라우저별 이벤트 순서 차이를 다 막지 못해서, deprecated된 keyCode === 229를 함께 봐야 합니다.
- 비동기 전송 폼에는 중복 제출 가드를 별도로 두시되, 근본 원인 수정을 대체하지는 마세요.
한글로 서비스를 만드신다면 채팅창, 검색창, 댓글창, 태그 입력창 — Enter가 뭔가를 실행하는 모든 입력창에 이 한 줄이 필요합니다. 지금 만들고 계신 프로젝트도 한번 열어서 확인해 보시죠. 높은 확률로 빠져 있을 겁니다.
'front-end development' 카테고리의 다른 글
| GitHub 프로젝트를 내 컴퓨터(로컬)에 연동하는 방법 (초보자 가이드) (0) | 2026.04.13 |
|---|---|
| 웹페이지 기획과 사용자 경험(UX) 디자인, 그리고 실제 구현 과정에서 자주 쓰이는 핵심 용어들 (1) | 2026.04.08 |
| border-radius 값에 따른 테두리 변경 바로 테스트해보기 (0) | 2026.02.04 |
| JavaScript DOM 완벽가이드 - Part 2: DOM 트리 구조 이해하기 (0) | 2026.01.24 |
| JavaScript DOM 완벽가이드 - Part 1: DOM이란 무엇인가? (개념 이해) (0) | 2026.01.23 |