junyeokk
Blog
react-internals·2024. 09. 28

합성 이벤트 시스템 (Synthetic Event System)

브라우저에서 버튼 클릭을 처리하려면 addEventListener를 사용한다. 그런데 리스트 아이템이 1000개 있고, 각각에 클릭 핸들러가 필요하다면? 1000개의 이벤트 리스너를 등록하면 메모리 낭비가 심각해진다. 그리고 브라우저마다 이벤트 객체의 속성이나 동작이 미묘하게 다르다. IE에서는 event.target 대신 event.srcElement을 써야 했고, event.preventDefault() 대신 event.returnValue = false를 써야 했다.

React의 합성 이벤트 시스템은 이 두 가지 문제를 동시에 해결한다. 이벤트 위임(Event Delegation)으로 리스너 수를 최소화하고, 합성 이벤트 객체(SyntheticEvent)로 브라우저 차이를 추상화한다.


이벤트 위임이란

이벤트 위임은 DOM의 이벤트 버블링을 활용한 패턴이다. 개별 요소에 리스너를 달지 않고, 상위 요소 하나에 리스너를 달아서 하위 요소의 이벤트를 한 곳에서 처리한다.

javascript
// ❌ 개별 등록 — 아이템이 늘어날수록 리스너도 늘어남
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// ✅ 이벤트 위임 — 리스너 하나로 모든 아이템 처리
document.querySelector('.list').addEventListener('click', (e) => {
  if (e.target.matches('.item')) {
    handleClick(e);
  }
});

이벤트 버블링은 DOM 트리 하위에서 발생한 이벤트가 상위 노드를 따라 올라가는 브라우저 기본 동작이다. 버튼을 클릭하면 그 이벤트는 button → div → body → html → document 순서로 올라간다. 상위 노드에서 event.target을 확인하면 실제로 어떤 요소에서 이벤트가 발생했는지 알 수 있다.

버블링과 캡처링

DOM 이벤트에는 세 가지 전파 단계가 있다.

캡처링 (위 → 아래) 버블링 (아래 → 위) document 1 ──────────┐ ┌────────── 7 └─ html 2 ────────┐ │ │ ┌──────── 6 └─ body 3 ──────┐ │ │ │ │ ┌────── 5 └─ button │ └─┴─┴─ 4 ─┴─┴─┘ │ (타겟)
  1. 캡처링 단계: 이벤트가 최상위(document)에서 타겟 요소까지 내려감
  2. 타겟 단계: 실제 이벤트가 발생한 요소에 도달
  3. 버블링 단계: 타겟에서 다시 최상위까지 올라감

addEventListener의 세 번째 인자로 true를 전달하면 캡처링 단계에서 이벤트를 잡을 수 있다. 기본값은 false(버블링 단계).

javascript
                   캡처링 (위 → 아래)     버블링 (아래 → 위)
document              1 ──────────┐     ┌────────── 7
  └─ html             2 ────────┐ │     │ ┌──────── 6
      └─ body         3 ──────┐ │ │     │ │ ┌────── 5
          └─ button   │       └─┴─┴─ 4 ─┴─┴─┘
                      │         (타겟)

React에서 onClickCapture 같은 Capture 접미사 핸들러가 바로 이 캡처링 단계에서 동작하는 것이다.


React의 이벤트 위임 구조

React는 컴포넌트에 onClick을 작성해도 실제 DOM 요소에 직접 리스너를 달지 않는다. 대신 루트 컨테이너(React 18 기준 root.render()를 호출한 DOM 노드)에 지원하는 모든 이벤트 타입의 리스너를 등록한다.

jsx
// 캡처링 단계에서 잡기
element.addEventListener('click', handler, true);
// 또는
element.addEventListener('click', handler, { capture: true });

위 코드에서 button DOM 요소에는 아무 리스너도 달리지 않는다. 루트 컨테이너에 등록된 click 리스너가 버블링으로 올라온 이벤트를 잡고, 내부적으로 React 파이버 트리를 거슬러 올라가면서 해당 경로에 있는 onClick 핸들러를 순서대로 실행한다.

지원하는 이벤트 목록

이벤트 시스템을 구현할 때 어떤 이벤트를 위임할지 정의해야 한다. 보통 Set 자료구조로 관리한다.

javascript
function App() {
  return (
    <div>
      <button onClick={() => console.log('clicked!')}>
        Click me
      </button>
    </div>
  );
}

이 Set에 포함된 이벤트들만 루트에 리스너를 등록한다. Set을 사용하는 이유는 has() 검색이 O(1)이라 props에서 이벤트 핸들러를 분류할 때 빠르기 때문이다.

리스너 등록 타이밍

루트에 이벤트 리스너를 등록하는 시점은 앱이 마운트될 때다. Set의 모든 이벤트에 대해 루트 컨테이너에 리스너를 건다.

javascript
const defaultEvents = new Set([
  // 클립보드
  "copy", "cut", "paste",

  // 컴포지션 (IME 입력)
  "compositionend", "compositionstart", "compositionupdate",

  // 포커스
  "focus", "blur",

  // 폼
  "change", "input", "submit",

  // 키보드
  "keydown", "keypress", "keyup",

  // 마우스
  "click", "contextmenu", "dblclick",
  "mousedown", "mouseenter", "mouseleave",
  "mousemove", "mouseout", "mouseover", "mouseup",

  // 드래그
  "drag", "dragend", "dragenter", "dragexit",
  "dragleave", "dragover", "dragstart", "drop",

  // 터치
  "touchcancel", "touchend", "touchmove", "touchstart",

  // 스크롤
  "scroll", "wheel",
]);

이렇게 하면 컴포넌트가 아무리 많아도 이벤트 리스너 수는 이벤트 타입 수 × 2(버블링 + 캡처링)로 고정된다.


SyntheticEvent 객체

브라우저 네이티브 이벤트 객체를 그대로 쓰지 않고, React는 이를 감싸는 SyntheticEvent 객체를 만든다.

javascript
function setupEventDelegation(rootContainer) {
  defaultEvents.forEach(eventName => {
    // 버블링 단계 리스너
    rootContainer.addEventListener(eventName, (nativeEvent) => {
      dispatchEvent(eventName, nativeEvent);
    });

    // 캡처링 단계 리스너 (onClickCapture 등을 위해)
    rootContainer.addEventListener(eventName, (nativeEvent) => {
      dispatchCaptureEvent(eventName, nativeEvent);
    }, true);
  });
}

왜 래핑하는가

  1. 브라우저 호환성: 모든 브라우저에서 동일한 인터페이스를 보장한다. event.target이 없는 구형 브라우저에서도 동일하게 동작한다.

  2. stopPropagation 제어: React의 가상 트리에서의 전파를 제어할 수 있다. 네이티브 stopPropagation은 DOM 트리 기준이지만, React의 것은 React 컴포넌트 트리 기준이다.

  3. 이벤트 풀링(Event Pooling): React 16 이하에서는 SyntheticEvent 객체를 재사용했다. 이벤트 핸들러 실행 후 모든 속성을 null로 초기화하고 풀에 반환해서 GC 부담을 줄였다. React 17부터는 성능 향상보다 혼란이 크다고 판단해서 폐기됐다.

javascript
class SyntheticEvent {
  constructor(nativeEvent) {
    this.nativeEvent = nativeEvent;
    this.target = nativeEvent.target;
    this.currentTarget = null; // dispatch 과정에서 설정됨
    this.type = nativeEvent.type;
    this.timeStamp = nativeEvent.timeStamp;
    this._propagationStopped = false;
    this._defaultPrevented = false;
  }

  preventDefault() {
    this._defaultPrevented = true;
    this.nativeEvent.preventDefault();
  }

  stopPropagation() {
    this._propagationStopped = true;
    this.nativeEvent.stopPropagation();
  }
}

이벤트 디스패치 흐름

이벤트가 발생했을 때 React 내부에서 일어나는 과정을 순서대로 보면:

1단계: 네이티브 이벤트 캡처

사용자가 버튼을 클릭하면 브라우저가 네이티브 click 이벤트를 발생시킨다. 이 이벤트가 버블링으로 루트 컨테이너까지 올라오면 React가 등록해둔 리스너가 잡는다.

2단계: 타겟 파이버 찾기

event.target (실제 클릭된 DOM 노드)에서 대응하는 React 파이버 노드를 찾는다. React는 DOM 노드에 내부 프로퍼티(__reactFiber$...)를 달아놓기 때문에 O(1)로 찾을 수 있다.

3단계: 핸들러 수집

타겟 파이버에서 루트까지 올라가면서 경로에 있는 모든 파이버의 해당 이벤트 핸들러를 수집한다.

javascript
// React 16에서의 문제 — 비동기에서 이벤트 접근 불가
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target); // null! 풀링 때문에 초기화됨
  }, 100);

  // 풀링을 우회하려면:
  e.persist(); // 풀에서 제거
}

// React 17+ — 풀링 없음, 그냥 동작함
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target); // 정상 동작
  }, 100);
}

4단계: SyntheticEvent 생성 및 디스패치

수집한 핸들러를 순서대로 실행한다. 이때 currentTarget을 각 핸들러의 파이버에 대응하는 DOM 노드로 설정해준다.

javascript
function collectHandlers(targetFiber, eventName) {
  const handlers = [];
  let fiber = targetFiber;

  while (fiber) {
    const handler = fiber.pendingProps?.[eventName];
    if (handler) {
      handlers.push({ fiber, handler });
    }
    fiber = fiber.return; // 부모 파이버로 이동
  }

  return handlers;
}

// 버블링: 타겟 → 루트 (수집 순서 그대로)
// 캡처링: 루트 → 타겟 (배열을 뒤집어서 실행)

전체 흐름 다이어그램

사용자 클릭 ↓ 브라우저 네이티브 이벤트 발생 ↓ 버블링 → 루트 컨테이너의 리스너가 잡음 ↓ event.target → 대응하는 React 파이버 찾기 ↓ 파이버 트리를 올라가며 핸들러 수집 ↓ SyntheticEvent 객체 생성 ↓ 수집된 핸들러를 순서대로 실행 ↓ stopPropagation이면 중단

React 17에서 달라진 점

React 16까지는 모든 이벤트를 document에 등록했다. React 17부터는 루트 컨테이너에 등록하도록 변경됐다.

javascript
function dispatchEvent(eventName, nativeEvent) {
  const targetFiber = getFiberFromDOM(nativeEvent.target);
  const syntheticEvent = new SyntheticEvent(nativeEvent);

  // onClick → "onClick"을 찾아야 함
  const reactPropName = `on${eventName[0].toUpperCase()}${eventName.slice(1)}`;
  const handlers = collectHandlers(targetFiber, reactPropName);

  for (const { fiber, handler } of handlers) {
    syntheticEvent.currentTarget = getDOMFromFiber(fiber);
    handler(syntheticEvent);

    if (syntheticEvent._propagationStopped) break;
  }
}

이 변경이 중요한 이유는 마이크로 프론트엔드 때문이다. 한 페이지에 여러 React 앱이 공존할 때, 모두 document에 리스너를 달면 이벤트가 충돌한다. 루트 컨테이너에 달면 각 앱이 독립적으로 이벤트를 관리할 수 있다.

html
사용자 클릭

브라우저 네이티브 이벤트 발생

버블링 → 루트 컨테이너의 리스너가 잡음

event.target → 대응하는 React 파이버 찾기

파이버 트리를 올라가며 핸들러 수집

SyntheticEvent 객체 생성

수집된 핸들러를 순서대로 실행

stopPropagation이면 중단

앱 A 내부에서 stopPropagation()을 호출해도 앱 B의 이벤트에 영향을 주지 않는다.


props에서 이벤트 핸들러 분류하기

컴포넌트의 props에는 이벤트 핸들러와 일반 속성이 섞여 있다. 이를 분류하는 로직이 필요하다.

javascript
// React 16
document.addEventListener('click', reactClickHandler);

// React 17+
rootContainer.addEventListener('click', reactClickHandler);

직접 등록 vs 위임

모든 이벤트가 위임에 적합하지는 않다. focus, blur는 버블링이 되지 않기 때문에 캡처링 단계에서 잡거나 focusin, focusout으로 대체해야 한다. scroll 이벤트도 특정 요소의 스크롤은 버블링되지 않는다.

javascript
<div id="app-a"></div>  <!-- React 앱 A의 루트 -->
<div id="app-b"></div>  <!-- React 앱 B의 루트 -->

직접 구현해보기: 간단한 이벤트 위임 시스템

위 개념들을 종합해서 간단한 이벤트 위임 시스템을 만들어보자.

javascript
function isEventProp(key) {
  // "on"으로 시작하고 세 번째 글자가 대문자이면 이벤트 핸들러
  return key.startsWith('on') && key[2] === key[2]?.toUpperCase();
}

function getEventName(propKey) {
  // "onClick" → "click", "onMouseDown" → "mousedown"
  return propKey.slice(2).toLowerCase();
}

function setProps(domElement, props) {
  Object.entries(props).forEach(([key, value]) => {
    if (key === 'children') return;

    if (isEventProp(key)) {
      const eventName = getEventName(key);
      // 이벤트 위임이면 여기서 등록하지 않고 파이버에 저장만
      // 직접 등록 방식이면 여기서 addEventListener
      domElement.addEventListener(eventName, value);
    } else {
      domElement.setAttribute(key, String(value));
    }
  });
}

사용 예:

javascript
// 버블링되지 않는 이벤트 처리
const nonBubblingEvents = new Set(['focus', 'blur', 'scroll', 'load', 'error']);

function setupEventListener(rootContainer, eventName) {
  if (nonBubblingEvents.has(eventName)) {
    // 캡처링 단계에서 잡기
    rootContainer.addEventListener(eventName, handler, true);
  } else {
    rootContainer.addEventListener(eventName, handler);
  }
}

WeakMap을 쓰는 이유

핸들러 맵에 WeakMap을 사용하면 DOM 노드가 제거될 때 연관된 핸들러도 자동으로 가비지 컬렉션된다. 일반 Map이었다면 DOM 노드를 키로 들고 있기 때문에 노드가 화면에서 제거돼도 GC되지 않아 메모리 누수가 발생한다.

javascript
class EventSystem {
  constructor(rootContainer) {
    this.root = rootContainer;
    this.handlerMap = new WeakMap(); // DOM 노드 → { eventName: handler }
    this.registeredEvents = new Set();
  }

  // 특정 DOM 노드에 대한 핸들러 등록 (실제로 addEventListener는 안 함)
  bindHandler(domNode, eventName, handler) {
    if (!this.handlerMap.has(domNode)) {
      this.handlerMap.set(domNode, {});
    }
    this.handlerMap.get(domNode)[eventName] = handler;

    // 이 이벤트 타입을 아직 루트에 등록 안 했으면 등록
    if (!this.registeredEvents.has(eventName)) {
      this.registeredEvents.add(eventName);
      this.root.addEventListener(eventName, (e) => this.dispatch(eventName, e));
    }
  }

  // 이벤트 디스패치
  dispatch(eventName, nativeEvent) {
    let target = nativeEvent.target;

    // 타겟에서 루트까지 올라가며 핸들러 찾기 (버블링 시뮬레이션)
    while (target && target !== this.root) {
      const handlers = this.handlerMap.get(target);
      if (handlers && handlers[eventName]) {
        const syntheticEvent = {
          target: nativeEvent.target,
          currentTarget: target,
          nativeEvent,
          type: eventName,
          _stopped: false,
          preventDefault() { nativeEvent.preventDefault(); },
          stopPropagation() { this._stopped = true; },
        };

        handlers[eventName](syntheticEvent);
        if (syntheticEvent._stopped) break;
      }
      target = target.parentNode;
    }
  }

  // 핸들러 제거
  unbindHandler(domNode, eventName) {
    const handlers = this.handlerMap.get(domNode);
    if (handlers) {
      delete handlers[eventName];
    }
  }
}

주의할 점

stopPropagation의 두 가지 레벨

React의 e.stopPropagation()은 React 트리 내에서의 버블링만 막는다. 네이티브 DOM 이벤트의 버블링은 이미 루트까지 올라온 상태이므로, React 바깥의 네이티브 리스너에는 영향을 주지 않을 수 있다.

javascript
const root = document.getElementById('app');
const eventSystem = new EventSystem(root);

// 버튼에 핸들러 등록 (실제 addEventListener는 root에만 1회)
const button = document.querySelector('.my-button');
eventSystem.bindHandler(button, 'click', (e) => {
  console.log('클릭됨:', e.currentTarget);
});

// 동적으로 추가된 요소도 자동으로 동작
// (부모에 리스너가 있으므로 자식이 추가/제거돼도 상관없음)

이벤트 핸들러와 리렌더링

이벤트 핸들러 함수는 렌더링할 때마다 새로 생성된다. 이벤트 위임 방식에서는 DOM에 직접 리스너를 달지 않으므로 removeEventListener/addEventListener를 반복할 필요가 없다. 파이버의 props에 저장된 함수 참조만 업데이트하면 된다.

jsx
// WeakMap — DOM 노드가 제거되면 엔트리도 자동 GC
const handlerMap = new WeakMap();
handlerMap.set(domNode, { click: handler });
// domNode가 DOM에서 제거되고 참조가 사라지면 → 엔트리도 GC

// Map — DOM 노드가 제거돼도 Map이 참조를 유지 → 메모리 누수
const handlerMap = new Map();
handlerMap.set(domNode, { click: handler });
// domNode가 DOM에서 제거돼도 Map이 key로 참조 → GC 안 됨

이것이 useCallback이 이벤트 핸들러 성능 최적화에 큰 의미가 없는 이유 중 하나다. DOM 리스너 재등록 비용이 없기 때문이다. useCallback이 진짜 의미 있는 건 자식 컴포넌트에 props로 넘길 때 불필요한 리렌더링을 방지하는 경우다.


관련 문서