오늘은 프로젝트를 하면서 많이 바뀌었던 내 코드에 대해 얘기해보겠다.
전체적인 맥락은 바닐라 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로 요소를 생성하는 방식은 읽는 데 피로도가 많이 든다.
- 프론트엔드 개발을 하는 많은 사용자들 중에 순수 바닐라 JS를 사용하고,
- 코드 작성 방식 : 하향식 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로 넘어갔었다.
프레임워크 및 라이브러리의 사용으로 내가 얼마나 쉽고 간편하게 개발하고 있는지 몰랐는데 이번 기회로 깨달은 것 같다.
'💻 개발 > toy project' 카테고리의 다른 글
React + TS 프로젝트에서 코드 리뷰 (0) | 2025.01.27 |
---|---|
영원히 입력 안되는 폼이 있다? : 진실혹은거짓 (0) | 2025.01.23 |
바닐라 JS로 토스트 구현: Class 컴포넌트 (0) | 2025.01.14 |
Modal 클래스 개선하기: 메서드 추출을 통한 코드 구조화 (1) | 2025.01.14 |
CSS 너 왜 늦는거야? - FOUC (0) | 2025.01.14 |