개요
바닐라 자바스크립트로 상태를 관리하여 UI 를 보여줘야 한다면 어떻게 하는 것이 좋을까? 그 해답에 대해 이번에 블랙커피 JS 코드리뷰 스터디를 진행하며 생각해보았다.
블랙커피 JS 코드리뷰 스터디 는 NEXTSTEP 에서 제공하는 교육이지만 UDEMY 에도 강의 영상이 있다.
이 스터디에서 만드는 것은 '문벅스 메뉴 관리' 라는 것인데, 이는 여러 프론트엔드 프레임워크를 처음 연습할 때 만드는 투두 리스트와 거의 동일하다. 다만 차이점은 카테고리별로 투두가 별도로 관리되며, 진화하는 투두리스트라는 것이다.
관심사 나눠보기
이 과제는 크게 보면 자바스크립트에서 상태를 관리하고 이를 DOM 으로 렌더링하는 과제이다. 이를 개발하며 나는 아래와 같은 관심사를 발견했고, 이에 따라 파일을 나눠 보았다.
필연적으로 사용하게 되는 상수 값에 대한 관심사
상수 값은 제대로 관리되지 않을 경우에 하드코딩되기 쉽다. 명확한 이름을 지어 한 곳에서 관리하여 SSOT(Single Source Of Truth) 를 지킨다면, 추후 변경이 있을 때 빠르게 대응할 수 있다.
- DOM 을 선택하기 위한 ID
- 다양한 최신 프론트엔드 프레임워크에서는 jsx 와 같은 방식으로 DOM 과 자바스크립트를 결합시키는 방식으로 진행하여 각각의 컴포넌트가 1개의 UI 요소를 가리킨다. 그리고 1개의 jsx 파일이 컴포넌트가 된다. 그러나 바닐라 자바스크립트에서는 그런 방식으로 진행하는게 오히려 소스코드가 복잡해보일 수 있기 때문에 크게 효율 좋은 일은 아닌 것 같다.
- 클래스나 태그명 상대경로 등 다양한 방법으로 돔 선택이 가능하지만 일반적으로 아이디로 선택하는 것이 가장 빠르기 때문에 아이디를 보관하는 것이 가장 효율적이라고 생각했다.
- 로컬 스토리지의 키
- 로컬스토리지를 사용한다는 가정 하에 데이터를 불러올 때 사용되는 키를 저장할 수 있다.
- API 의 주소
- API 에서 데이터를 불러온다는 가정하에 API 엔드포인트 경로를 저장할 수 있다.
export const elementIdMap = {
espressoMenuForm: "espresso-menu-form",
espressoMenuList: "espresso-menu-list",
espressoMenuNameInput: "espresso-menu-name",
removeButton: "removeButton",
updateButton: "updateButton",
soldOutButton: "soldOutButton",
menuName: "menuName",
menuWrapper: "menuWrapper",
menuCategoryButtonWrapper: "menuCategoryButtonWrapper",
menuTitleName: "menuTitleName",
};
export const getLocalStorageKey = (categoryName) =>
`moonbucksState.${categoryName}`;
DOM 을 컨트롤하는데 필요한 유틸 메서드에 대한 관심사
document.getElementById('...')
은 나한텐 너무 타이핑하기 귀찮으며 과도한 인지능력을 소비한다고 생각했다.$(id)
혹은getById()
와 같은 방식으로 코드를 간결히 하는게 더 이득이라고 생각했다.appendHtml()
메서드는 '엘리먼트 추가 + 이벤트 바인딩'이라는 계속 반복되는 코딩 패턴을 발견하고 일반화했다.
export function getById(id) {
return document.getElementById(id);
}
export function appendHtml(parentId, htmlTemplate, event) {
const $ = getById(`${parentId}`);
$.insertAdjacentHTML("beforeend", htmlTemplate);
if (event) {
const { eventName, callback } = event;
$.lastChild[eventName] = callback;
}
}
상태 관리에 대한 관심사
상태를 관리하는데는 리액트의 Hooks 를 따라해봤다. renderingFunction
을 따로 인자로 받아 매 상태 업데이트가 발생할 때마다 (setState()
가 호출될 때마다) 렌더링을 다시 해주도록 했다. 실제로 리액트에서는 diffing update 를 사용하기에 나의 구현과는 많이 다르다.
이렇게 코드를 작성해보며 자바스크립트 클로저에 대한 이해가 조금 더 오른 것 같다.
let currentIndex = 0;
const hookStates = [];
export function useState(initialState, rendering) {
const index = currentIndex;
if (hookStates.length === index) {
hookStates.push(initialState);
}
const setState = (newState) => {
hookStates[index] = newState;
rendering(hookStates[index]);
};
const getState = () => {
return hookStates[index];
};
currentIndex++;
return [getState, setState];
}
템플릿에 대한 관심사
이 방법이 나름대로 HTML 소스코드를 컴포넌트화 시키는 가장 효율적인 방법이라고 생각하여 템플릿에 대한 관심사를 따로 나누고 함수 단위로 추출해놓았다. 아마 더 좋은 방법이 있을 것 같은데... 일단은 이렇게 했다.
import { elementIdMap } from "./constant_utils.js";
export function $removeButton(seq) {
const { removeButton } = elementIdMap;
return `<button type="button" id="${removeButton}${seq}" class="bg-gray-50 text-gray-500 text-sm menu-remove-button">삭제</button>`;
}
export function $updateButton(seq) {
const { updateButton } = elementIdMap;
return `<button type="button" id="${updateButton}${seq}" class="bg-gray-50 text-gray-500 text-sm mr-1 menu-edit-button">수정</button>`;
}
export function $soldOutButton(seq) {
const { soldOutButton } = elementIdMap;
return `<button id="${soldOutButton}${seq}" type="button" class="bg-gray-50 text-gray-500 text-sm mr-1 menu-sold-out-button">
품절
</button>`;
}
export function $menuName(seq, name, isSoldOut) {
const { menuName } = elementIdMap;
return `<span id="${menuName}${seq}" class="w-100 pl-2 menu-name ${
isSoldOut ? "sold-out" : ""
}">${name}</span>`;
}
export function $menuWrapper(menuWrapperId) {
return `<li data-menu-id="${menuWrapperId}" id="${menuWrapperId}" class="menu-list-item d-flex items-center py-2"></li>`;
}
메뉴(도메인)에 대한 관심사
이 부분이 문벅스 메뉴 관리의 핵심 로직이다. 위의 관심사에 따른 컴포넌트를 조합하여 메뉴 상태관리 로직을 구성했다. 리액트에서 커스텀 훅스를 구성하듯이 위에서 직접 구성한 useState()
를 이용하는 방식으로 작성해보았다. 각 CRUD 메서드에서는 불변성을 지키는 코드를 작성하려 노력했으며, useMenu()
를 이용하는 클라이언트는 외부로 노출된 카테고리 설정과 CRUD 메서드들을 이용해 편리하게 메뉴를 관리할 수 있다.
작성하고 보니 의도치 않게 필드와 멤버 메서드가 있는 클래스와 같은 형태가 되었다.
import { elementIdMap, getLocalStorageKey } from "./utils/constant_utils.js";
import { useState } from "./utils/state_utils.js";
import { getById } from "./utils/control_dom_utils.js";
export default function useMenu(renderingFunction) {
/**
* Fields
*/
const initCategoryName = "espresso";
let getMenuState;
let setMenuState;
let proxySetMenuState;
setCategoryName(initCategoryName);
/**
* Member methods
*/
function setCategoryName(categoryName) {
const localStorageKey = getLocalStorageKey(categoryName);
const localStorageValue = JSON.parse(localStorage.getItem(localStorageKey));
[getMenuState, setMenuState] = useState({}, renderingFunction);
proxySetMenuState = (menuState) => {
setMenuState(menuState);
localStorage.setItem(localStorageKey, JSON.stringify(getMenuState()));
};
if (localStorageValue) {
proxySetMenuState(localStorageValue);
}
const { espressoMenuList } = elementIdMap;
getById(espressoMenuList).innerHTML = "";
renderingFunction(getMenuState());
}
function addMenu(name) {
if (name) {
proxySetMenuState({
...getMenuState(),
[getMenuNextSeq()]: {
name,
isSoldOut: false,
},
});
}
}
function removeMenu(seq) {
if (confirm("정말로 삭제하시겠습니까?")) {
const { [seq]: removeMenu, ...rest } = getMenuState();
proxySetMenuState({
...rest,
});
}
}
function soldOutMenu(seq) {
const { [seq]: soldOutMenu, ...rest } = getMenuState();
soldOutMenu.isSoldOut = !soldOutMenu.isSoldOut;
proxySetMenuState({
[seq]: soldOutMenu,
...rest,
});
}
function updateMenu(seq) {
const newName = prompt("수정하고 싶은 이름을 입력해주세요.");
if (newName) {
const { [seq]: updateMenu, ...rest } = getMenuState();
updateMenu.name = newName;
proxySetMenuState({
[seq]: updateMenu,
...rest,
});
}
}
function getMenuNextSeq() {
if (Object.keys(getMenuState()).length > 0) {
return Math.max(...Object.keys(getMenuState())) + 1;
}
return 1;
}
return [setCategoryName, addMenu, removeMenu, updateMenu, soldOutMenu];
}
메인 페이지의 관심사
메인에게는 돔 로드가 완료되면, 초기 이벤트를 바인딩하고 상태에 따라 컴포넌트를 렌더링하는 역할을 주었다.
import useMenu from "./useMenu.js";
import { elementIdMap } from "./utils/constant_utils.js";
import { getById, appendHtml } from "./utils/control_dom_utils.js";
import {
$menuName,
$menuWrapper,
$removeButton,
$soldOutButton,
$updateButton,
} from "./utils/template_utils.js";
const [setCategoryName, addMenu, removeMenu, updateMenu, soldOutMenu] =
useMenu(renderMenu);
const onSubmit = (e) => {
e.preventDefault();
const { espressoMenuNameInput } = elementIdMap;
const name = getById(espressoMenuNameInput).value;
if (!name) {
return;
}
addMenu(name);
const submitForm = e.target;
submitForm.reset();
};
function bindOnSubmitMenu() {
const { espressoMenuForm: espressoMenuFormId } = elementIdMap;
getById(espressoMenuFormId).onsubmit = onSubmit;
}
function bindOnClickMenuCategory() {
const { menuCategoryButtonWrapper } = elementIdMap;
const buttons = getById(menuCategoryButtonWrapper).children;
for (const $button of buttons) {
const categoryName = $button.getAttribute("data-category-name");
$button.onclick = (e) => {
const { menuTitleName, espressoMenuNameInput } = elementIdMap;
getById(menuTitleName).textContent = e.target.textContent;
getById(
espressoMenuNameInput
).placeholder = `${e.target.textContent.trim()} 메뉴 이름`;
setCategoryName(categoryName);
};
}
}
document.addEventListener(
"DOMContentLoaded",
() => {
bindOnSubmitMenu();
bindOnClickMenuCategory();
},
false
);
/**
* Rendering related functions.
*/
function getMenuWrapperId(seq) {
const { menuWrapper } = elementIdMap;
return `${menuWrapper}-${seq}`;
}
function renderMenu(menuState) {
renderAppendMenu(menuState);
renderSoldOutMenu(menuState);
renderUpdateMenu(menuState);
renderRemoveMenu(menuState);
renderCount(Object.keys(menuState).length);
}
function renderAppendMenu(menuState) {
for (const [seq, menu] of Object.entries(menuState)) {
const menuWrapperId = getMenuWrapperId(seq);
if (getById(menuWrapperId)) {
continue;
}
const { name, isSoldOut } = menu;
appendMenu(
seq,
name,
menuWrapperId,
{
onClickRemove: () => removeMenu(seq),
onClickUpdate: () => updateMenu(seq),
onClickSoldOut: () => soldOutMenu(seq),
},
isSoldOut
);
}
}
function renderSoldOutMenu(menuState) {
const { menuName } = elementIdMap;
for (const [seq, menu] of Object.entries(menuState)) {
const $menuName = getById(`${menuName}${seq}`);
const { isSoldOut } = menu;
if (isSoldOut && !$menuName.classList.contains("sold-out")) {
$menuName.classList.add("sold-out");
}
if (!isSoldOut && $menuName.classList.contains("sold-out")) {
$menuName.classList.remove("sold-out");
}
}
}
function renderUpdateMenu(menuState) {
const { menuName } = elementIdMap;
for (const [seq, menu] of Object.entries(menuState)) {
const { name } = menu;
const $menuName = getById(`${menuName}${seq}`);
if ($menuName.textContent !== name) {
$menuName.textContent = name;
}
}
}
function renderRemoveMenu(menuState) {
const { espressoMenuList } = elementIdMap;
for (const menu of getById(espressoMenuList).children) {
const seq = menu.id.replace("menuWrapper-", "");
if (!menuState[seq]) {
menu.remove();
}
}
}
function renderCount(count) {
getById("count").textContent = count;
}
function appendMenu(
seq,
name,
menuWrapperId,
{ onClickSoldOut, onClickUpdate, onClickRemove },
isSoldOut
) {
const { espressoMenuList: espressoMenuListId } = elementIdMap;
appendHtml(espressoMenuListId, $menuWrapper(menuWrapperId));
appendHtml(menuWrapperId, $menuName(seq, name, isSoldOut));
appendHtml(menuWrapperId, $soldOutButton(seq), {
eventName: "onclick",
callback: onClickSoldOut,
});
appendHtml(menuWrapperId, $updateButton(seq), {
eventName: "onclick",
callback: onClickUpdate,
});
appendHtml(menuWrapperId, $removeButton(seq), {
eventName: "onclick",
callback: onClickRemove,
});
}
function removeMenuWrapper(seq) {
if (getById(getMenuWrapperId(seq))) {
getById(getMenuWrapperId(seq)).remove();
}
}
회고
- 동작하는 진흙뭉치 소스를 먼저 짠 다음에 나중에 관심사에 따라 분류하는 일은 꽤 재밌었다.
- 초기 설계는 어느정도 하지만, 너무 처음부터 거대한 설계를 하지 말고 간단하게 동작하는 앱을 만드는 것부터 출발하는 것이 역시 나에게 잘 맞는 것 같다.
- 변화에 유연한 코드를 만들어 지속적으로 변화시키자.
- 소스코드를 변경하는 과정에서 버그가 몇개 있었다.
- 소스코드 작성 과정에서 어떠한 테스트 코드를 작성할 기회가 있었는지 다시한번 짚어 보고, 적절한 테스트 코드를 작성해보고 싶은 욕심이 있다. 그렇다면 변경이 더욱 쉽지 않았을까? 문벅스 메뉴는 일회용 앱이었지만, 내가 회사에서 작성하는 코드는 보통 오랫동안 사용될 확률이 높기 때문에 프론트 테스트코드 작성에 대해 공부해봐야겠다.
- DOM 의 아이디나 HTML 문자열 요소들을 관리하는 더 좋은 방법이 있었을까? 한번 찾아봐야겠다.
- 내가 해결했던 문제들을 다른 사람들은 어떻게 해결했는지도 한번 살펴보도록하자.
- 메뉴 오브젝트의 아이디를 단순히 시퀀스로 구분한 게 후회가 된다. 앞에
카테고리명_시퀀스
였으면 더 나았을 것 같기도 하다.
'회고 > 주간 회고' 카테고리의 다른 글
2023년 2월 2주차 임대차 계약 관련 회고 (0) | 2023.02.12 |
---|---|
7월 넷째주 회고 거리 (0) | 2022.07.26 |
7월 3째주 주간 회고 (0) | 2022.07.18 |
7월 2주차 주간회고 (0) | 2022.07.11 |
6월 4주차 회고 (0) | 2022.06.30 |