정말 정말 많이 고민했던 step 1이 끝나고 뒤늦게 회고하게 되었다.
이번 미션의 핵심 키워드는 컴포넌트
와 타입스크립트
였다.
컴포넌트
를 바닐라 JS 관점에서 어떻게 재사용이 가능하도록 설계가 가능한지, any
없이 어떻게 타입추론을 가능하게 할지가 이번 미션의 관건이었다.
타입스크립트는 기초적인 부분을 경험하는 것이 목적이었기 때문에 이번 회고에서 다루지 않을 예정이다.
즉, 핵심 주제인 컴포넌트
에 대해 어떤 관점에서 컴포넌트를 바라보고 설계했는지 위주로 정리해 볼 예정이다.
컴포넌트는 흔히 레고 블록에 비유하곤 한다.
애플리케이션을 개발하다보면, 흔히 이런 상황 들을 많이 마주한다고 한다.
xxx님, 이번 회의 전달 사항 입니다. 앱 내 ~한 부분에 대해 xx 기능을 추가(제거)해야 할 것 같습니다.
왜냐하면 사용자와 최전선에서 마주하며, 모든 사용자가 불편함을 겪지 않도록 개선하는 포지션이 바로 웹 프런트엔드 개발자이기 때문이다.
이걸 다시 말하면 고작 몇 명의 사용자가 불편함을 호소하면 그 기능을 수정해야 한다는 의미다.
즉, 변경은 딱 정의 되어 있지 않으며 언제 발생할지 알 수 없기 때문에 언제든지 대응할 준비가 되어 있어야 한다.
그렇기 때문에 아키텍처를 설계할 때 아래 사항을 최우선으로 개발한다고 한다.
- 어떤 역할을 하는지 명확히 파악 가능하며,
- 변경에 쉽게 대응할 수 있도록 하기
즉, 어떤 상황을 마주하게 되더라도 유연하게 대처할 수 있다면 그 제품은 끊임 없이 유저의 니즈를 충족시켜 지속적으로 성장하게 될 것이다.
그렇다면 어떻게 컴포넌트
라는 개념을 통해 변경에 쉽게 대응할 수 있는걸까?
컴포넌트를 설계하는 과정은 마치 기능 구현 목록을 구성하는 것과 같은 흐름으로 전개되곤 한다.
최소한의 기능 단위로 분리한다.
이 기능 들을 결합하여 새로운 기능을 만든다.
기능이 잘 동작하는지 확인한다.
1 - 3을 반복한다.
즉, 기능을 최대한 잘게 쪼갠 후 다시 커진다면 다시 분리하는 식으로 기능 구현 목록을 만든다.
컴포넌트도 비슷한 흐름으로 설계 하게 된다.
최소한의 기능에 대한 레고 블록을 만들고
이 레고를 조립하여 하나의 커다란 제품을 만든 다음
그 기능이 커진다 싶으면 다시 분리하는 식이다.
위 설명 들을 모두 들어도 직접 컴포넌트를 보지 못했기 때문에 피부로 와닿지 않을 수 있다.
그래서 한 예제를 준비해보았다.
⚠️ 약간의 리액트 지식을 필요로 합니다.
<Item
key={`item_${filtered.length}`}
index={filtered.length}
curIndex={index}
item={{
id : 'search_in_google',
name : `github: ${currentTargetValue}`,
htmlUrl: currentTargetValue,
}}
onClickItem=${onClickItem}
/>
Item이라는 컴포넌트는 key, index, curIndex, item, onClickItem 이라는 prop을 받고 있는데 아래와 같은 문제점이 있는 것을 알 수 있다.
- 어떠한 기능을 수행하는지 명확히 파악하기 어렵다. (Item 이라는 네이밍과 prop으로 받는 요소 들을 보았을 때 어떤 기능을 수행할지 매칭이 안된다.)
- 어떤 역할을 하는지 알 수 없다보니 이 컴포넌트를 사용하는 모든 코드를 뒤져가며 일일이 확인해야 한다.
즉, 현재의 컴포넌트는 아래와 같은 특징을 가진다.
- 어떤 역할을 하는지 명확히 파악 하기 어려우며,
- 모든 코드를 뒤져야 하기 때문에 변경에 유연하게 대처 하기 힘들다.
그러면 변경에 유연하게 대처하기 위해 어떻게 컴포넌트를 효과적으로 설계해 볼 수 있을까?
Headless 기반 추상화하기
(컴포넌트의 레이어를 명확히 분리하기)
한 가지의 역할 수행하기
(또는 한 가지 역할만 하는 컴포넌트의 조합으로 구성하기)
도메인 분리 하기
(도메인을 포함하는 컴포넌트와 그렇지 않은 컴포넌트로 분리하기)
이제 효과적인 컴포넌트 설계의 필요성을 피부로 느껴보았으니 본격적으로 이번 미션에서 위 요소들과 함께 컴포넌트
에 대해 고민했던 부분에 대해 설명해보려고 한다.
이 글에서 전반적인 레퍼런스를 참고했던 jBee(한재엽 님)의 지속 가능한 성장과 컴포넌트
주제에서 언급했던 부분은Headless 기반 추상화하기
이지만 이번 미션에서 고민할 것 까진 아니라고 판단되었다.
따라서, 컴포넌트의 레이어를 어떻게 나눌지에 대해 고민해보는 것에 초점을 맞추었다.
컴포넌트에서는 크게 3가지 Layer로 나눌 수 있다.
Data
View
Interaction
Action
을 확인 및 처리하는 역할즐겨 찾기 버튼인 StarIcon
을 예시로 한번 살펴보자.
render
이 함수는 UI Layer
로 favorite이 true, false인지에 대한 속성을 받아 동적으로 UI를 그리는 역할을 수행하고 있다.
setEvent
removeEvent
이 함수들은 Interaction Layer
로 각각 이 컴포넌트가 DOM에 올라가면(connectedCallback) 이벤트 핸들러가 등록되며, DOM에서 제거(disconnectedCallback)되면 이벤트 핸들러가 제거 된다.
또한 함수 내부에는 비즈니스 로직이 포함된 것이 아닌 Domain Layer(Restaurant)
에게 요청을 보내어 처리하도록 설정한 것을 살펴볼 수 있다.
이렇게 컴포넌트의 각 함수 마다 필요한 관심사를 잘 분리하면 코드를 읽기 수월해지며, side-effect 발생 위험이 줄어들어 변경에 대처하기도 쉬워진다.
한 가지 역할
이라는 단어가 굉장히 모호할 수 있다.
그래서 보통 애플리케이션을 설계할 때 만들어진 디자인을 가지고 컴포넌트의 동작에 따라 분리하는 편이다.
예를 들면 이런 식이다.
GnB(GlobalNavigationBar)
CategorySelect
SortSelect
MenuList
MenuList
이렇게 각 컴포넌트의 역할에 맞게 분리하면 어떤 동작
을 일으키는지, 어떤 데이터
를 필요로 하는지 명확해지기 때문에 가독성을 개선하여 유지보수에 원활한 컴포넌트가 될 수 있다.
또한, 하나의 역할을 하는 컴포넌트끼리 또 묶어 공통 컴포넌트로 분리하는 것도 가능해진다.
이 주제는 도메인을 포함하는 컴포넌트, 그렇지 않은 컴포넌트를 분리하는 것을 의미한다.
즉, 재 사용 가능한 컴포넌트를 분리한 후 이 UI를 통해 여러 개의 도메인을 포함하는 컴포넌트로 만드는 것이 핵심이다.
select 컴포넌트를 예시로 살펴보자.
CategoryDropdown.ts
class CategoryDropdown extends BaseComponent {
private eventListeners: CustomEventListenerDictionary = {
categoryFilter: {
eventName: "change",
eventHandler: this.handleChangeCategoryFilter.bind(this),
},
};
protected render(): void {
this.innerHTML = `
<select name="category" id="category-filter" class="restaurant-filter">
${createOptionElements(Object.values(MENU_CATEGORIES))}
</select>
`;
}
protected setEvent(): void {
this.on({
...this.eventListeners.categoryFilter,
target: $(ELEMENT_SELECTOR.categoryFilter),
});
}
private handleChangeCategoryFilter(event: Event) {
const targetElement = event?.target;
if (targetElement instanceof HTMLSelectElement) {
const category = targetElement.value;
this.emit(CUSTOM_EVENT_TYPE.filterCategory, category);
}
}
protected removeEvent(): void {
this.off({
...this.eventListeners.categoryFilter,
target: $(ELEMENT_SELECTOR.categoryFilter),
});
}
}
CategoryDropdown
을 자세히 살펴보자.
render
setEvent
removeEvent
SortDropdown.ts
class SortDropdown extends BaseComponent {
private eventListeners: CustomEventListenerDictionary = {
sortingFilterChange: {
eventName: "change",
eventHandler: this.handleChangeSort.bind(this),
},
};
protected render(): void {
this.innerHTML = `
<select name="sorting" id="sorting-filter" class="restaurant-filter">
${createOptionElements(Object.values(SORT_CATEGORIES_TYPE))}
</select>
`;
}
protected setEvent(): void {
this.on({
...this.eventListeners.sortingFilterChange,
target: $(ELEMENT_SELECTOR.sortingFilter),
});
}
private handleChangeSort(event: Event): void {
const targetElement = event?.target;
if (targetElement instanceof HTMLSelectElement) {
const selectedOption = targetElement.value;
this.emit(CUSTOM_EVENT_TYPE.sortChange, selectedOption);
}
}
protected removeEvent(): void {
this.on({
...this.eventListeners.sortingFilterChange,
target: $(ELEMENT_SELECTOR.sortingFilter),
});
}
}
render
setEvent
removeEvent
각 컴포넌트의 render
, setEvent
, removeEvent
를 살펴볼 때 공통점이 있음을 눈치 챌 수 있다.
name, id, class 속성 내 값과 eventTarget, eventType만 외부에서 받아서 공통화 시킬 수 없을까?
즉, 다음과 같은 생각을 해볼 수 있다.
한번 공통화를 시켜보자.
CommonDropdown.ts
class CommonDropdown extends BaseComponent {
private eventListeners = {
dropDown: {
eventName: "change",
eventHandler: this.handleChange.bind(this),
},
} as const;
protected render(): void {
const id = this.getAttribute("id");
const classList = this.getAttribute("classList");
const name = this.getAttribute("name");
const options = this.getAttribute("options")?.split(",");
const title = this.getAttribute("title");
const addOptionText = this.getAttribute("addOptionText");
this.innerHTML = /* html */ `
<select name="${name ?? ""}" id="${id}" class="${classList}">
${title ? `<option value="">${title}</option>` : ""}
${createOptionElements(
options ?? [],
addOptionText
? (value) => `${value}${addOptionText}`
: (value) => value
)}
</select>
`;
}
protected setEvent(): void {
const target = this.getAttribute("target");
if (!target) return;
this.on({
...this.eventListeners.dropDown,
target: $(target ?? "") ?? document,
});
}
private handleChange(event: Event) {
const eventType = this.getAttribute("eventType");
if (!eventType || !isCustomEventType(eventType)) return;
const targetElement = event?.target;
if (!(targetElement instanceof HTMLSelectElement)) return;
const value = targetElement.value;
this.emit(CUSTOM_EVENT_TYPE[eventType], value);
}
protected removeEvent(): void {
const target = this.getAttribute("target");
if (!target) return;
this.off({
...this.eventListeners.dropDown,
target: $(target ?? "") ?? document,
});
}
}
render 함수에선 id
, name
, class
를 setEvent에선 target(event target)
을, 핸들러 함수에선 eventType
를 받아 두 컴포넌트를 하나로 공통화 시킨 것을 알 수 있다.
class RestaurantDropdown extends BaseComponent {
protected render(): void {
const menuCategoryOptions = Object.values(MENU_CATEGORIES);
const sortCategoryOptions = Object.values(SORT_CATEGORIES_TYPE);
const tabStatus = this.getAttribute("status");
this.innerHTML = /* html */ `
<div
id="restaurant-dropdown-container"
class="${
tabStatus === RESTAURANT_TAB_STATUS_TABLE.favorite ? "close" : "open"
}"
>
<common-dropdown
id="category-filter"
classList="restaurant-filter"
eventType="${CUSTOM_EVENT_TYPE.filterCategory}"
target="${ELEMENT_SELECTOR.categoryFilter}"
options="${menuCategoryOptions}"
>
</common-dropdown>
<common-dropdown
id="sorting-filter"
classList="restaurant-filter"
eventType="${CUSTOM_EVENT_TYPE.sortChange}"
target="${ELEMENT_SELECTOR.sortingFilter}"
options="${sortCategoryOptions}"
>
</common-dropdown>
</div>`;
}
}
위와 같이 공통화 시킨 컴포넌트 통해 각각의 다른 동작을 가진 컴포넌트로 재 사용할 수 있는 것을 확인해 볼 수 있다.
이렇게 하나의 역할을 가진 컴포넌트를 통해 변경에는 닫혀 있으며, 또 다른 컴포넌트로 확장하는 것이 자유로워진다. (OCP)
바닐라 자바스크립트로 컴포넌트를 설계하며, 리액트의 컨셉들이 얼마나 유용함을 주는지 몸으로 느낄 수 있었다.
특히 리액트의 이벤트 핸들러(onClick, onChange)같은 것들을 통해 선언적으로 이벤트 핸들링이 가능하다는 점, JSX 문법을 통해 UI를 쉽게 렌더링할 수 있다는 점이었다.
왜 리액트가 선언적인 UI
를 메인 컨셉으로 내세우는지는 직접 render, setEvent, removeEvent를 설계하는 과정에서 라이브러리가 제공해주는 그 편안함
이 그리워졌다.
하지만 직접 컴포넌트 들을 만들어보며 재사용성
과 적절한 역할 할당
이 얼마나 중요하며 유용함을 주는지 느낄 수 있었던 거 같다.