기존에 바닐라 JS로 상태관리를 할 때 옵저버 패턴을 이용해서 했습니다. 이유는 구현이 간단하기 때문에 간단한 2차 코딩테스트에서는 유용하게 활용 될 수 있기 때문입니다.
이번 포스팅은 제가 기존에 활용하던 방식에서 불편함을 느끼고 그것을 수정해가는 과정을 작성하는 글입니다.
학습하는 과정에서 작성한 글이기 때문에 보다 좋은 방안이 있으면 댓글로 피드백해주시면 감사하겠습니다!
옵저버 패턴의 기본 로직은 다음과 같습니다.
Model의 상태가 변경되면 이를 구독하고 있는 View의 렌더링함수(혹은 등록해놓은 함수)가 실행된다.
본 글에서는
View
가Model
에render
함수를 등록해 놓는다라고 가정하겠습니다.
즉, 상태가 변경함에 따라서 View는 자동으로 렌더링이 되는 구조입니다.
여기서 구독이라는 단어는 단순하게 View
의 변경되면 실행해줄 render
메소드를 Model
에 등록해 놓는 것입니다.
버튼이 Model의 상태를 변경시키면 Model이 View가 등록해 놓은 render
함수를 호출해 View를 리렌더링 시키는 구조입니다.
아주 최소화 시킨 옵저버코드를 보겠습니다.
export default class Observer {
constructor() {
this._observers = new Set();
}
subscribe(observer) {
this._observers.add(observer);
}
notify() {
this._observers.forEach((observer) => observer());
}
}
_observer
각 View
에서 등록한 render
메소드가 저장될 곳입니다.
subscribe
View
에서 render
메소드를 등록하는 함수입니다.
notify
Model
이 상태가 변경이 될 때 View
가 등록한 render
함수들을 호출하는 함수입니다.
이제 위의 함수를 상속받는 모델(상태)를 만들어 보겠습니다.
export default class TextModel extends Observer {
constructor() {
super();
this.text = "hello world";
}
getText() {
return this.text;
}
setText(text) {
this.text = text; //상태 변경
this.notify(); //등록된 렌더링 함수들 호출
}
}
getText
현재 상태를 반환합니다.
setText
현태 상태를 받은 인자로 변경시키고 등록된 render
함수들을 호출합니다.
위의 Model을 구독하고 있는 View를 만들어 보겠습니다.
export default class TextView {
constructor({ model }) {
this.$target = document.createElement("div");
this.textModel = model;
this.textModel.subscribe(this.render.bind(this)); //Model에 구독
this.render();
}
render() {
const text = this.textModel.getText(); //Model의 상태를 가져와서 렌더링
this.$target.innerHTML = `
<div>${text}</div>
`;
}
$target
)를 만들고, 모델에 render
함수를 구독해 놓습니다.render
위의 View를 변경하는 ChangeTextView
을 만들어 보겠습니다. input의 내용으로 위의 View를 변경하는 컴포넌트입니다.
export default class ChangeTextBtn {
constructor({ model }) {
this.$target = document.createElement("div");
this.textModel = model;
this.render();
this.$target.addEventListener("click", this.handleClick.bind(this));
}
render() {
this.$target.innerHTML = `
<input tpye='text' />
<button>text 변경!</button>
`;
}
handleClick({ target }) {
if (target.tagName !== "BUTTON") return;
const input = this.$target.querySelector("input");
this.textModel.setText(input.value); // 상태변경
input.value = "";
}
}
$target
)를 만들고 초기 렌더링을 합니다.render
input
과 button
을 등록합니다.handleClick
위의 2가지를 조금이나마 편하게 하고자 함수형으로 조금 개선해봤습니다.
하지만 프로젝트를 진행하는 중 크리티컬한 문제점을 발견했는데 그 부분은 마지막에 다루도록 하겠습니다.
기본적인 로직은 뒤와 크게 다르지 않습니다.
결국 전역에 객체에 각 상태와 옵저버들을 저장해놓고 관리하는 것 입니다. ES6의 모듈(import
)를 사용해서 전역이 방어되기 때문에 클로저를 사용하지 않고 전역 객체로 두었습니다.
위의 클래스의 로직과 크게 다르지 않습니다. 다만 KEY
를 가지고 객체 안에 있는 상태에 접근하는 것 입니다.
const globalState = {};
const subscribe = (key, observer) => globalState[key]._observers.add(observer);
const _notify = (key) =>
globalState[key]._observers.forEach((observer) => observer());
const initState = ({ key, defaultValue }) => {
if (key in globalState) throw Error("이미 존재하는 key값 입니다.");
globalState[key] = {
_state: defaultValue,
_observers: new Set()
};
return key;
};
const getState = (key) => {
if (!(key in globalState)) throw Error("존재하지 않는 key값 입니다.");
return globalState[key]._state;
};
const setState = (key) => (newState) => {
if (!(key in globalState)) throw Error("존재하지 않는 key값 입니다.");
globalState[key]._state = newState;
_notify(key);
};
export { subscribe, initState, getState, setState };
subscribe(key,observer)
View
에서 render
메소드를 key
를 이용해 _observer
에 등록할 수 있는 함수
getState(key)
key
에 맞는 상태를 반환하는 함수
setState(key)
key
에 맞는 상태를 변경할 수 있는 함수를 반환하는 함수
key
는 어디서 정해야될까?같은 상태를 쓰는 컴포넌트들이 key
를 각각 Row한 String으로 관리한다면..?? 너무 위험하고 관리하가 까다롭다고 생각합니다. 그렇기 때문에 initState
를 활용해서 다른 컴포넌트에서 공용으로 사용할 key
를 만듬과 동시에 상태를 초기화해주면 될 것 같습니다.
import { initState } from "./observer";
export const textState = initState({
key: "textState",
defaultValue: "hello world"
});
위의 코드처럼 key
와 defaultValue
를 인자로 주고 observer를 초기화합니다.
다른 컴포넌트에서는 textState
를 활용해 subscribe
, getState
, setState
를 활용할 수 있습니다.
이제 다른 컴포넌트에서 어떻게 활용하는지 알아봅시다. 위의 예시와 비슷하게 사용해보겠습니다.
import { getState, subscribe } from "./observer";
import { textState } from "./store";
export default class TextView {
constructor() {
this.$target = document.createElement("div");
subscribe(textState, this.render.bind(this)); //key를 이용해 구독
this.render();
}
render() {
const text = getState(textState); //key를 이용해 상태를 가져와서 렌더링
this.$target.innerHTML = `
<div>${text}</div>
`;
}
}
import { setState } from "./observer";
import { textState } from "./store";
export default class ChangeTextBtn {
constructor() { //인자로 받지 않아도 된다.
this.$target = document.createElement("div");
this.setText = setState(textState); //key를 이용해 key에 맞는 상태를 변경하는 함수 생성
this.render();
this.$target.addEventListener("click", this.handleClick.bind(this));
}
render() {
this.$target.innerHTML = `
<input tpye='text' />
<button>text 변경!</button>
`;
}
handleClick({ target }) {
if (target.tagName !== "BUTTON") return;
const input = this.$target.querySelector("input");
this.setText(input.value); // 상태변경
input.value = "";
}
}
기존의 코드와 크게 다르지는 않습니다.
key
를 import
해 와서 필요한 상태관리 함수들만 사용할 수 있습니다.
인자로 반복해서 내려줘야 되거나, model을 계속해서 만들어줘야 되는 불편함은 줄일 수 있었습니다.
하지만 이 코드에도 큰 문제가 있습니다.
아직까지 제대로 해결을 못하고 임시방편으로만 해결해서 활용하고 있습니다. 아래와 같은 문제점을 찾을 수 있었습니다.
리렌더링이 되는 과정에서 컴포넌트가 new로 다시 인스턴스를 생성해 렌더링 되는 경우에는 컴포넌트의 렌더링 함수가 중첩이 돼서 observers(Set)에 들어가게 됩니다.
즉 컴포넌트가 사라지고 다시 렌더링 되는 시점 (언마운트 되는 시점)을 잡아내지 못한다면 Observers에 렌더링함수가 계속 쌓이게 됩니다.
해결을 하기 위해서는 언마운트가 되는 시점을 잡아서 unsubscribe
구독을 해제하는 작업을 해줘야 됩니다.
const unsubscribe = (key,observer) => globalState[key]._observers.delete(observer);
아직까지는 해결을 하지 못한 문제점입니다.
기존에 Set
으로 observers를 관리해왔는데 new
를 이용해 새롭게 렌더링하는 과정에서 같지만 다른 렌더링함수가 중복해서 들어가게 된다.
이를 해결하기 위해서 subscribe
하는 컴포넌트의 고유한 key
를 주어서 렌더링 함수가 쌓이는 것을 방지했다.
아래와 같이 코드가 변경됐다.
컴포넌트 명(클래스이름)이 중복되지 않는다 생각하고 componentKey
에 컴포넌트 명을 활용했다.
//subscribe
const subscribe = (key, componentKey, observer) => globalState[key]._observers.set(componentKey, observer);
//subscribe하는 곳에서의 사용
subscribe(textState, 'TextView', this.render.bind(this));
이번 프로젝트에서 어진님과 함께 작업을 하는데 문제점들을 잘 생각해 주셨다. 그 중 아래 문제가 큰 문제라고 생각 된다.
사용하지 않는 렌더링 함수들도(ex. 다른 페이지의 컴포넌트) 전역객체에 등록이 계속 되어있는 상태가 되면서 메모리 손실이 발생한다.
페이지가 변경되면 사용하지 않는 렌더링함수는 observers에 남아있을 필요가 없는데 언마운트되는 시점에 unsubscribe
를 해주지 못하기 때문에 계속해서 남아있게 됐다.
확실히 이 문제를 해결하려면 언마운트가 되는 시점을 잡아야 될 것 같다. 다른 옵저버 패턴의 예시들을 보면서 조금씩 완성시켜가지 않을까?
기존에 사용하던 옵저버 패턴을 조금더 편안하게 활용하기 위해서 조금씩 변경하고 활용했다.
이번에 함께 프로젝트를 하는 분인 어진님께서 깊게 고민해주시고 문제점도 말씀해주셔서 정말 감사했다.
편하게 사용하려다보니 전역으로 상태를 꺼내오게 됐고 점점 형태가 react의 recoil
을 닮아가게 됐다.
기존에 react 프로젝트를 하면서 useState를 인자로 내려주는 형식이나 context 사용성의 불편함등을 recoil을 사용하면서 해결할 수 있었는데 그 때의 기억인지 비슷한 방식으로 구현하게 됐다.
사용하면서 setState
도 함수를 받을 수 있게 변경해 사용하기도 하면서 조금씩 불편한 점? 아니면 리액트에 익숙한 점으로 맞춰가고 있다.
아직 완성되지 않았기 때문에 추후에 보완이 되면 계속 추가를 하던지 새로운 글을 작성하던지 할 예정이다! 지금은 클래스로만 컴포넌트를 만들었다면 함수형으로도 도전해봐야겠다.
지금 사용하고 있는 코드는 여기에 링크로 남겨두겠습니다.
컴포넌트를 만들어서 그곳에서 클리어 해주도록 해결했습니다.
코드