[우아한테크코스 #6] 추상화가 좋을지 판단해보자

추상화를 적용하게 된 이유

VideoList 종류가 여러 개이다!

  • 저장된 비디오들을 보여주는 Saved Video List 영역
  • 검색된 비디오들을 보여주는 Search Video List 영역

처음에 이 두 개의 UI 영역을 하나의 클래스로 개발하였습니다. 추상화 하기 전 방식

하지만 다음과 같은 문제가 생길 수도 있겠다 싶었어요!!

  • 새로운 비디오 리스트 UI 영역이 생긴다면 확장이 힘들것 같다

  • 다른 방식만 사용하는 메소드가 불필요함에도 가지고 있어야한다

그래서 다음과 같이 VideoListComponent 클래스를 추상화하여 비디오 리스트 속성 별로 이를 확장하는 코드를 짜보았습니다.

추상화된 Video List UI

코드를 자세히 볼 필요는 없습니다!!! 코드가 끝나는 지점에 어떤 코드인지 간략히 설명을 해두었고,

VideoListComponent : 이 클래스는 여러 비디오 리스트들이 공유하는 로직을 가지고 있습니다.

import SkeletonListComponent from './SkeletonListComponent';

class VideoListComponent {
  $videoList;

  parentElement = null;

  componentType = null;

  videoComponents = [];

  lazyLoadThrottleTimeout = null;

  skeletonListComponent = null;

  constructor(parentElement, type) {
    this.parentElement = parentElement;
    this.componentType = type;

    this.#mount();
    this.#initDOM();
    this.#bindEventHandler();
  }

  renderSkeletonVideoList(isWaitingResponse) {
    if (isWaitingResponse) {
      this.skeletonListComponent = new SkeletonListComponent(this.$videoList);
      return;
    }
    this.skeletonListComponent?.unmount();
  }

  unmount() {
    // 화면에서 사라질 때의 메소드
    this.$videoList.innerHTML = '';
  }

  #mount() {
    // 화면에서 처음 나타날 때의 메소드
    const template = this.#generateTemplate();
    this.parentElement.insertAdjacentHTML('beforeend', template);
  }

  #initDOM() {
    // UI 내부에서 사용되는 DOM Element를 멤버화하는 메소드
    this.$videoList = this.parentElement.querySelector('.video-list');
  }

  #generateTemplate() {
    // 제일 처음 렌더링 되는 템플릿을 만들어내는 메소드
    return `<ul class="video-list"></ul>`;
  }

  #bindEventHandler() {
    // 사용되는 이벤트 핸들러를 바인딩하는 메소드
    this.$videoList.addEventListener('scroll', this.#onScrollInVideoContainer);
  }

  #onScrollInVideoContainer = () => {
    // 이미지의 레이지 로드를 수행하는 스크롤 이벤트 핸들러 메소드
    if (this.lazyLoadThrottleTimeout) {
      clearTimeout(this.lazyLoadThrottleTimeout);
    }
    this.lazyLoadThrottleTimeout = setTimeout(() => {
      this.videoComponents.forEach((videoComponent) => {
        const { bottom } = this.$videoList.getBoundingClientRect();
        videoComponent.loadImg(bottom);
      });
    }, 100);
  };
}
export default VideoListComponent;

SearchVideoList Component, SavedVideoList : VideoListComponent에서 확장된 컴포넌트 클래스이다. 크게 마운트 이후 재 렌더링 로직, 각자의 영역에서만 사용되는 UI 로직을 서로 다르게 가지고 있습니다. (서로 다른 렌더링 로직을 가지고 있다. 그렇기 때문에 하나의 클래스로 관리하게 되면 if로 분기를 나누던 메소드를 분리하여 밖에서 구분하여 호출하던 불필요한 지점들이 생겼음)

// SearchVideoListComponent.js 
import VideoListComponent from '.';
import { VIDEO_COMPONENT_TYPE } from '../../constants/components';
import { CUSTOM_EVENT_KEY } from '../../constants/events';
import { dispatch } from '../../modules/eventFactory';
import { isFirstSearchByKeyword } from '../../utils/validation';
import SearchVideoComponent from '../VideoComponent/SearchVideoComponent';

class SearchVideoListComponent extends VideoListComponent {
  #searchVideoObserver = null;

  constructor(parentElement, type = VIDEO_COMPONENT_TYPE.SEARCH) {
    super(parentElement, type);

    this.$videoList.addEventListener('click', this.#onClickVideoList);
    this.#searchVideoObserver = new IntersectionObserver(this.#observeEntries, {
      root: null,
      rootMargin: '0px',
      threshold: 0.3,
    });
  }

  render({ videoList, prevVideoListLength }) {
    if (isFirstSearchByKeyword(prevVideoListLength)) {
      this.videoComponents = [];
      this.$videoList.innerHTML = '';
    }

    this.$videoList.classList.remove('hide');
    this.videoComponents = this.#generateVideoComponents(videoList, prevVideoListLength);
  }

  #observeEntries(entries, observer) {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        observer.unobserve(entry.target);
        dispatch(CUSTOM_EVENT_KEY.LOAD_NEW_VIDEO_LIST);
      }
    });
  }

  #onClickVideoList = (e) => {
    const {
      target: { className },
    } = e;
    const { dataset } = e.target.closest('.video-item');

    if (className.includes('video-item__save-button')) {
      dispatch(CUSTOM_EVENT_KEY.CLICK_SAVE_BUTTON, {
        detail: {
          saveVideoId: dataset.videoId,
        },
      });
    }
  };

  #generateVideoComponents(videoList, prevVideoListLength) {
    return [
      ...this.videoComponents,
      ...videoList.slice(prevVideoListLength).map(
        (video, idx, arr) =>
          new SearchVideoComponent(this.$videoList, {
            video,
            observer: idx === arr.length - 1 ? this.#searchVideoObserver : null,
            notLazyLoad: prevVideoListLength === 0,
          })
      ),
    ];
  }
}
export default SearchVideoListComponent;
// SavedVideoListComponent.js
import VideoListComponent from '.';
import { VIDEO_COMPONENT_TYPE } from '../../constants/components';
import { CUSTOM_EVENT_KEY } from '../../constants/events';
import { dispatch } from '../../modules/eventFactory';
import SavedVideoComponent from '../VideoComponent/SavedVideoComponent';

class SavedVideoListComponent extends VideoListComponent {
  constructor(parentElement, type) {
    super(parentElement, type);
    this.$videoList.addEventListener('click', this.#onClickVideoList);
  }

  render({ videoList: savedVideoList }, watchedVideoIdList) {
    this.$videoList.innerHTML = '';

    const { watchVideoList, watchedVideoList } = this.#parseWatchAndWahtchedVideo(
      savedVideoList,
      watchedVideoIdList
    );
    this.videoComponents = this.#generateVideoComponents(
      this.componentType === VIDEO_COMPONENT_TYPE.WATCH ? watchVideoList : watchedVideoList
    );
  }

  #onClickVideoList = (e) => {
    const {
      target: { className },
    } = e;
    const { dataset } = e.target.closest('.video-item');

    if (className.includes('video-item__delete-button')) {
      dispatch(CUSTOM_EVENT_KEY.CLICK_SAVED_DELETE_BUTTON, {
        detail: {
          savedVideoId: dataset.videoId,
        },
      });
    }
    if (className.includes('video-item__check-button')) {
      dispatch(CUSTOM_EVENT_KEY.CLICK_SAVED_CHECK_BUTTON, {
        detail: {
          savedVideoId: dataset.videoId,
          element: e.target,
        },
      });
    }
  };

  #generateVideoComponents(savedVideoList) {
    return savedVideoList.map(
      (savedVideo, idx) =>
        new SavedVideoComponent(this.$videoList, {
          video: savedVideo,
          notLazyLoad: idx < 10,
          type: this.componentType,
        })
    );
  }

  #parseWatchAndWahtchedVideo(savedVideoList, watchedVideoIdList) {
    return savedVideoList.reduce(
      (prev, savedVideo) => {
        const { videoId } = savedVideo.getVideoInfo();
        const isWatched = watchedVideoIdList.includes(videoId);

        return {
          ...prev,
          watchVideoList: isWatched ? prev.watchVideoList : [...prev.watchVideoList, savedVideo],
          watchedVideoList: isWatched
            ? [...prev.watchedVideoList, savedVideo]
            : prev.watchedVideoList,
        };
      },
      {
        watchVideoList: [],
        watchedVideoList: [],
      }
    );
  }
}
export default SavedVideoListComponent;

정리

VideoListComponent : 여러 비디오 리스트들이 공유하는 로직을 갖는다

~~~VideoListComponent : 각 비디오 리스트들 -> 각자만의 부가되는 로직을 갖고, VideoListComponent를 확장한다.

그래서 추상화해보니깐 어땠어?

장점

  1. 확장이 쉬울 것 같았어요.

    만약 새로운 Video List UI가 생긴다면 `VideoListComponent`를 확장 후 추가적인 로직만을 추가해주면 되니깐요
  2. 다른 방식에서 사용되는 로직(메소드,프로퍼티)를 가지고 있을 필요가 없어요.

    각자 클래스에서만 가지고 있으면 되니깐요
  3. 하나의 클래스 파일이 커지는 것을 방지할 수 있어요

    하나의 클래스 파일이 무거워지는 것은 가독성을 떨어뜨리는 일인것 같아요. 또 유지보수에도 많은 `side effect`가 따라오지 않을까 싶네요.
  4. 각자의 로직만을 수정해야한다면 더 편하겠죠?

    공통 로직은 추상화된 클래스에 있을테니 이 부분을 수정하면되고, 확장된 UI에서 변경사항이 발생해야한다면 그 부분만 수정하면 되니 유지보수에는 유리할 것 같아요 ! 

단점

  1. 구조가 너무 복잡해

    내가 만약 이 코드를 처음보는 입장이라면 이해하기 힘들었을지도 모르겠다. 
    
    (이를 쉽게 납득하기 위한 구조도가 있지 않은 이상...
    
    또 확장되는 UI가 더 많다면, 이 모든 UI들의 공통 로직을 뽑아내기에 이해하기 더 힘들었겠지..)

어떤 리뷰어의 리뷰(정답 아님) : 모든 로직을 전부 확장성 있게 짜면 오래걸리고 구조가 복잡해집니다. 딱히 바뀔 일이 없는 로직을 억지로 확장성 있게 짤 필요는 없습니다. 가능하다면 단순한 게 베스트죠. 추상화가 들어가는 순간 복잡도가 올라갑니다. 항상 트레이드 오프를 고민해야합니다.

  1. 섣부르게 한 추상화라 그런지 정리가 안된 느낌이 너무 쌔

    처음부터 추상화를 해두고 개발한 것이 아닌, 하나의 파일을 공통로직과 각자의 로직을 분리하여
    
    공통 로직만을 추상화한 경우여서 추상화의 기준이 코드에서 안보이는 느낌?

그렇다면 언제 추상화를 고려할 것 같아?

추상화가 무조건 정답이 아닌 것은 이번 미션을 통해 잘 깨달은 것 같아요. 하지만 장점도 가지고 있으니 언제쯤 사용하게 될지 정리해볼게요

  • 확장이 자주될 UI이다

    확장이 되지 않을 UI라면 추상화를 고려하지는 않을 것 같아요. 하지만 UI의 재사용이 잦고, 재사용에 로직 확장이 자주 발생한다면 개발에 빠른 시간을 가져가고, 각자의 로직을 분리하기 위해 추상화를 고려할 것 같아요.

  • 같은 속성, 역할을 가졌다라면 추상화 해볼지도?? 개념적으로 대체될 수 없다면 추상화를 위한 추상화이다!!

    두 객체가 동일한 "역할"을 수행하는지 충분히 고민해보고 추상화한다.
    동일한 역할은 대체 가능을 의미합니다. 개념적으로 대체될 수 없는 두 객체를 추상화 하는 건 추상화를 위한 추상화가 될 수 있습니다.

추가적인 추상화의 단점

부모 mount 함수를 실행시키고 추가된 돔 엘리먼트 뒤에 각자의 UI template을 넣어주는 자식 mount 함수가 있는 것인데, 추상화의 기준이 명확히 없이 섣부르게 하다보니 구조가 복잡해져 코드를 읽는 사람이 이해하지 못하는 이슈가 발생하였다.

좋은 웹페이지 즐겨찾기