
(순수 자바스크립트로 저런 걸 만드는 과제였따.)
저 때는 쇼핑몰을 한 번만 할 줄 알았찌...
또핑몰이 될지는 몰랐던 ㅎ
1-1. 프레임워크 없이 SPA 만들기
처음에는 이 정도면 할만할 꺼 같은데..? 라는 생각으로 뛰어들었지만 그거슨 나의 메타인지 부족이었다.
시간은 부족한데 테스트 코드에 내 코드는 자꾸 Fail을 하고 있었다.
순수 자바스크립트는 너무나도 오랜만에 핸들링해봐서 이벤트 함수를 선언하는 방법조차 까먹었고 있었다.
어찌저찌 GPT도 이용해먹고~ 과제 통과를 조금 쉽게 낮춰줘서 얼레벌레 통과한 감이 없지 않아 있었다.
그래서 복습해야하는 과제 중에 Top3 안에 든다고 생각했다.
그렇게 시간은 약 한 달 정도 걸려서 과제를 완성했다. 그 때 과제를 막 받았을 때 어떻게 한 주 만에 했는지 스스로도 의아할 지경이긴 했다.
아마도 지금은,
1. 강제성이 없다.
2. 그 전보다 구조화된 코드를 짜기 위해 노력했다.
3. 과제할 당시에는 하지 않았던 리팩토링을 여러 번 하였다.
4. 그 때는 과제를 하기 위해 직장에 있을 때 빼고는 온종히 과제를 하기 위해 매달렸고, 지금은 이래저래 과제에 온전히 몰입하지 못했기 때문이 아닐까
로 예상해본다.
이전
과제에서는 const도 아니고 let을 해당 페이지 컴포넌트에서 선언하여 어디서든 변경이 가능한 전역 구조의 형태가 띄었다.
// src/pages/HomePage.js
// 아래와 같은 방식으로 선언하여 내부에서 사용하였다.
let state = { products: [], ... };
또한 상태 변경이 됐을 때, 일일히 innerHTML를 사용해서 수동으로 렌더링해주는 기함을 토했다..
(그 때는 이런 스파게티같은 코드를 신경쓸 틈이 없었다고...)
// 대놓고 state에 데이터를 저장시켰다. wow.. (불변성은 어디감? ㅋㅋ)
state.products = projectData.products;
// 심지어 아래의 코드를 반복해서 쓰는데 함수화시킬 생각조차도 생각하지 못했다는 게 아쉬운 대목...
document.body.querySelector("#root").innerHTML = Main(state)
현재
createStore로 상태관리를 캡슐화
// createStore에 초기상태를 받아서 선언해주면 되도록 바꿔주었다.
const productListStore = createStore(initialState);
// 그래서 실제 다른데서 갖다 쓸 때는 커스텀 훅처럼 선언하여 쓸 수 있다.
const { getState, getProductDetail, setState } = productItemDetail();
상태 변화를 구독하여 자동으로 렌더링
// 이전에 선언했던것처럼 하지 않아도 구독하고 있기 때문에 setState로 데이터를 변경하면 자동으로 알아서 렌더링된다.
productListStore.setState({ products: newProducts });
이전
컴포넌트화도 제대로 하지 않고, page 파일에 모든 로직을 박아넣었기 때문에 굉장히 폴더 구조가 간단하다.
src/
├── components/
│ ├── FirstCategory.js
│ ├── List.js
│ ├── LoadingProduct.js
│ ├── Product.js
│ └── detail.js
├── pages/
│ ├── HomePage.js
│ ├── Main.js
│ ├── NotFoundPage.js
│ ├── ProjectDetail.js
│ └── ProjectDetailPage.js
└── main.js
현재
src/
├── state/
│ ├── baseStore.ts Observer 패턴 구현
│ ├── cart.ts
│ ├── productList.ts
│ ├── productDetail.ts
│ └── toast.ts
├── pages/
│ ├── list/
│ │ ├── index.ts
│ │ ├── card.ts
│ │ ├── searchBar.ts
│ │ ├── loading.ts
│ │ └── constants.ts (list에서만 쓰는 상수)
│ │
│ ├── detail/
│ │ ├── index.ts
│ │ ├── detail.ts
│ │ ├── breadcrumb.ts
│ │ ├── products.ts
│ │ ├── card.ts
│ │ ├── review.ts
│ │ └── loading.ts
│ │
│ └── error/
├── components/
│ ├── layout.ts
│ ├── header.ts
│ ├── footer.ts
│ ├── skeleton.ts
│ ├── cart/
│ └── toast/
│
├── utils/
│ ├── eventDelegation.ts
│ ├── fetch.ts
│ └── clean.ts cleanup 함수
│
├── type/
├── router.ts 라우터 모듈
└── main.js
이전
main.js에 router 코드를 진짜 단순하게 만들어서 그냥 main.js에서 사용했다.router 함수 export를 사용해서 만들어 버렸다. export function router() {
const [, route, id] = location.pathname.replace("/front_6th_chapter1-1", "").split("/");
if (route === "product" && id) {
return ProjectDetailPage(id);
}
if (!route) {
return HomePage();
}
document.body.querySelector("#root").innerHTML = _404_();
}
현재
main.js에서는 state를 구독하기 위한 코드로 넣어졌다.
// main.js
// subscribeToStore를 통해 state를 구독하였다.
function main() {
const appRouter = router();
const root = document.getElementById("root");
if (root) {
root.innerHTML = appRouter.render();
}
// ✅ 구독 - 상태 변경시 자동 렌더링
appRouter.subscribeToStore(toastStore);
appRouter.subscribeToStore(cartStore);
appRouter.subscribeToStore(productStore);
appRouter.subscribeToStore(productListStore);
// popstate 이벤트 리스너 등록 (뒤로가기/앞으로가기, 테스트의 goTo 지원)
window.addEventListener("popstate", () => {
appRouter.render();
});
}
router 파일을 따로 구축하여 main.js의 코드를 개선했다.
또한 캡슐화하면서 render, push, subscribe를 같이 구현하였다.
// router.ts
export const router = () => {
const render = (path?: string) => {
const link = path ? path : location.pathname;
const originLink = checkParameter(link)[0];
const pathnames = link.split("/");
let html = "";
// (... 컴포넌트 결정하는 함수 생략)
// DOM 업데이트
const root = document.getElementById("root");
if (root) {
root.innerHTML = html;
// DOM 변경 이벤트 발생
root.dispatchEvent(new Event("DOMContentLoaded", { bubbles: true }));
}
return html;
};
const push = (path: string) => {
window.history.pushState(null, "", path);
const pathnames = path.split("/");
//(...reset을 위한 코드 생략)
render(path);
};
// store를 구독 - 상태 변경시 자동 렌더링
const subscribeToStore = (store: { subscribe: (listener: () => void) => () => void }) => {
return store.subscribe(() => {
render();
});
};
return { render, push, subscribeToStore };
};
이전
어떤 (가령.. Proudct List, Product detail 라던지) 페이지에 addEventListener의 동일한 패턴의 코드를 계속 나열해서 사용하였다.
// HomePage.js
// 정말 쓸데없이 document.addEventListener의 계속 똑같은 코드가 나열되고 있다.
document.addEventListener("change", (e) => {
if (e.target.matches("#limit-select")) { ... }
if (e.target.matches("#sort-select")) { ... }
});
document.addEventListener("input", (e) => {
if (e.target.matches("#search-input")) { ... }
});
document.addEventListener("click", (e) => {
if (e.target.matches("#quantity-increase")) { ... }
});
현재
AbortController의 signal로 이벤트와 연결하여 cleanup 할 때는 abort하여 일괄 제거함으로써 메모리 누수를 방지하였다.// eventDelegation.ts
const createEventManager = () => {
const registeredHandlers: {
input: Map<string, (e: Event) => void>;
change: Map<string, (e: Event) => void>;
click: Map<string, (e: Event) => void>;
} = {
input: new Map(),
change: new Map(),
click: new Map(),
};
let globalInitialized = false;
let abortController = new AbortController();
// 공통 로직을 헬퍼 함수로 추출(생략)
const initializeGlobalListeners = () => {
if (globalInitialized) return;
document.addEventListener("input", (e: Event) => {
const id = (e.target as HTMLElement).id;
registeredHandlers.input.get(id)?.(e);
});
document.addEventListener("change", (e: Event) => {
const id = (e.target as HTMLElement).id;
registeredHandlers.change.get(id)?.(e);
});
document.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
});
globalInitialized = true;
};
return {
register: (handlers: EventHandlers) => {
initializeGlobalListeners();
registerHandlers("input", handlers.input);
registerHandlers("change", handlers.change);
registerHandlers("click", handlers.click, (id, handler) => {
document.querySelectorAll(`#${id}`).forEach((element) => {
if (!element.hasAttribute("data-click-attached")) {
element.setAttribute("data-click-attached", "true");
element.addEventListener("click", handler, { signal: abortController.signal });
}
});
});
},
cleanup: () => {
abortController.abort();
abortController = new AbortController();
},
};
};
// 싱글톤 인스턴스
const manager = createEventManager();
export const createEventDelegation = (handlers: EventHandlers) => {
return () => manager.register(handlers);
};
다른 파일에서 사용할 때는, createEventDelegation를 import해와서 선언하여 사용하면 이전처럼 일일히 addEventListener를 선언하지 않아도 사용할 수 있게 되었다.
createEventDelegation({
input: {
"search-input": (e) => { ... }
},
change: {
"limit-select": (e) => { ... },
"sort-select": (e) => { ... }
},
})();
이전
원래는 JS 파일을 쓰고 있었다. 그러다 보니 타입 추론하기가 쉽지 않아 화면에서 undefined가 나타나 프로덕션이 안정적이지 않았다.
let state = {
products: [],
pagination: {}
};
현재
타입을 추가하면서 특히 API에서 어떤 데이터가 떨어지는지 알기 쉽게 되어 타입에 대한 에러가 있을 경우, 즉각적으로 알 수 있게 되어 이전보다 한층 안정적인 프로덕션이 됐다.
interface Product {
title: string;
lprice: string;
productId: string;
// ...
}
interface Pagination {
page: number;
limit: number;
total: number;
// ...
}
const state: {
products: Product[];
pagination: Pagination;
} = { ... };
순수 자바스크립트로 리액트의 동작을 구현해보면서,
아 '이래서 상태변화는 최소한으로 하는게 좋구나'를 알 수 있고, 순수 자바스크립트에서 가상 돔을 구현하지 않는 이상 무조건 root의 innerHTML를 돌려야 하기 때문에 큰 처리 비용이 들어서 무조건 상태 변화는 적어야 프로덕션에 무조건 이득이라서 리액트에서도 왜 상태 처리를 한 번 하려고 하는지 알 수 있던 덕목 중에 하나이며,
라우터는 페이지 이동이나 화면에서 알맞는 컴포넌트를 렌더링하는 것으로 알고 있었긴 했는데 역시 라우터 또한 상태를 'subscribe'했기 때문에 알맞는 화면을 렌더링할 수 있다는 것을 이번에 복습하면서 특히 알 수 있었던 부분이기도 했다.
이번에 다시 과제를 하면서 느꼈지만, 왜 리팩토링은 해도 해도 끝이 없는 거 같달까 그래도 이전 했던 과제의 코드보다는 많은 것을 개선한 것 같다.
과제도 easy 버전만 했다면 이번에는 hard 버전까지 완료하면서
리팩토링 마친 것으로 끝!!!
(아니, 근데 솔루션 코드 보는데 라이프 사이클 구현이라니... 역시 클라쓰가 다르다 ㅋㅋㅋㅋ)
1주차 회고 잘 보고 가요 :) 2주차도 기대할게요~