[우아한테크코스 #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
를 확장한다.
그래서 추상화해보니깐 어땠어?
장점
-
확장이 쉬울 것 같았어요.
만약 새로운 Video List UI가 생긴다면 `VideoListComponent`를 확장 후 추가적인 로직만을 추가해주면 되니깐요
-
다른 방식에서 사용되는 로직(메소드,프로퍼티)를 가지고 있을 필요가 없어요.
각자 클래스에서만 가지고 있으면 되니깐요
-
하나의 클래스 파일이 커지는 것을 방지할 수 있어요
하나의 클래스 파일이 무거워지는 것은 가독성을 떨어뜨리는 일인것 같아요. 또 유지보수에도 많은 `side effect`가 따라오지 않을까 싶네요.
-
각자의 로직만을 수정해야한다면 더 편하겠죠?
공통 로직은 추상화된 클래스에 있을테니 이 부분을 수정하면되고, 확장된 UI에서 변경사항이 발생해야한다면 그 부분만 수정하면 되니 유지보수에는 유리할 것 같아요 !
단점
-
구조가 너무 복잡해
내가 만약 이 코드를 처음보는 입장이라면 이해하기 힘들었을지도 모르겠다. (이를 쉽게 납득하기 위한 구조도가 있지 않은 이상... 또 확장되는 UI가 더 많다면, 이 모든 UI들의 공통 로직을 뽑아내기에 이해하기 더 힘들었겠지..)
어떤 리뷰어의 리뷰(정답 아님) : 모든 로직을 전부 확장성 있게 짜면 오래걸리고 구조가 복잡해집니다. 딱히 바뀔 일이 없는 로직을 억지로 확장성 있게 짤 필요는 없습니다. 가능하다면 단순한 게 베스트죠. 추상화가 들어가는 순간 복잡도가 올라갑니다. 항상 트레이드 오프를 고민해야합니다.
-
섣부르게 한 추상화라 그런지 정리가 안된 느낌이 너무 쌔
처음부터 추상화를 해두고 개발한 것이 아닌, 하나의 파일을 공통로직과 각자의 로직을 분리하여 공통 로직만을 추상화한 경우여서 추상화의 기준이 코드에서 안보이는 느낌?
그렇다면 언제 추상화를 고려할 것 같아?
추상화가 무조건 정답이 아닌 것은 이번 미션을 통해 잘 깨달은 것 같아요. 하지만 장점도 가지고 있으니 언제쯤 사용하게 될지 정리해볼게요
-
확장이 자주될 UI이다
확장이 되지 않을 UI라면 추상화를 고려하지는 않을 것 같아요. 하지만 UI의 재사용이 잦고, 재사용에 로직 확장이 자주 발생한다면 개발에 빠른 시간을 가져가고, 각자의 로직을 분리하기 위해 추상화를 고려할 것 같아요.
-
같은
속성, 역할
을 가졌다라면 추상화 해볼지도?? 개념적으로 대체될 수 없다면 추상화를 위한 추상화이다!!두 객체가 동일한 "역할"을 수행하는지 충분히 고민해보고 추상화한다.
동일한 역할은 대체 가능을 의미합니다. 개념적으로 대체될 수 없는 두 객체를 추상화 하는 건 추상화를 위한 추상화가 될 수 있습니다.
추가적인 추상화의 단점
부모 mount
함수를 실행시키고 추가된 돔 엘리먼트 뒤에 각자의 UI template
을 넣어주는 자식 mount
함수가 있는 것인데, 추상화의 기준이 명확히 없이 섣부르게 하다보니 구조가 복잡해져 코드를 읽는 사람이 이해하지 못하는 이슈가 발생하였다.
Author And Source
이 문제에 관하여([우아한테크코스 #6] 추상화가 좋을지 판단해보자), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@rat8397/우아한테크코스-6-추상화가-좋을지-판단해보자저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)