Web: React의 Event 시스템 내부 구현 자세히 알아보기 (React v18.2.0)

Heechan
HcleeDev
Published in
21 min readAug 27, 2022
Photo by Marcos Luiz Photograph on Unsplash

지지난주에는 Event와 addEventListener 를, 지난주에는 Event Bubbling, Capturing, 그리고 Delegation 패턴에 대해 알아보았다.

근데 이 내용들은 다 순수하게 DOM, JS를 사용할 때의 얘기지, 사실상 내가 주로 사용하는 React에서는 살짝 다른 점들이 있다.

이번주는 React의 Event 처리에 대해 한번 자세히 알아보자. 실제 React 코드를 같이 읽어가면서 설명하는 형식이라 좀 쓸데 없다고 느낄 수도 있긴 한데, React 코드 구경한다는 느낌으로 가볍게 쭉 읽어나가도 괜찮을 것 같다.

React로 개발할 때는 addEventListener를 잘 쓰지 않는다

지난번에 이벤트를 등록하는 여러가지 방법에 대해 설명한 적이 있다.

그때 ‘컴포넌트에 직접 넣어주기’라는 방법과, ‘비교적 현대적인addEventListener 사용하기’라는 방법이 있었다.

JavaScript에서는 가급적이면 HTML 태그에 인라인으로 로직을 집어넣지 말고, addEventListener 를 사용하라고 권장하고 있다. 그렇게 했을 때 괜히 UI 파트에다가 로직을 집어넣지 않아도 되고, 메모리 관리, 한 컴포넌트에 여러 핸들러 붙이기가 가능하기 때문이다.

그런데 React에서는 주로 이벤트 핸들러를 등록할 때 컴포넌트에 직접 넣어준다.

출처: https://ko.reactjs.org/docs/handling-events.html

애초에 React가 DOM에 직접 접근해서 코딩하지 않기 위한 기능을 제공하는 만큼, DOM에서 getElementById같은 메서드를 이용해 오브젝트를 꺼내서 핸들러를 붙여줘야 하는 addEventListener 를 사용하는 것은 그 의도에 맞지 않다.

그래서 위 이미지처럼 React에서는 그냥 태그에 직접 핸들러를 넣어준다. <form onSubmit={handleSubmit}> 에서 확인할 수 있다. 확실히 개발하는 입장에서는 편한 포인트긴 하다.

근데 여기서 개인적으로 궁금해진 포인트가 생겼다. React에서는 기본적으로 JS에서 권장하는 방식하고 정반대로 코드를 짜도록 유도하고 있는데, DOM에서 발생한 이벤트를 어떻게 React App 으로 받아와서 처리해주고 있는걸까 궁금해졌다.

처음에는 React서 DOM을 구성할 때 우리가 넘겨준 핸들러를 컴포넌트에 넣어주거나, 알아서 addEventListener 처리를 해줄까? 라는 생각을 했는데, 자세히 알아보니 아니었다.

React가 이벤트를 처리하는 구조

이를 확인하기 위해 React 레포지토리를 클론 받아서 직접 찾아보기로 했다. 처음에는 괜찮은 블로그 글이 있어서 그걸 기반으로 공부하려고 했는데, 알고보니 해당 글이 React v16.8 기준이라 현재 하고는 구조가 좀 달라서… 직접 알아볼 수 밖에 없었다.

이 글은 React v18.2 기준의 코드를 베이스로 분석한 내용을 소개한다.

Native Event에 대한 Listener는 root에

유저가 클릭하거나, 커서를 옮기거나 같은 이벤트들을 DOM에서 발생하는 Native Event로 칭한다.

이 Native Event를 인지하기 위한 Listener는 React로 만들어진 가장 상위의 <div id="root"> 에 붙게 된다.

React 17에서의 변화에 대해 React 블로그에서 설명한 그림이 있다.

출처: https://reactjs.org/blog/2020/10/20/react-v17.html

기존에는 document에 Listener를 붙였지만, React 17부터는 document가 아닌 React App이 돌아가는 root 요소에 이벤트 Listener를 붙인다.

이 그림이 틀린건 아니다만, 약간의 오해의 소지가 있는 그림인 것 같다. 여기서 root에 붙이는 것은 하위 컴포넌트에 있는 수도 없이 많은 이벤트 핸들러들이 아니다. 예를 들어, <button onClick={() => console.log('btn')}> 이런 컴포넌트가 있다 하더라도, root에 rootNode.addEventListener('click', () => console.log('btn')) 처럼 붙이는게 아니라는 말이다.

여기서는 Native Event를 Listen하고, 그 이벤트에 따라 ‘알맞는 타겟의 핸들러를 찾아서 실행시키는’ 핸들러를 포함한 Lisnener를 root에 등록한다.

createRoot 에서 ReactDOMRoot가 만들어지기 직전에 listenToAllSupportedEvents 를 호출하고 있다.

해당 메서드를 자세히 보면, allNativeEvents.forEach 를 통해 모든 종류의 Native Event를 root에 붙이는 작업을 진행한다. listenToNativeEvent 는 내부에서 rootContainerElement 에다가 addEventListener 를 이용해 Listener를 붙이는 역할을 한다.

여기서 nonDelegatedEvents , 즉 버블링이 안되는 이벤트의 경우에는 캡처링 상태에 대한 Listener만 붙이고, 아니면 버블링이 되는 경우에는 버블링에 대한 Listener, 캡처링에 대한 Listener 각각 하나씩 붙인다.

이 Listener를 붙이는 과정은 createRoot라는 이름에서 알 수 있듯 처음 root가 만들어질 때, 즉 앱이 켜질 때 진행된다.

React 17부터 HTML document가 아닌 React App의 root 요소에 Listener를 붙이게 된 이유는 React와 Non-React 코드 베이스를 합쳐서 사용해야 하는 경우에 대응하기 위해서다. 그럴 때 React의 이벤트를 처리하는 Listener를 완전 최상단에 붙여두면 다른 코드 베이스와 함께 할 때 좀 의도와 다르게 동작할 수 있기 때문이다.

합성 이벤트, Synthetic Event

Native Event를 받기 위한 Listener는 잘 붙여두었다. 이제 유저가 실제로 클릭을 해서, 클릭이라는 Native Event가 발생했다. 그러면 우리가 붙여둔 Listener는 이 이벤트를 어떻게 처리하게 될까?

일단 처리하는 과정을 보기 전에, React에서 사용하는 SyntheticEvent라는 클래스를 알아봐야 한다.

사실 React 앱이 언제나 같은 브라우저에서 구동되는 것이 아니기 때문에, Native Event만으로 일관되게 처리할 수 없는 불편함이 있다.

그래서 React App 내에서 Event를 처리하기 위해 Native Event를 SyntheticEvent로 한번 감싸주는 과정을 거친다.

보면 target , currentTarget 같은 익숙한 프로퍼티들이 있다. nativeEvent 로 실제 DOM의 Event도 들고 있다. 만약 브라우저 고유의 이벤트에 접근해야 한다면 nativeEvent 에 접근하면 된다.

Native Event의 이벤트 이름은 막 'click' 이런 느낌인데, 우리가 React에서 클릭 이벤트를 처리하는 이름은 onClick 이다. 즉 SyntheticEvent에는 onClick 이라고 변환해서 넣어줘야 한다는 것인데, 이에 대해서도 앱이 처음 구동될 때 Map을 하나 만들어서 넣어둔다.

DOMPluginEventSystem.js를 보니 그냥 시작할 때 전역에서 실행되는 코드가 있다. 여기 있는 registerEvents 가 이제 clickonClick 같은 React식 명명법으로 변환할 수 있도록 Map에 매핑해주는 역할을 한다. 예를 들어서 SimpleEventPlugin의 registerEvents에 들어가보자.

Line 27의 registerSimpleEvents 를 보자. 여기서 이제 click 같은 애를 onClick 으로 둔갑해서 topLevelEventsToReactNames 라는 곳에 저장해주고 있다. 그리고 EventRegistry 파일에 있는, Line 40 registerTwoPhaseEvent 를 호출해주고 있다. 여기서는 이벤트 버블링, 이벤트 캡처링을 나누어서 두 번 등록해주고 있다. 클릭으로 예를 들자면 onClick , onClickCapture['click'] 이라는 값의 dependencies 와 함께 넘기는 상황이라고 보면 된다.

근데 여기 allNativeEvents 에 dependencies 값을 넣어주는걸 확인할 수 있다. 이 allNativeEvents 가 저 위에서 처음 createRoot 할 때 나왔던 그 녀석이다. 앱이 실행될 때 미리 기록해둔 수도 없이 많은 이벤트들을 여기서 저장해두고, root가 만들어질 때 그 정보를 가지고 Event Listener를 붙이는 것을 알 수 있었다.

그러면 SyntheticEvent는 어떻게 만들어질까? createSyntheticEvent 라는 메서드를 한번 보자.

일단 createSyntheticEvent 라는 메서드가 있다. 이 메서드는 SyntheticBaseEvent 라는 객체를 생성하는 메서드를 반환한다. 이 메서드는Interface 에 각 이벤트의 특성에 맞는 형태를 넣어주면, Native Event에서 그 Interface 에 부합하는 속성을 꺼내서 새로운 SyntheticEvent를 만들어 줄 것이다. 즉 SyntheticEvent 생성자라고 생각할 수 있다.

createSyntheticEvent 를 사용해서 받을 수 있는 SyntheticBaseEvent 라는 메서드는 이벤트 객체를 만드는 역할을 한다고 이해했다면, 이 메서드를 React가 미리 주르륵 선언해두고 있다는 점도 확인해보자.

예를 들어 SyntheticEventSyntheticUIEvent , 그리고 SyntheticMouseEvent 를 가지고 왔다. 얘네들은 각 이벤트에 필요한 데이터들을 Interface에 넣어서 createSyntheticEvent 에 넘겨주고 있다.

예를 들어 SyntheticMouseEvent는 createSyntheticEvent의 반환값, ‘Native Event를 받아 MouseEventInterface 정보를 가진 SyntheticEvent를 만드는 메서드’를 저장하게 된다. 그러면 다른 곳에서는 이 SyntheticMouseEvent(nativeEvent.name, ...) 뭐 이런식으로 NativeEvent를 SyntheticEvent로 변환할 수 있을 것이다.

근데 사실 이 단락 상단에서 봤던 SyntheticEvent와 들어있는 녀석들이 좀 다르다. 지금 여기서 만들어주고 있는 것은 ReactSyntheticEvent 라는 타입이다.

이렇게 보면 우리가 넣은 최소한의 요소들이 보이긴 한다. 보면 이 SyntheticBaseEvent가 SyntheticEvent와 무언가 연결되어있는 것 같은데, 나는 일단 그 연결고리에 대해서는 찾지 못했다. 궁금하구만…

지금까지 과정을 요약하면 이정도로 정리해볼 수 있겠다.

  • 앱이 실행될 때 미리 준비해뒀던 Native Event 목록을 이용해, clickonClick 으로 바꿔서 저장해둔다.
  • Native Event가 들어올 때 SyntheticEvent로 변환해줄 메서드, 즉 생성자도 미리 선언해둔다.
  • 비로소 createRoot 가 실행되면 그때 root에 Native Event 목록을 참고해 들어올 수 있는 Event에 대해 addEventListener 로 달아둔다.

실제로 이벤트가 들어오면, Event dispatch

이제 준비는 끝났다. React는 Listener도 붙였고 SyntheticEvent로 변환하기 위한 메서드도 구비해두었다. 이제 실제 이벤트가 들어오면 어떻게 굴러가는지 확인해봐야 한다.

root에다가 Listener를 붙일 때 어떤 핸들러를 붙이는지 확인해봐야 한다.

addTrappedEventListener 에서 root에 붙여줄 핸들러를 보여준다. 변수 listener 인데, 이걸 결정해주는 메서드도 같이 가지고 왔다. DOM Event 종류에 따라 우선순위를 정해 적절한 핸들러를 전달해준다. createEventListenerWrapperWithPriority 를 보면 dispatch~Event 라는 메서드를 전달해줌을 확인할 수 있다.

그렇다면 사용자에 의해 Native Event가 발생될 때면, 이 dispatchEvent 가 호출된다고 생각하면 되겠다.

dispatchEvent 를 파고 들어 내려가보면, 우리가 원하는 부분을 찾을 수 있었다.

dispatchEventsForPlugins 다. Native Event를 root에서 인지하면, 이를 기반으로 어떻게 Target 요소를 잡고, 어떻게 Event를 추출하고, 어떻게 실행시키는지에 대한 3단계를 모두 가지고 있다.

getEventTarget 은 Native Event의 target 을 가지고 오는데, 만약 텍스트 요소라면 그 부모 요소를 가지고 온다.

이 메서드를 보면, dispatchQueue 를 만들어서 extractEvents 에 넣어주고, 그 내부에서 뭔가 할 일들이 쌓이면 processDispatchQueue 에서 처리하는 방식으로 보인다.

그러면 extractEvents 를 확인해보자.

사실 TODO: 에 SimpleEventPlugin 개념을 없애야 한다고 하긴 하는데… 아무튼 적혀있는 것을 확인해보면, 지금 기본적인 이벤트는 다 SimpleEventPlugin에서 처리를 하고, 나머지의 경우에는 polyfill 상황, 즉 과거와의 호환성을 생각하고 넣어둔 것이라고 볼 수 있겠다.

그러면 SimpleEventPlugin.extractEvents 내부를 살펴보자.

Line 14부터 38 부분을 먼저 살펴보자. 이 부분은 현재 DOM으로부터 온 Event의 이름에 따라 적절한 SyntheticEvent로 변환해주는 역할을 한다. SyntheticEventCtor 는 SyntheticEvent의 생성자라는 의미를 지니고 있다.

저 위에서 우리는 미리 SyntheticEvent를 만드는 메서드, 생성자 함수를 잔뜩 미리 만들어두었다. switch 문에서는 이벤트의 이름에 따라 적절한 SyntheticEvent 생성자를 SyntheticEventCtor 에 넣어준다. Line 20을 보면 키보드가 눌리거나 했을 때는 SyntheticEventCtor = SyntheticKeyboardEvent 가 할당되고 있는 것을 확인할 수 있다.

Line 40부터 59 파트는 나도 정확히 어떤 조건에서 발동되는지는 이해하지 못했으나, 일반적인 DOM 이벤트가 아닌, 의도적으로 핸들러가 만들어지는 경우 사용되는 것으로 보인다. 그래서 넘어가겠다.

그러면 Line 61부터 있는 코드 블럭을 확인해보자. 일단 마지막 부분부터 보면, dispatchQueue.push({event, listeners}) 라고 되어있다. 아까 dispatchEventsForPlugins 에서 만든 그 dispatchQueue 에 SyntheticEvent와 그 이벤트에 맞는 핸들러들을 넣어주고 있는 것으로 보인다.

event 는 위에서 설정한 생성자, SyntheticEventCtor 를 이용해 React에서 처리하기 위한 SyntheticEvent를 만든다.

listenersaccumulateSinglePhaseListeners 메서드를 이용하고 있다. 그쪽으로 한번 넘어가보자.

Line 17을 보면 이 함수가 무엇을 하고 있는지 적혀 있다. Event의 Target으로부터 root까지 훑고 올라가면서 각 Fiber Node에 붙어있는 핸들러를 받아서 listeners 에 저장한다.

그 과정에는 getListener 라는 메서드가 사용되는데, 그 함수도 가지고 왔다. React 내의 컴포넌트는 Fiber Tree 구조 상에 위치해있고, 각 컴포넌트는 Fiber Node라고 볼 수 있는데, 그 Node에 컴포넌트에 대한 정보들을 가지고 있다. 그를 이용해서 현재 이벤트에 맞는 listener 를 뽑아내주고 있다.

이렇게 하고 나면 현재 Target에 붙어있는 이벤트에 대한 핸들러부터 root에 있는 관련 핸들러까지 모두 모아서 전달해줄 수 있게 된다.

DispatchQueue에는 이제 SyntheticEvent와 각 listener 들이 쭉 쌓였을 것이다.

그러면 다시 여기로 돌아와서, dispatchQueue 가 우리가 원하는 정보를 담고 있으니 이젠 processDispatchQueue 를 실행시키면 된다.

여긴 막 그렇게 특별한건 없다. dispatchQueue 를 받아서 그 안에 들어있는 수많은 핸들러를 순서대로 실행시켜주는 로직 정도다.

지금까지 DOM 이벤트가 실제로 발생했을 때 어떤 과정을 통해 우리가 넣어둔 이벤트 핸들러가 동작하게 되는지 쭉 알아볼 수 있었다.

요약을 한 번 해보자.

  • DOM에서 Native Event가 발생했음을 React가 root에 붙여둔 이벤트 리스너가 인지한다.
  • 해당 이벤트 리스너에 붙여둔 dispatchEvent 가 발생한다.
  • 받아온 Naitive Event를 분류해서 적절한 SyntheticEvent로 추출하고, Target에 대한 핸들러를 뽑아서 dispatchQueue 에 넣는다.
  • dispatchQueue 에 들어있는 이벤드 핸들러들을 순서대로 쭉 실행한다.

약간의 궁금증과 알아둘만한 점

Bubbling과 Capturing의 구현은?

사실 지금까지 거의 신경쓰지 않았지만 다시 살펴보면 eventSystemFlags , isCapturePhase 라는 플래그도 굉장히 많았다.

Bubbling과 Capturing에 있어서 확인해볼만한 부분은 2곳 정도 있다.

첫 번째는 Native Event를 인지하기 위해 root에 Listener를 붙이는 코드다.

Line 9에서 12를 보자. 이벤트의 종류에 따라, 버블링이 되는 이벤트면 isCapturePhaseListener 값을 false 로 해서 등록을 해두고, 버블링이 안되는, 즉 캡처링만 되는 경우에는 isCapturePhaseListenertrue 로 하는 리스너도 등록이 된다.

isCapturePhaseListener 값은 아래로 전해지고 전해지다 addTrappedEventListener 까지 이른다. 여기서 addEventCaptureListener , addEventBubbleListener 는 root에 addEventListener를 이용해 캡처링에 대비한 리스너, 버블링에 대비한 리스너를 모두 등록해준다.

이렇게 되면 어떤 과정으로 불리는걸까? HTML 구조를 가지고 왔다.

<body>
<div id="root"> <<- 여기에 이벤트 리스너 달려있음.
<p>여기 클릭</p>
</div>
</body>

만약 p 태그를 눌렀다고 치자. 그러면 최상위로부터 Event Target인 p 태그를 찾아 내려가는 캡처링 과정이 진행될 것이다. (이건 DOM에서의 캡처링 과정을 말한다)

근데 root에는 아까 addEventCaptureListener 로 붙여둔 이벤트가 있다. 만약 p 태그가 실은 React 코드에서 <p onClickCapture={handler}> 이런 상태였다면 해당 핸들러가 불렸을 것이다.

그리고 p 태그에 갔다가 다시 최상위까지 올라오는 버블링 과정이 있을 것이다. 이때 다시 root를 지나면서 addEventBubbleListener 로 붙여둔 핸들러가 실행될 것이다. 이때 <p onClick={handler}> 이런 느낌이었으면 또 핸들러가 불렸을 것이다.

그리고 이벤트를 추출해서 dispatchQueue 에 넣을 때를 생각해보자. 그때 분명 Target으로부터 root까지 순서대로 이벤트 핸들러를 넣었지, 그 반대 과정은 딱히 없었다.

dispatchQueue 를 실제로 실행할 때 inCapturePhase 라는 정보를 받아 순서대로 구동할지 역순으로 구동할지 결정한다.

이렇게 버블링과 캡처링을 구현하고 있다.

stopPropagation의 구현은?

Event에다가 e.stopPropagation()stopPropagation 메서드를 이용하면 더 이상 이벤트가 캡처링/버블링되는 것을 막을 수 있다.

살짝 위에 있는 코드를 보면, Line 11과 20에 event.isPropagationStopped 라는 지표가 있다는 것을 확인할 수 있다. 만약 SyntheticEvent 객체가 가지고 있는 isPropagationStopped 의 반환값이 true 라면 dispatchQueue 에 들어있더라도 실행하지 않고 return하게 된다.

그러면 이 isPropagationStopped 의 값은 어디서 변경하는걸까?

아까 봤던 createSyntheticEventSyntheticBaseEvent 를 보자. assign 을 이용해 SyntheticBaseEvent에 stopPropagation 을 설정하는 것을 확인할 수 있다.

stopPropagationthis.isPropagationStopped = functionThatReturnsTrue 로 설정함으로써 이 이벤트에 대한 핸들러가 더 실행되지 않도록 플래그를 정해준다.

stopPropagation과 Bubbling, 주의할 점

stopPropagation 은 SyntheticEvent일 때나 유효하다. React App 내에서는 버블링이 막히겠지만, DOM 전체에서 막히는 것은 아니다.

아까 말했듯 이벤트가 발생하면 DOM 내의 Target을 찾는 캡처링, Target에서 최상위까지 오는 버블링이 진행된다. 이때 root를 지나기 때문에 그때 React 내 이벤트를 처리하도록 한 것이고, React 내에서 stopPropagation 을 통해 더 이상의 버블링을 막아봤자, DOM의 실제 Native Event의 버블링을 막을 수는 없다.

React 예시를 하나 살펴보자.

const App = () => {
useEffect(() =>
document.addEventListener('click', () => console.log('document')), []);
return (
<div onClick={() => console.log('outer')}>
<div onClick={(e) => {
e.stopPropagation();
console.log('inner');
} />
</div>
);
}
// 클릭시 inner, document가 출력됨

stopPropagation 때문에 같은 React 내의 ‘outer’는 출력되지 않지만, Document에 붙어있는 이벤트 핸들러는 문제 없이 출력된다.

만약 DOM에서의 이벤트의 버블링도 아예 막고 싶다면 e.nativeEvent.stopPropagation() 으로 Native Event에 접근해서 막아줘야 할 것이다.

결론

지금껏 블로그에 쓴 것 중에 가장 역대급으로 긴 글이 될 것 같다. 누군가에겐 도움이 되었으면 좋겠다.

사실 코드 중간중간에 디테일 적인 설정도 굉장히 많았는데, 개인적으로 그것까지 다 이해하기엔 벅차서 큰 흐름만 이해하는 정도에서 만족했다. 그래도 이렇게 궁금한 점이 생겨서 React 레포까지 뜯어보고 이해하는 경험은 꽤 좋았던 것 같다. 꽤 재밌었다.

회사에선 이걸로 세미나도 한번 진행해야 할 것 같은데, 글로 써도 이렇게 긴데 이벤트에 대해 전반적으로 훑으려면 세미나에서도 얼마나 걸릴까…

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science