컴퓨터공학과 2학년 1학기 시절... 인터넷 프로그래밍 수업을 수강했습니다.
이 수업의 기말 과제는 쇼핑몰 또는 게임을 HTML로 직접 개발하는 것이어서
저는 아무런 라이브러리 없이 순수한 JavaScript로 게임을 개발해보기로 했습니다.
그런데 JavaScript의 DOM을 직접 조작해 UI를 다루는 일이 너무 귀찮고
번거로웠습니다 😭
React나 다른 라이브러리를 쓰지 않기로 마음 먹었기에,
그런 것 없이 이 번거로움을 해결할 방법을 고민하게 됐습니다.
당시 저는 Flutter의 Provider로 상태 관리를 하는 것에 익숙했기 때문에,
Flutter의 Provider와 비슷한 상태 관리 라이브러리를 직접 만들어 편하게 개발하고자 했습니다.
완벽히 설명하려면 복잡하기에, 간단히 요약해보겠습니다!
대부분의 UI는 단순화시키면 데이터(객체)로 나타낼 수 있습니다.
버튼을 누르면 숫자가 올라가는 앱은
{count = 3}
이렇게 간단하게 객체로 표현할 수 있습니다.
그렇다면 “UI를 직접 다루지 말고 이 데이터만 조작하자!”
여기서 탄생한 개념이 상태 관리 입니다.
이 글에선 {count = 3} 이라는 상태에서 count값만 바꾸면 UI도 자동으로 바뀌는 것,
그게 바로 상태 관리라고 봐도 무방합니다.
이런 아이디어로 만든 라이브러리의 예제는 다음과 같습니다:
view_model.js
import {ChangeNotifier} from "../service/provider.js"
export class ViewModel extends ChangeNotifier {
#count = 0;
get count() {
return this.#count;
}
plus() {
this.#count++;
this.notifyListeners();
}
}
index.js
import {ViewModel} from "./view_model.js"
import {Provider} from "../service/provider.js";
const provider = Provider.instance(new ViewModel());
provider.watch(function (model) {
const count = document.getElementById("count");
count.innerText = `${model.count}`;
});
const button = document.getElementById("button");
button.addEventListener("click", function () {
provider.model.plus();
});
버튼을 누르면 count 값이 1씩 증가하고,
UI는 count를 구독하고 있으므로 자동으로 업데이트됩니다.
provider.watch(function (model) {
const count = document.getElementById("count");
count.innerText = `${model.count}`;
});
위처럼 watch()를 호출하면, 리스너 함수가 내부 listeners 배열에 저장됩니다.
ChangeNotifier 구조
export class ChangeNotifier {
#listeners = [];
notifyListeners() {
for (let i = 0; i < this.#listeners.length; i++) {
let listener = this.#listeners[i];
listener(this);
}
}
addListener(listener) {
if (typeof listener != "function") {
console.log(typeof listener);
throw "ChangeNotifier.addListener: 리스너만 추가해주세요";
}
this.#listeners.push(listener);
}
}
notifyListeners()를 호출하면 모든 리스너가 실행됩니다.
즉, notifyListeners()는 전체 UI를 새로 고치는 역할을 합니다.
💡참고:
해당 프로젝트는 기말과제를 위해 만들어진 라이브러리 이므로, O(N)의 시간복잡도를 가집니다…!
하지만 렌더링은 N번 수행하지 않는데, 이 내용은 나중에 다루겠습니다.
Provider 구조
export class Provider {
#view_model;
static instance(model) {
return new Provider(model);
}
get model(){
return this.#view_model;
}
constructor(model) {
if (!model instanceof ChangeNotifier) {
throw "Provider: ChangeNotifier를 넣어주세요";
}
this.#view_model = model;
}
watch(listener) {
if (typeof listener != "function") {
throw "Provider.watch: 입력한 리스너를 확인해주세요";
}
this.#view_model.addListener(listener);
this.soonNotify();
return this;
}
flag = false;
soonNotify() {
if(this.flag) return;
this.flag = true;
requestAnimationFrame(()=>{
this.#view_model.notifyListeners();
this.flag = false;
})
}
}
watch()로 값을 바인딩한 후, 초기 UI를 갱신하기 위해
1프레임 뒤에 notifyListeners()를 호출하도록 구성되어 있습니다.
이걸 구현할 당시엔 Proxy도 잘 몰랐고,
UI 변경 시 시간 복잡도가 큰 구조였다는 단점도 있었지만,
직접 상태 관리 라이브러리를, 사용자의 입장에서 쓰기 편하도록 설계해봤다는 점에서 특별한 경험이었습니다!