React 이벤트 시스템 살펴보기

23 min read

우리가 React로 웹 어플리케이션을 만든다고 가정하면 페이지마다 수많은 이벤트들이 포함되게 됩니다. 개발자의 시선에서는 단순히 onClick에 함수를 넣어주는 것으로 끝나지만 React에서는 우리가 등록해놓은 무수히 많은 이벤트를 효율적으로 관리해야 합니다. 이번 글에서는 이러한 이벤트들을 React에서 어떻게 관리하는지 살펴보도록 하겠습니다.

이벤트 버블링과 캡처링

이벤트 버블링 개념

이를 이해하기 위해서는 먼저 이벤트 버블링(Event Bubbling)의 개념을 알아야 합니다. 버블하면 가장 먼저 떠오르는 개념은 버블 정렬(Bubble Sort)입니다. 아래 그림과 같이 인접한 두 원소를 비교하여 조건에 따라 계속해서 교환하는데 이 과정이 마치 거품이 수면 위로 올라오는 것처럼 보여서 생긴 이름입니다.

버블링(Bubbling)도 이와 유사합니다. depth를 지닌 엘리먼트들이 존재할 때 자식 요소에서 이벤트가 발생한 경우 가장 최상단까지 타고 올라가면서 이벤트 핸들러를 실행합니다. 아래 코드를 보면 child에 해당하는 엘리먼트를 클릭하게 되면 child -> parent -> grand-parent 순으로 이벤트 핸들러가 실행됩니다.

<div id='grand-parent' onClick={console.log('grand parent')}>
  <div id='parent' onClick={console.log('parent')}>
    <div id='child' onClick={console.log('child')}>
    </div>
  </div>
</div>

이벤트 버블링 예제

이러한 이벤트 버블링의 특성으로 자바스크립트와 HTML로 코드를 구성할 때 의도하지 않은 문제가 발생할 수 있습니다. 다음의 예제를 살펴보겠습니다. 아래와 같이 간단한 상품 카드와 장바구니에 대한 HTML 코드가 있습니다. 이때 "장바구니 담기" 버튼을 누르면 장바구니에 추가되는 것이 우리의 목적입니다.

<div id="card">
  <p>상품 카드</p>
  <button id="add-button">장바구니 담기</button>
</div>
<ul id="cart"></ul>

따라서 아래와 같이 이벤트 리스너를 추가해주었습니다. 상품 카드를 누르면 상품 상세 페이지로 이동하고 카트 추가 버튼을 누르면 카트 리스트에 상품 정보가 추가될 것처럼 보입니다. 그러나 이벤트 버블링에 의해 상품 정보가 추가된 이후 <button> 부모 엘리먼트인 <div> 태그 엘리먼트의 이벤트 핸들러도 호출되면서 의도치 않은 페이지 이동이 발생하게 됩니다.

const card = document.getElementById('card');
const cart = document.getElementById('cart');
const addToCartBtn = document.getElementById('add-button');
 
card.addEventListener('click', () => {
  window.location.href = 'http://localhost:3000/product/detail';
});
 
addToCartBtn.addEventListener('click', () => {
  addToCart('상품 정보');
});
 
function addToCart(message) {
  cart.innerHTML += `<li>${message}</li>`;
}

stopPropagation

이런 상황을 방지하고자 stopPropagation 메서드를 통해 버블링을 방지할 수 있습니다. 장바구니 추가 버튼 이벤트 핸들러 내에 stopPropagation을 호출함으로써 부모로의 이벤트 버블링을 막아 더 이상 페이지 이동이 발생하지 않게 됩니다.

addToCartBtn.addEventListener('click', event => {
  event.stopPropagation();
  addToCart('상품 정보');
});

이러한 이벤트 버블링을 처음 접한 사람에게는 이런 기능이 도대체 왜 필요한 것인지 의문일 것입니다. 이벤트 위임 내용에서 이벤트 버블링과 캡처링과 같은 이벤트 전파 패턴이 얼마나 강력하게 활용될 수 있는지 자세히 살펴보도록 하겠습니다. 그러기 위해서 먼저 이벤트 캡처링에 대해 알아보도록 하겠습니다.

이벤트 캡처링 개념

또 다른 이벤트 전파 형태인 이벤트 캡처링(Event Capturing)이 존재하는데, 이는 이벤트 버블링의 반대 순서에 가깝습니다. 이벤트 버블링은 대상 요소(target)에서 최상위 요소까지 전파되는 반면, 이벤트 캡처링은 최상위 요소에서 대상 요소까지 전파되는 것입니다. 그렇다면 캡처링 단계와 버블링 단계 중 어떤 것이 먼저일까요?

정답은 캡처링 단계입니다. 아래 그림과 같이 캡처링 단계를 거쳐 대상 요소에 도착한 뒤 버블링 단계를 수행합니다. 캡처링 단계는 아래와 같이 이벤트 리스너를 등록할 때 capture값을 true로 두면 해당 이벤트 핸들러가 캡처링 단계에서 실행되게 됩니다.

document.body.addEventListener('click', handleClick, { capture: true });
container.addEventListener('click', handleClick, { capture: true });
button.addEventListener('click', handleClick);

이벤트 위임

우리는 앞서 살펴본 이벤트 버블링과 이벤트 캡처링을 활용해 이벤트 위임을 구현할 수 있습니다. 이벤트 위임(Event Delegation)은 명칭 그대로 대상의 이벤트를 다른 대상에게 "위임"해주는 것입니다.

이벤트 위임 예제

간단한 할일 목록(TODO) 예제를 기반으로 이벤트 위임을 살펴보도록 하겠습니다. 다음과 같은 할 일 목록 코드에서 할 일 추가, 삭제, 완료에 대한 이벤트가 존재합니다. 이때 최상위 요소인 app에 모든 이벤트를 위임해보도록 하겠습니다.

<div id="app">
  <div>
    <input type="text" id="todo-input" placeholder="할 일을 입력하세요">
    <button id="add-btn">추가</button> <!-- 할 일 추가 -->
  </div>
  <div id="todo-list">
    <div class="todo-item">
      <span>첫 번째 할 일</span>
      <button class="delete-btn">×</button> <!-- 할 일 삭제 -->
      <button class="complete-btn">done</button> <!-- 할 일 완료 -->
    </div>
  </div>
</div>

아래와 같이 최상단 요소인 app에 대하여 단 한 개의 리스너를 등록해두고 리스너 내부에서 버블링된 이벤트 대상을 식별하여 각각의 이벤트를 처리해줄 수 있습니다.

const app = document.getElementById('app');
 
app.addEventListener('click', event => {
  // 이벤트 발생 시 버블링되어 올라온 target에 대하여 이벤트 처리
});

아래와 같이 target 객체의 정보에 따라 각각의 이벤트를 하나의 이벤트 리스너 내부에서 수행하는 코드입니다. 이렇게 이벤트 위임을 사용함으로써 할 일 목록 아이템이 100개, 200개가 되어도 하나의 리스너 내부에서 일관성 있게 처리할 수 있습니다. 또한 DOM 이벤트 리스너가 적기 때문에 브라우저 측면에서 이벤트 관리에 대한 부담(전파 과정에서 가비지 컬렉션 등)이 적어지고 이벤트에 대한 디버깅이 용이해집니다.

if (target.id === 'add-btn') {
  // 할 일 추가 처리
  addTodo();
  return;
}
 
const todoItem = target.closest('.todo-item');
if (!todoItem) return;
 
if (target.classList.contains('delete-btn')) {
  // 할 일 삭제 처리
  removeTodo(todoItem);
  return;
}
 
if (target.classList.contains('complete-btn')) {
  // 할 일 완료 처리
  completeTodo(todoItem);
  return;
}

이벤트 위임 예제 개선하기

물론 위의 코드만 본다면 이벤트가 매우 많아질 때 당연히 코드가 매우 길어져 가독성이 떨어지고 비효율적으로 동작할 가능성이 높습니다. SPA(Single Page Application) 방식으로 구현한다면 이벤트를 관리하는 EventManager 클래스를 두어 해당 클래스 내에서 루트 컨테이너(ex. App)에 이벤트를 등록하는 방식으로 로직을 개선할 수 있습니다.

class EventManager {
  ...
 
  addListener(eventType, selector, callback) {
    const boundCallback = (event) => {
      if (!event.target.closest(selector)) return false;
      callback(event);
    };
    // 기존 리스너 제거 후 새 리스너 추가
    this.$root.removeEventListener(eventType, boundCallback);
    this.$root.addEventListener(eventType, boundCallback);
  }
}

React 이벤트 시스템

당연하게도 React는 앞서 얘기한 이벤트 위임을 바탕으로 이벤트를 처리하도록 설계되어 있습니다. React 뿐만 아니라 Vue, Angular 등 거의 모든 웹 프레임워크에서 이러한 이벤트 위임 패턴을 기본으로 동작합니다. React에서는 다양한 웹 브라우저에 대응하고 이벤트의 동작을 표준화하기 위해 합성 이벤트를 사용하는데 이러한 합성 이벤트를 바탕으로 이벤트를 처리하는 시스템을 어떻게 설계하였는지 간단히 Flow를 기반으로 분석해보도록 하겠습니다.

합성 이벤트 살펴보기

리액트에서 발생한 합성 이벤트를 가장 직관적으로 확인할 수 있는 방법이 있습니다. 바로 onClick 시 넘어오는 이벤트 객체에 대해 로그를 찍어보는 것입니다.

const handleEvent = e => {
  console.log(e);
};
 
<button onClick={handleEvent}>click</button>;

아래와 같이 로그를 보면 이벤트 객체가 SyntheticBaseEvent로 나오는 것을 확인할 수 있습니다. 또한 nativeEvent 속성에서 원본 이벤트 객체가 PointerEvent임을 확인할 수 있습니다.

cover

아래는 각각 Chrome, Safari에서 자바스크립트만을 이용하여 각각 브라우저에서 발생한 버튼 클릭 이벤트입니다. Chrome에서는 PointerEvent, Safari에서는 MouseEvent로 속성이 조금씩 다름을 확인할 수 있습니다.

cover cover

이처럼 리액트는 브라우저에서 발생한 이벤트(Native Event)를 리액트만의 표준 이벤트인 합성 이벤트(Synthetic Event)로 변환하여 모든 브라우저에서 동일하게 작동하는 표준 이벤트 시스템을 제공합니다. 모든 브라우저에서 동일하게 동작하게 설계되었기 때문에 개발자 입장에서 코드를 일관성있게 작성할 수 있다는 장점이 있습니다.

React 이벤트 시스템

이제 리액트의 실제 코드에서 이벤트 처리의 핵심적인 내용을 중심으로 파이프라인을 따라가보도록 하겠습니다. 리액트에서 합성이벤트를 어떻게 생성하고 이벤트 위임을 어떻게 활용하는지를 중점으로 살펴보도록 하겠습니다.

Warning

실제 코드는 매우 방대하기 때문에 최대한 핵심적인 내용을 위주로 코드를 간소화해서 살펴보도록 하겠습니다. 직접 코드를 리뷰하다보니 내용에 오류가 포함되있을 수 있으며 피드백을 환영합니다 👐👐

리액트는 루트 컨테이너를 생성하면서 이벤트 리스너를 등록합니다.

export function createRoot(container, options) {
 
  // rootContainer를 설정
  const rootContainerElement = container.nodeType === COMMENT_NODE
    ? container.parentNode
    : container;
 
 
  // rootContainer에 지원하는 이벤트에 대한 리스너 등록
  listenToAllSupportedEvents(rootContainerElement);

listenToAllSupportedEvents는 Native Event Set을 순회하면서 각각의 이벤트마다 개별적으로 리스너를 등록합니다. 이때 이벤트를 루트 컨테이너에 위임하는 일반적인 경우 isCapturePhaseListener를 false로 설정하고 그렇지 않은 nonDelegatedEvents의 경우 true로 설정합니다.

Info

nonDelegatedEvents의 경우 beforetoggle, cancel, close, invalid, load, scroll, scrollend, toggle 등이 포함됩니다. 이러한 이벤트들은 호환성이나 정확성 등의 이유로 인해 해당 이벤트들을 직접 타겟 엘리먼트에 바인딩합니다.

export function listenToAllSupportedEvents(rootContainerElement) {
  ...
  allNativeEvents.forEach(domEventName => {
    if (!nonDelegatedEvents.has(domEventName)) {
      listenToNativeEvent(domEventName, false, rootContainerElement);
    }
    listenToNativeEvent(domEventName, true, rootContainerElement);
  })
}

listenToNativeEvent 함수 내에서 event system flag에 대한 별도의 처리를 한 뒤 addTrappedEventListener를 호출합니다. 함수 내부에서는 다음과 같은 순서로 이벤트 리스너를 등록합니다.

  1. 이벤트 우선순위에 의해 dispatch 함수를 리스너에 할당
  2. passiveListener인지 확인
  3. 캡처 단계, 버블 단계에 대한 이벤트 리스너 등록
function addTrappedEventListener(
  targetContainer,
  domEventName,
  eventSystemFlags,
  isCapturePhaseListener,
  isDeferredListenerForLegacyFBSupport,
) {
  let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
  );
 
  if (passiveBrowserEventsSupported) {
    // ... passive 이벤트 리스너 설정
  }
  
  if (isCapturePhaseListener) {
    // 캡쳐 페이즈 리스너
    unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener);
  } else {
    // 버블 페이즈 리스너
    unsubscribeListener = addEventBubbleListener(targetContainer, domEventName, listener);
  }
}

이때 passiveListener란 이벤트 핸들러가 preventDefault()를 호출하지 않을 것임을 브라우저에게 알려주는 옵션입니다.

if (passiveBrowserEventsSupported) {
  if (
    domEventName === 'touchstart' ||
    domEventName === 'touchmove' ||
    domEventName === 'wheel'
  ) {
    isPassiveListener = true;
  }

이제 리스너가 최종적으로 등록되는 시점은 알았으니 우선순위에 따라 리스너를 생성하는 로직을 살펴보겠습니다. 아래 getEventPriority의 경우 이벤트 이름에 따라 리액트에서 지정한 우선순위를 구별하여 값을 가져옵니다. 여기서 우선순위에 따라 각각 다른 함수를 변수에 할당해주고 있는데 핵심은 모두 같은 dispatchEvent를 기반으로 두고 있다는 것입니다. 결국 클라이언트에서 실제 이벤트가 발생되었을 때 dispatchEvent가 트리거되게 됩니다.

export function createEventListenerWrapperWithPriority(
  targetContainer,
  domEventName,
  eventSystemFlags,
) {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;
    case DefaultEventPriority:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
}

dispatchEvent는 몇 가지 과정을 거쳐 dispatchEventsForPlugins함수에 도달하게 됩니다. 해당 함수에서는 extractEvents -> processDispatchQueue 순으로 호출됩니다.
먼저 extractEvent의 흐름을 살펴보겠습니다.

  1. 해당 함수는 이벤트명을 기준으로 합성이벤트를 생성
  2. target 컨테이너를 기준으로 탐색하며 리스너를 수집 (accumulateSinglePhaseListeners)
  3. 합성이벤트와 리스너들을 dispatchQueue에 삽입

위와 같은 과정을 통해 합성이벤트 생성과 target 컨테이너에서 리스너를 수집하여 등록하였다면 processDispatchQueue 를 통해 적절한 우선순위에 따라 큐 안의 이벤트들을 처리하게 됩니다.

이벤트 시스템 구현

위와 같은 리액트의 접근 방식을 참고하여 합성 이벤트 시스템을 구현해보았습니다. 먼저 지원하는 이벤트명을 기준으로 해당 이벤트에 대한 리스너를 각각 등록합니다.

export function setupEventListeners(root) {
  if (root._eventsInitialized) return;
  supportedEventNames.forEach((eventName) => {
    listenToNativeEvent(root, eventName);
  });
 
  root._eventsInitialized = true;
}

간단히 합성이벤트를 직접 구현해보는데 의의를 두기위해 아래와 같이 핵심적인 이벤트만 포함하고 있습니다. (실제로 지원하는 이벤트는 굉장히 많습니다..!)

export const supportedEventNames = new Set([
  "click",
  "change",
  "input",
  "submit",
  "keydown",
  "keyup",
  "keypress",
  "mousedown",
  "mouseup",
  "mouseover",
  "mouseout",
  "mousemove",
  "focus",
  "blur",
]);

listenToNativeEvent는 위에서 설명한 리액트의 listenToNativeEvent와 유사하게 listener를 만들고 해당 listener를 target(여기에선 root)에 등록합니다. 해당 이벤트가 발생할 경우 리스너가 호출되고, 리스너 내부에서 dispatchEvent를 호출합니다.

function listenToNativeEvent(target, eventType) {
  const listener = (nativeEvent) => {
    dispatchEvent(eventType, target, nativeEvent);
  };
 
  target.addEventListener(eventType, listener, false);
}

이제 dispatchEvent 내부는 아래와 같은 흐름대로 동작합니다.

  1. 네이티브 이벤트를 합성 이벤트로 변환 (extractEvent)
  2. 이벤트 버블링 경로 상의 모든 핸들러 수집 (accumulateListeners)
  3. 수집한 핸들러 핸들러 실행
function dispatchEvent(domEventName, targetContainer, nativeEvent) {
  const syntheticEvent = extractEvent(
    domEventName,
    nativeEvent,
    nativeEvent.target,
  );
  const dispatchQueue = accumulateListeners(
    nativeEvent.target,
    targetContainer,
    domEventName,
  );
  
  for (let i = 0; i < dispatchQueue.length; i++) {
    const { handler, currentTarget } = dispatchQueue[i];
    syntheticEvent.currentTarget = currentTarget;
    // handler에 합성이벤트를 넘겨주기 때문에 디버그 시 이벤트 객체가 합성이벤트로 출력!
    handler(syntheticEvent);
    syntheticEvent.currentTarget = null;
  }
}

합성이벤트는 아래와 같이 domEventName을 기준으로 특정 합성이벤트의 생성자 객체를 할당해줍니다. SyntheticMouseEvent, SyntheticDragEvent등 다양한 합성 이벤트들이 존재하며 모든 합성 이벤트는 SyntheticBaseEvent를 확장하여 사용하고 있습니다.

export function extractEvent(domEventName, nativeEvent, nativeEventTarget) {
  if (!supportedEventNames.has(domEventName)) {
    console.error("Unsupported Event :", domEventName);
    return;
  }
  let SyntheticEventConstructor = SyntheticEvent;
  let eventType = domEventName;
  switch (domEventName) {
    case "click":
    case "mousedown":
    case "mousemove":
    case "mouseup":
    case "mouseout":
    case "mouseover":
    case "contextmenu":
      SyntheticEventConstructor = SyntheticMouseEvent;
      break;
    case "drag":
    case "dragend":
    case "dragenter":
    case "dragexit":
    case "dragleave":
    case "dragover":
    case "dragstart":
    case "drop":
      SyntheticEventConstructor = SyntheticDragEvent;
      break;
   (... 생략)
  }
 
  const event = new SyntheticEventConstructor(
    name,
    eventType,
    nativeEvent,
    nativeEventTarget,
  );
 
  return event;
}
 

일반적으로 이벤트 흐름을 제어하기 위해 네이티브 이벤트에선 기본 동작을 방지하는 preventDefault, 이벤트 전파를 중단하는 stopPropagation 메서드가 존재합니다. 그러나 이러한 메서드를 네이티브 이벤트를 통해 호출하여도 합성 이벤트를 기반으로 구축된 이벤트 시스템에서 이를 인지하기 어렵습니다. 따라서 이벤트 시스템 자체적으로 이러한 상태를 관리하고 추적하기 위해 별도의 메서드로 구현하여 관리해주도록 합니다.

Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault: function () { ... },
    stopPropagation: function () {
      const event = this.nativeEvent;
      if (!event) return;
      if (event.stopPropagation) {
        event.stopPropagation();
      }
      this.isPropagationStopped = () => true;
    },
  });

React의 이벤트 시스템을 모두 이해하기란 굉장히 어려운 일인 것 같습니다. 그럼에도 크로스 브라우저 호환성과 React 자체적인 이벤트 시스템의 표준을 구축하고 이를 추상화하여 제공함으로써 우리도 많은 편의를 얻고 있다는 점을 깨달을 수 있었습니다. 누군가 "React를 왜 사용하시나요?"라고 물어본다면 단순히 생태계가 크다는 것에 그치는 것이 아니라 합성 이벤트와 같이 React에서 추상화하여 제공하는 다양한 기능들 덕분에 개발자 경험(DX)이 향상되기 때문이라고 대답할 수 있을 것 같습니다.

React

댓글 2

LIM-SUVIN

LIM-SUVIN

2025년 03월 04일

퍼가요~

답글 0

서주희

서주희

2025년 04월 04일

퍼가요~

답글 0