Tech/Sofeware Development

[JavaScript] 웹 컴포넌트에 Observer Pattern을 적용하여 상태관리하기

행복한 시지프 2022. 3. 14. 23:41

0. 서언

 

컴포넌트 구조는 황준일님의 <Vanilla Javascript로 웹 컴포넌트 만들기> 를 참고했고,

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/

 

Vanilla Javascript로 웹 컴포넌트 만들기 | 개발자 황준일

Vanilla Javascript로 웹 컴포넌트 만들기 9월에 넥스트 스텝 (opens new window)에서 진행하는 블랙커피 스터디 (opens new window)에 참여했다. 이 포스트는 스터디 기간동안 계속 고민하며 만들었던 컴포넌트

junilhwang.github.io

Observer Pattern은 황준일님의 <Vanilla Javascript로 상태관리 시스템 만들기> 를 참고했습니다.

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Store/

 

Vanilla Javascript로 상태관리 시스템 만들기 | 개발자 황준일

Vanilla Javascript로 상태관리 시스템 만들기 본 포스트는 Vuex나 Redux 같은 상태관리 프레임워크를 직접 만들어보는 내용이다. 그리고 이 포스트를 읽기 전에 Vanilla Javascript로 웹 컴포넌트 만들기 (ope

junilhwang.github.io

Vanilla JS로 웹 컴포넌트 만드는 방법, 옵저버 패턴을 이해했다는 가정 하에 서술했습니다.

혹여 옵저버 패턴을 모른다고 하더라도, 밑 코드를 그대로 복사해가시면, 옵저버 패턴을 쉽게 사용할 수 있습니다.

1. 문제 인식

Component 구조 하에서, 옵저버 패턴을 사용할 때 옵저버가 중복으로 구독되는 현상이 발생하였습니다.

 

아래와 같이, render 횟수가 2의 배수로 커지는 것에서 확인해볼 수 있습니다.

 

아래 코드에서 문제점을 파악해보겠습니다.

 

let currentObserver = null;

// 구독 함수
const observe = fn => {
  currentObserver = fn;
  fn();
  currentObserver = null;
}

// 관찰하는 상태를 지정하기 위한 함수
const observable = obj => {
  Object.keys(obj).forEach(key => {
    let _value = obj[key];
    const observers = new Set();

    Object.defineProperty(obj, key, {
      // state의 각 key를 get하면, observer에 등록함
      get () {
        if (currentObserver) observers.add(currentObserver);
        return _value;
      },
      // state의 각 key를 set하면, observer에 등록된 함수를 실행함.
      set (value) {
        _value = value;
        observers.forEach(fn => fn());
      }
    })
  })
  return obj;
}

export const store = {
  // state를 관찰함
  state: observable({
    a: 10,
    b: 20,
  }),
  // state를 변화시킴
  setState (newState) {
    for (const [key, value] of Object.entries(newState)) {
      if (!this.state[key]) continue;
      this.state[key] = value;
    }
  }
}

 

간단히 옵저버 패턴 코드에 대해 설명하겠습니다.

옵저버 패턴은 상태를 구독하고, 이 상태가 변경되면 즉각 어떤 함수를 실행되게 하는 방법입니다. 

 

1. state가 변화되는지 관찰하고(observable),

2. state를 구독하고(get - subscribe),

3. state를 수정하면(set),

4. observer에 등록된 함수를 실행합니다.(publish)

 

Component 구조를 사용하지 않는다면, 위 코드는 문제없이 동작할 것입니다.

Component 구조를 사용한다고 하더라도, 문제가 없다고 판단하실 수도 있습니다.

왜냐하면, 소규모 프로젝트일 시 rendering이 몇배 더 이루어진다고 하여 속도 차이를 인지하지 못 할 수도 있기 때문입니다. 

 

위 코드에서 observers를 Set으로 선언하였습니다. 그리하여 중복을 방지하는 것처럼 읽히나, Component 구조에서는 그렇지 않습니다. 컴포넌트 구조는 화면이 바뀔 때, 새로운 클래스를 선언하여, 새로운 template을 render 시켜주기 때문입니다.

 

코드 예시를 들어보겠습니다.

처음 구독한 observer가 MainPage에 있는 render 메소드라고 해봅시다.

아래 코드에서 MainPage는 instance입니다.

그리고 App이 re-render 됨에 따라, MainPage는 새로운 instance를 가지게 됩니다.

그리고 다시 새로운 MainPage에 있는 render를 구독하게 되겠지요.

 

이때, Set으로 선언했다고한들, 중복으로 render함수가 구독되는 것을 볼 수 있습니다.

이는 메소드는 참조타입이기 때문에, 주소값을 가지고 있기 때문입니다.

똑같이 생긴 함수라고 하더라도, 다른 instance로부터 온 함수이므로 명백히 다른 함수이기 때문이지요.

 

export default class App extends Component {
  setup() {
    this.state = { isSearchModalOpened: false };
  }

  template() {
    return `
      <main id="main-page" class="classroom-container"></main>
    `;
  }

  afterMounted() {
    new MainPage(document.querySelector('#main-page'));
  }

}

 

export default class Component {
  constructor(target, props) {
    this.target = target;
    this.props = props;
    this.setup();
    this.setEvent();
    observer(() => {
      this.render();
    }
  }

  setup() {}

  mounted() {}

  template() {
    return '';
  }

  render() {
    this.target.innerHTML = this.template();
    this.mounted();
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }

  setEvent() {}
}

2. 해결 방법

그렇다면, 매번 새로 렌더링 되더라도, observer는 중복으로 등록되지 않게 할 방법을 강구해야 합니다.

제가 생각한 방법은 Component의 이름(primitive type)을 구독하는 것입니다.

즉, observer를 Set이 아니라 객체로 선언하고, key에 Component 클래스 명을 넣고, value에 this를 넣어두는 것입니다.

 

코드를 보겠습니다.

바뀐 부분은 2가지 입니다.

1. currentObserver 로직

2. observers의 자료구조

 

currentObserver를 처음에 null로 선언합니다.

그리고, Component의 render 함수의 시작부에서 setCurrentObserver(this) 라고 선언하고, 끝에 다시 setCurrentObserver(null)을 해줍니다.

 

그 이유는, 위 황준일님의 코드와 같이 render 시에 get 해오는 state를 가지는 컴포넌트를 구독하기 위함입니다.

그리고 observers 를 객체로 선언하고, 객체의 key를 currentObserver.constructor.name (Class 이름)으로 등록합니다.

이는 primitive 타입이기 때문에, instance가 새로 선언된다고 하더라도, 불변성이 유지됩니다.

 

let currentObserver = null;
// 바뀐 부분
export const setCurrentObserver = observer => {
  currentObserver = observer;
};

const observable = target => {
  Object.keys(target).forEach(key => {
    // 바뀐 부분
    const observers = {};
    let cache = target[key];

    Object.defineProperty(target, key, {
      get() {
        // 바뀐 부분
        if (currentObserver) {
          observers[currentObserver.constructor.name] = currentObserver;
        }

        return cache;
      },
      set(value) {
        cache = value;
        Object.keys(observers).map(key => observers[key].render());
      },
    });
  });

  return target;
};

export const store = {

  state: observable({
    a: 10,
    b: 20,
  }),

  setState (newState) {
    for (const [key, value] of Object.entries(newState)) {
      if (!this.state[key]) continue;
      this.state[key] = value;
    }
  }
}

 

export default class Component {
  constructor(target, props) {
    this.target = target;
    this.props = props;
    this.setup();
    this.setEvent();
    this.render();
  }

  setup() {}

  mounted() {}

  template() {
    return '';
  }

  render() {
    // 바뀐 부분
    setCurrentObserver(this);
    this.target.innerHTML = this.template();
    this.mounted();
    setCurrentObserver(null);
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }

  setEvent() {}
}

3. 마치며

옵저버 패턴은 보통 MVC 패턴과 함께 사용하기 때문에, 관련 자료를 찾는 것이 어려웠습니다. 대부분 observer를 array나 set으로 선언하여, 리렌더링 시 중복될 가능성을 내포하고 있었습니다. 그래서 컴포넌트 구조와 함께 사용하기는 맞지 않아 보여, 컴포넌트 구조와 함께 옵저버 패턴을 사용하는 방법을 찾아보았습니다.

 

잘못된 내용, 개선할 내용있으면 지적 부탁드립니다.