April 23 April 27, 2018

김동우 edited this page May 1, 2018 · 2 revisions

Weekly Magazine

Weekly Pick!

원문 : https://www.smashingmagazine.com/2018/01/rise-state-machines/

상태 기계(State Machine)의 부흥

벌써 2018년이 되었지만, 수없이 많은 프론트 엔드 개발자들은 여전히 복잡도 및 부동성(immobility)과 싸우고 있다. 그들은 빠르게 개발하면서도 높은 품질을 얻을 수 있을 수 있는 “버그 없는 어플리케이션 아키텍쳐”라는 성배를 항상 찾아다닌다. 나도 그러한 개발자들 중 한 명인데, 이 문제를 도울 수 있는 흥미로운 것을 찾아내었다.

우리는 ReactRedux와 같은 도구를 통해 좋은 발전을 해왔다. 하지만, 이런 도구들만으로는 대규모 어플리케이션에 대한 해법이 되지 못했다. 이 글에서는 프론트엔드 개발의 관점에서 본 상태 기계(state machine)의 개념을 소개할 것이다. 아마 미처 자각하지 못했을 뿐, 당신은 이미 몇 개의 상태 기계를 만들어 보았을 것이다.

상태 기계란?

상태 기계란 계산(computation)에 대한 수학적 모델이다. 상태 기계는 다양한 상태를 가질 수 있지만, 주어진 순간에 오직 하나의 상태만을 충족하는 기계에 대한 추상적인 개념이다. 상태 기계에는 다양한 종류가 있다. 내 생각에 가장 유명한 상태 기계는 튜링 기계이다. 튜링 기계는 무한 상태 기계이며, 이는 무한한 수의 상태를 가질 수 있다는 것을 의미한다. 튜링 기계는 오늘날의 UI 개발에 어울리지 않는데, 왜냐하면 우리는 대부분의 경우 유한한 수의 상태를 갖기 때문이다. 이런 이유로 MealyMoore와 같은 유한 상태 기계가 더 UI 개발에 적합하다고 할 수 있다.

이 둘의 차이점은, Moore 기계가 이전 상태만을 기반으로 상태를 변경한다는 점에 있다. 불행히도 우리는 사용자 인터렉션이나 네트워크 처리와 같은 수많은 외부 요인들을 갖고 있기 때문에, Moore 기계도 UI 개발에 적당하지 않다. 우리가 찾고 있는 것은 Mealy 기계이다. Mealy 기계는 초기 상태를 가지며, 입력과 현재 상태에 기반해서 새로운 상태로 전이(transition)한다.

역자 주) : 이 글에서 가장 많이 사용되는 단어는 state, transition, action 세 가지이다. state와 transition은 통상적인 컴퓨터 공학의 용어를 따라 "상태"와 "전이"라고 번역하고, action은 적절한 용어를 찾기 어려워 영단어 그대로 "액션"이라고 번역하도록 하겠다.

상태 기계가 어떻게 동작하는지를 명확히 설명하기 위한 가장 쉬운 방법들 중의 하나는 바로 턴스타일(tunstile) (역자 주: 동전/티켓 등을 넣으면 한 명씩만 통과할 수 있는 회전식 개찰구) 이다. 턴스타일은 “잠김(locked)”과 “잠기지 않음(unlocked)” 이라는 유한한 수의 상태를 갖는다. 다음은 이러한 상태들을 유한한 입력 및 전이와 함께 보여주는 간단한 그림이다.

turnstile-high-res-opt

턴스타일의 초기 상태는 잠김(locked) 상태이다. 아무리 밀어도(push) 턴스타일은 잠김 상태를 유지한다. 하지만 동전(coin)을 하나 넣으면, 잠기지 않음(unlocked) 상태로 변경된다. 이 시점에서 다른 코인을 넣어도 아무 변화가 없이 계속해서 잠기지 않음(unlocked) 상태일 것이다. 이제 미는(push) 액션이 가능하며, 턴스타일을 통과할 수 있게 된다. 이 액션은 다시 기계를 초기 상태인 잠김(locked) 상태로 변환시킨다.

만약 이 턴스타일을 제어하는 함수를 하나 만든다면, 아마도 두 개의 인자가 필요할 것이다. 바로 현재 상태와 액션이다. 만약 Redux를 사용하고 있다면, 이 개념이 아주 익숙하게 들릴 것이다. 이는 잘 알려진 reducer 함수와 유사하며, 현재 상태와 액션의 데이터를 기반으로 다음 상태가 무엇이 될 지를 결정한다. 상태 기계의 관점에서 보면 reducer 함수는 전이(transition)다. 사실, 상태를 가지며 그 상태를 변경할 수 있는 어떤 어플리케이션이든 상태 기계라고 부를 수 있다. 다만 우리가 모든 것을 수동으로 매번 구현하고 있을 뿐인 것이다.

상태 기계는 어떤 점이 좋을까?

나는 업무에서 Redux를 사용하고 있으며, 꽤 만족하고 있다. 하지만 내가 좋아하지 않는 몇가지 패턴들이 보이기 시작했다. "좋아하지 않는"다는 말이 동작하지 않는다는 말은 아니다. 좀더 정확히는 이들 패턴이 복잡도를 추가하며 더 많은 코드를 작성하도록 만든다는 것이다. 나는 사이드 프로젝트를 시작해 몇 가지 실험을 해 보면서 React와 Redux 의 개발 습관에 대해 다시 한 번 생각해보기로 했다. 나는 신경쓰이던 것들을 노트에 정리하기 시작했고, 상태 기계를 이용한 추상화가 이러한 문제들 중 몇 가지를 정말로 해결해 준다는 것을 깨달았다. 이제 실전에 돌입해서, 상태 기계를 자바스크립트로 어떻게 구현할 수 있는지를 알아보도록 하자.

우리는 간단한 문제를 해결해 볼 것이다. 백엔드 API로부터 데이터를 불러와서 유저에게 보여주는 것이다. 가장 먼저 할 일은 전이가 아닌 상태의 관점에서 생각하는 법을 배우는 것이다. 상태 기계를 이용하기 전에 이런 기능을 개발하는 나의 작업 순서는 다음과 같았다.

  • 데이터-불러오기 버튼을 화면에 표시한다.
  • 사용자가 데이터-불러오기 버튼을 클릭한다.
  • 백엔드에 데이터를 요청한다.
  • 데이터를 받아와서 해석(parse)한다.
  • 사용자에게 보여준다.
  • 만약 에러가 있으면, 에러 메시지를 보여준 후 데이터-불러오기 버튼을 보여줘서 이 과정을 다시 시작할 수 있도록 한다.

linear-high-res-opt

우리는 선형적으로 생각하고 있으며, 기본적으로 최종 결과로 향하는 가능한 모든 방향을 다루려 하고 있다. 한 단계가 다음 단계로 이어지고, 곧바로 우리는 코드를 분기하기 시작할 것이다. 만약 사용자가 버튼을 더블클릭 하거나, 백엔드의 응답을 기다리는 동안 버튼을 클릭하거나, 요청은 성공했지만 데이터가 손상된 상태라면 어떻게 해야 할까? 이런 경우들을 위해서는 아마도 어떤 일이 발생했는지를 알려주는 다양한 플래그들이 필요할 것이다. 플래그들을 갖는다는 것은 더 많은 if 절을 의미하고, 어플리케이션이 복잡해 질 수록 더 많은 충돌을 일으킬 것이다.

linear-complex-high-res-opt

이것은 우리가 전이를 기반으로 생각하기 때문이다. 우리는 이러한 전이들이 어떻게, 어떤 순서로 일어나는지에 집중하고 있다. 전이 대신에 어플리케이션의 다양한 상태에 집중하는 것이 훨씬 더 단순할 것이다. 우리는 얼마나 많은 상태를 가지며, 이들 상태에서 가능한 입력값은 무엇인가? 동일한 예제를 이용해보자.

  • 대기 (idle)
    • 이 상태에서는 데이터-불러오기 버튼을 보여주고, 기다린다. 가능한 액션은 :
      • 클릭
        • 사용자가 버튼을 클릭하면, 백엔드로 요청을 보낸 후 기계의 상태를 “불러오는 중(fetching)” 으로 전이시킨다.
  • 불러오는 중 (fetching)
    • 요청이 발송된 상태이며, 기다린다. 가능한 액션들은 :
      • 성공
        • 데이터가 손상되지 않고 성공적으로 도착했다. 데이터를 어떤 방식으로든 이용한 후에 “대기” 상태로 다시 전이시킨다.
      • 실패
        • 요청을 생성하거나 데이터를 해석할 때 에러가 발생했다면, “에러” 상태로 전이시킨다.
  • 에러
    • 에러 메시지를 보여준 후에 데이터-불러오기 버튼을 표시한다. 이 상태는 하나의 액션을 받는다.
      • 재시도
        • 사용자가 재시도 버튼을 누르면, 요청을 다시 전송한 후에 기계의 상태를 “불러오는 중” 으로 전이시킨다.

동일한 과정이지만, 이전과는 다르게 상태와 입력값을 이용해 대략적인 설명을 해 보았다.

sm-high-res-opt

이 방식은 로직을 단순화시키며, 더 예측 가능하게 만든다. 또한 위에서 언급했던 몇 가지 문제들도 해결할 수 있다. "불러오는 중" 상태에 있을 때 사용자로부터 어떠한 클릭도 허용하지 않는다는 것을 주목하자. 즉, 사용자가 버튼을 클릭하더라도 아무런 일도 발생하지 않는데, 이는 기계가 해당 상태에서 그 액션에 반응하도록 설정되지 않았기 때문이다. 이 접근 방식은 자동적으로 코드 내의 예측하기 어려운 분기 로직을 제거해준다. 이 말은 테스트를 할 때 다루어야 할 코드량이 줄어든다 는 의미가 된다. 또한 통합 테스트와 같은 특정 유형의 테스트를 자동화할 수도 있다. 어플리케이션이 하는 일에 대한 명확한 개념을 가질 수 있다면, 미리 정의된 상태와 전이를 검토해서 단언(assertion)을 생성해내는 스크립트를 만들 수 있을 것이다. 이 단언(assertion)들은 우리가 가능한 모든 상태에 도달하거나, 특정 경로를 다루었음을 증명해 줄 것이다.

사실 가능한 모든 상태를 기술하는 것이 가능한 모든 전이를 기술하는 것보다 더 쉬운데, 왜냐하면 우리가 어떤 상태가 필요한지를 알기 때문이다. 또한 대부분의 경우 처음부터 전이를 알아내기는 어렵다. 소프트웨어의 버그들은 잘못된 상태 혹은 잘못된 시간에 발생된 액션의 결과이다. 이들은 어플리케이션을 우리가 모르는 상태로 만들고, 이 때문에 프로그램이 깨지거나 잘못 동작하게 된다. 물론 우리는 이런 상황을 원하지 않는다. 상태 기계는 좋은 방화벽이다. 상태 기계는 모르는 상태로 변경되는 것을 방지해주는데, 이는 우리가 어떻게 해야하는지를 명시하지 않고도 언제 어떤 일이 발생할 지에 대한 경계선을 만들었기 때문이다. 이 상태 기계에 대한 개념은 단방향 데이터 흐름과 굉장히 잘 어울린다. 이 둘을 같이 사용하면 코드 복잡도가 줄어들고, 특정 상태가 어디에서 비롯되었는지를 명확하게 알 수 있다.

자바스크립트로 상태 기계 만들기

이야기는 충분히 했으니, 이제 코드를 작성해 보자. 위와 동일한 예제를 사용할 것이다. 위의 목록을 기반으로 다음과 같이 시작해보자.

const machine = {
  'idle': {
    click: function () { ... }
  },
  'fetching': {
    success: function () { ... },
    failure: function () { ... }
  },
  'error': {
    'retry': function () { ... }
  }
}

상태들이 객체 형태로 존재하며, 각 객체는 가능한 입력들을 함수 형태로 갖고 있다. 하지만 초기 상태가 빠져있다. 이 코드를 좀 더 다듬어보자.

const machine = {
  state: 'idle',
  transitions: {
    'idle': {
      click: function() { ... }
    },
    'fetching': {
      success: function() { ... },
      failure: function() { ... }
    },
    'error': {
      'retry': function() { ... }
    }
  }
}

가능한 모든 상태에 대해 정의하고 나면, 입력값을 전송해서 상태를 변경할 준비가 된 것이다. 아래에 있는 두 개의 헬퍼 메소드를 이용해보자.

const machine = {
  dispatch(actionName, ...payload) {
    const actions = this.transitions[this.state];
    const action = this.transitions[this.state][actionName];

    if (action) {
      action.apply(machine, ...payload);
    }
  },
  changeStateTo(newState) {
    this.state = newState;
  },
  ...
}

dispatch 함수는 현재 상태의 전이들 중에 주어진 이름의 액션이 있는지를 확인한다. 만약 있다면, 주어진 데이터를 이용해 액션을 실행한다. 또한 action 핸들러를 실행할 때 machine 을 컨텍스트로 지정하는데, 이렇게 하면 this.dispatch(<action>)와 같은 식으로 액션을 실행하거나 this.changeStateTo(<new state>) 와 같이 상태를 변경할 수 있다.

예제에서 사용자의 여정을 따라가보면, 우리가 수행해야 할 첫 번째 액션은 click 이다. 이 액션의 핸들러는 아래 코드와 같을 것이다.

transitions: {
  'idle': {
    click: function () {
      this.changeStateTo('fetching');
      service.getData().then(
        data => {
          try {
            this.dispatch('success', JSON.parse(data));
          } catch (error) {
            this.dispatch('failure', error)
          }
        },
        error => this.dispatch('failure', error)
      );
    }
  },
  ...
}

machine.dispatch('click');

먼저 기계의 상태를 fetching 으로 변경한다. 그 후에 백엔드로 요청을 발생시킨다. 서비스 모듈에 프라미스(promise)를 반환하는 getData 메소드가 존재한다고 가정하자. 이 프라미스가 해결되고 데이터 해석(parsing)이 정상적으로 완료되면 success를, 그렇지 않으면 failure를 발생시킨다.

잘 진행되고 있는 것 같다. 다음으로, successfailure 액션 및 입력들을 fetching 상태 아래에 구현해야 한다.

transitions: {
  'idle': { ... },
  'fetching': {
    success: function (data) {
      // render the data
      this.changeStateTo('idle');
    },
    failure: function (error) {
      this.changeStateTo('error');
    }
  },
  ...
}

이전 작업에 대해 생각해야 하는 문제로부터 우리의 뇌가 얼마나 해방되었는지를 주목하자. 우리는 사용자의 클릭이나 HTTP 요청에서 발생하는 일들에 대해 신경쓰지 않아도 된다. 우리는 어플리케이션이 fetching 상태에 있다는 것을 알고 있으며, 단 두 개의 액션만을 기대하고 있다. 이는 새로운 로직을 완전히 분리해서 작성하는 것과 약간 비슷하다.

마지막은 error상태이다. 어플리케이션이 실패에서 복귀할 수 있도록 재시도 로직을 제공하면 좋을 것이다.

transitions: {
  'error': {
    retry: function () {
      this.changeStateTo('idle');
      this.dispatch('click');
    }
  }
}

여기서는 click 핸들러에서 작성했던 로직이 중복되어야 한다. 이를 피하기 위해서는 두 개의 액션 모두에서 접근 가능한 함수를 핸들러로 정의할 수도 있고, 아니면 먼저 idle 상태로 전이시킨 후에 click 액션을 수동으로 실행할 수도 있다.

전체 예제는 내 CodePen에서 확인할 수 있다.

라이브러리를 이용한 상태 기계 관리

유한 상태 기계 패턴은 React나 Vue 혹은 Angular 등 어떤 것을 사용하더라도 잘 동작한다. 이전 섹션에서 살펴 보았듯이, 우리는 큰 문제 없이 상태 기계를 구현할 수 있다. 하지만 가끔은 라이브러리가 더 많은 유연성을 제공하기도 한다. 괜찮은 라이브러리들 중에는 Machina.jsXState가 있다. 하지만 이 글에서는 Redux와 유사한 형태로 상태 기계의 개념을 도입한 나의 Stent 라이브러리에 대해서 이야기해 보겠다.

Stent는 상태 기계 컨테이너를 구현한 것이다. Stent는 ReduxRedux-Saga 프로젝트의 개념들 중 몇 가지를 따르고 있지만, 개인적으로 더 간단하며 장황한 코드를 줄여준다고 생각한다. Stent는 Readme-주도 개발을 이용해 개발되었으며, API 설계에만 온전히 몇 주가 걸렸다. 이 라이브러리를 만들면서, 내가 Redux와 Flux 아키텍처를 사용할 때 만났던 문제들을 수정할 기회를 가질 수 있었다.

기계 만들기

대부분의 경우 우리의 어플리케이션은 여러개의 도메인을 다룬다. 하나의 기계만 갖고는 어플리케이션을 만들 수 없다. 그렇기 때문에 Stent는 여러 개의 기계를 만들 수 있게 해 준다.

import { Machine } from 'stent';

const machineA = Machine.create('A', {
  state: ...,
  transitions: ...
});
const machineB = Machine.create('B', {
  state: ...,
  transitions: ...
});

나중에, Machine.get 메소드를 이용해서 이들 기계에 접근할 수 있다.

const machineA = Machine.get('A');
const machineB = Machine.get('B');

기계를 렌더링 로직에 연결하기

내 경우 렌더링은 React를 이용하고 있지만, 다른 어떤 라이브러리도 사용할 수 있다. 렌더링을 발생시키는 콜백을 실행하기만 하면 된다. 내가 처음에 작업했던 기능은 connect 함수이다.

import { connect } from 'stent/lib/helpers';

Machine.create('MachineA', ...);
Machine.create('MachineB', ...);

connect()
  .with('MachineA', 'MachineB')
  .map((MachineA, MachineB) => {
    // ... rendering here
  });

어떤 기계가 필요한지와 이들 기계의 이름을 지정하면 된다. map 함수로 전달되는 콜백은 처음 한 번, 그리고 머신들의 상태가 변경될 때마다 실행된다. 이 때 렌더링을 수행하면 된다. 이 시점에서 연결된 기계들에 직접 접근할 수 있기 때문에 현재 상태와 메소드들을 받아올 수 있다. 콜백이 한 번만 실행하기 위해서는 mapOnce를, 초기 실행을 무시하기 위해서는 mapSilent를 사용할 수도 있다.

편의를 위해, React와의 연결을 위한 특별한 헬퍼 함수도 제공된다. 이 함수는 Redux의 connect(mapStateToProps) 함수와 아주 유사하다.

mport React from 'react';
import { connect } from 'stent/lib/react';

class TodoList extends React.Component {
  render() {
    const { isIdle, todos } = this.props;
    ...
  }
}

// MachineA and MachineB are machines defined
// using Machine.create function
export default connect(TodoList)
  .with('MachineA', 'MachineB')
  .map((MachineA, MachineB) => {
    isIdle: MachineA.isIdle,
    todos: MachineB.state.todos
  });

Stent는 맵핑을 위한 콜백을 실행해서 객체를 넘겨받으며, 그 객체는 React 컴포넌트에 props 형태로 전달된다.

Stent에서 상태란 무엇인가?

지금까지는 상태가 단순한 문자열이었다. 불행히도, 실제 세계에서는 문자열 이상의 상태를 관리해야 한다. 이러한 이유로 Stent의 상태는 실제로 내부에 프라퍼티를 가진 객체이다. 단 하나의 예약된 프라퍼티는 name 이다. 다른 모든 프라퍼티들은 어플리케이션을 위한 데이터이다. 예를 들면 다음과 같다.

{ name: 'idle' }
{ name: 'fetching', todos: [] }
{ name: 'forward', speed: 120, gear: 4 }

지금까지 Stent를 사용해본 경험에 따르면, 상태 객체가 커지게 되면 추가적인 프라퍼티를 다루기 위한 새로운 기계가 필요하게 된다. 다양한 상태들을 정의하는 것은 시간이 걸리지만, 나는 이것이 더 관리하기 쉬운 어플리케이션을 만들기 위한 큰 진전이라 생각한다. 이 작업은 미래를 예측하고, 가능한 액션들에 대한 프레임을 그려보는 것과 약간 유사하다.

상태 기계를 이용해 작업하기

처음 예제처럼, 가능한 (유한한) 상태를 정의하고 가능한 입력값들을 기술해야 한다.

import { Machine } from 'stent';

const machine = Machine.create('sprinter', {
  state: { name: 'idle' }, // initial state
  transitions: {
    'idle': {
      'run please': function () {
        return { name: 'running' };
      }
    },
    'running': {
      'stop now': function () {
        return { name: 'idle' };
      }
    }
  }
});

초기상태인 idlerun 액션을 받는다. 기계가 running 상태에 있을 때는 stop 액션을 실행할 수 있으며, 이 액션은 기계를 다시 idle 상태로 되돌린다.

이전에 작성했던 dispatchchangeStateTo 헬퍼를 기억하는가? Stent도 동일한 로직을 제공하지만, 내부에 숨겨져 있으므로 굳이 이들에 대해 생각할 필요가 없다. Stent는 편의를 위해 transitions 프라퍼티를 기반으로 다음의 것들을 생성해준다.

  • 기계가 특정 상태에 있는지를 확인하는 헬퍼 메소드 : idle 상태를 위해 isIdle()메소드를, running 상태를 위해 isRunning() 메소드를 생성한다.
  • 액션을 수행하기 위한 헬퍼 메소드: runPlease()stopNow()

그러므로, 위의 예제에서는 다음의 메소드를 사용할 수 있다.

machine.isIdle(); // boolean
machine.isRunning(); // boolean
machine.runPlease(); // fires action
machine.stopNow(); // fires action

자동적으로 생성된 함수들을 connect 유틸리티 함수를 이용해 조합하면, 순환을 완성할 수 있다. 사용자 인터렉션은 기계의 입력과 액션을 발생시키며, 이는 상태를 갱신한다. 이 갱신으로 인해 connect 함수로 전달되는 맵핑 함수가 실행되며, 상태 변경을 감지할 수 있게 된다. 그러면, 렌더링을 다시 진행한다.

입력과 액션 핸들러

아마도 가장 중요한 부분이 바로 액션 핸들러일 것이다. 우리가 대부분의 어플리케이션 로직을 작성하는 곳이 액션 핸들러인데, 이는 우리가 입력에 대해 반응해서 상태를 변경해야 하기 때문이다. 내가 Redux에서 가장 좋아하는 부분도 여기와 관련이 있다. 바로 불변성과 reducer 함수의 단순성이다. Stent 액션 핸들러의 본질도 동일하다. 액션 핸들러는 현재 상태와 액션 데이터(payload)를 받고, 반드시 새로운 상태를 반환해야 한다. 핸들러가 아무것도 반환하지 않으면 (undefined), 기계의 상태는 동일하게 유지된다.

transitions: {
  'fetching': {
    'success': function (state, payload) {
      const todos = [ ...state.todos, payload ];

      return { name: 'idle', todos };
    }
  }
}

원격 서버에서 데이터를 불러와야 한다고 가정해보자. 요청을 한 후에 기계를 fetching 상태로 전이시킨다. 백엔드로부터 데이터를 전달받으면, 다음과 같이 success 액션을 발생시킨다.

machine.success({ label: '...' });

그 이후에는 다시 idle 상태로 돌아가서 일부 데이터를 todos 배열의 형태로 보관한다. 액션 핸들러의 형태로 지정할 수 있는 몇가지 다른 형태의 값들도 있다. 먼저 가장 단순한 케이스는 다음 상태를 위한 문자열을 전달하는 것이다.

transitions: {
  'idle': {
    'run': 'running'
  }
}

이 코드는 run() 액션을 이용해 {name: ‘idle’} 상태에서 {name: ‘running’} 상태로 전이시킨다. 이 방식은 동기적으로 상태를 전이시키면서 추가적인 데이터가 필요없을 때 유용하다. 만약 상태에 다른 값이 있다면, 이런 방식의 전이는 그 값을 날려버릴 것이다. 유사하게, 상태 객체를 직접 전달할 수도 있다.

transitions: {
  'editing': {
    'delete all todos': { name: 'idle', todos: [] }
  }
}

우리는 deleteAllTodos 액션을 이용해서 상태를 editing 에서 idle 로 변경시키게 될 것이다.

이미 함수 핸들러는 살펴보았고, 마지막으로 살펴볼 형태의 액션 핸들러는 제너레이터 함수이다. 이 핸들러는 Redux-Saga 프로젝트에서 영감을 받았으며, 다음과 같은 모습이다.

import { call } from 'stent/lib/helpers';

Machine.create('app', {
  'idle': {
    'fetch data': function * (state, payload) {
      yield { name: 'fetching' }

      try {
        const data = yield call(requestToBackend, '/api/todos/', 'POST');

        return { name: 'idle', data };
      } catch (error) {
        return { name: 'error', error };
      }
    }
  }
});

만약 제너레이터에 대한 경험이 없다면, 이 코드가 약간 난해해 보일 것이다. 하지만 자바스크립트에서 제너레이터는 강력한 도구이다. 이를 통해 액션 핸들러를 멈출 수 있으며, 상태를 여러 번 변경하거나 비동기 로직을 다룰 수 있다.

제너레이터와의 즐거운 시간

처음 Redux-Saga를 알게 되었을 때, 나는 비동기 연산을 다루는 과도하게 복잡한 방식이라 생각했다. 사실, Redux-Saga는 커맨드 디자인 패턴의 훌륭한 구현이다. 이 패턴의 주된 이점은 로직을 발생시키는 부분과 실제 구현을 분리시킨다는 점에 있다.

다르게 말하면, 어떻게 해야하는지가 아닌, 무엇을 원하는지만 말하면 된다는 의미이다. 나는 Matt Hink의 블로그 시리즈를 통해 saga가 어떻게 구현되었는지를 이해할 수 있었으며, 여러분도 읽어보기를 강하게 추천한다. 나는 동일한 개념을 Stent로 가져왔는데, 이 글의 목적에 맞추어 설명해 보겠다. 무언가를 yield 한다는 것은 그것을 실제로 수행할 필요 없이 무엇을 원하는지에 대한 명령들을 전달한다는 뜻이다. 액션이 수행되고 나면 다시 제어권을 넘겨받는다.

현재, 몇가지 작업들이 yield 될 수 있다.

  • 기계의 상태를 변경하기 위한 상태 객체 (혹은 문자열)
  • call 헬퍼 (프라미스나 다른 제너레이터 함수를 반환하는 동기 함수를 전달받음) 실행 - 이는 "이걸 실행해줘, 만약 비동기라면 기다렸다가 완료된 다음에 나한테 결과를 전달해줘" 라는 의미이다.
  • wait 헬퍼(다른 액션을 나타내는 문자열을 전달받음) 실행 - 이 유틸리티 함수를 사용하면, 다른 액션이 실행될때까지 핸들러를 멈춘다.

다음은 이들을 표현한 함수이다.

const fireHTTPRequest = function () {
  return new Promise((resolve, reject) => {
    // ...
  });
}

...
transitions: {
  'idle': {
    'fetch data': function * () {
      yield 'fetching'; // sets the state to { name: 'fetching' }
      yield { name: 'fetching' }; // same as above

      // wait for getTheData and checkForErrors actions
      // to be dispatched
      const [ data, isError ] = yield wait('get the data', 'check for errors');

      // wait for the promise returned by fireHTTPRequest
      // to be resolved
      const result = yield call(fireHTTPRequest, '/api/data/users');

      return { name: 'finish', users: result };
    }
  }
}

이처럼 코드는 비동기처럼 보이지만, 실제로는 아니다. 프라미스가 해결되기를 기다린다거나 다른 제너레이터를 순회시키는 등의 지루한 작업을 Stent가 대신 해 주는 것이다.

Stent가 Redux의 문제점을 어떻게 해결하는가?

너무 많은 장황한 (boilerplate) 코드

Redux (그리고 Flux) 아키텍쳐는 시스템을 순환시키는 액션에 의존하고 있다. 어플리케이션이 커질수록 우리는 결국 수 많은 상수들과 액션 생성자들을 갖게 된다. 이 두 가지는 아주 빈번하게 여러 개의 폴더에 존재하기 때문에 코드의 실행을 따라가는 데에 시간이 걸리는 경우가 있다. 또한 새로운 기능을 추가할 때마다 수많은 묶음의 액션을 다루어야 하는데, 이 말은 더 많은 액션 이름과 액션 생성자를 정의해야 한다는 뜻이다.

Stent에서는 액션 이름이 필요 없으며, 라이브러리가 우리를 위해 액션 생성자를 자동으로 만들어준다.

const machine = Machine.create('todo-app', {
  state: { name: 'idle', todos: [] },
  transitions: {
    'idle': {
      'add todo': function (state, todo) {
        ...
      }
    }
  }
});

machine.addTodo({ title: 'Fix that bug' });

우리는 기계의 메소드로 직접 정의된 machine.addTodo 액션 생성자를 갖게 되었다. 이 방식은 내가 경험했던 다른 문제 또한 해결해 준다. 바로 특정 액션에 반응하는 reducer 함수를 찾는 일이다. 보통 React 컴포넌트에서, 우리는 addTodo와 같은 액션 생성자를 볼 수 있다. 하지만 reducer 에서는 상수로 정의된 액션의 타입을 이용한다. 가끔 나는 액션 생성자 코드를 직접 뒤져서 정확한 타입을 확인해야만 한다. 여기서는 이러한 타입이 전혀 없다.

예측하기 어려운 상태 변경

일반적으로 Redux는 불변성 개념을 이용해 상태를 잘 관리해준다. 문제는 Redux 자체에 있는 것이 아니라, 개발자가 특정 시점에 어떤 액션이든 발생시킬 수 있다는 것이다. 만약 불을 켜는 액션이 있다고 할 때, 한 번에 이 액션을 두 번 발생시켜도 되는가? 안된다면, Redux를 이용해서 이 문제를 어떻게 해결해야 할까? 글쎄, 아마도 reducer 코드 내에 불이 이미 켜진 상태인지 아닌지를 검사하는 로직을 추가할 것이고, 아마도 if 문을 이용해서 현재 상태를 검사하게 될 것이다. 자, 여기서 떠오르는 질문이 있다. 이 작업이 reducer의 역할을 벗어난 것은 아닐까? 정말 reducer가 이런 특정 케이스들을 모두 알아야 할까?

내가 Redux에 아쉬운 점은, 조건문을 이용해 reducer 함수를 어지럽히지 않으면서도 어플리케이션의 현재 상태에 따라 액션 발생을 막을 수 있는 방법이 없다는 것이다. 나는 액션 생성자가 실행되는 뷰 레이어에서 이러한 결정을 하는 것도 원하지 않는다. Stent를 이용하면 이 작업이 자동적으로 처리되는데, 왜냐하면 기계가 현재 상태 내부에 선언되지 않는 액션에 대해서는 반응하지 않기 때문이다. 다음의 코드를 살펴보자.

const machine = Machine.create('app', {
  state: { name: 'idle' },
  transitions: {
    'idle': {
      'run': 'running',
      'jump': 'jumping'
    },
    'running': {
      'stop': 'idle'
    }
  }
});

// 이 코드는 잘 동작한다.
machine.run();

// 이 코드는 동작하지 않는다.
// 왜냐하면 기계가 현재 running 상태이며, 
// running 상태에는 오직 'stop' 액션만이 허용되기 때문이다.
machine.jump();

기계가 특정 시점에 오직 특정한 입력만을 받는다는 사실은 우리를 이상한 버그들로부터 보호해주며, 어플리케이션을 더 예측 가능하도록 만들어 준다.

전이 말고, 상태

Redux나 Flux 등은 우리로 하여금 전이의 관점에서 생각하도록 만든다. Redux로 개발할 때의 정신적 모델은 액션과 이들 액션이 reducer에서 상태를 어떻게 변경시키는가에 의해서 아주 많은 영향을 받는다. 이게 꼭 나쁜 것은 아니지만, 나는 전이 대신에 상태의 관점에서 생각하는 것이 더 합리적이라는 것을 알게 되었다. 어플리케이션이 어떤 상태를 가질 수 있으며, 이들 상태들이 비지니스 요구사항을 어떻게 나타내는지의 관점에서 말이다.

결론

프로그래밍, 특히 UI 개발에서의 상태 기계에 대한 개념은 내게 새로운 세상을 열어주었다. 나는 모든 곳에서 상태 기계를 보기 시작했으며, 이 패러다임으로 옮겨가고 싶은 열망을 갖게 되었다. 나는 더 엄격하게 정의된 상태와 이들 간의 전이가 주는 장점이 명백하게 보인다. 나는 항상 어플리케이션이 단순하고 잘 읽혀지도록 만들 방법을 찾고 잇다. 나는 상태 기계가 이러한 방향으로의 한 단계 진전이라 믿는다. 이 개념은 간단하면서도 강력하다. 상태 기계는 수 많은 버그를 제거할 수 있는 가능성을 갖고 있다.

Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.