본문 바로가기
💻 개발/toy project

바닐라 JS 코드 리팩터링 스토리: 컴포넌트 설계 변화 탐구

by llddang 2025. 1. 17.

오늘은 프로젝트를 하면서 많이 바뀌었던 내 코드에 대해 얘기해보겠다.

전체적인 맥락은 바닐라 JS에서 컴포넌트 작성으로,

클래스형 컴포넌트로 작성했다가, 함수 및 event를 파일에 나열하는 방식으로 작성했다가, 함수 컴포넌트를 구현하는 과정이 담겨져 있다.

 

영화 상세 내용을 보여주는 모달을 구현한 코드 기준으로 살펴보겠다!

 

 

프로젝트 시작 - 클래스형 컴포넌트

처음에는 JS에서 사용자 정의 요소를 만들 수 있는 방법인 Class를 사용하기로 생각했다.

 

사용자 정의 요소를 만들었지만 내가 만드는 페이지는 동적인 페이지로 미리 HTML에 작성할 수 없다.
따라서 사용자 정의 요소로 만들지 않고, Class의 형태만 유지하도록 코드를 작성했다.

 

더보기
import { BookmarkType } from "../../types/bookmark.type.js";
import { Modal } from "../common/modal.js";
import { Toast } from "../common/toast.js";

export class MovieDetailModal extends Modal {
  constructor(type) {
    super();
    this.type = type;
  }

  open(movie, onClose) {
    this.movie = movie;
    this.onClose = onClose || (() => {});
    
    const content = this.createContent();
    const modal = super.createModal(movie.title, content);
    
    document.body.append(modal);
  }

  createContent() {
    const content = document.createElement("div");
    content.innerHTML = ` 중략 `;

    const bookmarkButton = this.createBookmarkButton();
    content.append(bookmarkButton);

    return content;
  }

  createBookmarkButton() {
    const bookmarkButton = document.createElement("button");
	
    if (this.type === BookmarkType.ADD) 
      bookmarkButton.addEventListener("click", this.handleAddBookmarkClick);
    else 
      bookmarkButton.addEventListener("click", this.handleCancelBookmarkClick);
    
    return bookmarkButton;
  }

  handleAddBookmarkClick() { /* 중략 */ }
  handleCancelBookmarkClick() { /* 중략 */ }
}

코드 구조를 보여주기 위해서 간략하게 생략한 코드를 가져왔지만 생각보다 긴 것 같아서, 토글 목록 안으로 코드를 넣었다.

 

자세히 살펴보고 싶다면 깃헙 레포지토리의 이 버전을 확인하면 된다.

 

사실 클래스형 컴포넌트라고 말은 지었지만 React에서 사용하는 클래스형 컴포넌트를 생각하면 안 된다.
코드를 보면 알겠지만... 구조도 많이 다르고 render 되는 방식 또한 다르다.

 

이 코드에서 불편한 점은 아래와 같다.

  • 요소의 생성 방식 : document vs JSX
    • 프론트엔드 개발을 하는 많은 사용자들 중에 순수 바닐라 JS를 사용하고,
      그 중에서도 document 객체를 통해 요소를 생성 및 추가하는 사람은 적을 것이다.
    • 왜냐면 우리는 직관적이고 가독성이 좋은 JSX 문법이 있기 때문이다. 
    • 따라서 필요한 부분 마다 document.createElement로 요소를 생성하는 방식은 읽는 데 피로도가 많이 든다.
  • 코드 작성 방식 : 하향식 vs 상향식
    • 현재 코드는 하향식으로 작성되었으며, 제일 상위에 전체적인 코드가 그려진다.
    • 코드를 보면 최상단의 open() 메서드에서 createContent() 와 createModal() 함수를 호출하여 전체 요소를 생성한다.
      이후 document.body.append() 메서드를 통해 DOM에 요소를 추가해주고 있다.
    • 현재 코드는 전체적인 흐름을 이해하기는 편하다.
    • 하지만 세부 구현의 디테일을 보기 위해서는 코드의 이동이 많아 리뷰하기에는 불편한 방식이다.
  • React에서의 컴포넌트 : 클래스형 vs 함수
    • React는 2019년 React 16.8에서 Hooks가 도입되면서 클래스형 컴포넌트에서 함수 컴포넌트로 전환이 진행되었다.
    • 함수 컴포넌트는 hook을 사용하여 상태 관련 로직을 쉽게 분리하고 재사용할 수 있으며,
      클래스형 컴포넌트의 this 바인딩 문제와 생명주기 메서드의 복잡성으로 인한 복잡한 코드에서 직관적인 구조로 작성 가능하다.
    • 물론 내 프로젝트에서는 hook이 없어, 상태 관리에는 어려움이 있겠지만 코드의 가독성을 높일 수 있다.

 

 

프로젝트 중반 - 함수와 이벤트를 파일에 나열

위에서 클래스형 컴포넌트와 함수 컴포넌트를 그렇게 얘기해놓고,

왜 그 어느 방법도 아닌 파일에 그저 함수와 이벤트를 나열하는 방식으로 바꾸었지? 라고 생각할 수 있다.

 

능력 부족이다...

기존의 웹사이트 디자인을 바꾸게 되면서 DOM의 구조도 바뀌게 되었다.

 

DOM 조작 변경과 동시에 처음 구현해보는 함수 컴포넌트로 코드를 구현하기에는 신경써야할 게 너무 많은 느낌이었다.

따라서 디자인 변경을 먼저하고 이후에 함수 컴포넌트로 코드 리펙토링을 해야겠다 생각했다.

 

그래서 기존의 class 방식은 탈피하고 최대한 템플릿 리터럴을 사용하여 JSX 문법처럼 읽을 수 있게 만들도록 노력했다.

 

더보기
import { SitemapType } from "../../types/sitemap.type.js";
import { Toast } from "../common/toast.js";

export function drawMovieModalUi(movie, movieCard) {
  const movieModalWrapper = document.createElement("div");
  movieModalWrapper.className = "movie-modal-wrapper";

  movieModalWrapper.innerHTML = `
    <div class="movie-modal-wrapper">
      <img class="movie-modal-dim" 
          src="https://image.tmdb.org/t/p/w1280${movie.backdrop_path}" />
      <div class="movie-modal">
        <img class="movie-modal-poster-image" 
            src="https://image.tmdb.org/t/p/w1280${movie.poster_path}" />
        <div class="movie-modal-button-container">
          <button id="toggle-bookmark-button">
            ${
              movie.isBookmarked
                ? `<i class="fa-solid fa-star"></i>`
                : `<i class="fa-regular fa-star"></i>`
            }
          </button>
          <button id="close-movie-modal-button">
            <i class="fa-solid fa-xmark"></i>
          </button>
        </div>
        <div class="movie-modal-content">
          <p class="movie-modal-title">${movie.title}</p>
          <p class="movie-modal-overview">${movie.overview}</p>
          <p class="movie-modal-score">평점 : ${movie.vote_average}</p>
        </div>
      </div>
    </div>`;

  movieModalWrapper.addEventListener("click", function (e) {
    handleMovieModalClick.call(this, e, movieCard);
  });
  document.body.appendChild(movieModalWrapper);
}

function handleMovieModalClick(e, movieCard) {
  const movieModalDim = e.target.closest(".movie-modal-dim");
  const toggleBookmarkButton = e.target.closest("#toggle-bookmark-button");
  const closeMovieModalButton = e.target.closest("#close-movie-modal-button");

  if (!movieModalDim && !toggleBookmarkButton && !closeMovieModalButton) return;

  if (toggleBookmarkButton) handleToggleBookmarkButtonClick(movieCard);
  this.remove();
}

function handleToggleBookmarkButtonClick(movieCard) { /* 중략 */ }

그렇게 작성된 코드는 위와 같다.

자세한 코드가 궁금하다면 깃헙 레포지토리의 이 버전에서 살펴볼 수 있다.

 

이전 코드 보다는 DOM의 구조를 쉽게 유추할 수 있고 로직을 단순화 시킨것 같다.

 

html의 코드가 길어서 생각보다 읽기는 힘들지만 이건 어쩔 수 없다고 생각한다.

물론 html 코드를 분리할 수 있지만 오히려 분리를 했기 때문에 읽기 힘들게 될 수도 있다는 생각이 든다.

 

이 코드에서 불편한 점은 아래와 같다.

  • 컴포넌트의 구조
    • 파일을 분리하여 모달에 필요한 코드로 분리를 하였다.
    • 현재 코드에서 모달에 필요한 로직을 부르기 위해서는 개별적인 함수명을 다 알고 있어야한다.
    • 하지만 함수 컴포넌트로 형성하면 하나의 큰 주제로 묶어 선언 만을 통해 바로 DOM 요소 추가가 가능하다.

사실 현재 코드는 로직이 그리 크지 않아서 변경하더라도 엄청난 가독성 향상을 노릴 순 없다.

하지만 확장성을 생각해서 바꾸기로 결심했다.

 

 

프로젝트 막바지 - 함수 컴포넌트

함수 컴포넌트를 바닐라 JS에서 사용해본 적이 없어서 고민이 되는 부분이 많았다.

어떻게 함수 컴포넌트의 구조를 형성할까?

  • 컴포넌트에 필요한 부분은 DOM에 추가할 요소, 요소가 변경되었을 때 필요한 렌더링, 이벤트 연결 이다.
  • 요소는 template 이라는 함수를 만들어서 JSX 문법과 같이 사용할 수 있도록 구현하였다.
  • 렌더링은 render 함수를 통해 값이 변경될 경우 재 호출할 수 있도록 하였다.
  • 이벤트 연결은 bindEvents 라는 함수 안에 이벤트 리스너를 관리하도록 설계하였다.

Render를 어디서 하면 좋을까?

컴포넌트를 호출하는 곳에서 반환 값을 받아서? 아니면 컴포넌트 내부에서?

  • 결론적으로 말하면 호출하는 곳에서 반환 값을 통해 DOM에 추가할 수 있도록 구현하였다.
  • 반환 값을 통해 호출되는 공간에서 렌더링을 하면, 렌더링을 상위 레벨에서 일관되게 관리할 수 있다.
  • 또한 컴포넌트의 결과물을 렌더링하기 전에 추가적으로 조작할 수 있다.
  • 따라서 나는 전자 방식으로 코드를 구현하였다. 

 

더보기
import { Toast } from "../common/toast.js";
import { SitemapType } from "../../types/sitemap.type.js";

export default function MovieModal({ movieInfo, movieCard }) {
  const $this = document.createElement("div");
  $this.classList.add("movie-modal-wrapper");

  let isNotLoaded = true;

  function innerHTMLTemplate() {
    return `
      ${
        isNotLoaded
          ? `<div class="movie-modal-dim"><div class="skeleton-ui-circle movie-modal-skeleton"></div></div>`
          : ""
      }
      <img class="movie-modal-dim ${isNotLoaded && "hidden"}" 
          src="https://image.tmdb.org/t/p/w1280${movieInfo.backdrop_path}" />
      <div class="movie-modal  ${isNotLoaded && "hidden"}">
        <img class="movie-modal-poster-image" 
            src="https://image.tmdb.org/t/p/w1280${movieInfo.poster_path}" />
        <div class="movie-modal-button-container">
          <button id="toggle-bookmark-button">
            ${
              movieInfo.isBookmarked
                ? `<i class="fa-solid fa-star"></i>`
                : `<i class="fa-regular fa-star"></i>`
            }
          </button>
          <button id="close-movie-modal-button">
            <i class="fa-solid fa-xmark"></i>
          </button>
        </div>
        <div class="movie-modal-content">
          <p class="movie-modal-title">${movieInfo.title}</p>
          <p class="movie-modal-overview">${movieInfo.overview}</p>
          <p class="movie-modal-score">평점 : ${movieInfo.vote_average}</p>
        </div>
      </div>`;
  }

  render();
  bindEvents();

  return {
    element: $this,
    render,
  };

  function render() {
    $this.innerHTML = innerHTMLTemplate();
  }

  function bindEvents() {
    $this.addEventListener("click", (e) => {
      if (e.target.closest("#toggle-bookmark-button"))
        handleClickBookmarkToggleButton();
      if (
        !e.target.closest(".movie-modal-dim") &&
        !e.target.closest("#close-movie-modal-button") &&
        !e.target.closest("#toggle-bookmark-button")
      )
        return;

      $this.remove();
      document.body.style.overflow = "auto";
    });

    $this
      .getElementsByClassName("movie-modal-poster-image")[0]
      .addEventListener("load", () => {
        isNotLoaded = false;
        render();
      });
  }

  function handleClickBookmarkToggleButton() {
    movieInfo.isBookmarked = !movieInfo.isBookmarked;
    movieCard.setAttribute("data-movie-info", JSON.stringify(movieInfo));

    if (movieInfo.isBookmarked)
      addBookmark(movieInfo.id, JSON.stringify(movieInfo));
    else removeBookmark(movieInfo.id);
  }

  function addBookmark(movieId, movieInfo) { /* 생략 */ }
  function removeBookmark(movieId) { /* 생략 */ }
}

이렇게 완성하였다!

 

아직 코드에 미숙한 점이 많을 수 있다.

 

하지만 지금은 바닐라 JS에서 함수 컴포넌트를 도입하려고 노력했고,
그 과정에서 템플릿 리터럴을 통하여 JSX 같은 코드를 구현했다는 것에 칭찬을 하고 싶다.

 

 

후기

이렇게 바닐라 JS만을 이용하여 프로젝트를 한 것은 정말 오랜만이다.

첫 프론트엔드에 들어와서 딱 2주 정도 클론 코딩을 하고 바로 React나 Next.js로 넘어갔었다.

프레임워크 및 라이브러리의 사용으로 내가 얼마나 쉽고 간편하게 개발하고 있는지 몰랐는데 이번 기회로 깨달은 것 같다.