개요

요즘 쓰이는 최신 프론트엔드 프레임워크를 쓰다보면 상태관리 시스템을 보게 됩니다. 프론트엔드 프레임워크에서 상태관리 시스템을 사용하면 여러 컴포넌트의 상태를 통합적으로 관리할 수 있다는 이점이 있습니다. 이를테면 Vue에는 Vuex라는 상태관리 시스템이 자주 쓰이고, React는 Redux라는 상태관리 시스템이 자주 쓰입니다.

이번 포스팅에서는 프론트 프레임워크와 상관없이 바닐라 자바스크립트로 상태관리 시스템을 만들어볼 것입니다. 상태관리 시스템을 만들면서, 기존의 상태관리 시스템은 어떻게 만들어져있구나 추측도 가능할 것입니다.

이 글은 원 글 Build a state management system with vanilla JavaScript를 참조하여 쓰여진 글입니다.

시작하기

먼저 보일러 플레이트를 다운받을 것입니다.

git clone https://github.com/andybelldesign/vanilla-js-state-management.git
cd vanilla-js-state-management
git checkout -b ab198031714881fa08aef15897e90a6c4ca50494

명령어를 순서대로 입력하여 우리만의 브랜치를 만들어줍시다.
만일 최종 결과 실행 시에 에러가 나거나 한다면 위의 원본 깃헙 소스를 참고하시면 큰 도움이 될 것입니다.

Pub/Sub 만들기

src라는 폴더 밑에 js라는 폴더를 만들고 그 밑에 lib라는 디렉토리와 pubsub.js라는 파일을 만들어주세요.

/js
├── lib
     └── pubsub.js

디렉토리 구조는 위와 같을 것입니다.

Pub/Sub(Publish/Subscribe)은 일종의 디자인 패턴인데 어려운 개념이 아닙니다. 간단하게 유튜브의 구독 개념을 생각하시면 쉽습니다. 우리는 유튜브에서 유튜버의 채널을 구독(Subscribe)할 수 있습니다. 구독하게 되면 어떤 일이 벌어지나요? 유튜버가 영상을 업로드(Publish)했을 때 우리가 알 수 있습니다.

다만, 우리가 여기서 구독할 것은 유튜버가 아니라 프로그램상에서 일어나는 '이벤트'입니다. 이를테면 브라우저상에서 onclick 이벤트를 구독한다면, 어디에서 클릭이 일어나든 알 수 있겠죠. 그리고 그 시점에 payload(여기서는 argument정도로 보셔도 괜찮을 것 같습니다)와 함께 어떠한 callback 함수를 실행하는 것도 가능할 것입니다.

Pub/Sub 패턴을 사용함으로써 여러분의 앱은 Reactive 해집니다. 즉, 데이터가 변할 시에 즉시 화면에 변화가 생깁니다.

pubsub.js를 다음과 같이 작성해줍니다.

export default class PubSub {
  constructor() {
    this.events = {};
  }
}

위의 소스에서 새로운 클래스를 만들었습니다. 그리고 this.events의 기본값을 빈 오브젝트로 만들어주었습니다. this.events 오브젝트는 앞으로 이벤트들을 담는데 사용될 것입니다.

이제 subscribe 메소드를 만들어봅시다.

export default class PubSub {
  constructor() {
    this.events = {};
  }

  subscribe(event, callback) {
    let self = this;

    if(!self.events.hasOwnProperty(event)) {
      self.events[event] = [];
    }

    return self.events[event].push(callback);
  }
}

subscribe 메소드는 이벤트를 받고 콜백 함수를 받습니다. 생성자에서 정의해두었던 events 오브젝트에 해당하는 인자로 받은 이벤트가 없다면 이벤트에 빈 배열을 만들어주고, 거기에 콜백 함수를 푸시합니다. 만일 해당 이벤트가 존재한다면 콜백함수만 푸시하게 될 것입니다. 그리고 우리가 반환하는 값은 해당하는 이벤트에 달린 콜백함수의 길이입니다.

이제 publish 메소드를 추가해봅시다.

export default class PubSub {
  constructor() {
    this.events = {};
  }

  subscribe(event, callback) {
    let self = this;

    if(!self.events.hasOwnProperty(event)) {
      self.events[event] = [];
    }

    return self.events[event].push(callback);
  }

  publish(event, data = {}) {
    let self = this;

    if(!self.events.hasOwnProperty(event)) {
      return [];
    }

    return self.events[event].map(callback => callback(data));
  }
}

publish 메소드는 일단 이벤트에 속한 콜백함수가 있는지 확인하고, 없다면 빈 배열을 반환합니다. 만일 있다면, data를 인자로하여 실행된 콜백 함수의 결과값을 갖은 배열을 반환합니다.

Store (저장소) 만들기

이제 pubsub 모듈을 만들었으니, store를 만들어봅시다. store는 우리에게 중심이 되는 오브젝트입니다. 매번 여러분은 import store from '../lib/store'의 명령어를 쳐야할지 모릅니다. 디렉토리 구조는 다음과 같습니다.

/js
└── lib
    └── pubsub.js
└──store
    └── store.js

store.js는 일단 다음과 같이 작성됩니다.

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

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

단, 여기서 유의깊게 봐야 할 것은 events 속성에 PubSub 클래스가 할당됐다는 것입니다. PubSub 클래스는 우리가 바로 방금 전에 만들었던 클래스로 자체 속성에 events를 갖고 있고, subscribe, publish 메소드를 갖고 있습니다.

그리고 아래 if문들은 클래스 생성 시에 파라미터로 넘긴 값에 actions 프로퍼티나 mutations 프로퍼티가 있다면 그대로 할당합니다.

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

  }
}

위의 소스에서 state를 Proxy 생성자로 재정의해주었습니다. Proxy는 오브젝트를 대신하여 필수적인 작업들을 해줍니다. 우리가 만일 get:이라는 트랩을 추가하면, 오브젝트의 데이터가 요청되었을 때, 매번 특정한 작업을 수행할 수 있습니다. set: 트랩을 추가하면, 당연히 우리가 오브젝트에 설정(set)했을 때, 매번 특정한 작업을 수행하겠지요. 오브젝트의 변화를 감지할 수 있습니다. 이게 오늘 배우는 내용 중에 가장 핵심이 되는 내용입니다.

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

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

여기까지 알 수 있는 점은 Pub/Sub 패턴과 Proxy로 인해 우리가 쉽게 상태관리를 할 수 있다는 점입니다. Pub/Sub 패턴은 각 이벤트에 따른 콜백을 등록하는데 이용되고, Proxy는 오브젝트에 씌워서 트랩을 걸 수 있습니다.

Dispatch와 Commit

이제 Store의 다른 핵심 메소드인 dispatchcommit을 작성해봅시다. dispatchactions를 불러오고, commitmutations를 불러옵니다.

먼저 dispatch 메소드부터 작성합니다.

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

  }

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

이번에 작성한 dispatch 메소드의 로직은 간단합니다. Store 클래스가 가지고 있는 actions 오브젝트에서 actionKey에 맞는 오브젝트가 함수라면 status를 'action'으로 바꾸고 actionKey에 맞는 함수를 store 오브젝트와 payload를 매개변수로 하여 수행하고 아니라면 존재하지 않는다는 메세지를 콘솔에 찍습니다.

이제 commit 메소드를 작성해봅시다.

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

  }

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

commit의 동작 방식은 dispatch와 사실 굉장히 비슷합니다. 다만, actionKey가 mutationKey가 되고 actions이 mutations가 된 것이죠. 그리고 마지막으로 가장 큰 차이점 하나는 상태에 변화를 준다는 것입니다.

기본 컴포넌트(base component) 만들기

우리는 리액트처럼 기본 컴포넌트를 만들고 모든 컴포넌트에서 기본 컴포넌트를 상속하여 쓸 것입니다. 리액트의 기본 컴포넌트 형태는 일반적으로 다음과 같습니다.

import React from 'react';

class HelloWorld extends React.Component {
  constructor(props) {
    super(props);
  }

  // Some Methods...
  doSomething(arg) {
    // Do something...
  }

  render() {
    return (
      // Some JSX...  
    )
  }
}

이제 우리가 만들어서 상속시킬 베이스 컴포넌트를 만들어봅시다. js 폴더 내부에 존재하는 libcomponent.js 파일을 만들어주세요.

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

코드에 대해 간단히 설명하자면, 우리가 기존에 만들어두었던 Store 클래스를 임포트했습니다. Store 클래스를 임포트한 이유는 인스턴스화 시키려는 것이 아니라 props.store가 Store 클래스의 인스턴스 형태인지 확인하기 위해서 입니다. 그리고 컴포넌트는 store 내부에 PubSub 오브젝트의 인스턴스인 events를 이용해 특정 이벤트를 구독합니다. stateChange라는 이벤트가 발생할 때마다 자기 자신의 self.render() 메소드를 수행합니다.

props.store가 Store인지 확인하는 것, render 메소드를 ||로 정의해준 것 모두 에러를 방지하기 위함입니다. 또한 글로벌한 stateChange 이벤트를 구독함으로써 내부 오브젝트들이 데이터의 변화에 따라 반응할 수 있습니다.

실제 화면에 들어갈 컴포넌트 만들기

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

위의 소스에 대한 설명은 간단합니다. 일단 우리가 작성했던 Component 클래스 기억나시나요? 생성자의 매개변수로 props라는 매개변수를 받았습니다. props에는 우리가 만들었던 store오브젝트와 상태의 변화에 따라 렌더링될 element가 들어가게 됩니다. 우리가 기본 base component 클래스에서 storestateChange라는 이벤트를 구독했던 게 기억나시나요? 그리고 우리는 그 이전 store에서 Proxy를 이용하여 상태가 바뀔 때마다 stateChange라는 이벤트가 publish되게 만들었습니다. 다음과 같이 작성했었습니다.

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

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

이제 count.js라는 파일을 component 디렉토리 내부에 만들겠습니다.

import Component from '../lib/component.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와 매우 흡사합니다. list.js 컴포넌트를 설명했던 것과 설명은 매우 비슷하기에 생략하겠습니다.

다음으로는 status.js라는 파일을 component 디렉토리 내부에 만들어보도록 하겠습니다.

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

또 위의 base component을 이용한 코딩을 했습니다. 이게 바로 객체지향 프로그래밍을 이용하는 것의 이점입니다.

여기까지 무사히 따라오셨다면 프로젝트 구조는 다음과 같습니다.

/src
├── js
│   ├── components
│   │   ├── count.js
│   │   ├── list.js
│   │   └── status.js
│   ├──lib
│   │  ├──component.js
│   │  └──pubsub.js
└───── store
       └──store.js
       └──main.js

js 연결시키기

이제 우리는 프론트엔드 컴포넌트를 만들었고 메인 Store를 가졌습니다. 우리가 해야 할 마지막 일은 우리가 만든 모든 것들을 연결하는 것입니다.

store디렉토리 내부에 state.js를 만들어봅시다.

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

다음과 같이 기본 state를 정의해줍니다.

그 이후에 우리가 할 일은 저장소에서 사용할 action을 정의하는 것입니다. 아래에 작성할 action은 컴포넌트의 dispatch 메소드를 이용하여 우리가 가져온 인자(payload)를 commit로 보내고 이 메소드를 통하여 특정한 mutation으로 넘깁니다. 기억나지 않으면 이전에 우리가 commit 메소드를 어떻게 작성했는지 다시한번 살펴봅시다.

어찌됐든 store 디렉토리 밑에 actions.js를 작성합시다.

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

다음으로 store 디렉토리 밑에 mutations.js를 작성합시다.

export default { 
  addItem(state, payload) {
    state.items.push(payload);

    return state;
  },
  clearItem(state, payload) {
    state.items.splice(payload.index, 1); // 인덱스의 배열을 하나 지웁니다.

    return state;
  }
}

이제 마지막으로 store의 모든 js를 합쳐주는 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
});

index.js의 내용은 간단합니다. Store 클래스를 인스턴스화시키고 props 오브젝트 내부에 acitons, mutations, state를 넣어줍니다.

전체적인 실행 순서를 다시 간단히 정리해보면 다음과 같습니다.

대충 다음과 같이 됩니다.

  1. 컴포넌트에서 store의 dispatch 메소드로 액션을 수행하라고 명령합니다.
store.dispatch('clearItem', {index});
// clearItem이 액션 이름이고, index는 payload입니다.
  1. store의 dispatch 메소드에서는 정의된 액션을 이용하여 액션을 수행합니다.
dispatch(actionKey, payload) {
  ...
  self.actions[actionKey](self, payload);
}
  1. 위에서 우리가 actions.js에 정의했던 action 은 commit 메소드를 불러옵니다.
context.commit('addItem', {index});
  1. commit 메소드는 mutation을 불러와서 실제 state 데이터에 변화를 일으킵니다.
commit(mutationKey, payload) {
  ...
  let newState = self.mutations[mutationKey](self.state, payload);
  self.state = Object.assign(self.state, newState); // 결과값을 할당하며, 불변성 유지
  ...
}

Component -> Store -> Action -> Commit -> Mutation의 순서로 진행되었습니다.

Mutation이 일어난 뒤에는 state에 실제로 변화가 일어났기 때문에 우리가 Proxy 오브젝트에서 trap으로 설정해두었던 Set 메소드가 실행되며 stateChange라는 이벤트도 발생하게 되어 render함수가 실행되고 실제 엘리먼트에 변화가 일어납니다.

다시 한번 정리하자면

Component -> Store -> Action -> Commit -> Mutation -> Set(Proxy) -> stateChange -> render

이제 이 모든 프로세스가 이해 되셨다면 성공적입니다.

마지막 퍼즐 맞추기

이제 메인 디렉토리에 main.js 파일을 만들고 우리가 만들었던 모든 퍼즐들을 합칠 것입니다.

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();

위의 formElement.addEventListener에 대한 코드는 우리가 todo list에 할 일을 입력했을 때, 그것이 HTML 웹 API의 기본 스펙처럼 submit되지 않고 우리가 지정한대로 동작하게 만들고, focus를 다시 주는 등의 일을 하기 위해 사용되었습니다.

또한 밑의 Instance 생성과 render는 state가 방금 생성된 초기 상태일 때에 대한 초기화 렌더링을 해줍니다.

여기까지 완성되었다면

npm install --global http-server
http-server

명령어를 이용하여 서버에 직접 우리가 만든 것을 띄워보고 제대로 작동하는지 확인해봅시다.
만일 제대로 작동하지 않는다면 원글 작성자의 깃헙 레포지토리의 소스를 참조해봅시다.