브라우저의 텍스트 입력 처리 과정

#에디터#JavaScript#브라우저

브라우저 이벤트(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 조합에 따라 이벤트의 발생 여부나 순서가 일부 달라질 수 있습니다.)

구분이벤트 발생 순서
영문keydownbeforeinputinputkeyup
한글keydowncompositionstartcompositionupdateinputkeyup

영문은 "키 입력 → 텍스트 확정"이 빠르게 끝나지만, 한글은 조합 상태가 존재합니다.

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는 keydowncompositionupdateinput 순서가 예측 가능합니다. 하지만 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;
});

안전한 입력 처리를 위한 규칙

  1. 조합 중 Selection 변경 금지
  2. 조합 중 DOM 정리/초기화 금지 (필요하면 조합 종료 후로 지연)
  3. beforeinput/input에서 inputType 기반 라우팅으로 입력/삭제/붙여넣기 분리

트러블슈팅

  1. 이벤트 로깅으로 실제 순서/누락 이벤트를 확인
  2. 조합 중 DOM/selection/focus를 만지는 코드를 역추적
  3. iOS는 실기기에서 검증(시뮬레이터와 다를 수 있음)

결론

"브라우저가 잘하는 건 맡기고, JS는 최소 침범"

IME와 selection은 브라우저/OS가 가장 잘합니다. JS가 입력을 직접 구현하려고 하면 플랫폼/언어 예외가 끝없이 늘어납니다. 따라서 입력은 브라우저가 처리하게 두고, JS는 beforeinput/input 기반으로 조용히 동기화하며, composition 중에는 DOM/selection/레이아웃 변경을 최대한 늦추는 전략이 가장 안정적입니다.