재렌더에 휩쓸려 사라지는 이벤트 리스너
카테고리: dom-rendering · 이 패턴은 8기 PR 26건 중 8건에서 관찰됩니다.
부모 컨테이너를 통째로 재작성해 자식 이벤트 리스너가 끊어진다.
Header나 배너 영역의 부모 컨테이너를 매 렌더마다 innerHTML 문자열 대입으로 통째로 재작성하는 패턴입니다. 그 안쪽에 붙어 있던 submit이나 click 리스너가 노드와 함께 사라지기 때문에, 검색이 한 번만 동작하거나 중복 등록되는 버그가 반복됩니다.
문제 코드
다음은 실제 8기 크루 PR에서 추출한 코드입니다. 작성자는 익명 처리하고 원본 PR 링크만 남깁니다.
사례 1
render(movie: Movie): void {
const backgroundContainer = document.querySelector(".background-container") as HTMLElement;
backgroundContainer.innerHTML = `
<div class="top-rated-movie">...${movie.title}...</div>
${this.renderImage()}
`;
}컨테이너를 통째로 교체하면서 자식인
search-form이 함께 재생성되어, 외부에서 등록한 submit 리스너가 끊깁니다. 원본: PR #275 ·src/features/View/Header.ts
사례 2
renderImage(searchMovie: string = ""): string {
return `<div class="overlay">...<form class="search-form"><input value="${searchMovie}" />...</form>...</div>`;
}검색 폼을 매 렌더마다 문자열로 다시 만들기 때문에, 이전에 등록된 submit 리스너 참조가 사라집니다. 원본: PR #285 ·
src/features/UI/Header.ts
사례 3
this.#section.innerHTML = `
<div class="overlay" aria-hidden="true"></div>
...
<button class="primary detail">자세히 보기</button>
...
`;render 호출마다 section을 통째로 교체해
.detail버튼에 위임 없이 붙은 리스너가 사라집니다. 원본: PR #272 ·src/view/movieBannerView.ts
스스로 진단해보기
해설을 펼치기 전에 다음 질문에 답한다.
- 이 컨테이너 안에 등록된 이벤트 리스너가 무엇인지 모두 나열한다.
- 전체 교체 대신
textContent와 속성만 갱신해도 되는 부분을 찾는다. - 검색 버튼을 두 번 눌렀을 때 동일하게 동작하는지 손으로 추적한다.
해설
해설 보기
element.innerHTML = "..."은 겉보기엔 “내용을 바꾼다”로 읽히지만, 실제로는 기존 자식 노드 전체를 파기하고 새 노드를 파싱해 삽입하는 연산입니다. 파기된 노드에 붙어 있던 이벤트 리스너는 참조가 사라지면서 가비지 컬렉션 대상이 되고, 새로 만들어진 같은 클래스의 DOM 노드는 기존 리스너와 아무 관련이 없는 별개의 객체입니다. 이 때문에 “리렌더 후 검색이 한 번만 되거나 아예 안 된다”는 버그가 반복적으로 등장합니다.
영향은 두 갈래로 퍼집니다. 첫째, 기능이 조용히 깨집니다. 에러가 나는 것이 아니라 클릭이 그냥 먹히지 않기 때문에 원인을 특정하기 어렵습니다. 둘째, 이를 우회하려고 리렌더 뒤에 리스너를 다시 등록하면 리스너 중복 등록이라는 반대쪽 함정에 빠집니다. 검색 한 번에 API가 두 번, 세 번 호출되기 시작하는 것이 전형적인 증상입니다.
학습 포인트는 “DOM 갱신의 단위를 어디에 둘 것인가”입니다. 리스너가 붙은 노드는 보존하고, 실제로 변경된 textContent와 속성만 갱신하는 것이 가장 안전한 접근입니다. 더 나아가 리스너를 정적인 상위 컨테이너에 한 번만 등록하고 event.target으로 분기하는 이벤트 위임(event delegation) 패턴을 쓰면, 내부 노드를 아무리 다시 그려도 리스너가 끊기지 않습니다.
개선 방향
Before
render(movie: Movie): void {
this.#container.innerHTML = `
<div class="top-rated-movie">${movie.title}</div>
<form class="search-form"><input /></form>
`;
}After
// 1) 컨테이너와 리스너는 한 번만 구성한다.
constructor() {
this.#container.addEventListener("submit", (e) => {
if ((e.target as HTMLElement).matches(".search-form")) {
this.#onSearch(e);
}
});
}
// 2) 렌더에서는 바뀌는 텍스트·속성만 갱신한다.
render(movie: Movie): void {
const title = this.#container.querySelector(".top-rated-movie")!;
title.textContent = movie.title;
// search-form 노드는 교체하지 않으므로 리스너도 그대로 유지된다.
}핵심 변화는 리스너가 붙은 노드를 파기하지 않는다는 점과, 리스너를 정적인 상위 컨테이너에 한 번만 등록했다는 점입니다. 값이 바뀌는 부분은 textContent로 최소 범위만 갱신하고, 새로 만들어지는 자식 노드는 상위 컨테이너의 위임 리스너가 자동으로 포착합니다.




