리액트의 이벤트 처리 방식 까보기

2025. 3. 11. 23:34·Frontend/React

이 글은 리액트가 루트 컨테이너에서 모든 이벤트 리스너를 관리하는 게 어떻게 가능한지 이해하기 위해 작성한 글입니다. 직접 리액트 원본 리소스(18, 19버전)를 뜯어보고 ChatGPT와 교차 검증하며 작성했지만, 틀린 내용이 있을 수 있습니다. 오류를 발견하시면 댓글 부탁드립니다!

리액트의 파이버 재조정자 관련 개념이 나오지만, 몰라도 읽을 수 있게끔 작성했습니다.


1. 네이티브 이벤트 처리 방식

HTML, JS를 사용한 이벤트 처리

리액트의 이벤트 처리 방식을 살펴보기 앞서, 가장 근본적으로 어떻게 브라우저 이벤트를 처리하는지 알아보도록 하자.

 

먼저 HTML 속성에 직접 인라인 이벤트를 추가하는 방법이 있다. 인라인 이벤트를 사용하면 이벤트 핸들러를 문자열 형식으로 전달해야 하기 때문에 복잡한 내용을 넣었을 때 가독성이 좋지 않다. 또 유지보수 면에서도 좋지 않아서 잘 쓰지 않는 방법이다.

<button id='my-button' onClick='console.log("clicked!")'>
  Click Me!
</button>

 

일반적으로 이벤트 핸들링처럼 동적인 작업이 필요할 때에는 Javascript를 사용한다. 아래 코드를 보면 이벤트 핸들링이 필요한 실제 DOM 요소를 찾아서, 해당 요소에 이벤트 리스너를 부착하고 있음을 알 수 있다.

<button id='my-button'>Click Me!</button>
const button = document.getElementById('my-button');
button.addEventListener('click', (e) => {
  console.log('Event:', e);
});

 

이벤트 위임(Event Delegation)

이벤트를 더 효율적으로 처리하기 위해 이벤트 위임(Event Delegation) 을 사용하기도 한다. 이벤트 위임이란 이벤트가 하위 노드에서 상위 노드로 전파(Event Propagation)되는 성질, 즉 이벤트 버블링(Event Bubbling)을 이용해서 이벤트 처리 방식을 최적화하는 기법이다. 이벤트 리스너를 하위 요소 대신 상위 요소에 부착하고, event.target으로 실제로 이벤트를 발생시킨 요소를 확인하여 처리할 수 있다.

 

다음 예시 코드에서는 여러 항목 요소 각각에 이벤트 리스너를 붙이지 않고, 항목 요소들을 감싸는 리스트 요소에 이벤트 리스너를 붙여 이벤트를 처리하고 있다.

<ul id='my-list'>
  <li id='item-1'>item 1</li>
  <li id='item-2'>item 2</li>
  <li id='item-3'>item 3</li>
</ul>
const list = document.getElementById('my-list');
list.addEventListener('click', (e) => {
  console.log('이벤트가 발생한 요소:', e.target.id); // 클릭된 li의 id
  console.log('이벤트 리스너가 부착된 요소:', e.currentTarget.id); // ul의 id
});

 

이벤트 위임을 사용하면 다음과 같은 이점을 얻을 수 있다.

  • 부모 요소에만 이벤트 리스너를 추가하니까 메모리를 절약할 수 있음
  • 동일한 이벤트 리스너를 여러 개 생성하지 않고 한 군데에서(중앙 집중) 관리할 수 있음
  • 자식 요소가 페이지 로딩 이후 동적으로 추가되어도, 이벤트 핸들링이 자동으로 적용됨

 

리액트는 이러한 이벤트 위임 방식을 사용해 이벤트를 처리한다. 우리가 리액트에서 특정 컴포넌트에 onClick을 붙여도, 실제로는 리액트의 최상위 컨테이너(root)에만 이벤트 리스너가 부착되고, 최상위 요소에서 모든 이벤트를 관리한다. 자세한 내용은 후술하겠다.

우리가 개발 단계에서 컴포넌트에 이벤트 핸들러를 부착했어도, 실제 DOM에 반영될 때는 이벤트 리스너가 최상위 요소에만 추가된다. 우리가 리액트로 만드는 건 메모리에서만 존재하는 가상의 DOM일 뿐이다. 이 가상 UI를 실제 브라우저 DOM에 반영하는 작업은 리액트가 수행한다는 점을 명심하자.

 

2. 리액트의 합성 이벤트(Synthetic Event) 시스템

탄생 배경

브라우저 네이티브 이벤트는 당연하게도 브라우저마다 구현이 다르다. 예를 들어 이벤트 속성에 접근할 때 일반적으로 event.target을 사용하지만, 과거 일부 브라우저에서는 event.srcElement를 사용했다(지금은 deprecated 되었다). 이러한 차이는 개발자들이 브라우저마다 맞춤형 코드를 작성해야 하는 수고로 이어졌다.

현대 브라우저들은 W3C를 준수해 대체로 큰 차이가 없지만, 리액트의 합성 이벤트 시스템은 여전히 유용하다.

 

리액트는 이러한 브라우저 간 비일관성 문제를 합성 이벤트(Synthetic Event)라는 통합 인터페이스를 통해 해결한다. 합성 이벤트는 브라우저 네이티브 이벤트를 한 번 감싼 래퍼(Wrapper) 객체로, 네이티브 이벤트와 이벤트 처리를 위한 다른 정보들을 포함한다. 실제로 어떻게 생겼는지 한 번 살펴보자.

 

합성 이벤트 객체 살펴보기

다음은 리액트에서 이벤트 핸들러로 받은 이벤트 객체를 출력하는 코드이다.

const App = () => {
  const handleInputChange = (e) => console.log(e); // 이벤트 객체 출력

  return (
    <input type='text' onChange={handleInputChange} />
  );
}

 

콘솔에 출력된 결과는 다음과 같다.

리액트 합성 이벤트 객체를 출력한 모습

 

보면 이름이 'SyntheticBaseEvent'라고 되어 있고, 원본 이벤트 객체인 NativeEvent를 비롯한 다양한 정보를 가지고 있다.

 

여기서 흥미로운 점은 바로 리액트 내 onChange의 원본 이벤트 객체가 'change'가 아닌 'input'이라는 점이다. 이는 의도된 것이며, 리액트 합성 이벤트가 네이티브 이벤트와 그대로 매핑되지 않음을 보여주는 한 예시이다. 리액트의 onChange는 'input' 이벤트와 매핑되어, 입력 값이 변경될 때마다 호출된다. 즉 실제 동작은 'input'이고 이름만 'change'인 셈이다.

리액트는 왜 onInput이라는 이름을 사용하지 않은 이유를 추측해보자면... 다양한 <input> 요소를 onChange라는 공통 이벤트로 다룰 수 있게 하기 위함이 아닐까 싶다. 또 개인적으로 onInput보다 onChange가 더 실시간으로 바뀐다는 느낌이 들어서 직관적이라고 느껴지는데, 이걸 노린 걸 수도 있다.

 

어쨌든 합성 이벤트는 네이티브 이벤트를 포함하고 있으며, 이에 대응되는 리액트 이벤트 등 다양한 정보를 가진다.

 

지금부터는 리액트가 DOM 요소에서 발생한 이벤트를 어떻게 캐치하고, 또 내부적으로 어떤 처리 과정을 거치는지 알아보겠다.

 

3. 리액트 내부 이벤트 처리 방식

개요

리액트가 DOM에서 발생한 이벤트를 처리하는 방식을 간단하게 요약하자면 다음과 같다.

  1. DOM 내 요소와 상호작용하여 브라우저 네이티브 이벤트(ex. click)가 발생한다.
  2. 이벤트는 DOM 트리를 따라 최상위 요소인 루트 컨테이너까지 전파(=버블링)된다.
  3. 루트 컨테이너에 등록된 리액트의 공통 이벤트 리스너가 이벤트를 가로채, 리액트 내부 이벤트 시스템으로 전달한다.
  4. event.target와 대응되는 파이버 노드부터 최상위 파이버 노드까지 파이버 트리를 따라 올라가면서, dispatch queue(이벤트 전파 목록)를 구성한다. 이 과정에서 이벤트 정보 정규화와 합성 이벤트 객체 생성 등이 이루어진다.
  5. 파이버 트리의 최상위 노드에 도달하면 dispatch queue에서 이벤트 핸들러를 하나씩 꺼내어 차례로 호출한다. 이때 핸들러가 인자로 받는 이벤트 객체는 4에서 생성된 합성 이벤트 객체이다.
더보기

리액트 파이버 트리가 뭔지 모르는 사람들을 위해...

사용자가 작성한 코드 -> 리액트가 파이버 트리로 변환(파이버 트리 내 노드들은 다양한 정보를 가짐) -> DOM에 반영

사용자가 JSX 코드(혹은 JS 코드)로 작성한 UI는 리액트 파이버 재조정자라는 리액트의 내부 시스템을 통해 "파이버 트리"라는 가상의 트리로 변환된다. 그리고 이 파이버 트리를 토대로 호스트 환경(브라우저의 경우 DOM)이 구성된다. 파이버 트리는 "파이버 노드"라는 객체로 구성되며, 각각의 파이버 노드는 사용자가 작성한 컴포넌트들과 대응된다. 파이버 노드에는 컴포넌트에 대한 정보와 파이버 트리에서 해당 파이버 노드의 위치, 부모, 자식, 형제 관계 등 다양한 정보가 담겨 있다.
쉽게 설명하기 위해 과정을 다소 축소했지만 이 글을 이해하기 위해서는 이 정도만 알아도 된다.

 

무슨 말인지 예제와 함께 살펴보겠다.

<div id="root"></div>
const App = () => {
  const handleSectionClick = () => console.log('Section Clicked!');
  const handleButtonClick = () => console.log('Button Clicked!');

  return (
    <div>
      <section onClick={handleSectionClick}>
        <button onClick={handleButtonClick}>
          Click Me!
        </button>
      </section>
    </div>
  );
}

const root = document.getElementById("root");
createRoot(root).render(<App/>);

 

위와 같은 리액트 코드가 있을 때, button을 클릭하면 어떤 일이 일어날까?

 

DOM 루트의 이벤트 리스너

먼저 'click' 네이티브 이벤트가 발생하면 최상위 노드인 root까지 이벤트가 전파된다.


root에는 네이티브 이벤트 리스너가 전부 달려 있다. 리액트가 앱을 최초로 렌더링할 때 네이티브 이벤트 목록을 순회하면서 각 이벤트에 대한 리스너를 root에 추가해놓기 때문이다. 실제로 개발자 도구에서 root에 해당하는 요소를 클릭하고, 이벤트 리스너 패널을 열면 root에 추가되어 있는 이벤트 리스너 목록을 확인할 수 있다.

root에 추가된 이벤트 리스너 목록

우리는 click 이벤트만 다루고 있는데, 그 외 이벤트 리스너들이 추가되어 있는 것을 볼 수 있다.

그리고 이벤트 리스너마다 실제로 코드 상에서 어떻게 구현되었는지 소스가 함께 연결되어 있는데, 'react-dom.development.js'처럼 실제 리액트 내부 코드로 연결되어 있음을 알 수 있다. 이걸 누르면 개발자 도구 소스 패널로 연결되며 해당 구현부를 직접 확인할 수 있다.

이벤트 리스너 구현부

 

dispatch...Event 꼴의 함수가 createEventListenerWrapperWithPriority라는 함수에서 호출되어 listenerWrapper로써 반환되고 있다. 다시 말해 리액트는 이벤트 리스너를 한 번 감싼 Wrapper 함수를 만들고 이걸 root에 추가하여, 브라우저 이벤트를 감지하고 이를 리액트 코드 내부 이벤트 시스템으로 이동하는 구조를 갖춘 것이다.

 

리액트 내부 이벤트 시스템

리액트 내부 합성 이벤트 시스템이 root 이벤트 리스너로부터 네이티브 이벤트를 전달받으면 해당 이벤트 객체의 target을 통해 이 이벤트를 발생시킨 요소와 매핑되는 파이버 노드를 찾아낸다. 그렇다. DOM에서 파이버로 역추적 하는 것이다. event.target에는 대응되는 파이버 노드가 비공개 프로퍼티로 숨겨져 있다. 이벤트 핸들러 안에서 Object.entries(event.target)을 콘솔에 출력하면 실제로 __reactFiber$xxx와 같은 프로퍼티가 파이버 노드를 값으로 하면서 들어 있는 것을 확인할 수 있다. (파이버 노드 뿐만 아니라 props도 저장되어 있다.)

event.target의 비공개 프로퍼티

 

DOM 요소에 파이버 노드와 props 정보를 저장하는 작업은, 파이버 재조정 중 렌더링 단계에서 수행된다(createInstance). 아래는 원본 리소스에서 해당 부분을 발췌한 것이다.

export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): Instance {
  // DOM 요소 생성
  const domElement: Instance = createElement(
    type,
    props,
    rootContainerInstance,
    parentNamespace,
  );

  // 생성한 DOM 요소에 파이버와 props를 저장
  precacheFiberNode(internalInstanceHandle, domElement);
  updateFiberProps(domElement, props);
  return domElement;
}

 

event.target에 대응되는 파이버 노드를 알아냈으니, 해당 파이버 노드부터 파이버 트리를 따라 위로 올라가며 리스너를 수집할 수 있다. 이 과정에서 합성 이벤트가 생성되며, dispatchQueue 안에 { 합성 이벤트, 리스너 } 형태로 들어간다. 파이버 트리의 최상단에 도달하면 dispatchQueue에서 순서대로(+우선순위를 고려하여) 하나씩 꺼내어 처리된다.

 

DispatchQueue의 형태

dispatchQueue의 타입은 다음과 같다.

type DispatchListener = {
  instance: null | Fiber, // 이벤트 핸들러가 부착된 컴포넌트의 파이버 노드
  listener: Function,     // 이벤트 핸들러 함수
  currentTarget: EventTarget,
};

type DispatchEntry = {
  event: ReactSyntheticEvent,
  listeners: Array<DispatchListener>,
};

export type DispatchQueue = Array<DispatchEntry>;

 

위 예제에서 버튼을 클릭했을 때 최종적으로 dispatchQueue는 이러한 모양을 가질 것이다.
(dispatchQueue의 구조를 이해하기 위해 추상화한 것으로, 내용은 정확하지 않을 수 있다)

dispatchQueue = [
  {
    event: SyntheticEvent {
      type: "click",
      target: <button />,            // 실제 DOM 클릭 타겟
      currentTarget: null,           // 나중에 실행 시 설정됨
      _targetInst: Fiber(Button),    // 이벤트가 발생한 DOM 노드에 매핑된 Fiber
      _dispatchInstances: [Fiber(Button), Fiber(Section)], // 이벤트가 전파되면서 호출될 Fiber 목록
      _dispatchListeners: [handleButtonClick, handleSectionClick]
    },
    listeners: [
      {
        instance: Fiber(Button),     // handleButtonClick이 바인딩된 Fiber
        listener: handleButtonClick,
        currentTarget: <button />    // 실행 시 여기로 설정됨
      },
      {
        instance: Fiber(Section),    // handleSectionClick이 바인딩된 Fiber
        listener: handleSectionClick,
        currentTarget: <section />   // 실행 시 여기로 설정됨
      }
    ]
  }
];

 

그리고 실제로 동작을 시켜보면 이벤트 핸들러와 버블링이 잘 동작하는 것을 볼 수 있다.

이벤트 버블링

 

4. 결론

리액트는 루트에서 모든 이벤트 리스너를 중앙 집중식으로 관리하면서, 앞서 말한 이벤트 위임의 장점을 모두 챙기고 있다. 특히 수많은 요소 각각에 이벤트 리스너를 달지 않아도 되고, 수시로 변경되는 DOM에 대해 쉽게 대응할 수 있다는 점이 가장 핵심적인 장점이라고 생각한다. 리액트는 동시에 파이버 구조와 합성 이벤트 시스템으로 이벤트 처리의 일관성과 유연함까지도 갖추고 있다. 이 시스템들을 각각 다른 시점에 설계했을 텐데, 마치 동시에 설계를 시작한 것처럼 잘 맞아떨어지며 돌아가는 게 새삼 신기하고 경이롭다(물론 당연히 맞춰 돌아가게끔 리팩토링 했겠지만).

 

리액트는 '루트'에서 하위 요소들로 뻗어내려가는 구조로 설계되어 있다. 리액트로 만든 앱을 실행하려면 전체 앱을 감싸는 '루트'가 필요하고, 파이버 트리에서도 '루트'를 두어 탐색이나 커밋을 용이하게 한다. 결국 리액트의 이벤트들이 루트에 집중되어 처리되는 것은 단순한 최적화를 넘어서 리액트의 루트 중심 구조와 맞닿아있는 자연스러운 설계 철학의 결과라고 볼 수 있다.

저작자표시 비영리 변경금지 (새창열림)

'Frontend > React' 카테고리의 다른 글

useState의 setState에 함수 넣기 (Functional Update)  (0) 2023.08.17
[react] 달력 만들기 01  (0) 2023.01.31
[HOC] about HOC(Higher Order Component)  (0) 2022.09.13
[Redux Tutorial] Login page 만들며 Redux 이해하기  (0) 2022.09.13
[Redux Tutorial] about Redux  (0) 2022.09.13
'Frontend/React' 카테고리의 다른 글
  • useState의 setState에 함수 넣기 (Functional Update)
  • [react] 달력 만들기 01
  • [HOC] about HOC(Higher Order Component)
  • [Redux Tutorial] Login page 만들며 Redux 이해하기
톱치
톱치
나를 위한 기록을 합니다
  • 톱치
    기록
    톱치
  • 전체
    오늘
    어제
  • 블로그 메뉴

    • 홈
    • 방명록
    • 글쓰기
    • 전체보기 (51)
      • Articles (0)
      • Frontend (3)
        • JS, TS (16)
        • HTML, CSS (5)
        • React (6)
        • Dart (3)
      • Backend (6)
      • Others (3)
      • Algorithm (5)
      • 회고 (4)
  • 링크

    • GitHub
  • 태그

    회고
    javascript
    React
    BFS
    dart
    Redux
    Node
    js
    TypeScript
    todolist
    login
    node.js
    object
    ts
    프리코스
    token
    programmers
    BCrypt
    우아한테크코스
    css
  • hELLO· Designed By정상우.v4.10.3
톱치
리액트의 이벤트 처리 방식 까보기
상단으로

티스토리툴바