커스텀 가상키보드 입력을 React에서 감지하는 방법-이벤트 버블링의 한계와 Property Descriptor

프론트엔드 개발에서 사용자 인터랙션을 감지하고 상태를 동기화하는 것은 핵심 과제이다. 대부분의 경우 브라우저가 제공하는 네이티브 이벤트(input, change, keydown 등)에 의존하면 되지만, 이 전제가 깨지는 순간이 있다. 바로 외부 시스템이 DOM을 직접 조작하는 경우이다.

대표적인 예가 보안키패드이다. 보안키패드는 키로깅 공격을 방지하기 위해 사용되는 가상 키보드로, 주로 금융권 웹사이트에서 비밀번호, 계좌번호, 주민등록번호 등 민감한 정보를 입력받을 때 사용된다. 국내에서는 은행, 증권사, 카드사 등 대부분의 금융 서비스에서 필수적으로 적용되어 있으며, 공공기관 웹사이트나 본인인증이 필요한 서비스에서도 흔히 볼 수 있다.

하지만 이 문제는 금융권에만 국한되지 않는다. 모바일 앱을 웹뷰로 구현할 때 네이티브 키패드와 연동하는 경우, 수식 입력기나 이모지 피커 같은 커스텀 가상 키보드를 직접 만드는 경우, 또는 서드파티 라이브러리가 input 값을 프로그래머틱하게 조작하는 경우에도 동일한 문제가 발생할 수 있다. 결국 “이벤트 없이 값이 바뀌는 상황” 을 어떻게 감지할 것인가의 문제이다.

이 글에서는 왜 일반적인 이벤트 리스너로는 이러한 입력을 감지할 수 없는지 살펴보고, Object.defineProperty를 활용한 해결책을 다룬다.


문제 상황

보안키패드(가상 키보드)를 통해 입력된 값이 React 컴포넌트의 상태에 반영되지 않는 현상이 발생했다. 분명히 input 필드에는 값이 들어가 있는데, React는 이 변화를 전혀 인지하지 못하고 있었다.

처음 시도한 방법은 다음과 같다.

['keypress', 'focus', 'change'].forEach(event => {
  document.addEventListener(event, () => {
    // input 값 변경 감지 후 React 상태 업데이트 시도
    triggerInputUpdate(input);
  });
});

하지만 이 코드는 전혀 동작하지 않았다.


이벤트 버블링의 이해

먼저 DOM 이벤트가 어떻게 동작하는지 살펴보자.

이벤트 전파의 3단계

DOM 이벤트는 세 단계를 거쳐 전파된다.

1. Capturing Phase (캡처링)
   window → document → html → body → ... → target

2. Target Phase (타겟)
   이벤트가 실제 대상 요소에 도달

3. Bubbling Phase (버블링)
   target → ... → body → html → document → window

addEventListener의 세 번째 인자로 캡처링/버블링 단계 중 언제 핸들러를 실행할지 지정할 수 있다.

// 버블링 단계에서 처리 (기본값)
element.addEventListener('click', handler);
element.addEventListener('click', handler, false);

// 캡처링 단계에서 처리
element.addEventListener('click', handler, true);

사용자 상호작용과 이벤트 발생

일반적인 키보드 입력 시 발생하는 이벤트 순서는 다음과 같다.

keydown → keypress → (값 변경) → input → keyup → change(blur 시)

이 이벤트들은 브라우저가 사용자의 물리적 상호작용을 감지했을 때 자동으로 발생시킨다. 그리고 이벤트 버블링을 통해 상위 요소까지 전파되므로, document에 리스너를 등록해도 하위 요소의 이벤트를 감지할 수 있다.

// document에서 모든 클릭 감지 가능 (버블링 덕분)
document.addEventListener('click', (e) => {
  console.log('클릭된 요소:', e.target);
});

보안키패드는 왜 다른가

보안키패드는 키로깅 방지를 위해 의도적으로 DOM 이벤트를 발생시키지 않는다. (솔루션 제품이나 구현방식에 따라 조금씩 다르다.)

일반 키보드 입력

사용자 키 입력

브라우저가 keydown, keypress, input 이벤트 발생

이벤트 버블링으로 document까지 전파

등록된 이벤트 리스너 실행

보안키패드 입력

보안키패드 UI에서 버튼 클릭

내부적으로 input.value = '암호화된값' 직접 할당

끝 (이벤트 없음)

보안키패드는 JavaScript로 input.value를 직접 조작한다. 이 방식은 keypress, keydown, input, change 등 어떤 네이티브 이벤트도 발생시키지 않는다.

구분일반 키보드보안키패드
keydown✅ 발생❌ 발생 안 함
keypress✅ 발생❌ 발생 안 함
input✅ 발생❌ 발생 안 함
change✅ 발생❌ 발생 안 함
value 변경

따라서 아무리 document에 이벤트 리스너를 등록해도, 발생하지 않는 이벤트는 감지할 수 없다.


해결책: Property Descriptor 활용

이벤트가 발생하지 않는다면, 값의 변경 자체를 가로채야 한다. JavaScript의 Object.defineProperty를 사용하면 가능하다.

Property Descriptor란?

그간 나의 경험으로는 브라우저 네이티브 이벤트를 활용하면 대부분의 이슈들은 해결이 되었기 때문에, 이 속성이 익숙하지 않았다. MDN을 읽으며 기본적인 이해부터 해보자.

JavaScript의 모든 객체 속성(property)은 내부적으로 descriptor를 가지고 있다. descriptor는 해당 속성의 동작 방식을 정의한다.

const obj = { name: 'Kim' };

console.log(Object.getOwnPropertyDescriptor(obj, 'name'));
// {
//   value: 'Kim',
//   writable: true,      // 값 수정 가능 여부
//   enumerable: true,    // for...in 순회 대상 여부
//   configurable: true   // 삭제/재정의 가능 여부
// }

Data Descriptor vs Accessor Descriptor

속성은 두 가지 방식으로 정의할 수 있다.

// Data Descriptor: 값을 직접 저장
Object.defineProperty(obj, 'age', {
  value: 25,
  writable: true
});

// Accessor Descriptor: getter/setter 함수로 값 접근
Object.defineProperty(obj, 'age', {
  get: function() { return this._age; },
  set: function(value) { this._age = value; }
});

Accessor Descriptor를 사용하면 속성에 값을 할당하거나 읽을 때마다 정의한 함수가 실행된다.

input.value의 Setter 재정의

이 원리를 input.value에 적용한다.

function defineSecureInput(inputElement) {
  // HTMLInputElement.prototype에서 원본 getter/setter 가져오기
  const descriptor = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value'
  );
  const originalGetter = descriptor.get;
  const originalSetter = descriptor.set;

  // 이 input 요소의 value 속성만 재정의
  Object.defineProperty(inputElement, 'value', {
    get: function() {
      return originalGetter.call(this);
    },
    set: function(newValue) {
      // 1. 원본 setter로 실제 값 설정
      originalSetter.call(this, newValue);

      // 2. 커스텀 이벤트 발생
      const event = new CustomEvent('custom-keyboard-input', {
        detail: { value: newValue },
        bubbles: true
      });
      this.dispatchEvent(event);
    }
  });
}

왜 prototype에서 가져오는가?

Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')

input.value는 인스턴스 자체가 아닌 HTMLInputElement.prototype에 정의되어 있다. 프로토타입 체인을 통해 모든 input 요소가 이 getter/setter를 공유한다.

개별 요소에 defineProperty를 하면, 프로토타입의 속성을 “가리는(shadow)” 새로운 인스턴스 속성이 생성된다. 이렇게 하면 해당 요소에서만 커스텀 동작이 적용되고, 다른 input 요소들은 영향받지 않는다.


전체 구현

function onReadySecureInput(input) {
  // input 요소 설정
  input.id = 'custom-keyboard-input';
  input.type = 'password';
  input.setAttribute('maxlength', '7');

  // 보안키패드 초기화 (각 솔루션마다 다름)
  window.secureKeypad.init();

  // 커스텀 이벤트 리스너 등록
  document.addEventListener('custom-keyboard-input', (e) => {
    const value = e.detail.value;

    // React 상태 업데이트를 위한 input 이벤트 발생
    input.dispatchEvent(new Event('input', { bubbles: true }));
  });

  // value setter 재정의
  defineSecureInput(input);
}

function defineSecureInput(inputElement) {
  if (!inputElement) return;

  const descriptor = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value'
  );
  const originalGetter = descriptor.get;
  const originalSetter = descriptor.set;

  Object.defineProperty(inputElement, 'value', {
    get: function() {
      return originalGetter.call(this);
    },
    set: function(newValue) {
      originalSetter.call(this, newValue);

      const event = new CustomEvent('custom-keyboard-input', {
        detail: { value: originalGetter.call(this) },
        bubbles: true
      });
      document.dispatchEvent(event);
    }
  });
}

동작 흐름

보안키패드에서 값 입력

input.value = '값' 할당 시도

재정의된 setter 실행

원본 setter로 실제 값 설정

CustomEvent 발생 및 dispatch

등록된 이벤트 리스너에서 감지

React input 이벤트 발생 → 상태 업데이트

주의사항

1. 원본 동작 보존

setter를 재정의할 때 반드시 원본 setter를 호출해야 한다. 그렇지 않으면 실제 값이 설정되지 않는다.

set: function(newValue) {
  // 이 줄이 없으면 값이 실제로 저장되지 않음
  originalSetter.call(this, newValue);
  // ...
}

2. this 바인딩

call(this, ...)를 사용해 올바른 컨텍스트를 유지해야 한다. 화살표 함수를 사용하면 this가 달라질 수 있으니 주의가 필요하다.

3. 성능 고려

모든 값 할당에서 추가 로직이 실행되므로, 필요한 요소에만 적용해야 한다.

4. React와의 통합

React는 자체적으로 input의 value를 관리한다. 커스텀 이벤트 발생 후 input 이벤트를 dispatch해야 React가 변경을 인지한다.

input.dispatchEvent(new Event('input', { bubbles: true }));

정리

방식동작 여부이유
이벤트 리스너 (keypress, change 등)보안키패드는 이벤트를 발생시키지 않음
MutationObservervalue 변경은 DOM 속성 변경이 아님
Object.defineProperty (setter 재정의)값 할당 자체를 가로챔

보안키패드처럼 이벤트 없이 값을 직접 조작하는 경우, 이벤트 버블링에 의존하는 일반적인 패턴은 동작하지 않는다. Object.defineProperty로 property descriptor를 재정의하면 값의 변경 시점을 정확히 포착할 수 있고, 이를 통해 React 등 프레임워크의 상태 관리 시스템과 연동할 수 있다.

이 패턴은 보안키패드 외에도 서드파티 라이브러리가 DOM을 직접 조작하는 상황에서 유용하게 활용할 수 있다.

가상키보드의 값 핸들링을 해보면서 모든 디버깅은 동작 원리의 기본 이해부터 출발한다는 것을 배웠다. 이벤트 버블링, DOM, JS 프로토타입..

그리고 React 패러다임과 그 안의 상태관리 방식에만 익숙해져 있었는데, 넓은 시야로 vanilla JS 생태계를 바라보고 더 잘 활용해야겠다는 생각도 들었다.

글 목록으로