브라우저의 텍스트 입력 처리 과정
브라우저 이벤트(Keyboard/Composition/BeforeInput)와 IME, 그리고 iOS Safari 차이
텍스트 입력은 단순히 문자열을 추가하는 일이 아닙니다. 한 글자가 화면에 찍히는 순간, 뒤에서는 IME 조합(composition), Selection/Range, DOM 업데이트, 포커스, 그리고 레이아웃(줄바꿈/재배치)이 동시에 얽혀 움직입니다.
그래서 텍스트 입력 관련 버그는 대부분 "문자열 처리"가 아니라,
- 무엇을 브라우저(IME/selection)에 맡기고
- 무엇을 JS가 책임질지
- 그리고 어떤 타이밍에 동기화할지
를 잘못 나눌 때 발생합니다.
이번 글에서는 텍스트 입력 브라우저 이벤트 흐름을 정리하고, contentEditable 기반 구조를 다뤄보려 합니다.
브라우저 텍스트 입력 이벤트의 이해
텍스트가 화면에 그려지기까지 브라우저 내부에서는 여러 입력 이벤트가 복잡하게 얽혀 일어납니다. 크게 세 종류로 나눌 수 있는데요.
Keyboard / Composition / Input
Keyboard Events (keydown, keyup)는 물리적 키 입력에 반응합니다. 어떤 키가 눌렸는지 알 수 있지만, 실제로 어떤 문자가 입력될지는 알 수 없습니다. 조합 중인 한글 상태를 정확히 반영하지 못하는 경우도 많습니다.
Composition Events (compositionstart, compositionupdate, compositionend)는 IME 조합 상태를 알려줍니다. 한글, 일본어, 중국어처럼 여러 키 입력이 하나의 문자로 합쳐지는 언어에서 발생합니다.
Input Events (beforeinput, input)는 실제 DOM에 값이 변경되었을 때 발생합니다. inputType으로 "무슨 편집인지" 알 수 있어서, 현대적인 에디터 개발에서 가장 중요한 이벤트입니다.
영문 vs 한글 입력 이벤트 순서 차이
(브라우저/OS/IME 조합에 따라 이벤트의 발생 여부나 순서가 일부 달라질 수 있습니다.)
| 구분 | 이벤트 발생 순서 |
|---|---|
| 영문 | keydown → beforeinput → input → keyup |
| 한글 | keydown → compositionstart → compositionupdate → input → keyup |
영문은 "키 입력 → 텍스트 확정"이 빠르게 끝나지만, 한글은 조합 상태가 존재합니다.
IME 입력에서는 조합 단계에서 composition*이 발생하고, 그 사이에 beforeinput/input이 끼어들 수 있습니다.
다만 환경에 따라 조합 단계에서 beforeinput이 생략되거나, inputType이 다르게 나올 수 있습니다.
isComposing의 의미
isComposing은 현재 문자가 조합 중(예: 'ㄱ'에서 '가'가 되는 과정)인지 알려주는 플래그입니다. KeyboardEvent와 InputEvent 모두에 존재하며, 이 플래그를 통해 엔터 키 입력 시 조합 중인 글자가 중복 입력되는 버그를 방지할 수 있습니다.
inputElement.addEventListener('input', (e) => {
if (e.isComposing) return; // 조합 중일 때는 로직 건너뛰기
console.log('최종 입력 완료:', e.target.value);
});다만 플랫폼별/브라우저별로 타이밍 편차가 있을 수 있어서, 실전에서는 compositionstart/end로 별도 플래그를 관리하고 isComposing은 보조 지표로 활용하는 편이 안정적입니다.
참고: Enter 키 처리에서
isComposing을 자주 사용합니다. 조합 중 Enter는 "확정" 동작일 수 있어 전송/submit을 막는 용도로 쓰입니다. 단,isComposing은 환경에 따라 신뢰도가 다를 수 있어compositionstart/compositionend플래그를 함께 두고 보강하는 편이 안전합니다.
let composing = false;
element.addEventListener('compositionstart', () => (composing = true));
element.addEventListener('compositionend', () => (composing = false));
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.isComposing || composing)) {
e.preventDefault();
}
});이벤트 로깅 코드
입력 이슈가 발생할 시 이벤트를 "그대로" 확인하면 원인을 빨리 알 수 있습니다.
[
'keydown', 'keyup',
'beforeinput', 'input',
'compositionstart', 'compositionupdate', 'compositionend',
].forEach((type) => {
element.addEventListener(type, (e) => {
console.log(type, {
key: e.key,
data: e.data,
inputType: e.inputType,
isComposing: e.isComposing,
});
});
});contentEditable 기반의 텍스트 편집 구조
contentEditable이 브라우저에게 맡기는 것
contentEditable="true" 를 설정하면 브라우저가 해당 요소의 텍스트 편집을 담당하게 됩니다. 브라우저는 다음을 처리합니다:
- 키 입력 → 문자 삽입
- IME 조합 (한글, 일본어 등)
- 커서/Selection 관리
- 기본적인 편집 명령 (복사, 붙여넣기, undo/redo 등)
이 영역은 브라우저/OS가 이미 수많은 케이스를 처리해온 영역이라, 가능한 한 활용하는 편이 안정적입니다.
JS 처리 vs 브라우저 처리 트레이드오프
| JS가 직접 처리 | 브라우저에게 위임 | |
|---|---|---|
| 장점 | 완전한 제어 가능 | IME/다국어 자동 지원 |
| 단점 | IME 구현 필요, 예외 폭발 | 세밀한 제어 어려움 |
JS가 직접 입력을 처리하려면 조합/후보 변환(일본어/중국어), RTL 언어, selection 동작까지 모두 떠안게 됩니다. 현실적으로는 브라우저에게 맡기는 편이 유지보수 관점에서 유리한 경우가 많습니다.
contentEditable의 데이터 흐름 : DOM → 모델
contentEditable을 사용하면 브라우저가 먼저 HTML을 수정하고, JS는 그 결과를 input 이벤트에서 확인해 모델을 동기화하는 구조가 됩니다.
사용자 입력 → 브라우저가 HTML 수정 → input 이벤트 → JS가 모델 업데이트
이 순서를 거스르면 문제가 생깁니다.
텍스트 입력 처리 방향
keydown/compositionupdate에서 DOM 직접 수정
// ❌ 조합 중 DOM을 재작성하면 깨질 확률이 높습니다
element.addEventListener('compositionupdate', (e) => {
element.textContent = transform(element.textContent);
});한글 입력 시 브라우저와 OS의 IME는 특정 DOM 노드(텍스트 노드)를 **"입력 대상으로"**하고 조합을 이어갑니다. 이때 JS가 해당 노드를 수정하거나 삭제하면 문제가 발생합니다.
- 상황: 사용자가 '가'를 입력 중(ㄱ + ㅏ)
- JS의 간섭:
compositionupdate시점에 텍스트를 읽어서 "가"를<span>가</span>로 감싸버림 - 결과: IME는 자신이 관리하던 텍스트 노드가 사라졌다고 판단. 조합이 강제 종료되거나, 커서가 에디터 맨 앞으로 튀거나, 마지막 글자가 중복 입력되는 버그 발생
조합 중에 DOM/selection을 건드리게되면, 브라우저는 입력 위치(editing location)를 잃었다고 판단할 수 있습니다.
추천 방향: beforeinput/input + inputType 라우팅
element.addEventListener('beforeinput', (e) => {
switch (e.inputType) {
case 'insertText':
// 일반 텍스트 삽입
break;
case 'insertCompositionText':
// (환경에 따라 다름) IME 조합 중 - 브라우저에 맡김
break;
case 'deleteContentBackward':
// 백스페이스
break;
case 'insertFromPaste':
// 붙여넣기
break;
}
});beforeinput/input은 실제 편집 행위를 나타내고, inputType으로 의도를 분류할 수 있습니다. JS는 브라우저가 수행한 편집을 기반으로 "동기화"에 집중하고, 조합 중에는 위험한 DOM/selection 변경을 최소화하는 구조를 만들 수 있습니다.
- beforeinput: 사용자의 의도(문자 입력인지, 삭제인지)를 파악.
preventDefault()로 직접 DOM을 그릴 수도 있지만, 한글 조합 중에는 위험합니다. - input: 브라우저가 DOM에 글자를 반영한 직후. 바뀐 DOM 상태를 가져와서 내부 데이터 모델만 업데이트합니다.
조합 중 editing location 고정
IME 조합이 정상 동작하려면 조합 중에 편집 위치가 변하면 안 됩니다.
조합이 깨지는 트리거
| 트리거 | 설명 |
|---|---|
| DOM 초기화/교체 | innerText = '', 노드 교체 |
| Selection 변경 | selection.collapse() 등 |
| 포커스 이동 | focus(), blur() |
| 레이아웃 재배치 | 줄바꿈/페이지 분할로 입력 노드 이동 |
| 스타일/제스처 영향 | user-select 정책 변화 등 |
조합 중 레이아웃 변경 지연 정책
에디터가 텍스트를 재배치해야 할 때(페이지 분할 등), 그 재배치가 selection 변경을 동반하면 조합이 깨집니다. 그래서 조합 중엔 레이아웃 변경을 늦추는 정책이 필요합니다.
let pendingLayoutUpdate = null;
let isComposing = false;
element.addEventListener('compositionstart', () => {
isComposing = true;
});
element.addEventListener('compositionend', () => {
isComposing = false;
if (pendingLayoutUpdate) {
pendingLayoutUpdate();
pendingLayoutUpdate = null;
}
});
function updateLayout() {
if (isComposing) {
pendingLayoutUpdate = () => actualLayoutUpdate();
return;
}
actualLayoutUpdate();
}iOS vs macOS
같은 Safari라도 iOS와 macOS는 입력 이벤트가 다르게 발생하는 경우가 있습니다.
iOS는 beforeinput/input 비중이 큼
macOS는 keydown → compositionupdate → input 순서가 예측 가능합니다.
하지만 iOS는 inputType 기반으로 들어오는 비중이 커서, keydown만 보고 구현하면 한계가 빠르게 옵니다.
iOS 한글 입력의 특징
iOS에서 한글 입력 시:
compositionstart/update/end가 발생하지 않는 경우가 많음insertText+deleteContentBackward조합으로 처리isComposing이 항상 false로 들어오기도 함
'ㄱ' 입력: insertText 'ㄱ'
'가' 완성: deleteContentBackward + insertText '가'
'간' 완성: deleteContentBackward + insertText '간'
macOS에서 되던 코드가 iOS에서 터지는 이유
// macOS에서는 동작
element.addEventListener('keyup', (e) => {
if (!e.isComposing) {
clearBuffer(); // iOS: 매번 실행됨 → 조합 깨짐
}
});iOS는 isComposing이 기대와 다르게(예: false로만) 들어오는 경우도 있어서 매 입력마다 버퍼가 초기화됩니다.
iOS 대응 팁
isComposing대신 compositionstart/end 기반 플래그 + 로그로 보강innerText초기화/노드 교체를 피하고 가능하면,compositionend이후로 지연user-select: none이 있으면caretRangeFromPoint가 동작 안 함 → 부모 스타일 점검
텍스트 입력 처리 시 주의사항
플래그 관리
const state = {
isComposing: false,
lastInputType: null,
};
element.addEventListener('compositionstart', () => {
state.isComposing = true;
});
element.addEventListener('compositionend', () => {
state.isComposing = false;
});
element.addEventListener('beforeinput', (e) => {
state.lastInputType = e.inputType;
});안전한 입력 처리를 위한 규칙
- 조합 중 Selection 변경 금지
- 조합 중 DOM 정리/초기화 금지 (필요하면 조합 종료 후로 지연)
- beforeinput/input에서 inputType 기반 라우팅으로 입력/삭제/붙여넣기 분리
트러블슈팅
- 이벤트 로깅으로 실제 순서/누락 이벤트를 확인
- 조합 중 DOM/selection/focus를 만지는 코드를 역추적
- iOS는 실기기에서 검증(시뮬레이터와 다를 수 있음)
결론
"브라우저가 잘하는 건 맡기고, JS는 최소 침범"
IME와 selection은 브라우저/OS가 가장 잘합니다. JS가 입력을 직접 구현하려고 하면 플랫폼/언어 예외가 끝없이 늘어납니다. 따라서 입력은 브라우저가 처리하게 두고, JS는 beforeinput/input 기반으로 조용히 동기화하며, composition 중에는 DOM/selection/레이아웃 변경을 최대한 늦추는 전략이 가장 안정적입니다.