빌드노트
위임한 once 클릭 리스너는 다른 버튼 클릭에도 소모된다
부모에 위임한 once 클릭 리스너는 대상 버튼을 거르기 전에 소모될 수 있다. 메뉴를 먼저 누른 경우의 호출·처리 횟수를 비교하고, 원하는 처리를 한 번 끝낸 뒤 removeEventListener로 제거하는 위치를 확인한다.
부모에 { once: true }로 클릭 리스너를 붙이고, 그 안에서 확인 버튼만 골라 처리하면 예상 밖의 순서에서 동작이 멈출 수 있다. 같은 부모 안의 메뉴 버튼을 먼저 누르면, 정작 확인 버튼을 처음 눌렀을 때는 반응하지 않는다. once가 제한하는 것은 조건을 통과한 처리 횟수가 아니라 등록한 콜백의 호출이다. 확인 처리를 한 번 끝낼 때까지 기다려야 한다면, once를 빼고 처리 뒤에 직접 리스너를 제거한다.
자기 코드에서는 부모의 addEventListener 옵션과 콜백 안의 조기 return을 함께 본다. 대상 버튼을 거르는 조건이 정확해도, 그 조건까지 실행됐다는 것은 이미 콜백이 호출됐다는 뜻이다.
메뉴를 눌렀는데 한 번이 끝났다
호출과 처리를 나누면 어디서 멈췄는지 보인다. 다음 내용을 빈 HTML 파일로 저장해 브라우저에서 열고 개발자 도구 콘솔을 켠다. calls는 필터 앞에서, handled는 확인 버튼 조건을 통과한 뒤에만 증가한다. 두 버튼에는 안쪽 요소 없이 텍스트만 넣었다.
<!doctype html>
<meta charset="utf-8">
<div id="actions">
<button id="other" type="button">메뉴</button>
<button id="target" type="button">확인</button>
</div>
<script>
const parent = document.querySelector("#actions");
const target = document.querySelector("#target");
let calls = 0;
let handled = 0;
parent.addEventListener("click", (event) => {
calls += 1;
console.log("call", calls, event.target.id);
if (event.target !== target) return;
handled += 1;
console.log("handled", handled);
}, { once: true });
</script>
메뉴 → 확인 → 확인 순서로 누르면, 이번 Chrome 실험에서 콘솔에는 다음 한 줄만 남았다.
call 1 other
메뉴의 클릭이 부모로 올라와 콜백을 실행했지만 필터에서 반환했다. 호출은 1회, 확인 처리는 0회다. 뒤의 두 확인 클릭에서는 호출 로그조차 추가되지 않았다. 처리 부분에만 로그를 넣었다면, 메뉴 클릭이 리스너를 소모한 순간을 놓쳤을 것이다.
DOM 표준은 once를 콜백을 한 번 호출하도록 하는 옵션으로 설명한다. 더 정확한 제거 시점은 이벤트 전달 알고리즘에 있다. 이벤트 종류와 전파 단계가 맞는 리스너를 골랐을 때, once 리스너를 먼저 제거한 다음 콜백을 호출한다. 콜백 안의 return이나 처리 성공 여부를 확인한 뒤 제거하는 순서가 아니다. addEventListener의 once 설명 (새 탭에서 열림), 리스너 호출 알고리즘 (새 탭에서 열림)
따라서 이 예제의 if를 더 정교하게 바꿔도 제거 시점은 달라지지 않는다. 반대로 페이지를 새로 열어 확인 버튼부터 누르면 call 1 target, handled 1이 출력되고 끝난다. 이 순서만 확인하면 코드가 요구대로 동작한다고 오판하기 쉽다.
확인 처리가 끝난 지점에서 제거한다
같은 예제에서 기존 parent.addEventListener(...) 블록을 아래 코드로 교체하고 파일을 다시 연다. 앞의 요소 조회와 두 카운터는 그대로 둔다. 이 수정은 메뉴 클릭을 무시한 뒤에도 기다리고, 확인 카운터를 한 번 올린 뒤에만 리스너를 제거한다.
function handleClick(event) {
calls += 1;
console.log("call", calls, event.target.id);
if (event.target !== target) return;
handled += 1;
console.log("handled", handled);
parent.removeEventListener("click", handleClick);
}
parent.addEventListener("click", handleClick);
이번에도 메뉴 → 확인 → 확인 순서로 누른다. 같은 Chrome에서 다음 세 줄이 남았다.
call 1 other
call 2 target
handled 1
메뉴 뒤에는 호출 1회·처리 0회, 첫 확인 뒤에는 호출 2회·처리 1회다. 두 번째 확인을 눌러도 로그가 늘지 않았다. 대상 조건을 통과한 클릭을 처리한 뒤 리스너가 제거됐기 때문이다. 확인부터 누른 대조 조건에서는 두 방식 모두 호출 1회·처리 1회로 끝났다.
제거할 때는 등록에 쓴 handleClick을 그대로 넘긴다. 위 코드의 등록과 제거는 모두 기본 capture: false다. 다른 함수로 바꾸거나 전파 단계 옵션이 다르면 제거할 리스너를 찾지 못할 수 있다. removeEventListener의 일치 조건 (새 탭에서 열림)
파일을 고친 뒤에도 메뉴 클릭의 call 로그부터 나오지 않으면 이 관찰과 증상이 다르다. 리스너가 등록됐는지, 클릭이 해당 부모까지 도달하는지부터 확인한다. call은 나오는데 확인 처리만 빠지면 필터가 비교하는 실제 대상을 본다. 로그 위치를 나눠야 리스너가 이미 제거된 경우와 조건에서 반환한 경우를 구별할 수 있다.
부모가 받는 첫 클릭 자체가 한 번의 기회라면 once가 맞다. 부모 안에서 특정 버튼의 처리를 한 번 완료하는 것이 요구라면, 그 완료 지점에서 제거하도록 수명을 정한다. 고정된 대상 버튼에 직접 리스너를 붙일 수 있는 구조라면 그 버튼에 once를 쓰는 선택도 가능하다. 다만 이 글의 실행 비교는 부모에 위임한 두 방식에 한정했다.
관찰 환경은 Windows 11, Chrome 156.0.8078.47의 headless 실행, Playwright 1.59.0이었다. 새 loopback 문서에서 브라우저 입력으로 버튼을 클릭했으며, 페이지 코드의 click()이나 dispatchEvent()로 대신하지 않았다. 여기서 처리 완료는 동기적으로 handled를 증가시킨 시점이다. 다른 브라우저, 버튼 안 아이콘, Shadow DOM, 처리 중 다시 발생하는 클릭, 비동기 요청의 성공 대기와 중복 처리는 검증하지 않았다. 특히 서버 응답까지 기다려야 하는 작업은 이 동기 예제의 제거 위치만으로 중복 실행 문제가 해결됐다고 볼 수 없다.