JavaScript 상태 관리

bp.chys·2020년 5월 24일
9
post-thumbnail

상태 관리란?

상태관리를 알기전에 상태를 어떻게 정의하고 있는지 알아야할 것이다. 상태(state)는 쉽게 말하면 데이터라고 생각할 수 있다.

객체지향 프로그래밍에서는 기본 단위가 객체이고, 프론트엔드에서는 비슷한 개념으로 컴포넌트라는 용어를 사용한다. 객체가 인스턴스 변수(데이터)로 상태를 갖고 있듯이 컴포넌트도 상태(데이터)를 가질 수 있다.

그렇다면 상태 관리는 왜 중요할까?

한 화면에서 여러 개의 컴포넌트들이 서로 협력하는 구조가 만들어지고 각 컴포넌트들은 상태(데이터)를 공유하며 상호작용한다.

하지만 데이터가 변할 때마다 데이터에 관련되 dom을 일일히 찾아야 한다면 중복되는 코드가 많아지고 불필요한 dom접근이 많아질 것이다.
이 데이터들을 전역으로 쉽게 관리할 수 있게 해주는 것이 바로 상태 관리이다.

상태 관리를 잘 활용하면 전체 데이터의 형태와 리스트를 한 곳에서 효율적으로 관리할 수 있다.

자바 스크립트로 상태 관리

프론트엔드의 여러 프레임워크를 사용하다보면, 프레임워크가 제공하는 상태 관리 시스템을 사용하게 된다. Vue에는 Vuex라는 상태관리 시스템이 자주 쓰이고, React는 Redux라는 상태관리 시스템이 자주 쓰인다.
이 글에서는 프레임워크 없이 바닐라 자바스크립트로 상태 관리 시스템을 만들어보는 연습을 해보겠다.

실습의 예제는 "Build a state management system with vanilla JavaScript"를 참고하였습니다.

예제 : Done List 만들기

1. 보일러 플레이트 세팅

git clone https://github.com/hankchizljaw/vanilla-js-state-management.git

2. pub/sub 패턴

  • pub/sub 은 일종의 디자인 패턴이다.
  • 어떤 이벤트를 구독하는 형식으로 등록해놓고, 이벤트가 일어날 때마다 그것에 대한 알림을 받는 형식이다.
  • 알림(Payload)을 받고 이어서 콜백함수를 호출하는 것도 가능하다.
  • pub/sub 패턴을 사용하면 화면은 reactive 해진다.

3. 클래스 생성

export default class PubSub {
  constructor() {
    this.events = {};
  }
  
  // subscibe 메서드
  subscribe(event, callback) {
    let self = this;
    
    if(!self.events.hasOwnProperty(event)) {
      self.events[event] = [];
    }
    
    return self.events[event].push(callback);
  }
  
  // publish 메서드
  publish(event, data = {}) {
    let self = this;
    
    if(!self.events.hasOwnProperty(event)) {
      return [];
    }
    
    return self.events[event].map(callback => callback(data));
  }
}
  • subscribe 메서드는 이벤트와 콜백함수를 인자로 받는다.
  • 생성자에서 정의해두었던 events 객체에 인자로 받은 event가 없다면 events[event]에 빈 배열을 새로 할당한다.
  • 배열에 콜백 함수를 넣어준다.
  • 만일 인자로 받은 event가 존재한다면 콜백함수만 넣어준다.
  • 콜백함수의 길이를 반환한다.
  • publish 메서드는 이벤트에 속한 콜백함수가 있는지 확인하고, 없다면 빈 배열을 반환한다.
  • 인자로 받은 이벤트가 있다면, 콜백 함수의 결과값을 갖는 배열을 반환합니다.

4. The Core Store(저장소) Object

이번엔 저장소 모듈을 만들어보자.

import PubSub from '../lib/pubsub.js';

export default class Store {
  constructor(params) {
    let self = this;
    
    self.actions = {};
    self.mutations = {};
    self.state = {};
    
    // A status enum to set during actions and mutations
    self.status = 'resting';
   
    // 방금 만든 PubSub 모듈을 events에 등록한다.
    self.events = new PubSub();
    
    if(params.hasOwnProperty('actions')) {
      self.actions = params.actions;
    }
    
    if(params.hasOwnProperty('mutations')) {
      self.mutations = params.mutations;
    }
  }
}

Store클래스는 클래스의 속성으로 actions, mutations, state, status, events 를 갖는다.

subscribe, publish 메서드를 갖고 있는 PubSub 클래스는 우리가 바로 방금 전에 만들었던 클래스로 자체 속성에 events를 갖고 있었다. 따라서 해당 모듈을 store의 events에 등록할 수 있다.

클래스 생성 시 넘겨진 프로퍼티 중 actions나 mutations가 있다면 그대로 할당해준다.
이렇게 되면 저장소의 초기 세팅이 완료된다.

5. 프록시 세팅

import PubSub from '../lib/pubsub.js';

export default class Store {
  constructor(params) {
    let self = this;
    
    self.actions = {};
    self.mutations = {};
    self.state = {};
    self.status = 'resting';
    
    self.events = new PubSub();
    
    if(params.hasOwnProperty('actions')) {
      self.actions = params.actions;
    }
    
    if(params.hasOwnProperty('mutations')) {
      self.mutations = params.mutations;
    }
    
    self.state = new Proxy((params.state || {}), {
      set: function(state, key, value) {
        state[key] = value;
        
        console.log(`stateChange: ${key}: ${value}`);
        
        self.events.publish('stateChange', self.state);
        
        if(self.status !== 'mutation') {
          console.warn(`You should use a mutation to set ${key}`);
        }
        
        self.status = 'resting';
        
        return true;
      }
    });
    
  }
}

프록시 객체를 생성하여 self.state에 재할당해주었다. 우리는 이 프록시 객체를 통해서 저장소의 객체들에 변화가 생길때마다 감지할 수 있다.

만일 get이라는 트랩을 추가하면, 객체의 데이터가 요청되었을 때, 매번 특정한 작업을 수행할 수 있다. 그리고 set 트랩을 추가하면, 객체에 변화를 주었을때, 매번 특정한 작업을 수행하게할 수 있다. 이게 오늘 배우는 내용 중에 가장 핵심이 되는 내용이다.

위의 소스 내용을 기반으로, 만일 state.name = 'Foo'라는 코드가 들어온다면, 콘솔에 stateChange: name: Foo라는 메세지가 출력되고, PubSub 클래스의 publish 메소드에 의해 stateChange라는 이벤트가 수행 될 것이다. publish가 수행되면, 해당 이벤트(stateChange)에 등록된 콜백함수가 수행된다.

만일, publish 수행 후에, statusmutation이 아니라면 경고 메세지를 출력해준다. 마지막으로 status'resting'으로 바꾸고 참을 반환한다.

여기까지 핵심은 Pub/Sub 패턴과 Proxy 객체를 통해서 상태관리를 쉽게 할 수 있다는 점이다.

6. Dispatch와 Commit

Store의 다른 핵심 메소드인 dispatch와 commit을 알아보자. dispatch는 actions를 불러오고, commit은 mutations를 불러온다.

// Store.js
...
// dispatch 메서드 
  dispatch(actionKey, payload) {
    let self = this;
    
    if(typeof self.actions[actionKey] !== 'function') {
      console.error(`Action "${actionKey} doesn't exist.`);
      return false;
    }
    
    console.groupCollapsed(`ACTION: ${actionKey}`);
    
    self.status = 'action';
    self.actions[actionKey](self, payload);
    
    console.groupEnd();
    
    return true;
  }
// commit 메서드
  commit(mutationKey, payload) {
    let self = this;
    
    if(typeof self.mutations[mutationKey] !== 'function') {
      console.log(`Mutation "${mutationKey}" doesn't exist`);
      return false;
    }
    
    self.status = 'mutation';
    
    let newState = self.mutations[mutationKey](self.state, payload);
    
    self.state = Object.assign(self.state, newState);
    
    return true;
  }

dispatch 메소드는 Store가 가지고 있는 actions을 찾고 만약 action이 존재한다면 status를 'action'으로 바꾼다.
store 오브젝트와 payload를 매개변수로 하여 해당 action 함수를 수행하고 만약 action이 존재하지 않는다면 에러를 로그로 남긴다.

dispatch 다음으로 작성한 commit의 동작 방식은 dispatch와 굉장히 비슷하다. store에 존재하는 mutation을 찾고 존재한다면 그것을 수행하고 반환값으로 부터 새로운 state를 받는다. 이렇게 함으로써 상태(state)에 변화를 줄 수 있다.

7. 기본 컴포넌트(Component) 작성

store와 소통하면서 저장된 내용에 따라 독립적으로 업데이트되는 3가지 영역이 있다. 추가된 항목, 해당 항목의 개수, 그리고 screen-reader에 대한 보다 정확한 정보다.

각각 컴포넌트가 하는일은 다르지만 활용하는 자원이 같은 내부 상태를 통제하기 위해 하나의 저장소에서 상태를 공유하는 것이 효과적이다. 이러한 자원을 공유하는 기본 컴포넌트 클래스를 만들어보자.

import Store from '../store/store.js';

export default class Component {
  constructor(props = {}) {
    let self = this;

    this.render = this.render || function() {};  // 방어 로직

    if(props.store instanceof Store) {  // 방어로직
      props.store.events.subscribe('stateChange', () => self.render());
    }

    if(props.hasOwnProperty('element')) {
      this.element = props.element;
    }
  }
}
  • props.store가 Store 클래스의 인스턴스 형태인지 확인하기 위해 Store 클래스를 임포트
  • 컴포넌트는 store 내부에 PubSub 오브젝트의 인스턴스인 events를 이용해 특정 이벤트를 구독한다.
  • stateChange라는 이벤트가 발생할 때마다 자기 자신의 self.render() 메서드를 호출한다.
  • 글로벌한 stateChange 이벤트를 구독함으로써 내부 오브젝트들이 데이터의 변화에 따라 반응할 수 있다.

8. 화면에 들어갈 컴포넌트 작성

js 디렉토리 밑에 components라는 디렉토리를 만들고, list.js 파일을 작성한다.

import Component from '../lib/component.js';
import store from '../store/index.js';

export default class List extends Component {

  constructor() {
    super({
      store,
      element: document.querySelector('.js-items')
    });
  }

  render() {
    let self = this;

    if(store.state.items.length === 0) {
      self.element.innerHTML = `<p class="no-items">You've done nothing yet &#x1f622;</p>`;
      return;
    }

    self.element.innerHTML = `
      <ul class="app__items">
        ${store.state.items.map(item => {
          return `
            <li>${item}<button aria-label="Delete this item">×</button></li>
          `
        }).join('')}
      </ul>
    `;

    self.element.querySelectorAll('button').forEach((button, index) => {
      button.addEventListener('click', () => {
        store.dispatch('clearItem', { index });
      });
    });
  }
};

앞서 작성한 Component 클래스에서 생성자의 매개변수로 props라는 매개변수를 받았습니다. props에는 우리가 만들었던 store 객체와 상태의 변화에 따라 렌더링될 element가 들어가게 된다.

기본 base component 클래스에서 store의 stateChange라는 이벤트를 구독했다. 그리고 그 이전 store에서 Proxy를 이용하여 상태가 바뀔 때마다 stateChange라는 이벤트가 publish 되게 만들었다.

다시 말하면, 상태가 변경될 때마다 지정된 element에 변화가 일어나는데, 그 변화는 render라는 내부 메소드에 의해 결정된다는 것입니다. list.js의 render메소드는 store 내부의 items 속성에 어떠한 데이터가 있느냐에 따라 엘리먼트에 대한 리스트를 렌더링 하게 됩니다. 그리고 옆에 x 모양의 버튼을 만들고 그 버튼에는 store에 해당하는 index에 대한 element에 대해 clearItem이라는 액션을 dispatch하게 만듭니다.

이제 count.js라는 파일을 추가로 작성해보자.

import store from '../store/index.js'

export default class Count extends Component {
  constructor() {
    super({
      store,
      element: document.querySelector('.js-count')
    });
  }
  
  render() {
    let suffix = store.state.items.length !== 1 ? 's' : '';
    let emoji = store.state.items.length > 0 ? '&#x1f64c;' : '&#x1f622;';
    
    this.element.innerHTML = `
      <small>You've done</small>
      ${store.state.items.length}
      <small>thing${suffix} today ${emoji}</small>
	`;
  }
}

list.js와 구조가 비슷하다. 이어서 status도 작성해보자.


import Component from '../lib/component.js';
import store from '../store/index.js';

export default class Status extends Component {
  constructor() {
    super({
      store,
      element: document.querySelector('.js-status')
    });
  }
  
  render() {
    let self = this;
    let suffix = store.state.items.length !== 1 ? 's' : '';

    self.element.innerHTML = `${store.state.items.length} item${suffix}`;
  }
}

현재까지 작성된 파일들의 계층 구조를 살펴보면 다음과 같다.

// 디렉터리 구조
/src
├── js
│   ├── components
│   │   ├── count.js
│   │   ├── list.js
│   │   └── status.js
│   ├──lib
│   │  ├──component.js
│   │  └──pubsub.js
└───── store
       └──store.js
       └──main.js

9. 컴포넌트를 하나로 연결!

store 디렉토리 내부에 state.js 파일을 만들고 다음과 같이 기본 state를 설정해준다.

export default {
  items: [
    'I made this',
    'Another thing'
  ]
};

그 다음엔 몇가지 action들을 추가해주자. store 디렉토리 내부에 action.js 파일을 만들고 기본 action을 설정해준다.

export default {
  addItem(context, payload) {
    context.commit('addItem', payload);
  },
  clearItem(context, payload) {
    context.commit('clearItem', payload);
  }
};

그 밑에 mutation.js도 추가로 작성해준다.

export default { 
  addItem(state, payload) {
    state.items.push(payload);
    
    return state;
  },
  clearItem(state, payload) {
    state.items.splice(payload.index, 1); // 인덱스의 배열을 하나 지운다.
    
    return state;
  }
}

마지막으로 이 store디렉토리 안의 파일들을 하나로 합쳐주는 index.js를 작성하자.

import actions from './actions.js';
import mutations from './mutations.js';
import state from './state.js';
import Store from './store.js';

export default new Store({
  actions,
  mutations,
  state
});

이렇게 하면 하나의 store가 완성된다.

9. 마지막 퍼즐

모든 파일들을 main에 띄우는 일만 남았다.
js 폴더 내부에 main.js 파일을 생성하고 다음과 같이 컴포넌트들을 임포트하자.
또한 우리는 store를 만들었고 여기에 dom 엘리먼트들을 저장할 수 있다.

import store from './store/index.js'; 

import Count from './components/count.js';
import List from './components/list.js';
import Status from './components/status.js';

const formElement = document.querySelector('.js-form');
const inputElement = document.querySelector('#new-item-field');

formElement.addEventListener('submit', evt => {
  evt.preventDefault();

  let value = inputElement.value.trim();

  if(value.length) {
    store.dispatch('addItem', value);
    inputElement.value = '';
    inputElement.focus();
  }
});

const countInstance = new Count();
const listInstance = new List();
const statusInstance = new Status();

countInstance.render();
listInstance.render();
statusInstance.render();
// HTML 코드가 하나로 합쳐진다.

10. 전체 실행 순서

1. Component에서 Store의 dispatch 메서드를 호출한다.

store.dispatch('clearItem', {index});

2. store에 존재하는 action을 찾아 수행한다.

dispatch(actionKey, payload) {
  ...
  self.actions[actionKey](self, payload);
}

3. action에서 commit 을 호출한다면 commit 메서드는 mutation을 불러와서 실제 state 데이터에 변화를 일으킨다.

context.commit('addItem', {index});

commit(mutationKey, payload) {
  ...
  let newState = self.mutations[mutationKey](self.state, payload);
  self.state = Object.assign(self.state, newState); // 결과값 할당 및 불변성 유지
  ...
}

4. mutation 이후 state에 변화가 일어났으므로 Proxy 객체에서 trap으로 설정된 set메서드가 실행되어 publish의 stateChange라는 이벤트를 발생시킨다.

5. stateChnage라는 이벤트가 발생하면 콜백함수가 실행되고 콜백함수로 등록된 render가 수행되면서 실제 엘리먼트에도 변화가 일어난다.

profile
하루에 한걸음씩, 꾸준히

0개의 댓글