대망의 1주차
이번 과제는 프레임워크/라이브러리 없이 SPA를 구현하는 거였다. 맨땅에서 SPA를 구현하는건 처음이라 어떻게 해야될지, 어떤거 부터 시작해야될지 생각이 너무 많아져서 머리가 아플 지경이였다.. 과제 양도 많고, 테스트 코드도 통과 시켜야 해서 대충 만든다고 해서 끝이 아니였다. 과제를 만들어주신 준일 코치님의 블로그와 발제 자료들을 보면서 하나씩 차근차근 목록을 정해봤다.
- 브라우저 History API를 이용한 라우터 관리
- 이벤트 버블링을 활용한 이벤트 위임
- Web Storage API, Observer Pattern를 활용한 전역 및 컴포넌트 상태 관리
- 템플릿 메서드 패턴을 활용한 Core가 되는 컴포넌트 생성
라우터를 구현해보자.
SPA에서 라우터는 페이지를 이동할 때 서버로부터 HTML을 받아오는 것이 아닌 자바스크립트로 화면을 다시 그려줘야된다. 이를 구현하기 위해서는 브라우저의 History API를 활용하여 브라우저의 URL을 변경하고 이에 맞는 화면을 클라이언트에서 렌더링할 수 있도록 했다.
class Router {
constructor() {
this.routes = {};
this.notFoundHandler = null;
window.addEventListener('popstate', this.handlePopState.bind(this));
}
addRoute(path, handler) {
this.routes[path] = handler;
}
setNotFound(handler) {
this.notFoundHandler = handler;
}
navigate(path) {
history.pushState(null, '', path);
this.handleRoute(path);
}
handlePopState() {
this.handleRoute(window.location.pathname);
}
handleRoute(path) {
const handler = this.routes[path];
if (handler) {
handler();
} else if (this.notFoundHandler) {
this.notFoundHandler();
}
}
listenLinkClicks(container = document.body) {
container.addEventListener('click', (e) => {
if (e.target.tagName === 'A' && e.target.href.startsWith(location.origin)) {
e.preventDefault();
const path = e.target.getAttribute('href');
this.navigate(path);
}
});
}
}
export default Router;
import Router from "./core/router.js";
const router = new Router();
function main() {
router.addRoute('/', () => {
document.getElementById('root').innerHTML = '<h1>Home</h1>';
});
router.addRoute('/about', () => {
document.getElementById('root').innerHTML = '<h1>About</h1>';
});
router.setNotFound(() => {
document.getElementById('root').innerHTML = '<h1>Not Found</h1>';
})
router.handleRoute(window.location.pathname);
router.listenLinkClicks();
}
main();
기능 요약
- addRoute() 함수를 실행하여 this.routes 객체에 path를 key로, handler 함수를 value로 등록한다.
- 앱이 처음 실행되면 현재 window.location.pathname 을 기반으로 handleRoute() 를 호출한다.
- handleRoute()에서는 전달받은 path 값과 매칭되는 this.routes 의 key를 찾아서 handler()를 실행한다.
- 예를 들어 매칭된 key 값이 “/” 이라면 id 값이 root인 DOM을 찾아 innerHTML을 <h1>Home</h1> 으로 바꿔준다.
- listenLinkClicks() 에서는 이벤트 위임을 통해 <a> 태그 클릭을 감지하고, 기본 동작을 막은 뒤 navigate()를 호출한다.
- navigate()는 history.pushState()로 URL만 변경하고, 다시 handleRoute()를 호출해 해당 경로에 맞는 화면을 렌더링한다.
진행하다보니 중첩 라우팅과 동적 파라미터 기능이 필요해 예제 코드와 상이하지만 기본적인 개념은 같다.
컴포넌트를 구현해보자.
처음에는 React의 함수 컴포넌트를 따라 만들고 싶었지만, useEffect 같은 훅 내부에서 라이프사이클이 어떻게 연결되어 동작하는지 눈에 보이지 않다 보니 구조를 완전히 이해하기가 어려웠다. 주어진 시간도 많지 않으니 더 시간을 소모하기 보다는 상속을 통해 공통 로직을 상위 클래스에 정의해두고, 각 컴포넌트에서 필요한 부분만 오버라이드하면서 기능을 확장할 수 있도록 클래스 기반의 컴포넌트를 설계했다.
class Component {
constructor(element, props) {
this.element = element;
this.props = props;
this.state = {};
this.mounted = false;
this.eventListeners = [];
}
setState(newState) {
this.state = { ...this.state, ...newState };
if (this.mounted) {
this.render();
}
}
addEventListener(element, event, handler) {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
}
mount() {
this.mounted = true;
this.onMount();
this.render();
this.attachEventListeners();
}
unmount() {
this.mounted = false;
this.onUnmount();
this.eventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.eventListeners = [];
}
onMount() {}
onUnmount() {}
render() {}
attachEventListeners() {}
}
export default Component;
기능 요약
- 리액트와 동일하게 mount, unmount 를 구현했다. mounted 상태 값을 통해 컴포넌트의 생명주기를 관리하며, mount가 되면 onMount, render, attchEventListeners 순으로 실행한다.
- setState() 호출로 상태를 업데이트하고, mounted 상태일 때 this.render()을 호출하여 리렌더링 트리거한다.
- 이벤트 관리는 attachEventListeners() 로 상속 받은 컴포넌트에서 오버라이드해 addEventListener에 이벤트들을 등록하고, unmount 시에는 중복된 이벤트를 제거하기 위해 모든 이벤트를 제거하는데 사용할 수도 있다.
사용 예시
import Component from './core/Component.js';
class HomePage extends Component {
constructor(element, props) {
super(element, props);
this.state = {
count: 0
};
}
// 마운트 시 실행
onMount() {
console.log('HomePage mounted!');
}
// 언마운트 시 실행
onUnmount() {
console.log('HomePage unmounted!');
}
// DOM 렌더링
render() {
this.element.innerHTML = `
<h1>Home Page</h1>
<p>클릭 횟수: ${this.state.count}</p>
<button class="increment-btn">+1</button>
`;
}
// 이벤트 위임 설정
attachEventListeners() {
this.addEventListener(this.element, 'click', (e) => {
if (e.target.matches('.increment-btn')) {
this.setState({ count: this.state.count + 1 });
}
});
}
}
export default HomePage;
이런 식으로 구조를 잡아 라이프사이클과 렌더링, 리렌더링, 상태 관리, 이벤트 관리까지 컨트롤 할 수 있게 됐다. 현재는 상태 값이 변경되면 전체 DOM을 다시 리렌더링 되지만, 추후에는 diffing 알고리즘을 도입해 변경된 부분만 리렌더링 될 수 있게 개선할 수 있을 것 같다.
전역 상태를 구현해보자.
여러 컴포넌트에서 하나의 상태를 공유하면서, 상태가 변경될 때마다 반응하도록 만들고 싶었다. 이를 위해 자주 들어왔던 옵저버/구독 패턴을 활용해 간단한 전역 상태 관리 시스템을 구현해봤다.
class CreateStore {
state = {};
listeners = [];
constructor(initialState) {
this.state = initialState;
}
getState() {
return this.state;
}
setState(newState) {
this.state = {
...this.state,
...newState,
};
this.listeners.forEach((fn) => fn(this.state));
}
subscribe(fn) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((f) => f !== fn);
};
}
}
export default CreateStore;
기능 요약
- 인스턴스를 생성할 때 초기 상태를 받아 this.state 에 저장한다.
- subscribe(fn) 을 호출하면 상태가 변경되면 실행할 함수를 listeners에 등록하고, 추후에 컴포넌트가 unmount 될 때 중복 이벤트를 제거하기 위해 listeners 에 상태들을 전부 삭제할 수 있는 unsubscribe 함수를 반환한다.
- setState() 를 통해 상태를 업데이트하고, listeners에 등록된 함수들을 실행한다.
예시
// store.js
import CreateStore from './core/CreateStore.js';
const store = new CreateStore({
count: 0,
});
export default store;
// CounterComponent.js
import Component from './core/Component.js';
import store from './store.js';
class CounterComponent extends Component {
onMount() {
this.unsubscribe = store.subscribe(() => this.render());
}
onUnmount() {
this.unsubscribe(); // 구독 해제
}
render() {
const { count } = store.getState();
this.element.innerHTML = `
<h2>카운터: ${count}</h2>
<button class="increase">+1</button>
`;
}
attachEventListeners() {
this.addEventListener(this.element, 'click', (e) => {
if (e.target.matches('.increase')) {
const current = store.getState().count;
store.setState({ count: current + 1 });
}
});
}
}
export default CounterComponent;
앞서 만들었던 CreateStore 클래스 인스턴스를 생성해 초기 상태 값을 저장하고, onMount 될 때 구독을 등록해 상태 값 변경 시 자동으로 리렌더링 되도록 했다. 그리고 onUmount 에서는 구독 해제를 통해 이벤트 중복을 방지한다.
LocalStorage, SessionStorage을 구현해보자.
이 부분은 전역 상태 관리 시스템을 만든 것과 매우 유사하다. 마찬가지로 옵저버/구독 패턴을 활용했다. 다른 점이 있다면 storage는 key 단위로 상태를 다루기 때문에, 특정 key에 구독자가 있을 때만 그들을 호출하기 위해 notify() 함수를 추가했다.
class CreateStorage {
constructor(storage = window.localStorage) {
this.storage = storage;
this.listeners = {};
}
get(key) {
const value = this.storage.getItem(key);
try {
return JSON.parse(value);
} catch {
return value;
}
}
set(key, value) {
const data = typeof value === 'string' ? value : JSON.stringify(value);
this.storage.setItem(key, data);
this.notify(key);
}
remove(key) {
this.storage.removeItem(key);
}
clear() {
this.storage.clear();
}
subscribe(key, callback) {
if (!this.listeners[key]) this.listeners[key] = [];
this.listeners[key].push(callback);
// 구독 해제 함수 반환
return () => {
this.listeners[key] = this.listeners[key].filter((fn) => fn !== callback);
};
}
notify(key) {
if (!this.listeners[key]) return;
const value = this.get(key);
this.listeners[key].forEach((cb) => cb(value));
}
}
export default CreateStorage;
사용 예시
// storage.js
import CreateStorage from './core/CreateStorage.js';
const storage = new CreateStorage(localStorage); // 또는 sessionStorage
export default storage;
// CounterComponent.js
import Component from './core/Component.js';
import storage from './storage.js';
class CounterComponent extends Component {
onMount() {
this.unsubscribe = storage.subscribe('count', () => this.render());
}
onUnmount() {
this.unsubscribe(); // 구독 해제
}
render() {
const count = storage.get('count') ?? 0;
this.element.innerHTML = `
<h2>LocalStorage 카운트: ${count}</h2>
<button class="increase">+1</button>
`;
}
attachEventListeners() {
this.addEventListener(this.element, 'click', (e) => {
if (e.target.matches('.increase')) {
const current = storage.get('count') ?? 0;
storage.set('count', current + 1);
}
});
}
}
export default CounterComponent;
- 컴포넌트가 mount 되면 subscribe 을 실행해 특정 key 를 구독하고 상태 변경 시 호출 될 함수도 같이 등록한다.
- 버튼을 클릭하면 storage.get 으로 현재 값을 가져오고 storage.set을 호출하여 상태를 업데이트 한다.
- storage.set 호출 시 localStorage 에 저장되고 이어서 notify() 가 실행된다.
- unsubscribe() 는 컴포넌트가 unmount 되면 호출되어 구독된 데이터를 정리한다.
마무리
위에서 만든 기능들을 조합하여 JavaScript로 SPA를 구현해봤다. 생각보다 과제 양이 많고, 고민해야 할 지점들도 많아서 매우 힘든 1주가 됐었다.. 그래도 팀원들과 소통하며 문제를 해결해 나갔고, 완주할 수 있었다. 이번 경험을 통해 리액트를 사용하면서 당연시 여겼던 라우터, 라이프사이클, 상태 관리, 전역 상태 관리, 렌더링/리렌더링 등등 많은 것들을 직접 구현하면서 의도와 목적들을 어느정도 이해하게 됐고 다시 한번 리액트의 위대함을 느끼게 됐다. 뼈저리게 깨달은건 나는 자바스크립트를 잘 아는 게 아니라 그저 리액트만 잘 썻던 거였다… 앞으로 자바스크트 딥 다이브를 해봐야겠다!🚀
'Front > 향해플러스' 카테고리의 다른 글
| Virtual DOM 만들기 - #5: updateElement (향해 플러스 2주차) (1) | 2025.08.11 |
|---|---|
| Virtual DOM 만들기 - #4: renderElement (향해 플러스 2주차) (1) | 2025.08.11 |
| Virtual DOM 만들기 - #3. createElement (향해 플러스 2주차) (2) | 2025.08.10 |
| Virtual DOM 만들기 - #2. normalizeVNode (향해 플러스 2주차) (0) | 2025.08.10 |
| Virtual DOM 만들기 - #1. createVNode (향해 플러스 2주차) (3) | 2025.08.02 |