티스토리 뷰

 이벤트를 바인딩한 DOM 요소가 삭제되는 경우, 바인딩 된 이벤트는 어떻게 되는지에 대해 알아보도록 하자.

 

 우선, 이 글은 정확한 정보 전달을 위한 글이 아님을 밝힌다. 정보보다는 초짜 개발자가 어떻게 궁금증을 해소하기 위해 고군분투 했는지에 대해 쓴 글이므로 넓은 마음과 아량으로 글을 읽어주면 좋을 것 같다 😂

 

 이 글은 Garbage Collection에 대한 기본적인 내용을 알고 있어야 이해하기 수월하므로 만약 Garbage Collection에 대한 이해가 부족하다면 아래의 글을 읽어보길 추천한다 👍👍👍

 

 

자바스크립트의 메모리관리 - JavaScript | MDN

자바스크립트의 메모리관리 C 언어같은 저수준 언어에서는 메모리 관리를 위해 malloc() 과 free()를 사용한다. 반면, 자바스크립트는 객체가 생성되었을 때 자동으로 메모리를 할당하고 쓸모

developer.mozilla.org

 

Javascript – Garbage Collection | 아이군의 블로그

자바스크립트의 메모리 관리는 우리에게는 보이지 않게 자동으로 실행됩니다. 우리가 원시타입의 변수나 혹은 객체, 함수를 선언할때도 모두 메모리를 사용합니다. 만약에 이러한 것들이 더이

theeye.pe.kr

 

 사건의 발단은 이러하다. SPA를 만들기 위해서는 자바스크립트를 이용해 DOM을 동적으로 조작해야한다. 그러기 위해서는 필연적으로 이전의 요소를 지우고 새로운 요소를 DOM tree에 추가해야한다. 이 때, DOM tree에서 삭제된 이전 요소에 바인드 된 이벤트는 어떻게 되는 가가 궁금증의 시작이었다.

 

 우선, 결론부터 말하면 최신 모던 브라우저에서는 삭제된 요소에 바인드 된 이벤트를 GC가 처리해준다. 하지만 GC의 정확한 동작 시점은 알 수 없었고 이에 대해 명확한 설명을 제시해주는 아티클도 찾지 못했다. 그래서 결국 직접 실험해보기로 했다.

 

 

자, 그럼 이제 예제 코드를 보면서 본인의 시행착오를 하나씩 살펴보자.

 

 

시나리오 공통 사항

 

'index.html'에는 두가지 버튼이 있다. 그리고 그 버튼들에는 각각 이벤트가 걸려있다.

'handleClickBtn1'은 'btn1'에 걸려있는 이벤트다. 'btn1'을 클릭하면 'btn1 clicked'를 표시한다.

'handleClickBtn2'는 'btn2'에 걸려있는 이벤트다. 'btn2'를 클릭하면 'btn1'이 클릭한 것으로 처리한다.

 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="stylesheet" href="../src/index.css" />
    <title>Document</title>
  </head>
  <body id="app">
    <button id="btn1">btn1</button>
    <button id="btn2">btn2</button>
  </body>
  <script type="module" src="../src/index.js"></script>
</html>
// src/index.js

const $btn1 = document.querySelector('#btn1');
const $btn2 = document.querySelector('#btn2');

$btn1.addEventListener('click', handleClickBtn1);

function handleClickBtn1() {
  console.log('btn1 clicked');
}

$btn1.remove(); // DOM에서 #btn1 삭제

$btn2.addEventListener('click', handleClickBtn2);

const clickEvent = new Event('click');

function handleClickBtn2() {
  console.log($btn1);
  $btn1.dispatchEvent(clickEvent); // #btn1 클릭 이벤트 발생
}

 

 

첫번째 시나리오는 '#btn1'을 DOM에서 삭제하는 것이다.

 

내 예상

'btn1' 버튼을 DOM에서 remove 했기 때문에 'btn1` 버튼의 DOM 요소를 참조하는 이벤트 리스너는 가비지 컬렉터에 의해 삭제될 것이다. 따라서 'btn2' 버튼을 눌러도 아무 일이 일어나지 않을 것이다.

 

실제 결과

'btn2' 버튼을 클릭하면 다음과 같은 결과를 얻는다.

 

 

예상과는 다르게 'btn1' 버튼에 등록된 이벤트 리스너가 사라지지 않았다.

 

크롬 개발자 도구를 통해 다음과 같이 'handleClickBtn1' 이벤트와 'handleClickBtn2' 이벤트가 메모리에 남아있음을 확인할 수 있다. 

 

이벤트가 걸린 DOM 요소를 삭제했음에도 불구하고 바인딩 된 이벤트가 가비지 컬렉터의 대상이 되지 않은 이유는 뭘까? 🤔

 

그 이유는 바로 remove라는 메서드 때문이다.

remove 메서드는 DOM element를 DOM tree에서 제거한다. 이 때의 '제거'는 메모리에서 삭제를 의미하지 않는다. 단순히 DOM tree에서 떼어내기만 할 뿐 메모리에는 그대로 남아있다. 

 

 

MDN remove 정의

 

MDN 문서에도 단순히 'removes the object from ...'이라고 나와있지만 정확한 표현은 'remove'가 아니라 'detach'가 맞다. 이 때문에 코드의 19번째 라인에서 null을 보여주지 않고 button element를 보여주는 것이다.

 

 

 

두번째 시나리오는 '#btn1'을 DOM에서 삭제하고 '$btn1' 변수에 null 값을 재할당 하는 것이다.

 

// src/index.js

let $btn1 = document.querySelector('#btn1');
const $btn2 = document.querySelector('#btn2');

$btn1.addEventListener('click', handleClickBtn1);

function handleClickBtn1() {
  console.log('btn1 clicked');
  if ($btn1) {
    $btn1.remove();
    $btn1 = null;
  }
}

$btn2.addEventListener('click', handleClickBtn2);

const clickEvent = new Event('click');

function handleClickBtn2() {
  console.log($btn1); // null
  $btn1.dispatchEvent(clickEvent); // TypeError: Cannot read property 'dispatchEvent' of null
}

 

내 예상

 'btn1'을 클릭하고 DOM을 제거하고 null 값으로 재할당 했으니 이제 진짜 'btn2'를 눌러도 'btn1 clicked'가 뜨지 않을 것이다!

 

실제 결과

22번째 라인에서 TypeError 발생 ($btn1이 null이 되면서 커스텀 이벤트를 발동시킬 수 없게 됐다.)

 

그렇다면 정말 방법이 없을까 🤔

아니다. 아직 방법은 있다. 'bind'를 이용해서 $btn1이 사라져도 handleClickBtn2에서 $btn1을 찾을 수 있도록 하면 된다.

 

 

 

세번째 시나리오는 'bind'를 이용해 TypeError 극복하기

// src/index.js

let $btn1 = document.querySelector('#btn1');
const $btn2 = document.querySelector('#btn2');

$btn1.addEventListener('click', handleClickBtn1);

function handleClickBtn1() {
  console.log('btn1 clicked');
  if ($btn1) {
    $btn1.remove();
    $btn1 = null;
  }
}

$btn2.addEventListener('click', handleClickBtn2.bind($btn1));

const clickEvent = new Event('click');

function handleClickBtn2() {
  console.log($btn1); // null
  console.log(this); // <button id="btn1">btn1</button>
  this.dispatchEvent(clickEvent);
}

 

내 예상

 'btn1'은 DOM에서 사라졌고 null 값으로 재할당도 했고, 'btn2'를 눌러도 에러가 발생하지 않도록 만들었으니 'btn2'를 클릭해도 'btn1 clicked'는 뜨지 않을 거야!

 

실제 결과

여전히 남아있는 btn1 이벤트...

 

 

이외에도 weakMap과 weakSet을 이용해 객체에 대한 참조가 끊겼을 때, GC가 되도록 시도했으나 원하는 결과를 얻지는 못했다.

 

따라서 여러번의 시행착오 끝에 다음과 같은 결론을 얻었다.

어떤 식으로든 'btn2'를 클릭하여 'btn1'의 이벤트가 남아있는지 확인하기 위해서는 참조가 필요하다.

'btn1'을 DOM tree에서 떼내고 '$btn1'을 null로 재할당해도 결국 커스텀 이벤트를 발동시키기 위해서는 'btn1'의 DOM element 메모리를 참조하게 된다. 결국 GC root에서 'btn1'에 접근가능하기 때문에 GC에 의해 이벤트는 삭제되지 않는다. 물론, 'btn1'의 DOM element도 GC의 대상이 아니다. 결국 이러한 실험으로는 증명하기 어렵다고 판단했다.

 

그래서 방법을 바꿔 크롬의 개발자 도구를 이용하기로 했다.

 

실험방법은 직접 만든 SPA 사이트에서 네비게이션 탭을 이것저것 눌러가면서 메모리의 변화를 관찰하는 것이다.

 

직접 만든 SPA 사이트는 네비게이션 탭이 클릭될 때마다, 해당 탭의 component를 innerHTML로 구성하고 그에 맞는 이벤트를 생성한다. 이때, 생성된 이벤트는 탭이 변경될 때 이벤트를 해제하는 'removeEventListener' 처리를 별도로 하지 않았다. 과연 모던 브라우저는 단순히 DOM tree에서 detach된 DOM에 걸려있던 이벤트를 GC 해줄 것인가????

 

 

결과는 GC 해준다!!!

 

 

탭을 열심히 바꿔갈 때마다 메모리가 점점 올라가는 것이 보인다. 만약 메모리 누수가 발생했다면 그래프가 우상향한다. 하지만 일정 시간이 지나면 메모리가 GC에 의해 다시 확보되는 것을 확인할 수 있다.

 

 

 

메모리 그래프가 하락하는 순간을 좀 더 확대해보면 위와 같이 Minor GC가 작동하는 것을 확인할 수 있다.

 

따라서 우리는 모던 브라우저의 GC 덕에 'removeEventListener'를 하지 않아도 메모리 누수를 겪지 않을 수 있다. 하지만 무조건 GC를 맹신하면 안 된다. 그 이유는 GC의 실행시점은 예측 불가능하기 때문이다. 또한 예상하지 못한 참조가 있는 경우 GC의 대상이 되지 않을 수 있다. 따라서 만약 웹의 성능이 시간이 갈수록 느려진다면 브라우저의 개발자 도구를 이용해 메모리 누수가 일어나는지 직접 확인해보기를 권장한다!