빌드노트
새 카드를 붙였는데 querySelectorAll 결과 개수는 그대로다
querySelectorAll로 저장한 목록은 새 카드 추가와 삭제를 자동 반영하지 않는다. 같은 길이인데 ID가 다른 관찰과 제거된 노드 참조를 확인하고, 작업 시 재조회할지 유지되는 부모의 이벤트 위임을 사용할지 고른다.
화면에 카드를 추가했는데 미리 저장한 querySelectorAll(".card") 결과로 반복하면 새 카드가 빠질 수 있다. querySelectorAll()이 반환하는 NodeList는 조회 시점의 요소 목록을 유지한다. DOM이 바뀌어도 그 목록에 요소가 자동으로 들어오거나 빠지지 않는다. 현재 카드를 처리하려면 작업할 때 다시 조회한다. MDN의 querySelectorAll 반환값 (새 탭에서 열림)
먼저 목록을 저장한 줄이 카드 추가보다 앞에 있는지 확인한다. 추가 후 같은 선택자로 다시 조회했을 때 새 카드가 잡힌다면, 이전 결과를 계속 사용하는 코드가 문제를 설명한다. 재조회에도 없다면 조회 시점, 선택자, 조회한 부모가 실제 카드를 포함하는지부터 확인해야 한다.
삭제 뒤에는 개수만 비교하면 놓친다
이 글에서는 새 로컬 HTML에 A·B 카드 두 개를 만들고, 조회 후 C를 추가한 다음 A를 제거했다. 실제 서비스의 장애 기록이 아니라 이 글을 위한 별도 실험이다. Windows 11의 headless Chrome 156.0.8078.47에서 실행했다.
직접 비교하려면 다음 내용을 UTF-8 파일 cards.html로 저장해 브라우저에서 연다. 외부 라이브러리나 서버는 필요 없다. 이 HTML은 비교할 카드 두 개만 준비한다.
<!doctype html>
<meta charset="utf-8">
<title>Static NodeList isolated lab</title>
<div id="cards">
<button class="card" id="card-a" type="button">A</button>
<button class="card" id="card-b" type="button">B</button>
</div>
개발자 도구 Console에서 다음 코드를 한 번 실행한다. saved는 처음 조회한 NodeList, fresh는 보고할 때마다 다시 조회한 NodeList, live는 처음 얻어 둔 HTMLCollection이다. 각 시점의 개수와 ID를 문자열로 남겨 비교한다. 처음부터 다시 실행할 때는 페이지를 새로 고친다.
const cards = document.getElementById("cards");
const saved = cards.querySelectorAll(".card");
const live = cards.getElementsByClassName("card");
const ids = nodes => Array.from(nodes, node => node.id).join(",");
function report(phase) {
const fresh = cards.querySelectorAll(".card");
console.log(phase, "saved", saved.length, ids(saved),
"| fresh", fresh.length, ids(fresh), "| live", live.length, ids(live));
}
report("initial");
cards.insertAdjacentHTML("beforeend",
'<button class="card" id="card-c" type="button">C</button>');
report("added");
saved[0].remove();
report("removed");
console.log("detached", saved[0].id, saved[0].isConnected);
saved[1].textContent = "B changed";
console.log("same-node", saved[1] === cards.querySelector("#card-b"),
cards.querySelector("#card-b").textContent);
실제 출력은 다음과 같았다. added에서 개수가 갈리고, removed에서는 개수가 같아져도 ID가 다르다는 점을 본다.
initial saved 2 card-a,card-b | fresh 2 card-a,card-b | live 2 card-a,card-b
added saved 2 card-a,card-b | fresh 3 card-a,card-b,card-c | live 3 card-a,card-b,card-c
removed saved 2 card-a,card-b | fresh 2 card-b,card-c | live 2 card-b,card-c
detached card-a false
same-node true B changed
C를 붙여도 saved에는 A·B만 남았다. A를 제거한 뒤에도 그 목록은 A·B였고, 재조회와 live는 B·C였다. 목록 길이가 현재 DOM의 개수와 같다는 것만으로 최신 결과라고 판단할 수 없다. DOM 표준도 querySelectorAll()을 정적 결과를 반환하는 메서드로 정의한다. 이 규칙은 예제처럼 부모 요소에서 조회할 때도 적용된다. DOM 표준의 ParentNode 조회 규칙 (새 탭에서 열림)
detached card-a false는 화면에서 제거한 A의 참조가 saved[0]에 여전히 남아 있음을 보여 준다. isConnected는 노드가 문서에 연결돼 있는지 나타낸다. 제거됐다고 이전 변수나 목록에서 그 객체가 사라지는 것은 아니다. MDN의 isConnected 설명 (새 탭에서 열림)
반대로 B의 글자를 saved[1]을 통해 바꾸자 현재 DOM의 B도 B changed가 됐고, 객체 비교는 true였다. 고정되는 것은 목록에 포함된 요소다. 요소의 속성과 내용까지 복제해 보관한 것은 아니다.
현재 카드를 처리하려면 조회를 작업 안으로 옮긴다
예제에서 추가·삭제가 끝난 뒤 saved.forEach()로 표시를 붙였더니, 화면에 남은 카드 중에서는 B만 표시됐다. 떨어져 있는 A에도 표시가 붙었지만 C는 빠졌다. 같은 작업에서 다시 조회하자 B·C가 모두 처리됐다.
아래 함수는 호출한 시점에 있는 카드마다 data-marked="yes"를 붙인다. 기존 코드에서도 바깥에 저장한 목록을 반복하는 대신, 실제 처리를 시작할 때 조회하도록 옮긴다. 앞선 Console 실행에 이어 호출할 수 있다.
function markCurrentCards() {
cards.querySelectorAll(".card").forEach(card => {
card.dataset.marked = "yes";
});
}
markCurrentCards();
이 호출에서 처리한 ID는 card-b,card-c였다. 함수가 끝난 뒤 또 추가한 카드까지 자동으로 표시되지는 않는다. 다음 작업 때 다시 호출해야 한다. 처음 선택한 대상만 끝까지 처리하려는 작업이라면 저장된 정적 목록을 유지하는 것이 의도에 맞을 수도 있다.
항상 최신 개수가 필요하다면 getElementsByClassName("card")처럼 live 컬렉션을 반환하는 API를 선택할 수 있다. 이 메서드는 클래스 이름을 받으므로 ".card"로 쓰지 않는다. 반환된 HTMLCollection은 해당 부모 아래의 일치 요소가 추가·제거되면 갱신된다. 다만 컬렉션 갱신이 이미 끝난 반복문이나 이벤트 등록을 다시 실행해 주는 것은 아니다. MDN의 getElementsByClassName 설명 (새 탭에서 열림)
자동 갱신에는 순회 중 대상이 바뀐다는 경계도 있다. live 컬렉션을 인덱스로 순회하면서 일치 요소를 제거하면 길이와 인덱스가 달라질 수 있다. MDN도 순회 중 일치 클래스를 제거하는 예제의 문제를 설명한다. 여기서는 삭제 순회 전략까지 비교하지 않았다. 기존 반복문을 live 컬렉션으로 교체할 때는 그 반복문이 대상 요소를 제거하거나 클래스를 바꾸는지 함께 살핀다.
NodeList라는 이름만 보고 모두 정적이라고 외우지는 않는다. childNodes처럼 live NodeList를 반환하는 API도 있다. 변수의 타입 이름보다 그 목록을 반환한 API의 계약을 확인한다. MDN의 live·static NodeList 구분 (새 탭에서 열림)
새 카드의 클릭을 받으려는 경우
목록을 다시 조회하는 것과 앞으로 생길 카드의 클릭을 처리하는 것은 목적이 다르다. 처음 카드들을 조회해 리스너를 등록한 반복문은 그때 끝난다. 이후 목록이 갱신되더라도 새 카드에 리스너가 저절로 등록되지는 않는다. 카드를 만드는 곳에서 직접 등록하거나, 계속 남아 있는 부모에 클릭 리스너를 한 번 두는 이벤트 위임을 선택한다. MDN의 이벤트 위임 설명 (새 탭에서 열림)
위임을 확인하려면 페이지를 새로 고쳐 A·B만 있는 상태에서 다음 코드를 한 번 실행한다. 부모에 먼저 등록한 뒤 C를 추가하므로, 새 카드가 생길 때 목록을 재조회할 필요가 없다. 실제 처리할 코드는 console.log 자리에 둔다.
const cards = document.getElementById("cards");
cards.addEventListener("click", event => {
if (!(event.target instanceof Element)) return;
const card = event.target.closest(".card");
if (!card || !cards.contains(card)) return;
console.log("delegated", card.id);
});
cards.insertAdjacentHTML("beforeend",
'<button class="card" id="card-c" type="button">C</button>');
새 상태에서 A와 C를 브라우저 클릭 입력으로 한 번씩 누르자 delegated card-a, delegated card-c가 출력됐다. 비교용으로 처음 A·B에만 직접 등록한 리스너는 A에서만 실행됐다. 저장된 조회 결과에 C가 들어온 것이 아니라, C의 클릭이 부모의 리스너에 도달한 것이다.
이 예제의 위임은 부모가 유지되고 클릭이 그곳까지 전달되는 일반 DOM을 전제로 한다. 부모 자체를 교체하거나 중간에서 전파를 막는 코드가 있다면 별도 확인이 필요하다. 직접 실행한 범위는 위 Chrome 버전의 동기 DOM 추가·제거, 표시 속성 변경과 일반 버튼 클릭이다. 다른 브라우저 엔진, Shadow DOM, 프레임워크 렌더링과 삭제하면서 순회하는 동작은 검증하지 않았다.