들어가며
웹사이트의 역할은 크게 출력과 입력, 두 가지다. 서버나 스토리지로부터 전달받은 데이터를 사용자에게 보여주는 게 출력이고, 사용자의 요청에 따라 행동하는 게 입력이다. 보통은 사용자가 사이트에 접속하거나 버튼을 클릭하거나, 아이템을 클릭하거나, 폼을 제출하는 등의 사용자의 입력이 있으면 사이트를 사용자에게 보여준다. 혹은 다음 페이지로 이동하거나 아이템 상세 정보를 보여주거나, 제출한 폼을 게시물에 등록하는 등 새로운 출력을 만들어낸다.
여기서 사용자의 요청(입력)을 이벤트라고도 한다. 사용자가 클릭을 하면 이는 MouseEvent, 키보드를 누르면 KeyboardEvent가 dispatch 된다. 더 세부적으로 나아가면 마우스를 누르면 mousedown 타입, 마우스를 떼면 mouseup 타입, 키보드를 누르면 keydown 타입, 키보드를 떼면 keyup 타입이 된다.
웹사이트에서는 이러한 이벤트, 즉 사용자의 요청에 따라 특정한 행동을 취하는 행동을 이벤트 리스너라고 하며, 웹사이트에서 우리는 이벤트 리스너를 함수로 등록해 특정한 행동을 취한다. 이 이벤트 리스너를 바닐라 자바스크립트에서 관리하는 방법에 대해 이번 글에서는 다뤄보고자 한다.
실전
가장 간단한 방법은 이벤트 핸들러를 해당하는 돔에 다는 것이다.
document.querySelector("#food-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = (
this.element.querySelector("#add-food") as HTMLInputElement
)?.value;
this.setState({ foods: [...this.state.foods, addFoodInputValue] });
});
위와 같이 돔에 직접 이벤트 리스너를 달 경우 두 가지의 문제점이 있다.
- 돔이 추가될 때마다 이벤트 리스너를 달고, 돔이 제거될 때마다 이벤트 리스너가 제거된다.
- 이벤트 타겟 요소를 찾는데 통일성이 없다.
만약 #add-food 요소에는 id가 설정되어 있어서 찾기에 쉬웠다. 그런데 만약 고유한 id값을 설정하기에는 에매한 요소라면?
문제점 1. 돔이 추가될 때마다 이벤트 리스너를 달고, 돔이 제거될 때마다 이벤트 리스너가 제거 된다.
이는 서비스 규모가 커질수록 문제가 부각된다. 만약 80개의 아이템이 있고, 각 아이템을 누르면 아이템의 상세 정보가 보인다고 해보자. 그리고 스크롤을 내리면 20개의 아이템씩 추가가 된다. 만약 각 아이템 별로 이벤트 리스너를 단다면 기존에는 80개의 이벤트 리스너가 달려있을 것이고, 아이템이 추가될 때마다 20개의 이벤트 리스너가 추가될 것이다. 이는 상당히 비효율적이다. 매 번 이벤트 리스너를 다는 행위는 리소스를 많이 필요로 한다.
이러한 문제를 해결하기 위해서는 이벤트 위임(Event Delegation) 패턴을 활용할 수 있다. 이벤트 위임은 여러 요소에 각각 이벤트 리스너를 추가하는 대신, 공통 조상 요소에 하나의 이벤트 리스너만 추가하는 방식이다. 이는 메모리 사용량을 크게 줄이고 동적으로 추가되는 요소들도 자동으로 이벤트 처리가 가능하게 한다.
window.addEventListener('submit',(event)=>{
event.preventDefault();
if(event.target.closest('#food-form')) {
const addFoodInputValue = (
this.element.querySelector("#add-food") as HTMLInputElement
)?.value;
this.setState({ foods: [...this.state.foods, addFoodInputValue] });
}
})
이러한 이벤트 위임 패턴을 사용하면 개별 아이템마다 이벤트 리스너를 추가하는 대신, window나 document와 같은 상위 요소에 하나의 이벤트 리스너만 추가하면 된다. 이벤트가 발생하면 event.target을 통해 실제 이벤트가 발생한 요소를 찾고, Element의 closest 메서드를 사용해 원하는 선택자와 일치하는 가장 가까운 조상 요소를 찾을 수 있다.
문제점 2. 이벤트 타겟 요소를 찾는데 통일성이 없다.
서비스 규모가 커지며 문제가 생겼다. A라는 곳에서는 id를 이용해 돔을 식별하고 있고, B라는 곳에서는 class, C라는 곳에서는 data 속성을 사용해 돔을 식별하고 있다. 동일한 ‘이벤트 타겟 요소를 찾는 행위’인데 식별하는 방법이 통일되어 있지 않으니 일관성이 떨어지고 유지보수가 어려워졌다. 이러한 문제를 해결하기 위해 돔을 식별하는 방법을 통일할 필요가 있겠다는 생각을 했다.
방법은 간단하다. dataset을 활용하고, data-action으로 이벤트 타겟 요소를 식별하는 것이다.
window.addEventListener('submit',(event)=>{
event.preventDefault();
if(event.target.closest('[data-action="submit-food"]')) {
const addFoodInputValue = (
this.element.querySelector("#add-food") as HTMLInputElement
)?.value;
this.setState({ foods: [...this.state.foods, addFoodInputValue] });
}
})
위와 같이 querySelector, closest같이 셀렉터 기반으로 요소를 탐색하는 메서드에는 속성으로 돔 요소를 찾을 수 있다.
문제점 3. 이벤트 리스너가 응집되지 않는다.
위와 같이 이벤트 리스너가 필요한 곳에서 window.addEventListener로 이벤트 리스너를 등록 할 경우, 각 위치마다 새로운 이벤트 리스너가 등록될 것이다. 그런데 이벤트 위임 방식에서는 그럴 필요가 없다. 페이지 내에 click 이벤트 리스너 1개, submit 이벤트 리스너 1개, keydown 이벤트 리스너 1개 이런 식이면 된다. 내부에서 분기 처리를 해 어떤 요소에서 발생한 이벤트인지만 체크하면 된다. 이를 위해서 클래스와 싱글톤 인스턴스로 이벤트 리스너를 관리해보자.
// modules/EventHandler.js
export class EventHandler {
#eventMap = new Map();
addEventListener({ eventType, callback, dataAction }: AddEventListenerProps) {
const value = this.#eventMap.get(eventType);
this.#eventMap.set(eventType, [
...(value ?? []),
{
callback,
dataAction,
},
]);
}
// ..
이벤트 타입 1개당 하나의 이벤트 리스너를 달 것이기에 이벤트 타입은 고유하다고 할 수 있다. 그래서 이벤트 타입을 key로 설정하고, 이벤트 리스너 함수와 이를 식별할 dataAction을 value로 설정했다. 그리고 이벤트 리스너 등록을 마치면 Map을 순회하며 이벤트 리스너를 부착할 것이다.
또한 위 클래스는 페이지 내에 하나의 인스턴스를 활용해야한다. 그래서 싱글톤으로 구현했다.
const eventHandlerInstance = new EventHandler();
export default eventHandlerInstance;
위 메서드를 사용하는 코드는 다음과 같다.
eventHandlerInstance.addEventListener({
eventType: 'click',
callback: () => this.setState({ isRestaurantAddModal: true }),
dataAction: 'restaurant-add',
});
이제 이벤트 리스너를 부착해보자. Map자료구조인 eventMap을 순회하며 하나의 이벤트 타입에 하나의 이벤트 리스너를 부착할 것이다.
// modules/EventHandler.js
export class EventHandler {
#eventMap = new Map();
attachEventListener() {
for (const [eventType, eventActions] of this.#eventMap) {
window.addEventListener(eventType, (event) => {
forEach(({ callback, dataAction, notTriggerDataAction }) => {
const target = event.target
if(!isHTMLElement(target)) return;
const currentTarget = target.closest(`[data-action="${dataAction}"]`) as HTMLElement;
if (!currentTarget) return;
callback({ event, target, currentTarget });
}, eventActions);
});
}
}
// ..
이벤트가 발생했을 때 생성되는 event 객체에는 target과 currentTarget이 있다. target은 이벤트가 발생한 요소, currentTarget은 이벤트 핸들러가 부착된 요소를 가리킨다. 이벤트가 발생한 요소는 event.target과 일치할 것이다. 하지만 우리가 이벤트 위임을 통해 이벤트 핸들러를 부착한 요소는 window이기에, event.currentTarget은 항상 window가 될 것이다. 만약에 사용하는 측에서 우리가 기존에 이벤트 핸들러를 부착하고자 했던 요소를 알고 싶다면 이 요소는 어떤 요소일까? 바로 data-action 속성이 설정된 요소라 할 수 있다. 그럼 이 요소를 찾아 currentTarget으로 반환하면 되는 것이다.
이제 사용자가 이벤트를 발생시키면 이벤트 타입에 맞는 이벤트 핸들러가 동작을 하며, 미리 등록한 eventMap을 순회하며 data-action을 이용해 해당하는 이벤트 함수가 있는지 체크할 것이다. 그리고 해당하는 이벤트 함수라면 이를 실행할 것이다.
문제점 4. 이벤트 중복
여기서 문제점이 있다. 예를들어 아래와 같이 아이템 내에 즐겨찾기 버튼이 있다고 해보자. 아이템을 클릭하면 아이템 상세 정보가 열려야하고, 즐겨찾기 버튼을 누르면 즐겨찾기에 추가가 되어야한다.
현재 방식에는 즐겨찾기를 누르면 즐겨찾기에 추가가 됨과 동시에 아이템 상세 정보가 열릴 것이다. 그렇다면 event.stopPropagation()을 이용해 이벤트 전파를 중단한다면? 그럼에도 열릴 것이다. 이는 현재 방식에 문제점이 있다. 현재 방식으로는 모든 이벤트 함수를 순회하며 해당 이벤트가 맞는지 체크하기 때문이다. 즐겨찾기 이벤트 함수에 해당하더라도, 순회를 중단하지 않고 이벤트 상세 정보 이벤트 함수까지 실행을 할 것이다.
이를 해결하기 위해서는 두 가지의 방식이 있다.
- for문으로 순회하며 해당하는 dataAction을 찾았다면 for문을 종료한다.
- 명시적으로 해당 이벤트를 실행하고 싶지 않은 dataAction을 함께 전달한다.
1번은 addEventListener를 호출할 때 순서를 신경써야한다. 하지만 순서를 신경쓰는 일은 상당히 복잡할 것이라 생각했다. 파일의 실행 순서까지 우리가 제어할 수는 없기 때문이다. 그래서 명시적으로 실행시키지 않을 dataAction을 전달하는 2번 방식이 더 적절하다고 생각했다. 예를 들어 A 안에 B 요소가 있다고 해보자. B요소를 눌렀을 때는 이벤트 리스너가 동작하지만 B바깥인 A를 눌렀을 때는 동작하지 않도록 하고 싶다. 그렇다면 A요소를 data-action으로 전달하고, B요소를 notTriggerAction(이벤트 리스너를 동작시키지 않는 요소)에 전달하면 되는 것이다.
// modules/EventHandler.js
export class EventHandler {
#eventMap = new Map();
attachEventListener() {x
for (const [eventType, eventActions] of this.#eventMap) {
window.addEventListener(eventType, (event) => {
forEach(({ callback, dataAction, notTriggerDataAction }) => {
const target = event.target as HTMLElement;
const currentTarget = target.closest(`[data-action="${dataAction}"]`) as HTMLElement;
const isNotTriggerTarget = target.closest(`[data-action="${notTriggerDataAction}"]`);
if (!currentTarget || isNotTriggerTarget) return;
callback({ event, target, currentTarget });
}, eventActions);
});
}
}
// ..
방식은 간단하다. 위와 같이 notTriggerDataAction 인자를 하나 더 등록하는 것이다. 위 예시에서는 즐겨찾기 dataAction을 등록할 때 상세 정보의 dataAction을 notTriggerDataAction로 설정하면, 즐겨찾기 dataAction이 발생했을 때 상세 정보의 dataAction은 발생하지 않을 것이다.
전체 코드는 아래에서 확인할 수 있습니다.
맺으며
리액트를 사용하면 이벤트 리스너에 대해 크게 걱정하지 않을 수 있다. 왜냐하면 리액트는 JSX문법의 HTML코드에 원하는 요소에 on이벤트 = function(){}같은 식으로 부착하면 되기 때문이다. 우리가 이와 같이 부착한 이벤트 리스너 또한 리액트에서 document레벨로 이벤트 위임을 한다고 한다. 그렇기에 이벤트 리스너를 많이 부착해서 생기는 리소스나, 요소가 추가될 때마다 이벤트 리스너를 새롭게 부착하는 등 고민을 할 필요가 없던 것이다.
이벤트 리스너, 많아지면 진짜 골치다.
결국 위임 → data-action 통일 → 중앙 관리(EventHandler) 순으로 정리됐고, 중첩 이벤트는 notTriggerDataAction으로 막았다.
이벤트 리스너를 어떻게 하면 잘 관리하고, 잘 달 것인가에 대해 고민을 했던 흥미로운 시간이었다. 또한 이벤트 위임같은 기술을 사용하지 않고 먼저 개발해보며 이 불편함을 몸소 느끼고, 왜 리액트는 이벤트 리스너를 위임을 통해 특정 문제를 해결했는지 깨달을 수 있는 시간이었다.