[React] Debounce SyntheticEvent

React에서 사용하는 onClick, onChange 등을 통해서 핸들링할 수 있는 이벤트 객체는 브라우저 네이티브 이벤트 객체가 아니다. 브라우저 네이티브 이벤트 객체를 감싼 React 고유의 이벤트 객체인데, 이를 React에서는 SyntheticEvent라고 부른다. SyntheticEvent는 브라우저 네이티브 이벤트와 매우 비슷한 API를 갖고 있기 때문에 네이티브 이벤트로 착각하기가 쉽지만, 실제로는 여러가지 차이점을 가진다.

Event Pooling

그중 가장 큰 차이점은 Event Pooling이다. SyntheticEvent는 이벤트 객체를 재활용하기 때문에, 해당 이벤트가 발생하고 그 이벤트 핸들러가 실행된 이후에는 SyntheticEvent의 모든 프로퍼티가 null로 변한다.

위의 예제에서 버튼을 클릭해보면 이벤트 핸들러에서 setTimeout을 실행하기 전의 이벤트 객체(Before)에는 프로퍼티에 데이터가 잘 들어가 있었지만, setTimeout을 실행한 이후의 이벤트 객체(After)에서는 모든 프로퍼티가 null로 변했다는걸 확인할 수 있다. 이걸 두고 Event Pooling이라고 부른다. 특정 이벤트의 이벤트 객체는 단 한 번, 해당 이벤트 핸들러가 실행되는 동안에만 사용할 수 있다. 성능적인 이유로 이렇게 한다고 설명되어있는데, 구체적으로 어떻게 성능이 나아지는지는 잘 모르겠다. 이벤트 객체를 하나만 사용하니 확실히 메모리는 적게 먹겠지만.

Asynchronous Problem

문제는 종종 이벤트 핸들러에서 비동기적인 동작을 수행해야하는 경우가 생긴다는 것이다. 비동기 동작을 수행한 이후에 이벤트 객체가 들고있는 정보가 필요한 경우 어떻게 해야할까? 가장 간단한 해결방법은 그냥 해당 이벤트 객체가 가지는 특정 데이터를 다른 변수에 복사해두고 비동기 동작의 수행이 끝난 뒤 사용하는 것이다. 코드를 몇 줄 더 써야되기는 해도 꽤 괜찮은 해결방법이다.

React에서는 이 해결책 말고도 한 가지 특별한 해결책을 제시한다. 바로 event.persist()다.

위의 예제는 첫번째 예제에 event.persist()를 적용한 예제다. 버튼을 눌러보면 좀 전과는 달리 이벤트 객체의 프로퍼티들이 제대로 보존되어있는 것을 확인할 수 있다. event.persist()를 사용하면 비동기적인 동작을 수행한 이후에도 데이터가 남아있어서 이벤트 객체를 활용할 수 있다. 물론 이렇게 하면 아까도 언급했던 성능적인 이점이 줄어들 것이다.

Debounce SyntheticEvent

예전에 Lodash의 debouncethrottle 함수를 소개하면서 말했듯이, onmousemove, onscroll 등의 이벤트는 1초 안에도 수십번씩 발생할 수 있는 이벤트이기 때문에 Throttle을 걸어주거나 Debounce 처리해주어야 하는 경우가 종종 있다. 물론 React를 사용하더라도 해당되는 얘기다.

하지만 Lodash의 throttledebounce는 기본적으로 이벤트 핸들러의 동기적인 실행을 막는 메소드이기 때문에 React의 Synthetic 이벤트와 궁합이 잘 맞지 않는다. 다음 예제를 보자.

이 예제에서 <input/>의 이벤트 핸들러는 Debounce 처리 되었다. 다시 말해 <input/>에 무언가를 입력해도 바로 이벤트 핸들러가 실행되는게 아니라, 무언가를 입력한 이후에 500ms 동안 타이핑을 멈춰야 이벤트 핸들러가 실행된다. 입력창에 실제로 무언가를 입력해보고 이벤트를 실행시켜보면 에러가 발생하는 것을 볼 수 있다. 왜냐하면 이벤트 핸들러가 마지막 이벤트의 500ms 이후에 실행되기 때문이다. SyntheticEvent의 특성상 이미 모든 프로퍼티가 null로 바뀐지 오래일 것이다. 결과적으로, Debounce된 이벤트 핸들러 내부에서 SyntheticEvent 객체에 접근하는 경우 항상 null로 모든 프로퍼티가 도배된 객체만을 얻을 수 있다.

이 문제는 어떻게 해결할 수 있을까? 가장 쉽게 떠올릴 수 있는 방법은 위에서 잠깐 소개한 event.persist()를 사용하는 것이다. 하지만 이 방법은 해결책이 되지 않는다. event.persist()는 이벤트 핸들러 내부에서만 호출할 수 있다. 이벤트가 일어나기 전에 호출할 수 없다는 말이다. 결국은 이 케이스에서 사용할 수는 없는 방법이다.

Solution

해결방법은 간단하다. 이벤트 핸들러를 두 개로 쪼개면 된다. React의 SyntheticEvent를 동기적으로 핸들링할 Debounce되지 않은 이벤트 핸들러 하나, 필요한 데이터를 받아 무거운 동작을 수행할 Debounce된 이벤트 핸들러 하나.

위의 예제에서는 onChange 이벤트에 대한 핸들러로 handleChangedebouncedHandleChange 이렇게 두 가지를 사용하고 있다. handleChange에서는 온전히 SyntheticEvent를 받은 뒤에 필요한 데이터를 이벤트 객체로부터 추출해서 Debounce된 debouncedHandleChange로 넘겨주고 있다. 이제 debouncedHandleChange에서 얻어진 데이터를 가지고 실질적인 동작을 수행하면 될 것이다.

이 때 주의할 점은, SyntheticEvent 객체를 그대로 Debounce된 이벤트 핸들러에 넘겨주면 안된다는 것이다. JavaScript에서 함수에 객체를 넘길 때는 항상 Call by reference이므로 그냥 SyntheticEvent 객체의 참조가 넘어가고 결국 다시 모든 프로퍼티가 null인 이벤트 객체를 다시 보게 될 것이다. 귀찮더라도 필요한 프로퍼티를 적당히 추출해서 넘기는게 좋은 방법이다.

References