[실수노트] JavaScript에서 동일한 이벤트 리스너는 단 한 번만 등록하자

익명 함수와 이벤트 리스너


상황

어느 날, 동료 직원분께서 시스템을 이용하는데 데이터 조회가 가끔가다 한 번씩 이상하다고 제게 문의를 해왔습니다. 어떤 문제가 있었을까요?

데이터 조회는 데이터들이 있는 테이블에서 한 행을 클릭해 나타나는 모달창을 통해 이루어졌습니다. 동료분께서 조회를 원하는 데이터를 테이블에서 클릭했는데, 간혹 클릭한 데이터가 아닌 다른 데이터가 출력된다는 것이었습니다. 그뿐만 아니라 어떨 때는 아예 모달 창이 뜨지도 않았다고 하더라고요.

제일 큰 문제는 이와 같은 상황이 항상 발생하는 것이 아니라 '간혹'가다 발생한다는 것이었습니다. 평소에는 잘 동작하다가 간혹 문제가 발생한다는 것에서 문제를 처음 들었을 때부터 디버깅이 쉽지 않겠다는 생각이 들었습니다.

문제

예상한 것과 같이 디버깅은 쉽지 않았습니다. 'console.log()' 신공으로 의심 가는 모든 곳에 디버깅 로그를 찍고 동작하는 상황을 유심히 관찰했습니다. 여러 시행착오 끝에 문제를 해결할 만한 단서를 발견합니다.

특정한 동작을 한 뒤에 테이블의 행을 클릭하면 로그가 중복해 찍히는 현상을 찾아낸 것이죠. 문제가 발생하는 동작을 수행한 횟수만큼 로그도 동일한 횟수로 중복되어 찍히고 있었습니다. 예를 들어, 이상 동작을 5번 반복하면 테이블의 행을 클릭했을 때 동일한 로그가 5번 반복해서 찍혔던 것이죠.

위와 같은 상황이 발생한 원인은 테이블의 행을 클릭한 이벤트에 대해 동일한 이벤트 리스너가 중복해서 등록되었기 때문입니다. 문제가 발생하는 동작을 수행하면 테이블의 데이터를 지웠다가 다시 그리게 됩니다. 이때, 테이블을 다시 그리면서 테이블 행 클릭 이벤트에 대한 리스너가 중복해서 등록되었던 것이죠.

그리고 이로 인해 '3페이지 첫 번째 행의 데이터'를 조회하고자 했는데 중복된 이벤트 리스너들 중 하나가 오동작하여 '1페이지 첫 번째 행의 데이터'를 조회해버리는 이상 동작이 발생했습니다. 그리고 리스너가 너무 많이 호출될 때에는 데이터 조회가 제대로 동작하지 않았습니다.

아래의 코드가 위의 상황을 단순화시킨 예입니다. 테이블의 데이터를 지웠다 그리는 것은 생략하고 동일한 이벤트 리스너가 5번 중복해서 등록되는 상황을 최대한 단순화 시켜 작성해보았습니다.

<button id="btn-favorites" type="button">Add to favorites</button>

<script>
  var el = document.getElementById("btn-favorites");

  for (let i = 0; i < 5; i++) {
    el.addEventListener("click", () => {
      console.log("onClickBtn");
    });
  }
</script>

위의 페이지에서 'Add to favorites' 버튼을 클릭하면 콘솔에는 'onClickBtn'이라는 텍스트가 5번 연속해서 출력하는 것을 확인할 수 있습니다.

해결책

이에 대한 해결책을 찾다가 'MDN web docs'의 'EventTarget.addEventListener()' 항목을 읽게 되었습니다.

EventTarget.addEventListener() - MDN web docs

그런데, 위 문서에서는 동일한 파라미터를 갖는 리스너가 동일한 EventTarget에 중복되어 등록된다면 중복된 것들은 제거되고 오직 하나만 등록된다고 합니다. 그럼 위의 예제에서는 왜 콘솔에 5번 로그가 찍혔을까요? 그 이유는 저의 상황과 위의 예제에서는 '익명 함수'로 이벤트 리스너를 등록했기 때문입니다. 조금 더 알아볼까요?

JavaScript에서는 함수도 레퍼런스를 갖는 객체입니다. 동일한 레퍼런스를 갖는 이벤트 리스너가 여러 번 등록된다면 중복 처리되어 하나만 남게 됩니다. 그런데, 위의 예제에서 익명 함수로 작성된 이벤트 리스너는 이벤트에 대해 등록될 때마다 새로운 객체로 생성됩니다. 즉, 5번 등록된 리스너의 레퍼런스는 모두 다 다르죠. 그렇기 때문에 중복처리가 따로 안 되고 5개의 리스너 모두가 등록되어 버린 것입니다.

한번 함께 확인해보겠습니다. 아래의 코드에서 이벤트 리스너를 미리 변수에 저장한 뒤 중복해서 다섯 번 등록했습니다.

<button id="btn-favorites" type="button">Add to favorites</button>

<script>
  const onClickBtn = () => {
    console.log("onClickBtn");
  };

  const el = document.getElementById("btn-favorites");
  for (let i = 0; i < 5; i++) {
    el.addEventListener("click", onClickBtn);
  }
</script>

얼핏 보아서는 처음 예제와 동일하게 동작할 것 같은 느낌도 듭니다. 이번엔 'Add to favorites' 버튼을 누르면 로그가 몇 번 출력될까요? 예상대로 단 한 번의 로그만 출력됩니다.

실천방안

위의 내용을 알게 된 후 기존에 익명 함수로 정의된 이벤트 리스너를 선언적 함수로 정의하여 리스너로 등록하였습니다. 그뿐만 아니라 더욱 근본적으로 이벤트 리스너를 등록하는 과정이 페이지 초기화 단계에서 명확히 한번만 수행될 수 있도록 코드의 구조를 잡아야겠다는 생각도 했습니다. JavaScript는 이벤트가 중심이 되어 로직을 처리하는 흥미로운 언어입니다. 그래서 저는 커스텀 이벤트도 종종 쓰는데요. 이번 기회를 통해 이벤트에 대해 간과하고 있었던 부분을 짚고 넘어갈 수 있었습니다.