옵저버 패턴으로 바닐라JS 상태관리하기

Kyle·2021년 7월 21일
31

javascript

목록 보기
17/18
post-thumbnail

기존에 바닐라 JS로 상태관리를 할 때 옵저버 패턴을 이용해서 했습니다. 이유는 구현이 간단하기 때문에 간단한 2차 코딩테스트에서는 유용하게 활용 될 수 있기 때문입니다.

이번 포스팅은 제가 기존에 활용하던 방식에서 불편함을 느끼고 그것을 수정해가는 과정을 작성하는 글입니다.

학습하는 과정에서 작성한 글이기 때문에 보다 좋은 방안이 있으면 댓글로 피드백해주시면 감사하겠습니다!

옵저버 패턴의 기본 로직

옵저버 패턴의 기본 로직은 다음과 같습니다.

Model의 상태가 변경되면 이를 구독하고 있는 View의 렌더링함수(혹은 등록해놓은 함수)가 실행된다.

본 글에서는 ViewModelrender함수를 등록해 놓는다라고 가정하겠습니다.

즉, 상태가 변경함에 따라서 View는 자동으로 렌더링이 되는 구조입니다.

구독(subscribe)이란?

여기서 구독이라는 단어는 단순하게 View의 변경되면 실행해줄 render 메소드를 Model에 등록해 놓는 것입니다.

이제 위의 사진을 보겠습니다.

버튼이 Model의 상태를 변경시키면 Model이 View가 등록해 놓은 render함수를 호출해 View를 리렌더링 시키는 구조입니다.

옵저버 코드

Observer 코드

아주 최소화 시킨 옵저버코드를 보겠습니다.

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함수들을 호출하는 함수입니다.

Model

이제 위의 함수를 상속받는 모델(상태)를 만들어 보겠습니다.

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 함수들을 호출합니다.

View

위의 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>
    `;
}
  • 생성자함수
    • 빈 div($target)를 만들고, 모델에 render 함수를 구독해 놓습니다.
    • 초기 렌더링을 합니다.
  • render
    model에 있는 text상태를 가져온 뒤에 렌더링

View 변경하는 버튼

위의 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 = "";
  }
}
  • 생성자함수
    • 빈 div($target)를 만들고 초기 렌더링을 합니다.
    • 버튼에 클릭 콜백 함수를 등록합니다.
  • render
    inputbutton을 등록합니다.
  • handleClick
    input의 value 값으로 상태를 변경시킵니다.

위의 코드의 실행입니다.


위와 같은 클래스형 옵저버의 문제점 2가지

1. 상태가 모델로 되어있기 때문에 만들 때마다 클래스를 작성해줘야되는 번거로움이 있습니다.

2. 하위 컴포넌트에서 model을 사용하고 싶다면 계속해서 인자로 내려줘야 한다.


위의 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를 만듬과 동시에 상태를 초기화해주면 될 것 같습니다.

initState의 활용

import { initState } from "./observer";

export const textState = initState({
  key: "textState",
  defaultValue: "hello world"
});

위의 코드처럼 keydefaultValue를 인자로 주고 observer를 초기화합니다.

다른 컴포넌트에서는 textState를 활용해 subscribe, getState, setState를 활용할 수 있습니다.

이제 다른 컴포넌트에서 어떻게 활용하는지 알아봅시다. 위의 예시와 비슷하게 사용해보겠습니다.

View

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>
    `;
  }
}

View를 변경하는 버튼


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 = "";
  }
}

위코드의 실행결과

기존의 코드와 크게 다르지는 않습니다.
keyimport해 와서 필요한 상태관리 함수들만 사용할 수 있습니다.

인자로 반복해서 내려줘야 되거나, model을 계속해서 만들어줘야 되는 불편함은 줄일 수 있었습니다.
하지만 이 코드에도 큰 문제가 있습니다.


치명적인 문제점

아직까지 제대로 해결을 못하고 임시방편으로만 해결해서 활용하고 있습니다. 아래와 같은 문제점을 찾을 수 있었습니다.

리렌더링이 되는 과정에서 컴포넌트가 new로 다시 인스턴스를 생성해 렌더링 되는 경우에는 컴포넌트의 렌더링 함수가 중첩이 돼서 observers(Set)에 들어가게 됩니다.

즉 컴포넌트가 사라지고 다시 렌더링 되는 시점 (언마운트 되는 시점)을 잡아내지 못한다면 Observers에 렌더링함수가 계속 쌓이게 됩니다.

해결을 하기 위해서는 언마운트가 되는 시점을 잡아서 unsubscribe 구독을 해제하는 작업을 해줘야 됩니다.

const unsubscribe = (key,observer) => globalState[key]._observers.delete(observer);

아직까지는 해결을 하지 못한 문제점입니다.

그러면?? 당장 임시방편은 어떻게?

observers를 Map으로 관리

기존에 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));

Map을 사용해도 남아있는 문제점

이번 프로젝트에서 어진님과 함께 작업을 하는데 문제점들을 잘 생각해 주셨다. 그 중 아래 문제가 큰 문제라고 생각 된다.

사용하지 않는 렌더링 함수들도(ex. 다른 페이지의 컴포넌트) 전역객체에 등록이 계속 되어있는 상태가 되면서 메모리 손실이 발생한다.

페이지가 변경되면 사용하지 않는 렌더링함수는 observers에 남아있을 필요가 없는데 언마운트되는 시점에 unsubscribe를 해주지 못하기 때문에 계속해서 남아있게 됐다.

확실히 이 문제를 해결하려면 언마운트가 되는 시점을 잡아야 될 것 같다. 다른 옵저버 패턴의 예시들을 보면서 조금씩 완성시켜가지 않을까?

마무리

기존에 사용하던 옵저버 패턴을 조금더 편안하게 활용하기 위해서 조금씩 변경하고 활용했다.
이번에 함께 프로젝트를 하는 분인 어진님께서 깊게 고민해주시고 문제점도 말씀해주셔서 정말 감사했다.

편하게 사용하려다보니 전역으로 상태를 꺼내오게 됐고 점점 형태가 react의 recoil을 닮아가게 됐다.
기존에 react 프로젝트를 하면서 useState를 인자로 내려주는 형식이나 context 사용성의 불편함등을 recoil을 사용하면서 해결할 수 있었는데 그 때의 기억인지 비슷한 방식으로 구현하게 됐다.

사용하면서 setState도 함수를 받을 수 있게 변경해 사용하기도 하면서 조금씩 불편한 점? 아니면 리액트에 익숙한 점으로 맞춰가고 있다.

아직 완성되지 않았기 때문에 추후에 보완이 되면 계속 추가를 하던지 새로운 글을 작성하던지 할 예정이다! 지금은 클래스로만 컴포넌트를 만들었다면 함수형으로도 도전해봐야겠다.

지금 사용하고 있는 코드는 여기에 링크로 남겨두겠습니다.


해결

컴포넌트를 만들어서 그곳에서 클리어 해주도록 해결했습니다.
코드

profile
Kyle 발전기

0개의 댓글