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

빌드노트

cloneNode(true)로 복제한 버튼에 클릭 동작이 없는 이유

cloneNode(true)는 자식 노드와 속성을 복사해도 addEventListener 리스너는 옮기지 않는다. 실제 클릭으로 원본·복제·재등록 복제를 비교하고, 고유 ID와 객체 참조를 확인한 뒤 직접 등록 또는 유지되는 컨테이너의 이벤트 위임을 선택한다.

원본 버튼은 클릭되는데 cloneNode(true)로 붙인 버튼은 같은 모양만 보이고 동작하지 않는다면, 복제 직후의 이벤트 등록 코드를 확인한다. cloneNode()는 DOM 노드를 복사하지만 원본에 addEventListener()로 등록한 리스너는 복사하지 않는다. true는 자식 노드까지 복사한다는 뜻이다. 버튼 안의 글자나 span을 포함해도 리스너까지 옮겨 주지는 않는다. MDN의 cloneNode 설명 (새 탭에서 열림)

기존 코드에서는 cloneNode()가 반환한 참조에 addEventListener()를 호출하는지 먼저 본다. 복제한 버튼마다 직접 처리할 거라면 그 참조에 등록한다. 같은 동작의 버튼이 계속 추가되는 목록이라면 유지되는 컨테이너에 한 번 등록하는 이벤트 위임을 선택할 수 있다.

구조가 같은 세 버튼을 눌러 봤다

이 글을 위해 저장 요청 대신 카운터를 올리는 합성 버튼을 만들었다. 2026년 10월 22일 새 브라우저 프로필의 loopback 페이지에서 비교했고, Windows의 headless Chromium 147.0.7727.15에 Playwright 마우스 입력을 보내 버튼 안 글자를 한 번씩 눌렀다. 아래처럼 로컬 HTML 파일을 여는 방법도 같은 입력으로 확인했다.

직접 비교하려면 아래 내용을 UTF-8 파일 clone.html로 저장해 브라우저에서 연다. 외부 라이브러리나 서버는 필요 없다. 버튼의 type="button"은 폼 제출과 클릭 처리를 섞지 않기 위한 조건이다. 이 조각은 비교할 원본 버튼 하나를 만든다.

<!doctype html>
<meta charset="utf-8">
<div id="actions">
  <button id="save" type="button" data-action="save"><span>저장</span></button>
</div>

페이지를 연 뒤 개발자 도구 Console에서 다음 코드를 한 번 실행한다. 왼쪽부터 원본, 깊은 복제, 같은 함수를 다시 등록한 복제가 생긴다. 실제 코드에서 고칠 지점은 bound.addEventListener("click", mark)에 해당하는 줄이다. 처음부터 다시 비교하려면 페이지를 새로 고친다.

const original = document.getElementById("save");
const counts = { save: 0, "save-copy": 0, "save-bound": 0 };
function mark(event) {
  const id = event.currentTarget.id;
  console.log(id, ++counts[id]);
}
original.addEventListener("click", mark);
const copy = original.cloneNode(true);
copy.id = "save-copy";
const bound = original.cloneNode(true);
bound.id = "save-bound";
bound.addEventListener("click", mark);
original.after(copy, bound);

세 버튼을 왼쪽부터 한 번씩 누르자 Console에는 save 1, save-bound 1만 출력됐다. counts를 조회한 값은 원본 save: 1, 단순 복제 save-copy: 0, 재등록 복제 save-bound: 1이었다. 별도 관찰용 리스너에는 세 클릭이 모두 도착했다. 가운데 버튼이 클릭을 받지 못한 것이 아니라, 그 버튼에는 카운터를 올릴 리스너가 없었다.

세 버튼의 innerHTML은 모두 <span>저장</span>이었고, type은 button, data-action은 save였다. 반면 original === copy는 false였다. 같은 구조를 가진 별개의 객체다. DOM 표준은 리스너 목록을 각 EventTarget 객체의 상태로 정의하고, 요소 복제 과정에서는 새 요소를 만들고 속성과 필요한 자손을 복사한다. EventTarget의 리스너 목록 (새 탭에서 열림), 노드 복제 알고리즘 (새 탭에서 열림)

따라서 HTML 문자열이 같다고 동작도 같다고 판단하면 안 된다. 이번 세 버튼은 getAttribute("onclick")과 onclick 프로퍼티도 모두 null이었다. 원본에도 addEventListener 리스너가 있는데 onclick은 null일 수 있다. 이 두 조회로 등록된 리스너 전체의 유무를 판정할 수 없다.

원본과 재등록 복제도 처리되지 않으면 이 비교의 조건부터 달라진 것이다. Console 오류와 선택한 요소를 확인하고, 핸들러 진입 로그가 찍히는지 본다. 기존 코드의 핸들러가 원본 버튼을 변수로 고정해 참조한다면 같은 함수를 복제에 등록해도 원본을 조작할 수 있다. 예제의 mark는 리스너가 붙은 event.currentTarget을 읽는다. 실제 저장 함수도 어떤 버튼의 데이터를 읽어야 하는지 함께 확인한다.

id를 바꾸는 것과 리스너를 붙이는 것은 별개다

id도 복사되는 속성이다. 원본의 id="save"를 그대로 둔 복제를 원본 뒤에 붙인 별도 조건에서는 document.querySelectorAll("#save").length가 2였다. 그 상태에서 document.getElementById("save")로 다시 찾아 등록하자 조회 결과는 원본이었고, 복제를 눌러도 새 핸들러는 실행되지 않았다. 원본을 누를 때만 원래 핸들러와 새 핸들러가 각각 한 번 실행됐다.

이 결과는 조회 순서와 일치한다. getElementById()는 일치하는 요소 중 트리 순서상 첫 요소를 반환한다. DOM 표준의 ID 조회 규칙 (새 탭에서 열림)

앞 예제처럼 복제 직후 고유한 ID를 지정하고, 복제가 반환한 객체 참조에 바로 등록하면 엉뚱한 요소를 다시 찾는 일을 피할 수 있다. 실험에서 중복 ID만 고친 뒤 눌러 봐도 복제의 리스너는 생기지 않았다. ID 정리와 이벤트 등록은 각각 해야 한다. 여러 요소를 가진 덩어리를 복제한다면 자손의 ID와 그 ID를 가리키는 연결도 함께 점검한다.

onclick이라는 이름도 등록 방식에 따라 구별해야 한다. 문서상 onclick="..."처럼 HTML 속성에 쓴 코드는 복사 대상이지만, JavaScript에서 button.onclick = 함수로 넣은 함수는 복사 대상이 아니다. 별도 비교에서도 인라인 속성 버튼과 그 복제를 한 번씩 눌렀을 때는 카운터가 총 2회 증가했고, 프로퍼티에 함수를 대입한 버튼과 복제는 총 1회만 증가했다. 여기서 선택할 수정은 복제에도 리스너를 명시적으로 등록하는 것이다. 인라인 속성으로 바꿔야 동작하는 것은 아니다. MDN의 복사 범위 설명 (새 탭에서 열림)

계속 추가할 버튼은 유지되는 컨테이너에서 처리한다

직접 등록은 버튼을 만드는 코드에서 초기화를 끝낼 수 있을 때 간단하다. 반대로 같은 동작의 복제가 여러 경로에서 추가된다면 부모의 클릭 리스너 하나로 처리할 수 있다. 자식 클릭이 부모로 전달되는 버블링을 이용하는 방식이다. MDN의 이벤트 위임 설명 (새 탭에서 열림)

위임을 비교할 때는 페이지를 새로 고쳐 처음의 원본 버튼 하나만 남긴 뒤 아래 코드만 Console에서 실행한다. 이 코드는 컨테이너에 먼저 등록하고, 버튼에는 직접 등록하지 않은 채 복제를 추가한다.

const actions = document.getElementById("actions");
const delegated = { save: 0, "save-copy": 0 };
actions.addEventListener("click", event => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest('button[data-action="save"]');
  if (!button || !actions.contains(button)) return;
  console.log(button.id, ++delegated[button.id]);
});
const source = document.getElementById("save");
const added = source.cloneNode(true);
added.id = "save-copy";
actions.append(added);

두 버튼 안 글자를 한 번씩 누르자 이번에는 save 1, save-copy 1이 출력됐다. 나중에 추가한 복제도 기존 컨테이너로 클릭을 전달했다. 복제에 리스너가 복사된 것은 아니다. closest()는 글자 span에서 동작 버튼을 찾는 데 사용했다. 여기서 판단할 점은 버튼이 추가될 때마다 직접 등록할지, 계속 남아 있는 부모가 처리하게 할지다.

이 위임 코드는 컨테이너가 유지되고 클릭이 그곳까지 전달되는 일반 DOM을 전제로 한다. 중간에서 stopPropagation()으로 막으면 버블링 리스너에 도착하지 않을 수 있다. 컨테이너 자체를 복제한 별도 조건에서는 새 컨테이너 안 버튼을 눌러도 기존 위임 카운터가 늘지 않았다. 새 컨테이너에도 등록이 필요하다. 실제 코드에 위임을 도입할 때는 같은 저장 동작의 직접 등록도 함께 실행되어 두 번 처리되지 않는지 확인한다.

직접 확인한 범위는 위 Chromium 버전의 일반 버튼과 동기 카운터다. 다른 브라우저 엔진, Shadow DOM, 프레임워크의 이벤트 처리, 실제 저장 요청은 실행하지 않았다. stopPropagation()이 있는 경로나 원본을 고정 참조하는 업무 핸들러도 실행하지 않았으므로, 해당 조건은 실제 코드에서 따로 확인해야 한다.

← 전체 글 보기