과거 웹오피스의 워드를 개발했던 때에, 각주 기능을 개발했던 적이 있다.
문서의 특정 부분에 커서를 두고 "각주"를 추가하면 해당 부분에 대한 코멘트를 문서의 우측 영역에 작성할 수 있는 기능이었다.
며칠 만에 기능을 완성하고 개별적으로 성능을 측정했다. 각주의 수를 늘려가면서 추가 삭제를 반복하는 일이었다. (IE6가 지원 대상이었고 마땅한 성능 측정 도구가 없어 CPU, 메모리 증가 여부와 사용감 정도로 체크했었다.)
성능 측정 결과는 매우 실망스러웠다. 각주의 추가 삭제를 반복할수록 메모리 누수로 인해 메모리 사용량이 누적되는 것을 발견했다.
무슨 이유로 메모리 사용량이 누적되는 것일까? 문제점을 찾기 위해 각주 생성 삭제 과정을 다시 한번 검토해보았다.
각주 생성 버튼을 통해 각주 입력화면과 기능이 포함된 엘리먼트(Element)를 생성한다.
//임시로 작성된 코드입니다.
var footnoteArea = document.getElementById('footnote-area'); //... function addFootnote(index) { var footnote = document.createElement('DIV'); footnote.id = 'footnote' + index; footnote.innerHTML = FOOTNOTE_TEMPLATE; return footnote; } //... var newFootnote = addFootnote(lastIndex + 1); //...
2. 생성된 엘리먼트들에 각 기능(등록, 닫기, 삭제) 이벤트 리스너를 하나하나 추가한다.
```javascript
function onClickRegisterBtn() {
// 각주 내용 등록
}
function onClickCloseBtn() {
// 각주 닫기
}
function onClickRemoveBtn() {
// 각주 삭제
}
function attachEvent(footnote) {
var register = footnote.querySelector('button.register');
var close = footnote.querySelector('button.close');
var remove = footnote.querySelector('button.remove');
register.addEventListener('click', onClickRegisterBtn);
close.addEventListener('click', onClickClose);
remove.addEventListener('click',onClickRemoveBtn);
}
//...
attachEvent(newFootnote);
//...
사용자에게 각주가 노출되고, 사용자는 등록 버튼을 통해 각주를 등록한다.
<!-- div로 모든 영역을 커버하던 시절이기에 div로 재현해 보았다. -->
<div id="footnote-area">
<div id="footnote1" class="footnote">
<div>각주 내용 입니다.</div>
<!-- 각주 기능 버튼들 -->
</div>
</div>
#### 각주 삭제 과정
1. 사용자가 각주에 있는 삭제 버튼 클릭하여 각주 엘리먼트를 삭제한다.
```javascript
function removeFootnote(id) {
var footnote = document.getElementById(id);
footnoteArea.removeChild(footnote);
}
//...
removeFootnote('footnote1');
//...
각주 영역에서 해당 내용이 삭제된 것을 확인한다.
<div id="footnote-area"></div>
과정을 다시 한번 천천히 살펴보자. 무엇인 문제였을까?
* 각주 추가 시에는 엘리먼트를 추가하고 기능에 대한 이벤트 리스너를 추가한다.
* 각주 삭제 시에는 엘리먼트만을 삭제한다.
그렇다. 각주 삭제 과정에서 이벤트 리스너 삭제 없이 엘리먼트만 삭제했던 것이다.
이로 인해 메모리 누수가 발생하여 각주가 삭제되었음에도 메모리 누적이 계속되었던 것이다.
그래서 바로 이벤트 리스너 삭제 코드를 추가했다.
```javascript
function detachEvent(footnote) {
var register = footnote.querySelector('button.register');
var close = footnote.querySelector('button.close');
var remove = footnote.querySelector('button.remove');
register.removeEventListener('click', onClickRegisterBtn);
close.removeEventListener('click', onClickClose);
remove.removeEventListener('click',onClickRemoveBtn);
}
//...
detachEvent(footnote);
리스너 삭제 코드를 추가하니 다행히 누수 현상은 해결되었다.
하지만 각주 기능을 개발하면서 계속되었던 고민이 있었다. 그것은 동적으로 추가되는 엘리먼트에 매번 이벤트 리스너를 추가 하는 것에 대한 고민이었다. 10가지 이벤트가 동작하는 기능을 100번 등록하면 이벤트 리스너는 1000번을 추가해야 한다.
메모리 누수 문제도 그렇다. 애초에 매번 등록하지 않으면 메모리 누수 가능성도 줄어들고 그로 인해 매번 해제하지 않아도 될 것이다. 안타깝게도 그때 당시에는 그 고민을 해결하지 못 했다.
하지만 지금은 그에 대한 해결 방법을 알고 있다. 바로 이벤트 위임(delegation)을 하면 된다.
사용자의 액션에 의해 이벤트 발생 시 이벤트는 document 레벨까지 버블링 되어 올라간다. 이 때문에 자식 엘리먼트에서 발생하는 이벤트를 부모 엘리먼트에서도 감지할 수 있다. 이러한 동작을 이용해 사용할 수 있는 방법이 이벤트 위임이다. 특정 엘리먼트에 하나하나 이벤트를 등록하지 않고 하나의 부모에 등록하여 부모에게 이벤트를 위임하는 방법이 바로 그것이다.
코드를 통해 살펴보자. 예를 들어, 다음과 같은 마크업을 가진 메뉴가 있다고 하자.
<ul id="menu">
<li><button id="file">파일</button></li>
<li><button id="edit">편집</button></li>
<li><button id="view">보기</button></li>
</ul>
각각의 기능 클릭 시 특정 동작을 하게 하려면 보통은 다음과 같이 이벤트를 등록한다.
document.getElementById("file").addEventListener("click", function(e) {
// 파일 메뉴 동작
});
document.getElementById("edit").addEventListener("click", function(e) {
// 편집 메뉴 동작
});
document.getElementById("view").addEventListener("click", function(e) {
// 보기 메뉴 동작
});
메뉴가 추가될 때마다 이벤트 핸들러가 하나씩 늘어날 것이다.
하지만 이벤트 위임을 사용하면 상위 엘리먼트인 <div id='menu'>
에만 이벤트 리스너를 추가하면 된다.
document.getElementById("menu").addEventListener("click", function(e) {
var target = e.target;
if (target.id === "file") {
// 파일 메뉴 동작
} else if (target.id === "edit") {
// 편집 메뉴 동작
} else if (target.id === "view") {
// 보기 메뉴 동작
}
});
파일, 편집, 보기 등을 클릭하면 항상 부모에 등록된 핸들러를 거치게 된다. target(또는 srcElement)은 이벤트가 발생한 엘리먼트를 반환하기 때문에 엘리먼트의 특징에 따라 분기 처리만 하면 된다. 메뉴가 추가될 때마다 핸들러를 추가할 필요도 없다.
이와 같이 이벤트 위임 이용하면 각주 개발 시 고민했던 매번 이벤트를 붙여야 하는 문제를 해결할 수 있을 것이다.
그러면 이제 이벤트 위임이 갖는 이점을 정리해 보자.
이벤트 위임의 이점은 다음 4가지로 정리할 수 있다.
이렇게 좋은 이벤트 위임을 이용하지 않을 이유가 있을까?
위의 예제들에서는 네이티브 코드에 대한 예제를 보여드렸다. jQuery같은 라이브러리나 Backbond.js Angular.js와 같은 프레임워크들을 사용하는 경우에는 어떻게 이벤트 위임을 하는지에 대해 간단히 소개하겠다.
jQuery에서는 $.on
을 통해 이벤트 위임을 지원한다. Understanding Event Delegation이라는 글은 jQuery에서 이벤트 위임을 사용하는 방법에 대해 자세히 설명하고 있다.
위의에서 살펴 본 메뉴 예제와 같은 경우 jQuery에서는 다음과 같이 이벤트 위임을 할 수 있다.
<ul id="menu">
<li><button id="file">파일</button></li>
<li><button id="edit">편집</button></li>
<li><button id="view">보기</button></li>
</ul>
// 이벤트 위임 이전
$("#file").on("click", function() {
// 파일 메뉴 동작
});
$("#edit").on("click", function() {
// 편집 메뉴 동작
});
$("#view").click(function() {
// 보기 메뉴 동작
});
// 이벤트 위임1
$("#menu").on("click", "li button", function() {
if (this.id === "file") {
// 파일 메뉴 동작
} else if (target.id === "edit") {
// 편집 메뉴 동작
} else if (target.id === "view") {
// 보기 메뉴 동작
}
});
// 이벤트 위임2
$("#menu").on("click", "#file", function() {
// 파일 메뉴 동작
});
$("#menu").on("click", "#edit", function() {
// 편집 메뉴 동작
});
$("#menu").on("click", "#view", function() {
// 보기 메뉴 동작
});
Backbone.js, Ember.js, React 같은 경우에는 내부적으로 이벤트 위임을 하기 때문에 자체적으로 제공하는 이벤트 등록 방식을 사용하면 된다. Angular.js의 경우는 별도의 모듈을 추가해 이벤트 위임을 할 수 있다.
Backbone.js
Ember.js
React.js
Angular.js
이벤트 위임에 대해서는 예전부터 들어왔고, 심지어 장점까지도 많이들 알고 있을 것이다. 알고 있음에도 개발이 쉽다고 기능 엘리먼트에 직접 리스너를 등록하여 사용하지는 않는가? 개발하고 있는 서비스의 합리적인 메모리 사용과 이벤트 관리 그리고 메모리 누수를 막기 위해서는 이벤트 위임을 이용하는 습관을 갖는 것이 필요하다.