[React] 리액트를 처음부터 배워보자. — 06. 합성 이벤트와 Event Pooling

Stark Studio
Cross-Platform Korea
13 min readSep 6, 2020

--

이 글을 남기는 계기는 Dan Ambramov의 블로그 Overreacted를 보며 React.js에 대해 더 깊이 공부할 필요성을 느꼈기 때문이다.

지난 글을 쓰고 다음 글감을 고민하던 중 스크롤 이벤트를 처리하면서 debounce와 throttle를 쓰는 경험을 하였다.

이 과정에서 그동안 잘 사용하던 event 객체의 내부 값이 null이 되어 사라지는 문제를 겪었고 그간 아무 생각 없이 사용하던 event.persist()에 대한 궁금증이 생겼다.

이에 대한 원리를 파악하기 위해 React의 이벤트 처리와 합성 이벤트(Synthetic Event) 문서를 읽으며 이벤트 풀링과 합성 이벤트라는 개념에 대해 더 구체적으로 알게 되었다.

React는 이벤트를 처리하기 위해 바닐라 자바스크립트와 달리 엘리먼트가 렌더링 될 때 이벤트 리스너(Event Listener)를 제공해 이벤트를 처리한다.

[그림 01] Valilla Javascript에서 HTML DOM 요소에 이벤트 리스너를 추가하는 방법

다만 이 이벤트 핸들러는 모든 브라우저에서 동일한 처리를 보장하기 위해 React에서 제공하는 SyntheticEvent 객체를 전달 받는다.

소프트웨어에서 래핑(Wrapping)이란 기본 기능을 감싸는 새로운 기능을 만드는 것을 말한다. 유사 개념으로 GoF 디자인 패턴의 어댑터(Adapter), 브릿지(Bridge) 패턴이 있다.

즉, 한 단계 래핑(Wrapping)된 이벤트 객체를 통해 이벤트를 다루는 것이다.

SyntheticEvent 객체는 모든 브라우저에서 이벤트를 동일하게 처리하기 위한 래퍼 객체이다. 대부분의 인터페이스는 브라우저 고유 이벤트와 같다.

이번 글은 SynctheticEvent객체가 어떤 원리로 동작하고, 의사 코드를 통해 알게 된 이벤트 풀링의 대략적 원리에 대한 글이다.

01. 합성 이벤트(Synthetic Event)와 이벤트 풀링

React 공식 문서에는 이벤트 풀링에 대해 다음과 같이 써 있다.

SyntheticEventPooling되며 성능상의 이유로 SyntheticEvent객체는 재사용 되고 모든 속성은 이벤트 핸들러가 호출된 다음 초기화 됩니다.
따라서 비동기적으로 이벤트 객체에 접근할 수 없습니다.

이 부분에 대한 설명이 내가 이 글을 쓰게 된 계기이다.

핵심은 React에서 엘리먼트의 이벤트를 처리하기 위해 제공하는 이벤트 리스너의 인자(event, e)가 매번 초기화가 된다는 것이다.

[그림 02] event 객체가 render -> commit 이후에 초기화 되어 event 객체의 속성이 null이 된다.

해당 개념에 대해 이해하기 위해 풀링(Pooling)이라는 개념을 먼저 이해해보자. 풀링은 컴퓨터 과학에서 풀링은 다음과 같이 정의한다.

컴퓨터 과학에서 풀(pool)은 재사용 될 준비를 하는 자원(Resource)의 집합입니다.

즉, 특정 이벤트가(클릭, 터치, 호버, 스크롤 등) 발생할 때 다음과 같은 순서로 이벤트 로직이 실행되는 것이다.

  • 합성 이벤트 풀(Synthetic Event Pool)에서 SyntheticEvent 객체에 대한 참조를 받음
  • 이벤트 정보 Synthetic Event 객체에 넣어줌
  • 유저가 정의한 이벤트 리스너가 수행
  • SyntheticEvent 객체를 초기화 한다는 것이다.

즉, 내가 겪은 debounce 코드에서 event 객체의 속성값이 null이 되는 문제는 위와 같은 React 내부 동작에 의한 자연스러운 동작이었다.

또한, 해당 문제를 해결하기 위해서 React에서는 event.persist()라는 기능을 제공한다.

02. 이벤트 풀링 Event Pooling

문제에 대한 원인과 해결책을 찾았지만, 사실 해당 부분에 대한 추상적 이해를 넘어 구현체에 대한 궁금증이 생겼다.

하지만 아쉽게도 해당 부분에 대한 명확한 파악이 불가능하여, 이벤트 풀링 구현체 대한 코드와 원리에 대한 질문을 Stack Overflow에 올렸다.

해당 질문에 대해 Alex Povar라는 유저가 답변을 해주었고, 해당 의사코드에 기반을 두어 이벤트 풀링이 어떤 원리로 동작 하는지 파악하고자 한다.

[그림 03] SyntheticEvent 객체 내부 속성 및 상태에 대한 가정

먼저 SyntheticEvent에 대한 의사코드를 보자. 해당 부분에서 확인할 수 있는 것은 SyntheticEvent에서 3가지 상태를 가진다는 것을 알 수 있다.

실제 리액트 코드에서는 status 와 같은 부분이 없지만, 이벤트 풀링의 원리를 이해하기 위해 있다고 가정한다.

  • POOLED 이벤트 풀에 등록되어 대기중인 상태
  • PERSISTED 이벤트 처리가 끝났는데도 남아있는 상태
  • IN_USE 네이티브 이벤트를 할당 받아 SyntheticEvent로 래핑된 상태

status에 따라 이벤트 풀은 Synthetic Event를 아래와 같이 관리한다.

[그림 04] Event Pool에 등록된 Synthetic Event를 가져와서 nativeEvent를 복사하고, 사용이 끝나면 초기화 한다.

위의 과정에서 핵심적으로 볼 것은 pullEventtryPushEvent이다.

EventPool 인스턴스는 내부적으로 POOLED상태의 비어있는 SyntheticEvent 객체의 배열을 가지고, 실제 이벤트가 발생할 때 pullEvent 메소드를 호출해 다음 동작을 한다.

  1. allocateNewEvent()를 통해 이벤트 풀에 대기중인 SyntheticEvent를 채움
  2. getFromPool() 메소드를 통해 POOLED 상태의 SyntheticEvent를 반환
  3. populateEvent() 메소드를 통해 nativeEventSyntheticEvent에 복사한 후 IN_USE 상태로 변경
  4. SyntheticEvent를 반환

이렇게 생성된 SyntheticEvent는 이벤트 핸들러 동작이 완료된 후 tryPushEvent 메소드를 통해 다음 과정으로 정리 된다.

  1. persist()함수가 호출되어 PERSITED 상태가 되면 아무 동작을 안함
  2. SyntheticEventPERSITED 상태가 아니면 clearEvent를 통해 SyntheticEvent 안의 속성들을 null로 초기화

이제 위의 EventPool 인스턴스가 어떻게 동작할 수 있는지 파악해보자.

[그림 05] 이벤트 풀을 가지고 이벤트를 처리하는 것에 대한 의사 코드

위의 예시를 보면 nativeEvent를 어떻게 SyntheticEvent로 변환하고, 이를 사용한 후 다시 Event Pool에 반환하는지에 대한 과정을 확인할 수 있다.

위의 구현체를 파악한 결과 userDefinedOnClikHandler에서 event.persist()를 호출하지 않는 경우 tryPushEvent를 통해 syntheticEvent가 사라진다는 것을 확인할 수 있었다.

따라서, 해당 의사 구현체가 리액트의 이벤트 풀링의 원리를 설명하는 것에 부족함이 없을 것 같다.

03. 이벤트 델리게이션 Event Delegation

이 글을 쓰며, 이벤트에 대한 다양한 것을 배우며 리액트에서 이벤트를 다루기 위해 쓰는 이벤트 델리게이션 패턴에 대해서도 알게 되었다.

Vanilla Javascript에서는 이벤트 리스너를 달아준 후, 해당 요소가 사라질 때이벤트 리스너가 지워지지 않았으면 좀비 메모리가 발생한다.

델리게이션(Delegation)은 소프트웨어 공학에서 하나의 패턴과 같으며, 로직을 중앙에 집중(Centralization)시키기 위한 패턴이다.

React는 이벤트를 다룰 때 이벤트 델리게이션 개념을 사용해 자식 요소에 모든 이벤트 리스너를 달아주는 것이 아니라, 가장 상위의 부모 요소에 이벤트 리스너를 달아준다.

[그림 06] 개발자 도구 > Elements > Event Listeners 에서 DOM 이벤트 핸들러를 확인할 수 있다.

이벤트 델리게이션(Event Delegation)은 이벤트 캡처링과 버블링을 활용하는 방식이다.
공통된 부모 요소의 event.target을 이용해 실제 이벤트의 발생을 감지하고 핸들링 하는 것이 핵심이다.

즉, React는 자체적으로 최상위 요소에만 이벤트를 등록하고 자식 요소의 이벤트를 event.target으로 감지해 핸들링 한다.

해당 부분에 대해 파악하기 위해 실제 리액트에서 이벤트 처리를 담당하는 메인 소스 코드 dispatchEvent 를 파악해보자.

[그림 07] dispatchEvent에서 getEventTarget을 통해 Fiber 단위를 찾아내는 것을 확인할 수 있다.

해당 부분에서 nativeEventnativeEventTarget을 통해 Fiber를 찾아내는 것을 확인할 수 있다.

이 부분에 대해서 정확하지 않지만 React에서 내부적으로 target node의 id를 통해 이벤트 델리게이션을 구현하고 있다고 한다.

[그림 08] getEventTarget을 통해 nativeEvent에서 event.target을 얻어오는 내부 구현

event에서 target을 가져오는getEventTarget은 다음과 같이 구현되어 있다.
해당 부분에서 target 외의 속성은 각각 이슈가 있어 추가를 한 것이라고 주석에 적혀있다.

해당 부분에 대해 궁금하면getEventTarget 에서 해당 이슈에 대해 파악해보면 좋을 것 같다.

결론

이 글을 쓰며, React의 합성 이벤트(Synthetic Event), 이벤트 풀링(Event Pooling), 이벤트 델리게이션(Event Delegation)에 대해 알 수 있었다.

프론트 엔드 개발을 하면 대부분은 이벤트 처리와 관련된 코드를 작성하면서 이벤트를 어떻게 다루고, 내부적으로 어떤 컨셉을 사용하는지 큰 관심이 없었던 것 같다.

우연한 기회로 이 부분에 대한 글을 쓰며 프론트 뿐만 아니라 소프트웨어에서 이벤트라는 것을 어떻게 정의하고 다루는 지 알 수 있었다.

특히, 이벤트 기반 아키텍처에 대해 읽으며 개발에 대해 기존에는 생각하지 못한 전략에 대해 고민해 볼 수 있었다.

예를 들어, 핵심 로직은 기존 구성에서 변경 없이 그 하나의 로직 메소드 마다 이벤트를 발생시키고, 새로운 기능(플러그인 기능)이 생길 때 마다 이벤트 리스너를 달아주는 전략에 대해 고민할 수 있게 되었다.

이 방식에도 반드시 문제점이 있을 수 있겠지만, 해당 방식으로 개발을 진행하면서 이 방법론이 왜 나왔고 어떻게 사용할 수 있을지에 대해 고민해 보며 소프트웨어를 만들어가는 새로운 방식을 배워 볼 예정이다.

맺음말

이 글을 쓰기 위해 Thread Pooling에 대해 조사하면서 이해가 되지 않는 부분이 많아 StackOverflow를 시작하였다.

올 초 부터 StackOverflow를 한 번 해보려고 했지만, 항상 영어의 진입 장벽과 “내가 이해할 수 있을까?” 하는 막연한 걱정으로 이를 겁냈지만, 막상 시도해 보니 개발을 하는데 새로운 활력이 되었다.

StackOverflow에서 내 질문에 대해 답을 해주지 않아, 명성(Reputation)을 올려야 하나 싶어 밤새 내가 할 수 있는 질문에 대해 답변하며 하루를 보내곤 했다.

[그림 09] StackOverflow에 도전하며 내가 누군가에게 도움이 될 수 있었고, 많은 것을 배웠다.

약간의 명성을 얻고 Event Pooling 구현체에 관한 질문을 올렸는데, React에서는 Event Pooling으로 큰 효과를 얻지 못해 곧 릴리즈 되는 v17.x부터 Event Pooling이 사라진다는 소식도 들었다.

처음에는 그저 질문을 하기 위해서 시작했는데, 좋은 정보를 얻고 누군가에게 도움이 된다는 사실이 긍정적으로 느껴졌다.

이 글을 보는 사람 중에도 혹시 StackOverflow에 관심이 있지만 영어에 대한 장벽으로 두려워 하는 사람이 있다면, 이 기회에 한번 도전하길 바란다 :)

유용한 정보를 많이 얻을 수 있고 누군가에게 도움이 된다는 점, 그리고 점수가 쌓여 간다는 기쁨이 지쳐가는 개발 공부에 작은 단비같은 경험으로 남을 수 있을 것이다.

참고자료

지난 글

--

--