React는 이벤트를 어떻게 처리할까?

2023-12-22

회사에서 리액트의 js 컴포넌트를 tsx로 전환하는 과정에서 이벤트들에 대한 타입을 어떻게 선언해야 좋을지 궁금했다.

버튼에 대한 onClick 이벤트가 있다면 단순하게 MouseEvent<HTMLButtonElement>로 작성했지만, 생각해보니 어떤 함수에서는 MouseEventHandler<HTMLButtonElement>를 사용했다.

그러다 보니 어떤 상황에서 어떤 인터페이스를 사용하는 것이 더 적절한지 궁금해졌고, 리액트가 자체적으로 이벤트에 대한 래퍼를 갖고 있다는 사실은 알았지만, 내부에서는 어떻게 다루고 있는지도 더 궁금해졌다.


우선 가장 처음에 궁금했던 MouseEventMouseEventHandler의 차이점을 알아보자.

MouseEvent vs. MouseEventHandler

사용 방법은 아래와 같다.

import { MouseEvent, MouseEventHandler } from 'react';

export default function App() {
  // 1. MouseEvent
  const onClick1 = (e: MouseEvent<HTMLButtonElement>) => {
    console.log('onClick1: ', e);
  };

  // 2. MouseEventHandler
  const onClick2: MouseEventHandler<HTMLButtonElement> = (e) => {
    console.log('onClick2: ', e);
  };

  return (
    <div className="App">
      <button onClick={onClick1}>case 1: MouseEvent</button>
      <br />
      <button onClick={onClick2}>case 2: MouseEventHandler</button>
    </div>
  );
}

MouseEvent는 매개변수에 대한 타입이며, MouseEventHandler는 해당 함수 자체에 대한 타입이다. 그리고 두 인터페이스를 보면, MouseEventHandlerMouseEvent를 사용하는 형태인 것을 확인할 수 있다.

@types/react/index.d.ts
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/b580df54c0819ec9df62b0835a315dd48b8594a9/types/react/index.d.ts#L1315
interface MouseEvent<T = Element, E = NativeMouseEvent> extends UIEvent<T, E> {
  altKey: boolean;
  button: number;
  buttons: number;
  clientX: number;
  clientY: number;
  ctrlKey: boolean;
  /**
   * See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method.
   */
  getModifierState(key: ModifierKey): boolean;
  metaKey: boolean;
  movementX: number;
  movementY: number;
  pageX: number;
  pageY: number;
  relatedTarget: EventTarget | null;
  screenX: number;
  screenY: number;
  shiftKey: boolean;
}

type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;

그래서 두 onClick 함수의 e를 찍어보면 동일한 것도 확인 가능하다.

onClick1의 e

onClick1의 e

onClick2의 e

onClick2의 e

MouseEventHandler은 void를 반환하므로, 반환 값이 필요없는 함수일 경우 사용한다.

@types/react/index.d.ts
type EventHandler<E extends SyntheticEvent<any>> = {
  bivarianceHack(event: E): void;
}['bivarianceHack'];

물론 반환 값을 준다고 해도 타입 에러는 뜨지 않지만, 무시 되기 때문에 권장되지 않는다.

onClick1에 return이 추론된다.

onClick1에 return을 주었을 때

onClick2에 return은 무시된다.

onClick2에 return을 주었을 때

참고로 MouseEvent에서 참조하고 있는 NativeMouseEvent는 브라우저의 마우스 이벤트로 MDN > MouseEvent에 설명되어 있다.

MouseEventUIEvent를 상속하고, UIEventEvent를 상속한다 나와 있듯이, 리액트의 타입에도 비슷한 구조로 선언되어 있다.

type NativeMouseEvent = MouseEvent;

interface UIEvent<T = Element, E = NativeUIEvent> extends SyntheticEvent<T, E> {
  detail: number;
  view: AbstractView;
}

그런데 한 가지 다른 점은, 리액트 타입에는 UIEvent에서 바로 Event 타입으로 가는 것이 아닌, SyntheticEvent가 중간에 있다는 점이다.

interface SyntheticEvent<T = Element, E = Event>
  extends BaseSyntheticEvent<E, EventTarget & T, EventTarget> {}

interface BaseSyntheticEvent<E = object, C = any, T = any> {
  nativeEvent: E;
  currentTarget: C;
  target: T;
  bubbles: boolean;
  cancelable: boolean;
  defaultPrevented: boolean;
  eventPhase: number;
  isTrusted: boolean;
  preventDefault(): void;
  isDefaultPrevented(): boolean;
  stopPropagation(): void;
  isPropagationStopped(): boolean;
  persist(): void;
  timeStamp: number;
  type: string;
}

SyntheticEvent

리액트의 SyntheticEvent는 브라우저의 네이티브 이벤트를 감싸는 리액트의 이벤트 래퍼다. 위에서 확인했던 e는 전부 브라우저가 아닌 리액트의 이벤트 객체다. SyntheticEvent는 기본적으로 DOM 이벤트 표준을 따르지만, 일부 브라우저마다 다르게 동작하는 기능을 일관되게 동작하게 해준다.

예를 들어, 리액트의 onMouseLeavemouseout라는 네이티브 이벤트를 가리킨다. 특정 기능은 퍼블릭 API에 매핑되지 않았으며, 미래에 언제든지 변경될 수 있다. 그러니 만약 기본 브라우저 이벤트가 필요하다면, e.nativeEvent를 참조해야 한다.

속성과 메서드

위 BaseSyntheticEvent 인터페이스는 기본적으로 표준 Event에서 구현되었으며, React event object에서 확인 가능하다.

Event를 기반으로 구현된 속성과 메서드

  • bubbles: (boolean) 이벤트가 DOM을 통해 버블링되었는지를 반환한다.
  • cancelable: (boolean) 이벤트를 취소할 수 있는지를 반환한다.
  • currentTarget: (DOM 노드) 리액트 트리에서 현재 이벤트 핸들러가 첨부된 노드를 반환한다.
  • deafultPrevented: (boolean) preventDefault가 호출되었는지를 반환한다.
  • eventPahse: (number) 이벤트가 현재 어느 단계에 있는지 반환한다.
  • isTrusted: (boolean) 이벤트가 사용자에 의해 시작되었는지 반환한다.
  • target: (DOM 노드) 이벤트가 발생한 노드(멀리 떨어진 자식 노드일 수 있음)를 반환한다.
  • timeStamp: (number) 이벤트가 발생한 시간을 반환한다.
  • preventDefault(): 브라우저의 기본 동작을 막는다.
  • stopPropagation(): 리액트 트리에서 발생하는 이벤트 전파를 막는다.

React만의 속성과 메서드

  • nativeEvent: (Event) 원래의 브라우저 이벤트 객체를 반환한다.
  • isDefaultPrevented(): (boolean) preventDefault가 호출 되었는지를 반환한다.
  • persist(): 리액트 DOM에서는 사용되지 않으며, 리액트 네이티브에서 이벤트 속성을 이벤트 이후에 읽기 위해 사용된다.
  • isPersistent(): 리액트 DOM에서는 사용되지 않으며, 리액트 네이티브에서 persist가 호출되었는지를 반환한다.

주의사항

currentTarget, eventPhase, target 그리고 type의 값은 리액트 코드가 예상하는 값들을 반영한다. 내부적으로 리액트는 루트에 이벤트 핸들러를 부착(attach)하지만, 이는 리액트의 이벤트 객체에는 반영되지 않는다. 예를 들어, e.currentTarget은 기본 e.nativeEvent.currentTarget과는 다를 수 있다. 만약 폴리필된 이벤트인 경우, e.type(리액트 이벤트 타입)은 e.nativeEvent.type(기본 타입)과 다를 수 있다. 따라서 기본 브라우저 이벤트가 필요한 경우 반드시 e.nativeEvent를 참조해야 한다.

이벤트 위임 (Event Delegation)

자바스크립트의 이벤트 위임 덕분에 여러 자식 요소에서 발생하는 동일한 유형의 이벤트를 부모 요소에서 한 번에 처리할 수 있다.

<html>
  <script>
    document.getElementById('parent-list').addEventListener('click', function (e) {
      // 만약 li 태그라면
      if (e.target && e.target.nodeName == 'LI') {
        console.log('List item ', e.target.id.replace('post-', ''), ' was clicked!');
      }
    });
  </script>
  <body>
    <ul id="parent-list">
      <li id="post-1">Item 1</li>
      <li id="post-2">Item 2</li>
      <li id="post-3">Item 3</li>
    </ul>
  </body>
</html>

리액트 역시 첫 출시부터 이벤트 위임을 지원했지만, 이벤트 시스템의 작동 방식은 불안정했다. 일반적으로 리액트 컴포넌트에서 이벤트 핸들러는 인라인 방식으로 사용된다.

<button onClick={handleClick} />

하지만 이를 DOM 노드에 직접적으로 부착하는 방식이 아니라, document 노드에 직접 이벤트를 타입별로 부착했다. 따라서 어플리케이션 내에서 이벤트가 발생한다면, 리액트는 어떤 컴포넌트를 호출할지 파악하고, 이벤트는 컴포넌트를 통해 '버블링'되어 발생한다. 만약 한 페이지에 여러 버전의 리액트가 존재한다면, 이벤트들은 모두 최상위에 등록되어 e.stopPropagation()이 정상적으로 동작하지 않을 수 있다.

예를 들어 아래 코드에서 내부의 InnerButton과 외부의 OuterComponent의 리액트 버전이 다를 경우를 가정해보자.

const InnerButton = () => {
  const handleInnerClick = (e) => {
    e.stopPropagation();
    console.log('내부 버튼 클릭');
  };

  return <button onClick={handleInnerClick}>내부 버튼</button>;
};

const OuterComponent = () => {
  const handleOuterClick = () => {
    console.log('외부 컴포넌트 클릭');
  };

  return (
    <div onClick={handleOuterClick}>
      <InnerButton />
    </div>
  );
};

일반적으로 내부 버튼을 클릭하면 e.stopPropagation() 호출로 인해 외부 컴포넌트의 이벤트(handleOuterClick)는 실행되지 않아야 하지만 실제로는 실행된다. 이는 리액트 17 이전 버전에서 발생할 수 있으며, Atom 에디터에서 발생했었다.

그래서 리액트 17 버전부터는 document 레벨이 아닌 루트 DOM 컨테이너에 이벤트 핸들러들을 부착한다.

이러한 변경 덕분에, 리액트의 e.stopPropagation()은 일반적인 DOM에 더 가깝게 작동하여 원하는 결과(ex. 외부에 이벤트 전파 막기)를 기대할 수 있게 되었다. (단, 모든 버전이 리액트 17 이상이어야 한다)

초반에 작성한 코드에서 이벤트의 target과 이벤트가 실제로 부착된 target을 각각 찍어보면 타겟은 button이지만 부착된 노드는 root인걸 확인할 수 있다.

onClick의 e.target과 e.nativeEvent.currentTarget

onClick의 e.target과 e.nativeEvent.currentTarget

리액트에 이벤트가 부착되는 시점은?

리액트에서 이벤트 핸들러가 root에 부착되는 시점은 리액트가 렌더링되는 시점이다.

const rootNode = document.getElementById('root');

// React 17
ReactDOM.render(<App />, rootNode);

// React 18
const root = ReactDOM.createRoot(rootNode);
root.render(<App />);

리액트 18 버전을 예로, 렌더 함수(root.render)를 따라가보면 아래와 같은 코드로 동작하는 걸 확인할 수 있다.

react-dom/src/client/ReactDOMRoot.js
// https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOMRoot.js#L162
export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  // 생략...

  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks,
  );
  // ...

  const rootContainerElement: Document | Element | DocumentFragment =
    container.nodeType === COMMENT_NODE
      ? (container.parentNode: any)
      : container;
  listenToAllSupportedEvents(rootContainerElement);

  // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
  return new ReactDOMRoot(root);
}

아래 코드는 흐름대로 읽기 위해 순서를 변경했으며, 실제 코드는 명시한 url에서 확인할 수 있다.

react-dom-bindings/src/events/DOMPluginEventSystem.js
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

const listeningMarker = '_reactListening' + Math.random().toString(36).slice(2);

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  // rootContainerElement, 즉 id가 root인 엘리먼트에 이벤트 리스너가 부착되어 있지 않은 경우 실행한다.
  if (!(rootContainerElement: any)[listeningMarker]) {
    // 내부에서 이벤트가 부착되었다는 flag를 설정하여, 이후에 다시 실행되지 않게 동작. 따라서 첫 렌더링에만 실행된다.
    rootContainerElement[listeningMarker] = true;
    // 모든 이벤트들에 대해 이벤트 리스너를 부착한다.
    allNativeEvents.forEach(domEventName => {
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });
    // ...
  }
}

// 실제 브라우저 이벤트를 연결한다
export function listenToNativeEvent(
  domEventName: DOMEventName,
  isCapturePhaseListener: boolean,
  target: EventTarget,
): void {
  // ...
  let eventSystemFlags = 0; // 기본 값이 0인 것을 주목하자
  if (isCapturePhaseListener) {
    eventSystemFlags |= IS_CAPTURE_PHASE; // 캡쳐링을 한다면 4로 할당
  }
  addTrappedEventListener(
    target,
    domEventName,
    eventSystemFlags,
    isCapturePhaseListener,
  );
}

function addTrappedEventListener(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  isCapturePhaseListener: boolean,
  isDeferredListenerForLegacyFBSupport?: boolean,
) {
  // 우선순위에 따라 이벤트 리스너를 생성한다.
   let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
  );

  // ...
  let unsubscribeListener;

  if (isCapturePhaseListener) {
    // ...
    unsubscribeListener = addEventCaptureListener(
        targetContainer,
        domEventName,
        listener,
      );
  } else {
    // ...
    unsubscribeListener = addEventBubbleListener(
        targetContainer,
        domEventName,
        listener,
      );
  }
}

그리하여 마침내 익숙한 addEventListener에 도달했다.

react-dom-bindings/src/events/EventListner.js
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/EventListener.js
export function addEventBubbleListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, false);
  return listener;
}

export function addEventCaptureListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, true);
  return listener;
}

따라서 사용자에게 화면이 보여질때는 이미 이벤트 핸들러들이 다 부착된 상태이기에 클릭, 작성 등의 이벤트들이 동작한다.

이벤트를 실행한다면 어떤 일이 일어날까?

위 코드들로 이벤트가 root에 이벤트 리스너 함수(addEventListener)가 부착된다는 걸 보았다. 그렇다면 반대로 button을 클릭 할 때, root에 부착된 이벤트가 어떻게 실행되는 걸까?

버튼을 클릭 할 경우, 이벤트 버블링이 일어나면서 DOM 트리를 통해 상위 요소로 버블링되며 최종적으로 root 요소에 도달한다. 그리고 바인딩 된 이벤트를 찾아 실행하는데 코드로 살펴보자.

아까 위에서 우선순위로 이벤트 리스너를 바인딩하는 함수(createEventListenerWrapperWithPriority())에서 리스너에 이벤트를 실행하는 dispatch 함수를 같이 바인딩 해둔다.

react-dom-bindings/src/events/ReactDOMListener.js
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L148
export function dispatchEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
): void {
  // ...
}

해당 함수에서 이벤트의 활성화 여부, 이벤트 전파 중단 여부 등을 확인하고, 이벤트를 실행할 경우 dispatchEventForPluginEventSystem() 함수가 호출된다.

react-dom-bindings/src/events/DOMPluginEventSystem.js
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L547
export function dispatchEventForPluginEventSystem(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  // ...
  if (targetInst !== null) {
    // rootContainer를 찾기 위해 타겟에서 계속해서 위로 올라가며 탐색한다
    let node: null | Fiber = targetInst;
    mainLoop: while (true) {
      // ...
    }
  }
  // ...
  batchedUpdates(() =>
    dispatchEventsForPlugins(
      domEventName,
      eventSystemFlags,
      nativeEvent,
      ancestorInst,
      targetContainer,
    ),
  );
}

function dispatchEventsForPlugins() {
  // ...
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
}

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  // 기본적으로 해당 플러그인으로 이벤트 수행
  SimpleEventPlugin.extractEvents(...)
  const shouldProcessPolyfillPlugins = (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    // 폴리필 여부를 판단한 후, 상황에 맞는 이벤트를 실행
  }
}

마지막에 나오는 extractEvents() 함수에서 일반적으로는 SimpleEventPlugin만 실행 될 것이다. 왜냐하면 eventSystemFlags는 상단 이벤트 리스너를 바인딩하는 곳에서 결정되는데, 리액트에서 이벤트는 기본적으로 캡처링 모드는 비활성화 되어 있으며, eventSystemFlags가 기본값인 0으로 설정될 것이기 때문이다.

SimpleEventPlugin.extractEvents는 리액트의 이벤트와 브라우저의 네이티브 이벤트를 바인딩 시키고, 그에 대응되는 코드는 SyntheticEvent에서 확인할 수 있다.

결론

부끄럽지만 여태 리액트에 이벤트를 선언할 때 단순히 요구사항에 맞는 함수를 만들어 선언하고 각 노드에 해당 함수를 연결해주고 더 깊게 생각하지 않았었다.

이번에 리액트 소스코드를 보다보니 각 노드들에 바인딩 된 이벤트들이 root에 바인딩 되며, 실행도 root를 통해서 되고 있다는 사실을 알았다. 리액트가 단방향 바인딩을 추구하고 있다는 건 알고 있었지만, 코드로 보니 더 확 와 닿았다.

이와 별개로 이번에 이벤트 관련 코드들을 보니 생각보다 브라우저 호환성을 위한 분기 처리나 핸들링이 적다는 생각이 들었다. 크롬은 V8을, 파이어폭스는 Gecko 엔진을 사용하는데 이 두 가지 모두 메인 페이지에 ECMASCript 혹은 JavaScript에 대한 내용이 있는 반면, 사파리가 사용하는 WebKit 엔진의 메인 페이지에서는 해당 내용을 찾아볼 수 없다. 그래서 상대적으로 자유분방(?)한 사파리에서 유독 이벤트 관련해서 더 많은 예외 케이스가 발생하는 것 같다. 리액트도 이런 케이스를 하나하나 대응하기에 힘들기 때문에 따로 처리하지 않은게 아닌가 유추해본다.



참고