Web: Event와 addEventListner 알아보기 (개념, React에서 주의할 점)

Heechan
HcleeDev
Published in
12 min readAug 11, 2022
Photo by David von Diemar on Unsplash

일반적으로 사용자로부터 데이터를 입력받고 할 때는 input 같은걸 쓰면 되다보니 키보드 입력을 어떻게 받는지에 대해서 생각해본 적은 없었다. 그런데 최근에 단축키 기능 같은걸 만들어야 했어서 코드를 뒤져보니 이벤트 리스너를 사용하고 있었다.

이번주에는 Event와 addEventListener , 그리고 주의해야 할 점에 대해 알아보도록 하자.

Event, Event Handler

일단 이벤트란 무엇일까. 이벤트는 우리의 프로그램에서 발생하는 어떤 사건이다. 이는 주로 사용자와의 상호작용으로부터 나오고, 어떨 때는 비동기 작업 처리를 위해서 만들어질 때도 있다.

마우스가 어떤 요소를 클릭하거나, 어떤 요소 위에 올라가는 경우, 키보드에서 어떤 키를 눌렀을 경우, 사용자가 브라우저 창의 크기를 조정하는 경우, 사용자가 파일을 드래그 앤 드롭으로 브라우저에 업로드한 경우 등 다양한 상호작용이 이벤트로 처리된다.

이 MDN 문서를 보면 굉장히 많은 이벤트 종류가 있다는 것을 확인할 수 있다.

실제로 우리가 코딩을 할 때 사용하게 될 이벤트 종류는 JavaScript의 Event 인터페이스를 상속하고 있다.

이정도만 봐도 정말 온갖 이벤트가 다 있다는 것을 알 수 있다.

DOM에 있는 이벤트를 타입스크립트 타입으로 정의해둔 파일을 확인해보면 이런 식으로 Event를 상속하는 것을 확인할 수 있다.

이벤트는 우리에게 활용 대상이다. 사용자가 어떤 버튼을 클릭을 했다던가, 컨트롤 Z를 눌렀다든가, 어떤 행동을 했다면 그에 맞춘 적절한 행동을 구현하기를 원한다.

이벤트에 맞추어 적절한 처리를 담아둔 메서드를 Event Handler라고 부른다. 만약에 사용자가 키보드의 Esc 버튼을 눌렀을 때 적절한 처리를 하고 싶다면, 우리는 KeyboardEvent를 매개변수로 받는 메서드를 만들고, KeyboardEvent 내에서 key 값을 확인하고, code가 ‘Escape’라면 우리는 그에 적절한 대응을 하도록 하면 된다. React 환경에서 그런 메서드를 만든다면 아래와 같다.

const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
cancelSomething();
}
};

이런 느낌으로 이벤트 핸들러를 만들 수 있다.

Event Handler를 등록하는 방법들과 addEventListener

그러면 이벤트도 있고, 이벤트를 처리하는 함수도 만들었으니, 이제 이벤트가 발생하면 이 핸들러가 호출되도록 만들어주기만 하면 된다.

그 역할을 수행하는 것이 바로 Event Listener다. Event Listener는 이벤트가 발생하는지 계속 쳐다보고 있다가, 이벤트가 발생하면 우리가 미리 등록해둔 핸들러를 호출해준다.

Event Listener를 통해 핸들러를 연결해주는 것을 ‘register’, 등록이라고 말한다. 핸들러를 등록하는 방법은 사실 오늘 소개할 addEventListener 를 제외하고도 여러가지가 있다.

가장 많이 사용하는 방식은 컴포넌트에 직접 넣어주는 방식이다.

// HTML<button onclick="handler()"></button>// React<button onClick={handler}></button>

사실 이렇게 넣어주는게 가장 일반적이다. HTML로 개발을 많이 안해봐서 모르겠지만 React에서는 대부분 저런 느낌으로 이벤트 핸들러를 넣어주는 경우가 많을 것이다.

HTML에서는 DOM에 직접 접근해서 건드려야 하는 경우가 많지만 React는 애초에 DOM에 귀찮게 접근하지 말자는 의도로 만들어졌다보니… 그냥 저렇게 이벤트 핸들러를 넣어주는게 이상한건 아니다.

DOM에 직접 접근하는 경우에는 아래처럼 해당 요소의 onclick 에다가 우리가 만들어둔 핸들러를 직접 지정해주는 방식도 있다.

var btn = document.getElementById("button");
btn.onclick = buttonClick();

이런 방식으로 핸들러를 정해주는 것을 자주 해본 쪽은 Image를 핸들링할 때 같다.

const image = new Image();
image.src = "https://...";
image.onload = () => {
setImage(image);
};

이런 느낌으로 네트워크를 통해 받아온 이미지 데이터가 로딩이 완료되면 onLoad 이벤트가 발생하고, 우리가 정해둔 핸들러가 돌아가도록 하는 경우가 있다. 이미지 뿐만 아니라 FileReader를 이용할 때도 주로 이렇게 처리하는 것 같다. 위에서 살짝 언급한 유저 상호작용 외에도 비동기 작업으로 인해 발생하는 이벤트가 이런 종류다.

위에서 봤던 두 가지 방식의 단점을 개선한 보다 현대적인 방식의 addEventListener 메서드가 등장했다. 사용 예시는 다음과 같다.

const btn = document.querySelector('button');

function bgChange() {
...
}

btn.addEventListener('click', bgChange);

이렇게 하면 해당 컴포넌트에 ‘click’이라는 이벤트가 발생하면 bgChange 를 실행시키도록 핸들러를 등록할 수 있다.

이걸 사용했을 때 기존의 방식보다 몇 가지 장점이 있다고 한다.

첫 번째는 추가된 Event Listener를 제거하는 removeEventListener 도 있다. 위와 같은 상황에서는 btn.removeEventListener('click', bgChange) 로 삭제할 수 있다. 사실 간단한 경우에는 이렇게 굳이 지워주지 않아도 되지만 복잡한 프로그램이나 랜더링이 많이 되는 경우에는 체크해주는 것이 좋다.

두 번째는 한 컴포넌트에 같은 이벤트에 대한 핸들러를 여러개 달 수 있다는 점이다.

btn.addEventListener('click', bgChange1);
btn.addEventListener('click', bgChange2);

이런 느낌으로 말이다. 다른 방식은 한 번에 단 하나의 핸들러만 달 수 있었지만, 이를 이용하면 여러 개를 붙일 수 있다.

이 특징은 라이브러리, 모듈 등 각각이 서로의 기능을 해치지 않으면서 이벤트 핸들러를 적용해야 할 때 유용하다. 위 예시에서는 btn 이라는 되게 구체적인 컴포넌트를 하나 꺼내서 적용했지만, 하다보면 window 같은 객체에 대해서도 달게 된다. 그럴 때 window 의 ‘keydown’에 여러 개가 동시에 작동해야 할 수도 있는 노릇이니, 그럴 때는 이 기능이 유용하게 사용될 수 있다.

MDN에서는 가급적이면 이 기능을 이용해 이벤트 핸들러를 등록하기를 권장하고 있다. 약간의 장점 때문만이 아니라, 만약 HTML 태그 속에 직접 JavaScript 로직을 넣거나 하는 것은 UI와 로직이 분리되지 않아서 보기 좋지 않다고 한다. 그래서 아예 addEventListener 를 통해 UI 코드 밖에서 처리하라는 뜻 같다.

React에서 사용할 때 주의해야 할 점

첫 번째는 메모리 관리다.

과거에 나는 이런 실수를 한 적이 있었다.

const Component = () => {
window.addEventListener('keydown', handleKeyDown);
...
}

이렇게 되면 컴포넌트가 리랜더링될 때마다 window에 핸들러를 계속 추가하게 된다. 이러다보면 뭔가 의도치 않은 동작을 하게 되기도 하고, 메모리적으로 문제가 생기기도 한다. 그래서 어지간하면 매번 적절히 removeEventListener 를 불러주는 것이 좋다.

여기서도 주의할 점이 있는데, 아래 코드를 한 번 봐보자.

function App() {
const [state, setState] = useState<number>(0);
console.log('rerender');
const handler = () => {
console.log('hi');
}
return (
<>
<p>{state}</p>
<button onClick={() => {
window.addEventListener('keydown', handler)
setState(state + 1);
}}>add</button>
<button onClick={() => {
window.removeEventListener('keydown', handler);
setState(state - 1);
}}>remove</button>
</>
);
}

add라고 적힌 버튼을 누르면 state 가 1 올라가고 window에 addEventListener 가 호출된다. remove라고 적힌 버튼은 state 가 1 내려가고 removeEventListener 가 호출된다. 이러면 우리는 add 버튼을 누른 후에는 정상적으로 진행, remove라고 적힌 버튼을 누르면 핸들러가 불리지 않기를 기대할 것이다.

하지만 그렇게 동작하지 않는다. 그 이유는 addEventListenerremoveEventListener 에 들어가는 핸들러는 (당연하게도) 함수에 대한 참조가 들어가게 되어있는데, 만약 그 사이에 리랜더링이 되게 되면, 핸들러도 새롭게 선언이 되어 새 주소에 저장이 된다. 이러면 removeEventListener 에서는 엉뚱한 주소의 핸들러를 보내주고 있으므로 기존의 핸들러가 삭제가 안된다.

따라서 핸들러의 참조를 제대로 추가, 삭제할 수 있도록 잘 생각해봐야 할 것이다. 위와 같은 상황에서는 handleruseCallback 같은 핸들러로 감싸서 주소가 변하지 않도록 해주는 방법이 있을 것 같긴 하다.

두 번째는 캡처링이다.

흔히 페이지가 랜더링된 직후부터 Event Listener를 달아야 하는 경우에는 useEffect(..., []) 안에 코드를 넣어두는 경우가 흔하다. 예를 들어서 우리가 이 페이지에서는 키보드의 아무 키나 누를 때마다 현재 state 값을 콘솔에 찍어줘야 한다고 생각해보자.

function App() {
const [state, setState] = useState<number>(0);
const handler = () => {
console.log(state);
}
useEffect(() => {
window.addEventListener('keydown', handler);
}, []);
return (
<>
<p>{'Current: ' + state}</p>
<button onClick={() => {
setState(state + 1);
}}>add</button>
</>
);
}

하지만 이렇게 짜면 콘솔에서는 계속 0이 찍힌다.

왜 그럴까? 왜냐면 window.addEventListener 가 호출되는 시점에서의 handler 로 고정이 되어버리기 때문이다.

지금 state 는 객체같은 참조 타입이 아니라 number라는 값 타입이다. 따라서 처음 랜더링될 때 딱 만들어지는 handler 는 최초의 값인 0을 출력하는 역할만 하게 된 것이다. 또한, 리랜더링되면서 최신 state 값을 반영하는 handler 는 기존 handler 와 또 다른 함수로 새롭게 선언되기 때문에 애초에 같은 함수도 아니다.

이 경우에는 크게 두 가지의 해결법이 있다.

그냥 state 값을 useRef는 class든을 사용해 어떤 주소를 참조하도록 만들면, 값이 변경될 때마다 반영될 수 있다.

추천하는 방법은 그냥 useEffect의 deps에 state 를 추가하는거다.

useEffect(() => {
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
}
}, [state]);

이렇게 하면 state 가 변경될 때마다 기존의 Event Listener가 사라지고 새로운 값이 반영된 Event Listener가 추가되기 때문에 우리가 의도한대로 동작할 수 있게 된다.

만약 여기서 useEffect의 cleanup에 removeEventListener 를 넣어주지 않는다면, 아까 말했듯 여러 개의 핸들러를 붙일 수 있으므로 점점 늘릴 때마다 0, 1, 2, 3.. 의 숫자들이 한 번에 나오게 된다. 따라서 기존 핸들러는 꼭 정리해주도록 하고, 그렇게 하는 편이 메모리 관리에도 도움이 된다.

결론

쓸 내용이 좀 없을까봐 걱정했는데 생각보다 이벤트가 뭔가 많은 것 같다. 주제가 안맞아서 여기서 안다룬 것도 있는데 차후 좀 더 자세히 알아봐도 괜찮을 것 같다.

그리고 React에서의 이벤트는 어떻게 돌아가도록 만들었는지 알아봐도 괜찮을 것 같다…

참고한 것

--

--

Heechan
HcleeDev

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