본문 바로가기

TIL

TIL - 이벤트 리스너에서 querySelectorAll 를 사용하면서 생긴 문제와 해결방안: 동적으로 생성되는 요소에 대해서도 이벤트를 처리

 

이번 주 내일 배움 캠프 스파르타코딩클럽 react_5기 프로젝트는 개인과제로 API에서 받아온 데이터를 가지고 평점순으로 정렬해서 카드 형식으로 flex 또는 grid를 이용해서 검색 기능까지 구현하는 사이트였다.

 

과제 필수 요구사항

  • 바닐라 JS
  • 영화정보 카드 리스트 UI 구현( 카드에는 title(제목), overview(내용 요약), poster_path(포스터 이미지 경로), vote_average(평점) 이렇게 4가지 정보가 필수)
  • 영화 검색 UI 구현
    • API로 받아온 전체 영화들 중 영화 제목에 input 창에 입력한 문자값이 포함되는 영화들만 화면에 표시
    • 입력 후 검색버튼 클릭 시 실행
  • 카드 클릭 시에는 클릭한 영화 id를 나타내는 alert 창 표시

선택요구사항

  • CSS flex/grid 사용하기
  • 웹사이트 랜딩 또는 새로고침 후 검색 입력란에 커서 자동 위치시키기
  • 대소문자 관계없이 검색 가능하게 하기
  • 키보드 enter키를 입력해도 검색버튼 클릭한 것과 동일하게 검색 실행

HTML

    <header>
        <a href="#" id="header-title">
            <h1>The Most Popular Movies🎥</h1>
            <div class="toggle-container">
                <input type="checkbox" id="dake-mode-toggle">
                <label for="dake-mode-toggle" class="toggle-label"></label>
            </div>            
        </a>
    </header>
    <main>
        <section id="serch">
            <form id="serch-form">
                <input type="text"  id="search-input" placeholder="Search movie title" autofocus>
                <button type="submit" class="submit-btn" alt="search"></button>
                <button type="button" id="input-clear-btn" alt="serch-bar-clear-btn">Clear</button>
            </form>
        </section>
        <section id="movie-list">
            <!-- <div class="movie-card">
                <img src="" alt="">
                <div class="movie-rating"></div>
                <h3 class="movie-title"></h3>
                <p class="movie-content"></p>                
            </div> -->
        </section>
    </main>

 

가장 처음으로 API에서 데이터를 받아와서 

// 평점 순으로 정렬 후 영화 표시 함수
const sortAndDisplayMovies = (movies) => {
    const sortedMovies = movies.sort((a, b) => b.vote_average - a.vote_average);
    displayMovies(sortedMovies);
};

sort()를 이용해서 데이터를 평점순으로 정렬

 

필수요구사항 - 1 영화정보 카드 UI 구현

// 영화 리스트 초기화 함수
const clearMovieList = (movieList) => {
    movieList.innerHTML = "";
};

// 화면에 영화 목록 표시 함수
function displayMovies(movies) {
    const movieList = document.getElementById("movie-list");
    clearMovieList(movieList); // 영화 리스트 초기화

    movies.forEach((movie, index) => {
        const movieCard = createMovieCard(movie, index); // 영화 카드 생성
        movieList.appendChild(movieCard);
    });
};


// 영화 타이틀 요소 생성 함수
const createMovieTitle = (titleText) => {
    const title = document.createElement("h3");
    title.classList.add("movie-title");
    title.textContent = titleText;
    return title;
};

// 영화 소개 요소 생성 함수
const createMovieContent = (overviewText) => {
    const content = document.createElement("p");
    content.classList.add("movie-content");
    content.textContent = overviewText;
    return content;
};

// 영화 평점 요소 생성 함수
const createMovieRating = (voteAverage) => {
    const rating = document.createElement('div');
    rating.classList.add('movie-rating');
    rating.textContent = `⭐ ${voteAverage.toFixed(1)}`;
    return rating;
};

// 1위 ~ 3위 영화 뱃지 생성 함수
const createPlaceBadge = (index) => {
    const placeBadge = document.createElement('div');
    placeBadge.classList.add('place-badge');
    placeBadge.textContent = `${index + 1}st`;
    return placeBadge;
};


// 영화 카드 생성 함수
const createMovieCard = (movie, index) => {
    const movieCard = document.createElement("div");
    movieCard.classList.add("movie-card");
    movieCard.id = `${movie.id}`;

    const img = document.createElement("img");
    img.src = `https://image.tmdb.org/t/p/w500${movie.poster_path}`;
    img.alt = movie.title;

    const title = createMovieTitle(movie.title); // 영화 타이틀 요소 생성
    const content = createMovieContent(movie.overview); // 영화 소개 요소 생성
    const rating = createMovieRating(movie.vote_average); // 영화 평점 요소 생성
    const placeBadge = createPlaceBadge(index); // 1위 ~ 3위 영화 뱃지 생성

    movieCard.append(img, title, rating, content);
    if (index < 3) {
        movieCard.appendChild(placeBadge);
    }

    // // 영화 카드 클릭시 alert창이 뜨는 이벤트리스너
    // movieCard.addEventListener("click", () => {
    //     const clickedCardId = movieCard.id;
    //     window.alert(`ID: ${clickedCardId}`);
    // });

    return movieCard;
};

 

처음에 JS 코드를 짤 때는 createMovieCard()에 기능이 여러 개 있었고 위화감이 있어서 찾아보니 

DRY 원칙은 "Do not Repeat Yourself"
모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을만한 표현 양식을 가져야 한다

 

단일 책임 원칙인 각 함수에 하나의 역할만 수행하도록 작성하려고 노력했다. 이는 코드의 가독성과 유지보수성을 향상한다고 한다.

 

필수 요구사항 2 - 영화 검색 UI 구현

// 검색 이벤트 리스너 
const searchForm = document.getElementById("serch-form");
searchForm.addEventListener("submit", (event) => {
    event.preventDefault();
    const inputValue = searchForm.querySelector("input").value;
    filterMovies(inputValue);
});

// 입력값에 해당하는 영화 필터링 함수
const filterMovies = (inputValue) => {
    const movieCards = document.querySelectorAll(".movie-card");
    movieCards.forEach(movieCard => {
        const title = movieCard.querySelector(".movie-title").textContent.toLowerCase();
        movieCard.style.display = title.includes(inputValue.toLowerCase()) ? "block" : "none";
    });
};

// HTML 요소를 DOM으로 가져오기
const searchInput = document.getElementById("search-input");

// 메인 타이틀 클릭 시 페이지 새로고침 
document.getElementById("header-title").addEventListener("click", () => {
    searchInput.focus();
    location.reload();    
});

// 검색창 클리어 버튼 이벤트 처리 리스너
document.getElementById("input-clear-btn").addEventListener("click", () => {
    searchInput.value = "";
});

// 페이지 로드 또는 새로고침 이벤트가 발생하면 검색 입력란에 포커스
window.addEventListener("load", () => {
    searchInput.focus();
});

 

input에 입력된 값을 toLowerCase()로 소문자로 변환해서 movie-card에 title에 포함되는지 includes 메서드 이용해서 true면 display 값을 block 아니면 none으로 표시 한 개의 페이지에서 페이지 내의 데이터를 검색하는 것이라 input type도 text로 했고 뒤로 가기 버튼을 만들 수 없어서 메인타이틀을 누르면 새로고침 하게 만들었고 선택 요구사항인 focus도 js에 그리고 html에 input 속성에 autofocus를 넣어줬다.

 

 

필수 요구사항 3 - 카드 클릭 시에는 클릭한 영화 id를 나타내는 alert 창 표시

처음 잘못된 코드

document.querySelectorAll(".movie-card").forEach(movieCard => {
    // 영화 카드 클릭시 alert창이 뜨는 이벤트리스너
    movieCard.addEventListener("click", () => {
        const clickedCardId = movieCard.id;
        window.alert(`ID: ${clickedCardId}`);
    });
});

 

문제점 

  • document.querySelectorAll(". movie-card")는 스크립트가 처음 실행될 때 실행돼서 해당 시점에는 아직 생성되지 않은 영화 카드를 선택이 불가하고 초기에만 존재하는 요소에 대해서만 이벤트 리스너를 추가 가능하다. 즉 동적으로 생성되는 요소에 대해서는 이 방법을 사용할 수 없었다

해결방안

  • 요소가 생성될 때마다 이벤트 리스너를 추가해서 새로운 요소가 생성될 때마다 해당 요소에 대한 이벤트 처리

두 번째로 해결한 코드

// 영화 카드 생성 함수
const createMovieCard = (movie, index) => {
    const movieCard = document.createElement("div");
    movieCard.classList.add("movie-card");
    movieCard.id = `${movie.id}`;

    const img = document.createElement("img");
    img.src = `https://image.tmdb.org/t/p/w500${movie.poster_path}`;
    img.alt = movie.title;

    const title = createMovieTitle(movie.title); // 영화 타이틀 요소 생성
    const content = createMovieContent(movie.overview); // 영화 소개 요소 생성
    const rating = createMovieRating(movie.vote_average); // 영화 평점 요소 생성
    const placeBadge = createPlaceBadge(index); // 1위 ~ 3위 영화 뱃지 생성

    movieCard.append(img, title, rating, content);
    if (index < 3) {
        movieCard.appendChild(placeBadge);
    }

    // 영화 카드 클릭시 alert창이 뜨는 이벤트리스너  (1)
    movieCard.addEventListener("click", () => {
        const clickedCardId = movieCard.id;
        window.alert(`ID: ${clickedCardId}`);
    });

    return movieCard;
};

 

createMovieCard 함수 내부에서 클릭 이벤트 리스너를 정의하면, 해당 함수가 실행될 때 클로저가 생성

클로저는 함수가 정의될 때 주변 맥락에 대한 참조를 유지하는데, 이는 클로저가 함수가 호출된 후에도 외부 변수에 접근가능해서 movieCard 변수를 기억하고 이벤트 리스너를 포함한 외부 컨텍스트를 유지하기 때문에 이벤트 리스너 내부에서 movieCard 변수에 접근하여 해당 카드의 ID를 얻을 수 있었다. 

 

코드에서 마음에 안 들었던 점

  • 카드가 많아 질수록 이벤트 리스너의 수도 비례적으로 증가하므로 메모리와 처리 속도에 영향이 간다고 생각했고 , 클라이언트 측에서 처리해야 할 작업량이 증가하게 되어 성능적으로 좋지 않다고 생각했다. 따라서 클라이언트 측의 리소스를 절약하기 위해 동적으로 생성되는 영화 카드에 대한 이벤트 리스너를 최소화하는 방법을 모색했다.

최종 코드

// 영화 리스트의 부모 요소에 클릭 이벤트 리스너 추가
document.getElementById("movie-list").addEventListener("click", (event) => {
    // 클릭된 요소가 영화 카드 또는 그 자식 요소인 경우에 처리
    const movieCard = event.target.closest(".movie-card");
    if (movieCard) {
        const clickedCardId = movieCard.id;
        window.alert(`ID: ${clickedCardId}`);
    }
});

 

해결방안

  • 어렴풋이 저번 주 튜터님께서 다른 조의 발표를 피드백할 때 자식 요소의 이벤트를 부모 요소에서 처리하는 방식이 핸들러의 수를 줄이고 메모리를 절약하는 데 도움이 되고 코드의  유연성을 높일 수 있다고 말씀하셨다.
  • 또한 동적으로 생성되는 요소에 대해서도 이벤트를 처리가 가능하다. 부모 요소는 페이지 로드 시 존재하는 요소에 대한 이벤트만 처리하는 것이 아니라, 나중에 추가되는 요소에 대해서도 동일한 이벤트를 적용할 수 있다.

결과물