바닐라 JS로 컴포넌트 구현하기

정균·2023년 4월 26일
0

우아한테크코스

목록 보기
6/15
post-thumbnail

개요

레벨1 점심 뭐먹지 미션을 진행하면서 중점을 뒀던 키워드는 컴포넌트이다.

예전에 리액트로 프로젝트를 몇번 하면서 리액트 컴포넌트를 사용하는 것은 나름 익숙했다. 하지만 바닐라 JS로 컴포넌트 애플리케이션을 만들어 보는 것은 난생 처음이었다.

또한 컴포넌트의 개념도 추상적으로는 머리속에 있지만 이걸 설명해보라고 하면 쉽게 설명하지 못하는 상황이었다. 미션을 진행하며 컴포넌트의 개념을 다시 학습하고 바닐라 JS로 컴포넌트를 구현하는 과정을 정리했다.

점심 뭐먹지 미션 레포지토리
배포 사이트

컴포넌트란?

프론트엔드 개발에서 컴포넌트(Component)란, UI(User Interface)를 구성하는 독립적인 블록(block)으로 생각할 수 있습니다. 즉, 하나의 웹 페이지나 애플리케이션을 여러 개의 작은 조각으로 분할하여 각 조각을 컴포넌트라는 개별적인 단위로 취급하여 개발하고 관리하는 방식입니다.

-chatGPT 선생님

쉽게 말하면 컴포넌트는 일종의 UI 블록이다. 하나의 컴포넌트는 UI 화면에 보여지는 HTML, CSS, JS 코드를 포함하는 하나의 조각이다. 이렇게 만든 각각의 컴포넌트들을 조립하여 커다란 하나의 애플리케이션을 구성한다.

오늘의집 메인 페이지

예를 들어 쇼핑몰 사이트를 개발한다고 해보자. 먼저 화면 상단에 위치한 로고나 검색창, 로그인 버튼 등이 하나의 컴포넌트가 될 수 있다. 이런 컴포넌트들을 조립해서 헤더라는 또 하나의 컴포넌트를 만든다. 이렇게 만든 헤더 컴포넌트는 광고 배너 컴포넌트, 상품 리스트 컴포넌트 등 과 같은 컴포넌트들을 조립해서 하나의 쇼핑몰 사이트를 완성할 수 있다.

점심 뭐 먹지 애플리케이션에는 어떤 컴포넌트가 있을까?

점심 뭐 먹지 미션에서는 하나의 큰 기능을 수행하는 UI 단위 별 컴포넌트를 나눴다. Select Box를 담당하는 컴포넌트, Button 컴포넌트처럼 재사용이 되는 요소들을 상세하게 컴포넌트로 나누고 싶었지만, 미션 시간이 촉박할 수도 있을 것 같아서 UI 화면에서 큰 기능 단위로 컴포넌트를 나눴다. 분리한 컴포넌트들은 다음과 같다.

  • 화면의 상단부
  • 로고와 음식점 추가 버튼을 포함하고 있다.
  • 음식점 추가 버튼을 누르면 음식점 추가 모달이 띄워진다.

Tab

  • 모든 음식점과 자주 가는 음식점을 구분하여 볼 수 있는 탭
  • 탭을 선택하면 해당하는 음식점 리스트들을 렌더링한다.

Filter

  • 음식점 리스트를 카테고리 별 필터링과 정렬을 할 수 있는 Select Box
  • 좌측 Select Box에서는 필터링 할 카테고리를 고를 수 있다.
  • 우측 Select Box에서는 정렬 방식을 고를 수 있다.
  • 선택된 옵션에 따라 음식점 리스트가 렌더링 된다.

RestaurantList

  • 음식점 리스트
  • 음식점 데이터에 맞는 RestaurantItem 들의 리스트를 보여준다.
  • 선택된 탭 상태, 필터링 상태에 따라 리스트가 새로 렌더링된다.

RestaurantItem

  • 음식점 아이템
  • 음식점 데이터에 따른 음식점 정보를 보여준다.
  • 클릭하면 음식점 상세 정보를 담은 모달 창이 띄워진다.

  • 음식점 추가 Form 모달, 음식점 상세 정보 모달
  • 헤더의 음식점 추가 버튼을 누르면 음식점 추가 모달을 띄운다.
  • 음식점 아이템을 누르면 해당 음식점 상세 정보 모달을 띄운다.

바닐라 JS로 어떻게 컴포넌트를 만들 수 있을까?

이전까지 컴포넌트 개발은 리액트 환경에서만 개발해왔었기 때문에 내 머릿 속의 컴포넌트는컴포넌트 === 리액트 컴포넌트 라고 생각했었다. 컴포넌트 개발이란 리액트스럽게 개발하는 것..?이라고 생각했었다. 어떻게 바닐라 JS를 리액트스럽게 개발하지? 고민 하던 중 황준일님의 Vanilla Javascript로 웹 컴포넌트 만들기 라는 글을 보게되었다.

블로그의 코드들이 신기했다. 클래스 별 state를 구현하고, state 변경 함수를 사용하면 자동으로 렌더링되고, 이런 state를 props로 다른 컴포넌트에게 넘겨주고.. 리액트에서 사용하던 것 처럼 그대로 사용할 수 있었다. 이 컴포넌트 방식을 적용하려고 시도했었다.

Core Component

export default class Component {
  $target;
  props;
  state;

  constructor($target, props) {
    this.$target = $target;
    this.props = props;
    this.setup();
    this.render();
    this.setEvent();
  }
	
	// 렌더링 전 초기 세팅 함수
  setup() {}

	// 렌더링 후 진행할 함수
  mounted() {}

	// 렌더링 할 템플릿
  template() {
    return "";
  }

	// 렌더링 함수
  render() {
    this.$target.innerHTML = this.template();
    this.mounted();
  }

	// 이벤트 등록 함수
  setEvent() {}

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }

  addEvent(eventType, selector, callback) {
    this.$target.addEventListener(eventType, (event) => {
      if (!event.target.closest(selector)) return;
      callback(event);
    });
  }
}

먼저 컴포넌트의 각종 기능들을 추상화하여 클래스로 만들었다.

App Component

export default class App extends Component {
  setup() {
		...

    this.state = {
      restaurantList: sortedList,
      modalOpen: false,
      sortingWay: SORT.NAME,
      category: CATEGORY.ALL,
    };
  }

  template() {
    const { modalOpen } = this.state;
    return `
      <header class="gnb"></header>
      <main>
        <section class="restaurant-filter-container"></section>
        <section class="restaurant-list-container"></section>
        <div class="modal${modalOpen ? " modal--open" : ""}"></div>
      </main>
    `;
  }

  mounted() {
		// App 클래스의 함수들과 state 가져오기
    const { toggleModal, addRestaurant, onChangeSortingWay, onChangeCategory } = this;
    const { restaurantList, sortingWay, category } = this.state;
		
		// 하위 컴포넌트들의 target DOM 선언
    const $header = this.$target.querySelector(".gnb");
    const $restaurantFilter = this.$target.querySelector(".restaurant-filter-container");
    const $restaurantList = this.$target.querySelector(".restaurant-list-container");
    const $modal = this.$target.querySelector(".modal");
		
		// 하위 컴포넌트 마운트
    new Header($header, { toggleModal: toggleModal.bind(this) });
    new Filter($restaurantFilter, {
      sortingWay,
      category,
      onChangeSortingWay: onChangeSortingWay.bind(this),
      onChangeCategory: onChangeCategory.bind(this),
    });
    new RestaurantList($restaurantList, { restaurantList });
    new Modal($modal, {
      toggleModal: toggleModal.bind(this),
      addRestaurant: addRestaurant.bind(this),
    });
  }

  ...
}

컴포넌트들은 core Component 클래스를 상속하고, Component 클래스의 함수들을 오버라이딩하여 사용한다.

좋긴한데 불편하다.

state를 선언하고 setState로 state를 변경해주면 자동으로 해당 컴포넌트가 리렌더링이 되는 과정이 깔끔하고 좋았다. 그러나 단점 역시도 크게 느껴졌다. 리액트처럼 state와 props를 사용하다보니 리액트에서 느꼈던 상태 관리의 어려움이 이 프로젝트에서도 똑같이 느껴졌다.

여러 컴포넌트에서 공통적으로 필요한 상태가 있을 경우 그 컴포넌트들의 공통 상위 컴포넌트에서 선언 후 각각 props로 내려주는 방식을 사용한다. 예를 들어 모달이 켜져있는지 확인하는 isModalOpen 상태의 경우 모달을 켜는 Header 컴포넌트에도 필요하고, Modal 컴포넌트 자체에도 필요하다. 이렇게 되면 두 컴포넌트의 공통 부모 컴포넌트인 App 컴포넌트에 modalOpen 상태를 둬야했다.

대부분의 컴포넌트의 상태(isModalOpen, restaurantList 등)들은 여러 컴포넌트에서 동시에 사용해야하는 상태이기 때문에 해당 컴포넌트들의 상위 컴포넌트인 App 컴포넌트에 상태를 선언해야 했다. 그러다보니 App 컴포넌트의 크기와 역할이 너무 커진다는 문제가 발생했고 또한 상태의 흐름을 읽기 힘들다는 문제도 있었다. 전역 상태를 관리 시스템을 만들까 싶었지만 짧은 미션 기간 동안에는 구현하기가 너무 벅차보였다.

state & props 방식 컴포넌트를 적용한 브랜치

머릿 속 리액트를 지우자

어떻게 상태 관리를 깔끔하게 할지 고민 하던 중, 문득 상태와 프롭스에 굳이 얽매여야 하나?라는 생각이 들었다. 위에 언급했던 상태 관리와 복잡도에 대한 문제를 없애기 위해 상태와 프롭스를 사용하는 리액트 방식(core Component)을 과감하게 제거했다. 하지만 state를 사용해서 컴포넌트가 다른 컴포넌트를 렌더링하는 방식이었기에 state를 사용하지 않고 다른 컴포넌트를 렌더링하는 방법에 대해서 많은 고민을 해야 했다.

상호작용하는 컴포넌트를 클래스 조합 방식으로 가져와서 사용하자

클래스 조합이 뭔지 알고싶다면?

다른 컴포넌트를 렌더링하는 방법으로 다른 컴포넌트 인스턴스를 생성자 인자로 받아 사용하는 구조 즉, 클래스 조합 방식을 택했다. 예를 들어 Filter 컴포넌트에서는 필터링 방식 선택시 음식점 리스트를 새로 렌더링 해야하므로 RestaurantList 컴포넌트가 필요하다. Filter의 생성자에 RestaurantList 컴포넌트의 인스턴스를 인자로 넘겨준다.

class Filter {
	$target;
	restaurantList;
	
	constructor($target, restaurantList) {
		this.$target = $target;
		this.restaurantList = restaurantList;

		this.render();
		this.setEvent();
	}

	render() {
		...
	}

	...

	setEvent() {
	  this.setOnChangeCategoryEvent(this.restaurantList);
	  this.setOnChangeSortEvent(this.restaurantList);
	}

	// 정렬 방식 변경 이벤트리스너 함수
	setOnChangeSortEvent(restaurantList) {
		this.$target.querySelector("#sorting-filter").addEventListener("change", (event) => {
		  ...
		  // RestaurantList 컴포넌트의 함수 사용
		  restaurantList.renderFilteredList(selectedCategory, selectedSortingWay);
		  ...
		});
	}

	// 카테고리 필터링 변경 이벤트리스너 함수
	setOnChangeCategoryEvent(restaurantList) {
		this.$target.querySelector("#category-filter").addEventListener("change", (event) => {
		  ...
		  // RestaurantList 컴포넌트의 함수 사용
		  restaurantList.renderFilteredList(selectedCategory, selectedSortingWay);
		  ...
		});
	}
}

위와 같이 조합방식을 사용하니 App 컴포넌트의 크기가 대폭 작아지고, 또한 각 컴포넌트들의 역할이 분명해졌다는 장점이 있었다. 기존의 100줄 가까이 되던 App 파일을 아래처럼 짧게 줄일 수 있었다.

// App.js
...

const modal = new Modal($(".modal"));
const restaurantList = new RestaurantList($(".restaurant-list-container"), modal);
const header = new Header($(".gnb"), modal);
const tab = new Tab($(".restaurant-tab-container"), restaurantList);
const filter = new Filter($(".restaurant-filter-container"), restuarantList);

생성자의 인자로 컴포넌트를 넣는 방식의 문제점

하지만 생성자의 인자로 컴포넌트를 가져오면 다음과 같은 문제점들이 생긴다.

컴포넌트의 생성 순서를 지켜야만 한다. Filter 컴포넌트의 경우 RestaurantList 컴포넌트를 사용해야 하므로 반드시 RestaurantList 컴포넌트를 Filter 컴포넌트보다 빨리 선언해야 한다.

또한 양방향으로 컴포넌트를 참조하지 못한다. 만약 Filter에서 RestaurantList 컴포넌트 함수를 사용하고, RestaurantList에서도 Filter의 함수를 사용해야 한다고 가정한다면 이는 불가능하다.

const restaurantList = new RestaurantList($(".restaurant-list-container") /** filter를 넣지 못한다*/);
const filter = new Filter($(".restaurant-filter-container"), restuarantList);

뿐만 아니라 각 컴포넌트 클래스의 클래스 변수가 많아진다. 이는 클래스의 관심사가 높아진다는 의미가 되므로 지양해야한다.

위 문제들을 해결하기 위한 방안이 필요했다.

생성자의 인자 대신 setEvent의 인자로

각 클래스 컴포넌트는 이벤트를 바인딩 하는 setEvent 함수가 존재한다.

// Filter 컴포넌트의 setEvent
setEvent() {
  this.setOnChangeCategoryEvent(this.restaurantList);
  this.setOnChangeSortEvent(this.restaurantList);
}

컴포넌트의 인스턴스를 클래스 변수 대신 setEvent의 인자로 넣어서 사용한다면 위에서 나열한 문제점들을 깔끔하게 해결할 수 있었다.

// 바뀐 Filter 컴포넌트의 setEvent
setEvent(restaurantList) {
  this.setOnChangeCategoryEvent(restaurantList);
  this.setOnChangeSortEvent(restaurantList);
}

App 컴포넌트에서는 아래와 같이 사용한다. setEvent 함수를 클래스 내부에서 실행하는 것이 아닌, 상위 컴포넌트에서 클래스를 생성한 뒤 실행한다.

// App.js
const filter = new Filter($(".restaurant-filter-container"));
const restaurantList = new RestaurantList($(".restaurant-list-container"));

filter.setEvent(restaurantList);

조합 방식 컴포넌트를 적용한 브랜치

시도했던 두 컴포넌트 방식의 특징과 장단점 정리

state & props 방식의 컴포넌트 구조에서 조합 방식의 컴포넌트 구조로 변경하면서 원했던 목표인 App의 크기 줄이기각 컴포넌트의 역할 명확히 하기 는 달성했지만 마냥 장점만 있는 것은 아니었다.

일단 컴포넌트 별 의존성이 커진다는 단점이 있고, 또한 컴포넌트의 깊이가 1보다 큰 컴포넌트(RestaurantList 컴포넌트 속의 RestaurantItem 등)에서 다른 컴포넌트 인스턴스가 필요할 경우 중간 단계에서 계속 건내줘야하는 소위 프롭스 드릴링 이 발생한다.

state & props 방식 컴포넌트 특징

  • 각 컴포넌트 별 상태를 둘 수 있고, props로 상태와 같은 데이터나 함수를 넘겨줄 수 있음
  • 상태를 바꾸는 setState 함수를 쓰면 리렌더링까지 자동으로 진행됨
  • 리액트와 동일하게 상태 관리하기 어려움
  • 상위 컴포넌트의 상태가 바뀌면 하위 컴포넌트까지 모두 리렌더링 되기 때문에 렌더링 효율 떨어짐

클래스 조합 방식 컴포넌트 특징

  • 다른 컴포넌트의 함수를 사용하는 컴포넌트의 경우, 다른 컴포넌트의 인스턴스를 조합으로 가져와서
  • 각 컴포넌트 별 역할이 명확해지고, 컴포넌트 별로 코드를 읽기 쉬워짐
  • 하지만 컴포넌트 별 의존성이 생김
  • 또한 어떤 컴포넌트의 하위 컴포넌트에서 다른 컴포넌트를 사용할 경우 드릴링이 발생함
profile
💻✏️🌱

0개의 댓글