레벨1 점심 뭐먹지
미션을 진행하면서 중점을 뒀던 키워드는 컴포넌트
이다.
예전에 리액트로 프로젝트를 몇번 하면서 리액트 컴포넌트를 사용하는 것은 나름 익숙했다. 하지만 바닐라 JS로 컴포넌트 애플리케이션을 만들어 보는 것은 난생 처음이었다.
또한 컴포넌트의 개념도 추상적으로는 머리속에 있지만 이걸 설명해보라고 하면 쉽게 설명하지 못하는 상황이었다. 미션을 진행하며 컴포넌트의 개념을 다시 학습하고 바닐라 JS로 컴포넌트를 구현하는 과정을 정리했다.
프론트엔드 개발에서 컴포넌트(Component)란, UI(User Interface)를 구성하는 독립적인 블록(block)으로 생각할 수 있습니다. 즉, 하나의 웹 페이지나 애플리케이션을 여러 개의 작은 조각으로 분할하여 각 조각을 컴포넌트라는 개별적인 단위로 취급하여 개발하고 관리하는 방식입니다.
-chatGPT 선생님
쉽게 말하면 컴포넌트는 일종의 UI 블록이다. 하나의 컴포넌트는 UI 화면에 보여지는 HTML, CSS, JS 코드를 포함하는 하나의 조각이다. 이렇게 만든 각각의 컴포넌트들을 조립하여 커다란 하나의 애플리케이션을 구성한다.
오늘의집 메인 페이지
예를 들어 쇼핑몰 사이트를 개발한다고 해보자. 먼저 화면 상단에 위치한 로고나 검색창, 로그인 버튼 등이 하나의 컴포넌트가 될 수 있다. 이런 컴포넌트들을 조립해서 헤더라는 또 하나의 컴포넌트를 만든다. 이렇게 만든 헤더 컴포넌트는 광고 배너 컴포넌트, 상품 리스트 컴포넌트 등 과 같은 컴포넌트들을 조립해서 하나의 쇼핑몰 사이트를 완성할 수 있다.
점심 뭐 먹지 미션에서는 하나의 큰 기능을 수행하는 UI 단위 별 컴포넌트를 나눴다. Select Box를 담당하는 컴포넌트, Button 컴포넌트처럼 재사용이 되는 요소들을 상세하게 컴포넌트로 나누고 싶었지만, 미션 시간이 촉박할 수도 있을 것 같아서 UI 화면에서 큰 기능 단위로 컴포넌트를 나눴다. 분리한 컴포넌트들은 다음과 같다.
이전까지 컴포넌트 개발은 리액트 환경에서만 개발해왔었기 때문에 내 머릿 속의 컴포넌트는컴포넌트 === 리액트 컴포넌트
라고 생각했었다. 컴포넌트 개발이란 리액트스럽게 개발하는 것..?이라고 생각했었다. 어떻게 바닐라 JS를 리액트스럽게 개발하지? 고민 하던 중 황준일님의 Vanilla Javascript로 웹 컴포넌트 만들기 라는 글을 보게되었다.
블로그의 코드들이 신기했다. 클래스 별 state를 구현하고, state 변경 함수를 사용하면 자동으로 렌더링되고, 이런 state를 props로 다른 컴포넌트에게 넘겨주고.. 리액트에서 사용하던 것 처럼 그대로 사용할 수 있었다. 이 컴포넌트 방식을 적용하려고 시도했었다.
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);
});
}
}
먼저 컴포넌트의 각종 기능들을 추상화하여 클래스로 만들었다.
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 컴포넌트의 크기와 역할이 너무 커진다는 문제가 발생했고 또한 상태의 흐름을 읽기 힘들다는 문제도 있었다. 전역 상태를 관리 시스템을 만들까 싶었지만 짧은 미션 기간 동안에는 구현하기가 너무 벅차보였다.
어떻게 상태 관리를 깔끔하게 할지 고민 하던 중, 문득 상태와 프롭스에 굳이 얽매여야 하나?라는 생각이 들었다. 위에 언급했던 상태 관리와 복잡도에 대한 문제를 없애기 위해 상태와 프롭스를 사용하는 리액트 방식(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 함수가 존재한다.
// 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 등)에서 다른 컴포넌트 인스턴스가 필요할 경우 중간 단계에서 계속 건내줘야하는 소위 프롭스 드릴링
이 발생한다.