[우아한테크코스] - 점심 뭐 먹지 회고

jiny·2024년 3월 18일
0
post-thumbnail

Intro

정말 정말 많이 고민했던 step 1이 끝나고 뒤늦게 회고하게 되었다.

이번 미션의 핵심 키워드는 컴포넌트타입스크립트 였다.

컴포넌트를 바닐라 JS 관점에서 어떻게 재사용이 가능하도록 설계가 가능한지, any 없이 어떻게 타입추론을 가능하게 할지가 이번 미션의 관건이었다.

타입스크립트는 기초적인 부분을 경험하는 것이 목적이었기 때문에 이번 회고에서 다루지 않을 예정이다.

즉, 핵심 주제인 컴포넌트에 대해 어떤 관점에서 컴포넌트를 바라보고 설계했는지 위주로 정리해 볼 예정이다.

컴포넌트

컴포넌트는 흔히 레고 블록에 비유하곤 한다.

애플리케이션을 개발하다보면, 흔히 이런 상황 들을 많이 마주한다고 한다.

xxx님, 이번 회의 전달 사항 입니다. 앱 내 ~한 부분에 대해 xx 기능을 추가(제거)해야 할 것 같습니다.

왜냐하면 사용자와 최전선에서 마주하며, 모든 사용자가 불편함을 겪지 않도록 개선하는 포지션이 바로 웹 프런트엔드 개발자이기 때문이다.

이걸 다시 말하면 고작 몇 명의 사용자가 불편함을 호소하면 그 기능을 수정해야 한다는 의미다.

즉, 변경은 딱 정의 되어 있지 않으며 언제 발생할지 알 수 없기 때문에 언제든지 대응할 준비가 되어 있어야 한다.

그렇기 때문에 아키텍처를 설계할 때 아래 사항을 최우선으로 개발한다고 한다.

  • 어떤 역할을 하는지 명확히 파악 가능하며,

  • 변경에 쉽게 대응할 수 있도록 하기

즉, 어떤 상황을 마주하게 되더라도 유연하게 대처할 수 있다면 그 제품은 끊임 없이 유저의 니즈를 충족시켜 지속적으로 성장하게 될 것이다.

그렇다면 어떻게 컴포넌트라는 개념을 통해 변경에 쉽게 대응할 수 있는걸까?

컴포넌트를 설계하는 과정

컴포넌트를 설계하는 과정은 마치 기능 구현 목록을 구성하는 것과 같은 흐름으로 전개되곤 한다.

  1. 최소한의 기능 단위로 분리한다.

  2. 이 기능 들을 결합하여 새로운 기능을 만든다.

  3. 기능이 잘 동작하는지 확인한다.

  4. 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을 받고 있는데 아래와 같은 문제점이 있는 것을 알 수 있다.

  1. 어떠한 기능을 수행하는지 명확히 파악하기 어렵다. (Item 이라는 네이밍과 prop으로 받는 요소 들을 보았을 때 어떤 기능을 수행할지 매칭이 안된다.)

  2. 어떤 역할을 하는지 알 수 없다보니 이 컴포넌트를 사용하는 모든 코드를 뒤져가며 일일이 확인해야 한다.

즉, 현재의 컴포넌트는 아래와 같은 특징을 가진다.

  • 어떤 역할을 하는지 명확히 파악 하기 어려우며,

  • 모든 코드를 뒤져야 하기 때문에 변경에 유연하게 대처 하기 힘들다.

그러면 변경에 유연하게 대처하기 위해 어떻게 컴포넌트를 효과적으로 설계해 볼 수 있을까?

  1. Headless 기반 추상화하기
    (컴포넌트의 레이어를 명확히 분리하기)

  2. 한 가지의 역할 수행하기
    (또는 한 가지 역할만 하는 컴포넌트의 조합으로 구성하기)

  3. 도메인 분리 하기
    (도메인을 포함하는 컴포넌트와 그렇지 않은 컴포넌트로 분리하기)

이제 효과적인 컴포넌트 설계의 필요성을 피부로 느껴보았으니 본격적으로 이번 미션에서 위 요소들과 함께 컴포넌트에 대해 고민했던 부분에 대해 설명해보려고 한다.

컴포넌트의 레이어를 명확히 분리하기

이 글에서 전반적인 레퍼런스를 참고했던 jBee(한재엽 님)의 지속 가능한 성장과 컴포넌트주제에서 언급했던 부분은Headless 기반 추상화하기 이지만 이번 미션에서 고민할 것 까진 아니라고 판단되었다.

따라서, 컴포넌트의 레이어를 어떻게 나눌지에 대해 고민해보는 것에 초점을 맞추었다.

컴포넌트에서는 크게 3가지 Layer로 나눌 수 있다.

Data

  • UI를 그리기 위해 필요한 요소
  • 주로 비즈니스 데이터를 의미한다.

View

  • UI를 그리는 역할

Interaction

  • 유저가 발생시키는 Action을 확인 및 처리하는 역할

즐겨 찾기 버튼인 StarIcon을 예시로 한번 살펴보자.

render

이 함수는 UI Layerfavorite이 true, false인지에 대한 속성을 받아 동적으로 UI를 그리는 역할을 수행하고 있다.

setEvent
removeEvent

이 함수들은 Interaction Layer로 각각 이 컴포넌트가 DOM에 올라가면(connectedCallback) 이벤트 핸들러가 등록되며, DOM에서 제거(disconnectedCallback)되면 이벤트 핸들러가 제거 된다.

또한 함수 내부에는 비즈니스 로직이 포함된 것이 아닌 Domain Layer(Restaurant)에게 요청을 보내어 처리하도록 설정한 것을 살펴볼 수 있다.

이렇게 컴포넌트의 각 함수 마다 필요한 관심사를 잘 분리하면 코드를 읽기 수월해지며, side-effect 발생 위험이 줄어들어 변경에 대처하기도 쉬워진다.

한 가지의 역할 수행하기

한 가지 역할이라는 단어가 굉장히 모호할 수 있다.

그래서 보통 애플리케이션을 설계할 때 만들어진 디자인을 가지고 컴포넌트의 동작에 따라 분리하는 편이다.

예를 들면 이런 식이다.

GnB(GlobalNavigationBar)

  • 햄버거 메뉴 버튼을 누르면 Modal을 open하는 역할

CategorySelect

  • 특정 카테고리를 select 했을 때 그 카테고리로 List가 필터링 되어 보여주도록 하는 역할

SortSelect

  • 특정 정렬순서로 select 했을 때 그 순서 대로 List가 정렬 되어 보여주도록 하는 역할

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

  • this.innerHTML에 select와 option을 commit 한다.
    • select는 name, id, class 속성을 필요로 한다.

setEvent

  • change 이벤트가 발생했을 때 handleChangeCategoryFilter 핸들러를 실행하기 위해 이벤트를 등록한다.
    • 이벤트 발생시킨 target element가 HTMLSelectElement라면 그 element의 value를 받아 filterCategory 이벤트를 dispatch 한다. (이 때 해당 value를 포함시킨다.)

removeEvent

  • DOM에서 제거될 때 setEvent에서 등록했던 이벤트를 제거한다.

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

  • this.innerHTML에 select와 option을 commit 한다.
    • select는 name, id, class 속성을 필요로 한다.

setEvent

  • change 이벤트가 발생했을 때 handleChangeSort 핸들러를 실행하기 위해 이벤트를 등록한다.
    • 이벤트 발생시킨 target element가 HTMLSelectElement라면 그 element의 value를 받아 sortChange 이벤트를 dispatch 한다. (이 때 해당 value를 포함시킨다.)

removeEvent

  • DOM에서 제거될 때 setEvent에서 등록했던 이벤트를 제거한다.

각 컴포넌트의 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를 설계하는 과정에서 라이브러리가 제공해주는 그 편안함이 그리워졌다.

하지만 직접 컴포넌트 들을 만들어보며 재사용성적절한 역할 할당이 얼마나 중요하며 유용함을 주는지 느낄 수 있었던 거 같다.

0개의 댓글