본문 바로가기
빌드노트 검색

빌드노트

removeEventListener를 호출했는데 클릭이 계속 처리된다

같은 내용의 익명 함수로는 등록된 클릭 리스너를 제거할 수 없다. 실제 브라우저 클릭으로 함수 참조와 capture 일치, 중복 등록의 경계, AbortController 취소 전후를 비교하고 정리할 수명에 맞는 방식을 고른다.

버튼에 붙인 클릭 리스너를 지우려고 removeEventListener("click", () => ...)를 호출했는데 다음 클릭도 처리된다면, 등록할 때 넘긴 함수를 다시 만들고 있지 않은지 본다. 함수 본문이 같아도 새로 만든 함수는 다른 참조다. 등록한 함수 참조를 보관하고, 같은 요소·이벤트 종류·capture 값으로 제거해야 한다. 화면이 닫힐 때 여러 리스너를 함께 정리하려면 등록 시 같은 AbortSignal을 전달하는 방법도 있다.

removeEventListener()는 제거 성공 여부를 반환하지 않는다. 이번 브라우저 실험에서도 제거에 실패한 호출과 성공한 호출 모두 반환값이 undefined였다. 오류가 없었다는 사실만으로 판단하지 말고, 다음 클릭에서 핸들러가 다시 실행되는지 확인한다. MDN의 removeEventListener 설명 (새 탭에서 열림)은 일치하는 리스너가 없으면 아무 효과가 없다고 명시한다.

같은 내용을 쓴 두 함수로 확인한다

다음 내용을 UTF-8 파일 listener.html로 저장해 브라우저에서 연다. 서버나 라이브러리는 필요 없다. 개발자 도구의 Console을 열고 ‘확인’ 버튼을 한 번 누른다.

<!doctype html>
<meta charset="utf-8">
<button type="button" id="probe">확인</button>
<script>
const button = document.querySelector("#probe");
let calls = 0;
const handleClick = () => console.log(++calls);
button.addEventListener("click", handleClick);
window.stopWrong = () =>
  button.removeEventListener("click", () => console.log(++calls));
window.stopRight = () =>
  button.removeEventListener("click", handleClick);
</script>

첫 클릭은 1을 출력한다. Console에서 stopWrong()을 실행한 뒤 다시 누르면 2가 나온다. 두 화살표 함수의 내용은 같지만, 제거 호출이 새로 만든 함수는 등록된 handleClick과 다르다. 이어서 stopRight()을 실행하고 다시 누르면 새 숫자가 출력되지 않는다. 같은 참조를 넘겼기 때문에 이번에는 제거된다.

이 파일을 Windows의 headless Chromium 147.0.7727.15에서 열고 브라우저 마우스 입력으로 그대로 확인했다. 세 클릭 뒤 누적 호출 수는 1 → 2 → 2였고 Console에는 1, 2만 남았다. 실제 서비스 장애를 옮긴 것이 아니라 이 글을 위한 별도 재현이다. MDN의 익명 함수 등록 설명 (새 탭에서 열림)은 같은 소스 코드로 만든 익명 함수도 별개의 함수로 취급된다고 설명한다.

이 수정의 핵심은 함수에 이름을 붙이는 문법 자체가 아니다. const handleClick = () => ...처럼 화살표 함수여도 그 참조를 재사용하면 된다. 실제 화면에서는 등록한 요소와 함수가 정리 코드에서도 유지되도록 두고, 제거 시점에 새 함수를 만들지 않는다.

함수 참조가 같으면 capture를 비교한다

참조를 보관해도 등록 때 capture: true를 썼다면 제거 때도 맞춰야 한다. capture는 이벤트 전달 단계와 관련된 등록 옵션이고 기본값은 false다. 세 번째 인자 true는 { capture: true }와 같은 뜻이다. 버튼 자신에 붙인 리스너라도 이 값은 제거 조건에 포함된다. DOM 표준의 EventTarget 정의 (새 탭에서 열림)

새 합성 버튼마다 리스너를 등록하고 한 번 클릭한 뒤, 제거를 시도하고 다시 한 번 클릭했다. 다음 숫자는 두 시점의 누적 핸들러 호출 수다.

  • 새 함수 참조로 제거: 1 → 2.
  • 같은 함수 참조, 등록과 제거 모두 capture: false: 1 → 1.
  • 같은 함수 참조, 등록은 true, 제거는 false: 1 → 2.
  • 같은 함수 참조, 등록은 true, 제거는 { capture: true }: 1 → 1.
  • 같은 함수 참조, 등록은 명시적 false, 제거에서 옵션 생략: 1 → 1.

따라서 button.addEventListener("click", handleClick, true)로 등록했다면 button.removeEventListener("click", handleClick, true)로 제거한다. 세 번째 인자를 생략하면 false로 대조하므로 해당 등록은 남는다.

옵션 객체 자체를 보관해야 하는 것은 아니다. 별도 버튼에 { capture: false, passive: true, once: false }로 등록하고, 새 객체 { capture: false, passive: false, once: true }로 제거한 조건에서도 호출 수는 1 → 1이었다. 표준의 제거 알고리즘이 옵션 중 대조하는 값은 capture다. passive나 once까지 같은 값으로 복사해야만 제거되는 것은 아니다. MDN의 제거 조건 설명 (새 탭에서 열림)

클릭 한 번에 두 번 처리되면 등록도 비교한다

제거가 안 되는 코드에서 초기화를 반복하면 리스너가 더 붙을 수 있다. 다만 addEventListener()를 두 번 호출했다고 항상 두 번 등록되지는 않는다. 같은 버튼·"click"·함수 참조·capture 값으로 두 번 등록한 조건은 첫 클릭에 한 번, 두 번째 클릭까지 합쳐 두 번 실행됐다. 반면 내용이 같은 화살표 함수를 각각 만들어 등록한 조건은 첫 클릭에 두 번, 두 번째까지 네 번 실행됐다.

같은 함수라도 capture를 false와 true로 각각 등록한 버튼에서는 첫 클릭에 두 번 실행됐다. false인 등록만 제거하고 누르자 한 번 더 실행돼 누적 세 번이 됐다. true인 등록도 제거한 뒤에는 더 늘지 않았다. 두 등록은 각각 제거해야 한다.

중복 여부는 옵션 전체의 모양으로 결정되지 않는다. 같은 참조를 먼저 once: false, passive: true로 등록하고, 이어서 once: true, passive: false로 다시 등록한 버튼은 두 번 클릭에 총 두 번 실행됐다. 두 번째 등록으로 핸들러가 늘어나지도, 기존 등록이 한 번만 실행되도록 바뀌지도 않았다. 이 경계는 DOM 표준의 리스너 추가 알고리즘 (새 탭에서 열림)에서 확인할 수 있다.

클릭이 여러 번 처리된다면 등록 코드를 호출하는 횟수와 그때 생성하는 함수 참조를 함께 본다. 초기화할 때마다 새 화살표 함수를 만드는 코드는 같은 함수를 재사용하는 코드와 결과가 다르다.

함께 끝날 리스너는 signal로 수명을 묶는다

특정 핸들러 하나를 따로 지워야 하면 참조와 capture를 보관하는 방식이 직접적이다. 화면을 한 번 열고 닫는 동안 필요한 리스너들을 함께 끝내려면, 그 수명에 맞춰 컨트롤러를 만들고 등록 때 signal을 전달할 수 있다. 앞 예제에서 stopRight()까지 실행했다면 Console에 다음 코드를 실행해 볼 수 있다.

function mountClick(button, onClick) {
  const controller = new AbortController();
  button.addEventListener("click", onClick, { signal: controller.signal });
  return () => controller.abort();
}
const dispose = mountClick(button, () => console.log("처리"));

버튼을 누르면 처리가 출력된다. Console에서 dispose()를 실행한 뒤 다시 누르면 새 출력이 없다. 실제 코드에서는 이 정리 함수를 화면 닫기나 컴포넌트 해제 경로에서 호출한다. 등록할 때 넘긴 인라인 함수를 제거 시점에 다시 작성할 필요가 없다. MDN의 signal 옵션 설명 (새 탭에서 열림)

별도 실험에서는 두 버튼에 같은 signal을, 세 번째 버튼에는 다른 컨트롤러의 signal을 사용했다. 처음 한 번씩 눌렀을 때 호출 수는 모두 1이었다. 앞 컨트롤러를 abort()한 뒤 다시 누르자 두 버튼은 1에 머물렀고 세 번째 버튼만 2가 됐다. 함께 정리할 리스너들에만 같은 signal을 배정하면 된다.

취소한 컨트롤러를 다음 화면에서도 재사용하면 안 된다. 이미 취소된 signal로 새 리스너를 등록하고 눌렀을 때 호출 수는 늘지 않았다. 새 컨트롤러로 등록한 뒤 누르자 다시 한 번 실행됐다. 등록 시 signal이 이미 취소됐으면 추가를 중단하는 것이 DOM 표준의 동작 (새 탭에서 열림)이다. 화면을 다시 열 때는 새 컨트롤러를 만든다.

직접 확인한 범위는 새 브라우저 컨텍스트의 일반 DOM 버튼과 동기 카운터다. 제거 뒤에도 클릭 자체는 도착하는지 별도 관찰 리스너로 확인했다. 참조와 capture를 맞췄는데 실제 화면에서 계속 처리된다면, 같은 DOM 요소 인스턴스와 이벤트 종류를 사용하는지, 초기화가 새 함수를 추가하는지부터 확인한다. onclick 속성, 프레임워크의 이벤트 계층, 다른 브라우저 엔진은 이번 실험에 포함하지 않았다. abort() 후 이미 시작한 비동기 작업의 완료나 메모리 해제 여부도 측정하지 않았다.

← 전체 글 보기