자바스크립트로 만든 유한 상태 기계 XState

🗓 2022-09-22

FSM(Finite State Machine, 유한 상태 기계)은 UI를 구현할 때 가끔씩 사용했었습니다. 기본적인 개념만 응용해서 enum과 조건문을 이용해서 구현했었죠. 정해진 상태들이 많이 있고 상태에 따라 다른 동작들이 가능한 상황에서는 고민 없이 FSM을 적용할 것 같습니다. 그럴 땐 어떻게 코드를 짜도 복잡하기 때문에 코드만이라도 상태에 따른 정해진 동작으로 최대한 일관성 있게 만드는 편이 복잡도를 줄일 수 있습니다. 우연히 XState에 대한 내용을 SNS에서 접하고는 대충 FSM의 자바스크립트 구현체인 것은 알고 있었어요. 언젠간 한번 살펴봐야지 했었는데 최근에 시간을 내서 이것저것 공부해 봤습니다. 먼저 요점만 말씀드리면 생각보다 괜찮았으며 다른 상태관리도구 안쓰고 XState만 사용해서 어플리케이션을 만들어도 충분할 것 같다는 생각이 들었습니다. 토이 프로젝트에서 작게 몇 번 사용해 보고 최종적으로 생각을 정리해 봐야겠습니다.

이 글은 XState에 대한 기본적인 개념과 사용방법을 정리했습니다. 공식 사이트 문서들을 보시기 전에 이 글을 읽어보시면 개념 파악에 도움되실 것입니다. 혹시라도 제가 잘못 이해한 부분이 있다면 언제든지 이메일(shirenbeat@gmail.com)로 알려주시면 바로 수정하도록 하겠습니다.

유한 상태 기계란?

유한 상태 기계는 위키에 잘 정의되어 있습니다만 문서상의 정의를 보시면 아마 정이 뚝 떨어지실 겁니다. 저도 저런 문서보다는 더 쉽게 설명된 책을 통해서 이해를 했습니다.

짧게 설명드리면, 특정한 상태들에 따라 동작을 달리해야 하는 경우, 상태의 개수에 따라 복잡도가 매우 증가하게 되는데요. 이런 상황에서 FSM은 제한된 구조와 몇 가지 제약으로 복잡하게 얽힌 코드를 정리합니다. 디자인 패턴중 하나라고 생각하시면 될 것 같습니다.

예를 들어 게임을 만든다고 할게요. 조이스틱의 버튼에 따라 캐릭터의 동작이 달라져야 할 텐데요. A버튼을 누르면 점프를 하고 B버튼을 누르면 공격을 합니다.

const isJumping = false;

function handleInput(button) {
  if(button === 'AButton') {
    jump();
  } else if(button === 'BButton') {
    attck();
  }
}

코드로 보면 이런 식으로 핸들러를 만들겠죠. 아직까진 괜찮아요.

그런데 여기에 추가로 점프 버튼을 누르고 공격을 하면 날아차기를 해야합니다.

const isJumping = false;

function handleInput(button) {
  if(button === 'AButton') {
    isJumping = true;
    jump();
  } else if(button === 'BButton') {
    if (isJumping) {
      isJumping = false;
      jumpAttack();
    } else {
      attck();
    }
  }
}

isJumping 이란 플래그로 상황을 판단합니다. 플래그가 하나 들어갔는데 갑자기 복잡도가 확 올라갔네요.

근데 조건이 날아차기 중에는 다시 점프나 공격이 안돼야 한다고 합니다. 그러면 조건이 좀 더 복잡해지겠죠?

const isJumping = false;
const isJumpAttack = false;

function handleInput(button) {
  if(button === 'AButton') {
    if (!isJumpAttack) {
      isJumping = true;
      jump();
    }
  } else if(button === 'BButton' && !isJumpAttack) {
    if (isJumping) {
      isJumping = false;
      isJumpAttack = true;
      jumpAttack();
    } else {
      attck();
    }
  }
}

상태가 두 가지만 돼도 이렇게 코드가 복잡해집니다. 상황을 표현하는 플래그가 늘어날수록 더 복잡해지겠네요.

이제 코드를 FSM으로 바꿔 볼게요.

const State = {
  STANDING: 0,
  JUMP: 1,
  JUMP_ATTCK: 2,
}

let currentState = State.STANDING;

function handleInput(button) {
  switch(currentState) {
    case State.STANDING:
      if(input === 'AButton') {
        currentState = State.JUMPING;
        jump();
      } else if(input === 'BButton') {
        attck();
      }
      break;
    case State.JUMPING:
      if(input === 'BButton') {
        currentState = State.JUMP_ATTACK;
        jumpAttack();
      }
      break;
  }
}

상태의 종류는 여러 개라도 존재할 수 있는 상태를 하나로 제한하면서 복잡도를 낮췄고요. 상태에 따라 가능한 동작을 정의했어요. 코드의 길이는 오히려 늘었지만 어떤가요? 코드를 읽기가 더 쉬워지지 않았나요? 저는 그렇게 보입니다. 패턴이 있잖아요. 현재 상태와 그에 따라 발생할 수 있는 이벤트(동작)로 단순하게 정리해서 코드를 통해 상태의 관계나 동작을 이해하기 쉽게 만들어줍니다.

FSM으로 정의할 때 필요한 내용을 요약하면 이렇습니다.

  • 가질 수 있는 상태가 한정된다.(미리 정해져 있다.)
  • 한 번에 한 가지 상태만 될 수 있다.(점프와 동시에 서 있을 수 없다.)
  • 입력이나 이벤트가 기계에 전달된다.(버튼 누르기 떼기)
  • 각 상태에는 입력에 따라 다음 상태로 바뀌는 전이가 있다.

직접 구현한 FSM 코드는 기능도 간단하고 딱히 FSM을 별도로 추상화한 내용이 없기 때문에 코드만 봤을 때는 외견상의 큰 잇점은 없어 보이는데요. XState를 이용하면 굉장히 선언적이고 더 가독성 있고 다양한 기능을 포함하는 방법으로 FSM을 사용할 수 있습니다.

설치하기

XState만 설치해서 사용해도 되지만 리액트를 사용해 예시 코드를 작성할 예정이라 리액트용 패키지도 추가로 설치합니다.

> npm i xstate @xstate/react

XState는 react, vue, svelte를 자체적으로 지원하며 개별적으로 필요한 패키지를 설치해줘야합니다.

리액트 패키지를 설치하면 훅을 이용해 XState를 더 쉽게 사용할 수 있게 해줍니다.

상태 기계 정의하기

간단한 장바구니를 만들면서 XState의 개념을 하나씩 훑어보겠습니다.

import { createMachine } from 'xstate';

const cartMachine = createMachine({
  states: {},
});

상태 기계는 createMachine()을 사용해 정의합니다.

states 객체에 이 상태 기계가 어떤 상태를 가질 수 있는지 정의합니다.

장바구니니까 empty, hold 정도로 우선 정의해 보겠습니다. 상태는 비어있거나 뭔가를 담고 있거나 둘 중 하나입니다.

const cartMachine = createMachine({
  id: 'cart',
  initial: 'empty',
  states: {
    empty: {},
    hold: {},
  },
});

두 가지 상태가 정의되었습니다. 추가로 초기 상태 값 initial과 상태 기계의 id도 정의해 줬습니다. XState가 제공하는 상태 기계는 상태의 중첩도 지원하고 심지어 상태 기계 내부에서 또 다른 상태 기계를 품고 있을 정도로 복잡한 상태 구조를 정의할 수 있습니다. 그래서 상태 기계를 식별할 수 있는 고유한 id를 정의합니다.

이벤트 실행하기

현재는 사용할 수 있는 상태만 정의되어 있지 상태가 어떻게 전이될 수 있는지에 대한 정의가 없습니다. 상태는 이벤트를 통해 다른 상태로 전이될 수 있습니다. 이벤트를 추가해 보겠습니다.

상태 노드 안에서 on으로 객체를 만들어 이벤트를 정의합니다. onClick 할 때의 접두사 on이라고 이해하시면 될 것 같습니다.

const cartMachine = createMachine({
  id: 'cart',
  initial: 'empty',
  states: {
    empty: {
      on: {
        ADD_ITEM: {
          target: 'hold',
        },
      },
    },
    hold: {},
  },
});

empty 상태에서 실행될 수 있는 이벤트 ADD_ITEM을 정의했습니다. target 값은 이벤트가 발생되면 전이될 상태의 이름입니다. 즉 empty 상태에서 ADD_ITEM 이벤트가 발생하면 hold 상태로 전이됩니다. 그렇게 되는지 간단한 리액트 컴포넌트를 만들어 확인해 보겠습니다.

import { useMachine } from '@xstate/react';

// ..머신 정의 코드 생략...

const Cart = () => {
  const [state, send] = useMachine(cartMachine);

  return (
    <div>
      <p>{state.value}</p>
      <button
        onClick={() => {
          send('ADD_ITEM');
        }}
      >
        Add Item
      </button>
    </div>
  );
};

XState를 설치할 때 같이 설치했던 @xstate/react덕에 리액트에서 XState를 쉽게 사용할 수 있습니다.

state는 상태 기계의 현재 상태를 표현하는 객체입니다. 단순히 상태에 대한 데이터만 있는 것이 아니라 다양한 데이터를 포함합니다. 현재 상태의 텍스트 정보는 state.value를 통해 얻을 수 있습니다.

그리고 send()함수를 이용해 상태 기계에 이벤트를 전달 합니다.

처음에는 "empty"라는 문자열이 출력되고 Add Item 버튼을 클릭하면 send()에 의해 ADD_ITEM 이벤트가 발생하고 상태가 전이돼 "hold"가 출력됩니다.

컨텍스트와 액션

현재 상태 기계는 상태에 대한 정보는 없이 "들고 있음" 혹은 "들고 있지 않음"이라는 상태만 갖고 있습니다. 즉 실제 들고 있는 상품이 뭔지에 대한 정보가 없습니다. XState는 장바구니에 담긴 상품과 같이 흐름을 제어하는 상태와 구별되는 부가적인 데이터들을 컨텍스트(Context)에 저장합니다. 컨텍스트는 특별할게 없는 단순한 객체입니다.

저는 이점이 가장 마음에 들었습니다. 리액트에서는 상태와 데이터를 모두 useState를 사용하는 경우가 많아 서로 구분을 하지 않는데요. XState은 상태데이터를 명확하게 구분해서 사용합니다. 문서에는 각각 유한한 상태와 무한한 상태라고 표현하면서 컨텍스트를 "extended state"라고도 하는데요. 아직은 조금 복잡한 설명 같습니다.

우선 컨텍스트를 만들어볼게요. 쉽습니다. 배열하나 정의해서 텍스트 형태로 상품이름을 저장하겠습니다.

const cartMachine = createMachine<Context>(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    //...
});

상태 기계의 옵션 안에 context란 이름으로 객체를 정의해 그 안에서 사용할 컨텍스트 데이터들을 초기값과 함께 선언합니다.

컨텍스트의 값을 변경하거나 추가할 때는 액션을 사용합니다. 액션은 XState에서 사이드 이펙트를 사용하는 방법 중에 하나입니다. 컨텍스트를 변경할 때도 사용됩니다.

const cartMachine = createMachine(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    states: {
      empty: {
        on: {
          ADD_ITEM: {
            target: 'hold',
            actions: ['addItem'], // [1]          },
        },
      },
      hold: {},
    },
  },
  {
    actions: { // [2]      addItem: (context, event) => {
        context.items.push(event.item);
        console.log(context.items);
      },
    },
  }
);

[2]에서 actions 객체를 만들어 액션을 정의합니다. 주의하실 것은 createMachine의 두 번째 인자로 정의된다는 것입니다. 추가된 액션은 addItem입니다. 말이 액션이지 일반 함수입니다.

[1]에서 actions란 이름으로 배열을 선언해 ADD_ITEM 이밴트가 발생했을 때 실행될 액션의 목록을 선언합니다.

[2]에서 정의한 액션 함수에는 첫 번째 인자로 context 객체가 통채로 넘어오고 두 번째 인자로 ADD_ITEM 이벤트가 발생됐을 때 전달받은 페이로드가 들어옵니다. 정상적으로 동작하는지 리액트 컴포넌트를 수정해 확인해 보겠습니다.

const Cart = () => {
  const [state, send] = useMachine(cartMachine);

  return (
    <div>
      <p>{state.value}</p>
      <ul>
        {state.context.items.map((name, index) => ( // [1]          <li key={index}>{name}</li>
        ))}
      </ul>
      <button
        onClick={() => {
          send('ADD_ITEM', { item: `item${Date.now()}` }); // [2]        }}
      >
        Add Item
      </button>
    </div>
  );
};

[1]에서 state.context로 컨텍스트에 접근합니다. items을 순회하면서 <li>로 랜더링 합니다.

[2]에서는 send() 함수의 두 번째 인자로 이벤트의 페이로드를 전달합니다. 이 객체에 DOM 이벤트 객체를 그대로 전달해 이벤트를 머신에서 처리하게 할 수도 있습니다. 그런 예시가 XState 공식 문서에도 있는데요. 그럴싸했습니다.

자기 전이

emtpy에서 ADD_ITEM 이벤트가 발생해 hold로 전이된 이후로는 아무리 버튼을 눌러도 아이템이 추가되지 않습니다. hold 노드에는 아무런 이벤트가 정의되지 않았기 때문입니다. empty노드에서 복붙해오겠습니다.

// ...
hold: {
  on: {
    ADD_ITEM: {
      actions: ['addItem'],
    },
  },
},
// ...

이제 버튼을 누르는대로 아이템이 추가됩니다.

눈치채셨을지 모르겠지만 empty와 달리 hold 에서의 ADD_ITEM 이벤트에는 전이할 대상인 target가 없습니다. 이건 실수가 아니고 의도한 것인데요. 전이가 없는 것이 아니라 "self-transition" 즉 hold에서 hold로 자기 전이한 것입니다. 장바구니는 hold 상태에서 아이템이 몇 개가 추가되더라도 구매하거나 의도적으로 장바구니를 비울 때까진 hold 상태이겠죠. 그래서 ADD_ITEM 이벤트가 발생할 때마다 hold로 다시 진입합니다. 컨텍스트 items의 개수는 증가하지만 상태는 그대로인 거죠.

assign으로 컨텍스트 간단하게 업데이트

XState는 상태 기계를 정의하기 위한 여러 가지 편의 함수들을 제공하는데요. 그중에 assign()이라는 함수는 컨텍스트 업데이트를 간단하게 구현할 수 있게 해줍니다.

item배열을 업데이트하는 액션 addItem()을 다시 한번 보겠습니다.

{
  actions: {
    addItem: (context, event) => {
      context.items.push(event.item);
    },
  },
}

items를 배열의 push() 메서드를 사용해 직접 배열을 변경하고 있습니다. 순수한 함수형 API를 제공하는 XState에서는 왠지 불변성을 유지해야 할 것 같은데요. 지금 상태에서 스프레드를 사용할 수 있겠지만 assign()을 사용하면 조금 더 선언적인 형태로 깔끔하게 코드를 작성할 수 있습니다.

{
  actions: {
    addItem: assign({
      items: ({ items }, event) => [...items, event.item],
    }),
  }
}

assign() 함수의 인자로 객체를 넘겨 기존 컨텍스트의 값을 업데이트합니다. addItem 액션은 컨텍스트와 이벤트에 접근해야 하기 때문에 함수로 정의했습니다만 그렇지 않은 경우에는 값이나 리터럴을 그대로 사용해도 됩니다. 예를 들어 초기화하는 액션이 그렇겠네요. 어차피 나중에 필요한 액션이기 때문에 지금 추가해 보겠습니다.

{
  actions: {
    addItem: assign({
      items: ({ items }, event) => [...items, event.item],
    }),
    resetItems: assign({      items: [],    }),  },
}

hold 노드에 RESET_ITEMS 이벤트를 정의하고, 이벤트를 발생시키는 버튼을 리액트 컴포넌트에 추가하면 동작을 확인할 수 있습니다. RESET_ITEMS 이벤트가 발생되면 전이될 다음 상태는 empty가 되어야겠네요. target: 'empty'를 추가합니다.


// machine
RESET_ITEMS: {
  target: 'empty',
  actions: ['resetItems'],
},

// component
<button
  onClick={() => {
    send('RESET_ITEMS');
  }}
>
  Reset Item
</button>

가드

이제 액션을 하나를 더 추가해 장바구니의 아이템을 삭제하는 기능을 만들어 보겠습니다.

액션은 filter()를 사용해서 쉽게 구현할 수 있습니다.


// ...
removeItem: assign({
   items: ({ items }, event) => items.filter((item) => item !== event.name),
}),

일부러 이벤트와 컨텍스트를 구분하려고 event는 디스트럭처링하지 않았습니다.

그리고 hold 노드에 REMOVE_ITEM 이벤트를 추가합니다. 이벤트가 없으면 액션은 실행되지 않습니다.

hold: {
  on: {
    // ...
    REMOVE_ITEM: {
      actions: ['removeItem'],
    },
  }
}

REMOVE_ITEM 이벤트가 발생하면 다시 hold노드로 자기 전이합니다. 그래서 target은 필요 없습니다.

리액트 컴포넌트는 아이템을 렌더링 할 때 REMOVE_ITEM이벤트를 발생시키는 삭제 버튼을 추가해 주면 되겠네요.

// ...
{state.context.items.map((name, index) => (
  <li key={index}>
    {name}
    <button onClick={() => send('REMOVE_ITEM', { name })}>X</button>
  </li>
))}

여기까지 하면 상품 아이템을 하나씩 지우는 것 까지는 잘 동작합니다. 하지만 이미 눈치채신 분들도 계시겠지만 이 코드에는 버그가 있습니다. 아이템을 하나씩 지우다가 결국 아이템이 하나도 없는데도 불구하고 상태는 hold인 버그입니다.

이때 사용할 수 있는 컨셉이 "Eventless Transition" 입니다 always라고도 합니다. 특정 상태로 전이됐을 때 상황에 따라 다른 상태로 곧바로 전이하는 것을 말합니다. 전이될 때마다 자동으로 실행되는 이벤트라고 생각하시면 될 것 같아요.

이때 상황을 판단하는 함수를 따로 정의해서 재사용 할 수 있는데 그런 함수를 가드라고 합니다. 액션을 정의했던 객체에서 guards란 이름으로 객체를 만들어 가드 함수를 정의합니다. 액션과 동일하게 가드 함수의 첫 번째 인자로 컨텍스트가 넘어옵니다.

우리에게 필요한 가드는 isEmpty()items의 크기가 0인지를 판단하는 함수입니다.

{
  actions: {
    // ...
  },
  guards: {
    isEmpty: ({ items }) => items.length === 0
  }
}

이제 해야할 작업은 always를 사용해 hold로 전이될 때마다 isEmpty() 인지를 판단하고 참이면 empty 로 바로 전이시키는 겁니다.

hold: {
  always: {
    target: 'empty',
    cond: 'isEmpty',
  },
  on: {
    // ...
  }
}

현재는 전이될 조건이 하나이지만 always를 배열로 정의해서 여러 개의 조건을 사용할 수 있습니다.

예를들면 이렇게 말이죠

hold: {
  always: [
    {
      target: 'empty',
      cond: 'isEmpty',
    },
    {
      target: 'full',
      cond: 'isFull',
    }
  ],
  on: {
    // ...
  }
}

그리고 가드를 사용한 "조건 전이"는 always뿐 아니라 이벤트에서도 사용할 수 있습니다. always와 동일하게 배열로 이벤트를 정의하면서 가드만 추가하면 됩니다.

장바구니와는 전혀 상관없는 코드지만 공식 사이트의 예시를 잠깐 보여드리겠습니다.

on: {
  OPEN: [
    { target: 'opened', cond: 'isAdmin' },
    { target: '.error', cond: 'shouldAlert' },
    { target: '.idle' }
  ]
}

배열의 첫 번째부터 실행되기 때문에 첫 번째 조건이 우선순위가 높습니다. 그래서 isAdminshouldAlert 조건이 모두 참인 상태이라면 opened로 전이 되게 됩니다. 그리고 배열의 마지막처럼 cond 없이 정의해 .idle로 전이하는 디폴트 전이를 만들 수 있습니다.

서버에 구매요청하기

장바구니에 아이템을 저장했으니 이제 서버에 구매 요청을 해야겠습니다. XState는 비동기 작업을 포함해 상태 기계를 외부와 연동하는 다양한 방법을 제공합니다. 액터 모델을 사용할 수도 있고 서비스를 이용할 수도 있습니다. 여기서는 서비스를 사용해서 API를 호출하는 비동기 작업을 구현해 보겠습니다.

purchasing이라는 상태를 추가하고 그 상태에서 API를 호출하고 결과까지 받아서 최종 완료 상태인 done으로 전이 시키겠습니다.

empty -> hold -> purchase -> (API CALL) -> done

전체적으로 이런 순서로 전이가 이루어집니다. 추가로 =hold=에서 다시 =empty=로 가는 전이도 있습니다.

서비스를 사용해서 API를 호출한다고 말씀드렸는데요. 결국은 Promise를 사용하는 것이기 때문에 이해하는 것이 크게 어렵지 않습니다. 서비스는 프로미스나 콜백 기반의 함수를 사용할 수도 있으며 다른 머신을 서비스로 사용할 수도 있습니다. 심지어 RxjsObservable(옵저버블)을 사용할 수도 있습니다. 여기서는 fetch 메서드로 Promise를 사용해 보겠습니다.

hold 노드 밑에 새로운 노드 purchasing을 만듭니다.

hold: {
  // ...
},
purchasing: {
  invoke: {
    id: 'purchasing',
    src: (context) => postPurchase(context.items),
    onDone: {
      target: 'done',
      actions: ['purchased'],
    },
    onError: {
      // ...
    }
  },
},

서비스는 invoke로 정의합니다. 여기서 src에 프로미스나 머신, 옵저버블 혹은 콜백 함수를 사용해서 서비스 대상과 통신할 수 있습니다. 저희는 프로미스를 사용할 것이기에 postPurchase() 함수는 프로미스를 리턴하겠죠? 함수 구현은 생략하겠습니다. 내부에서 items 목록을 fetch() 함수를 이용해서 서버에 전달한다고 가정합니다. postPurchase()fetch()의 리턴 값인 프로미스를 그대로 리턴합니다.

프로미스를 사용할 수 있다는 건 결국 프로미스의 결과를 받을 수 있는 방법이 존재한다는 것이겠죠. onDone은 프로미스가 resolve됐을 때의 전이를 정의하고 onError는 예상하셨듯 프로미스가 reject됐을 때의 전이를 정의합니다.

onDone에는 실행할 액션으로 purchased가 등록되어 있는데요. 이것은 마지막 부분에서 설명드리겠습니다.

done 상태 노드는 단순합니다.

purchasing: {
  // ...
},
done: {
  type: 'final',
  entry: ['resetItems'],
},

type은 상태 노드의 타입을 정의할 수 있습니다. 현재는 상태 기계가 단순한데요. 상태 노드는 상태 노드 내부에 또 다른 상태 노드들을 중첩해서 정의할 수 있으며 자식 상태들을 병렬적으로 운용할 수도 있습니다. 그만큼 다양한 타입이 있습니다. 공식 사이트 문서를 참고하시면 귀여운 그림과 함께 쉽게 이해하실 수 있습니다.

done 상태 노드의 typefinal입니다. 이 뜻은 이 상태가 마지막 상태란 뜻입니다. 즉 상태 기계가 원하는 목표를 이루고 종료된 것이죠.

상태 기계의 재사용

파이널 노드에 도달해 상태 기계가 종료되면 onDone 콜백을 실행해 줍니다. 그래서 상태 기계를 사용하는 컴포넌트에서 이벤트를 받아 필요한 비지니스 로직을 처리할 수 있습니다. 우리가 만든 장바구니에서는 장바구니를 닫거나 혹은 다른 UI와 연동할 수 있겠네요.

onDone으로 최종 콜백을 등록하는 형태로 상태 기계의 액션이나 가드를 확장해서 컴포넌트별로 다른 비지니스 로직을 처리하게 재사용성을 높일 수 있습니다.

리액트 컴포넌트에서 onDone 콜백은 service 인스턴스를 이용해서 등록할 수 있습니다.

const [state, send, service] = useMachine(cartMachine);

useMachine의 세 번째 인자가 service 인스턴스입니다. 일단 상태 기계의 인스턴스라고 생각하시면 될 것 같습니다.

useEffect(() => {
  const listener = () => console.log('done');

  service.onDone(listener);

  return () => service.off(listener);
}, []);

service.onDone()을 사용해 이벤트 리스너를 등록합니다. 등록된 리스너는 typefinal인 상태 노드로 전이되면 실행됩니다.

그리고 useMachine을 사용할 때 상태 기계의 액션이나 가드를 확장할 수 있는데요.

const [state, send] = useMachine(cartMachine, {
  actions: {
    purchased: (context) => {
      console.log(context.items.length);
    },
  },
});

이런 식으로 useMachine을 사용할 때 두 번째 인자 객체를 이용해 액션과 가드를 확장할 수 있습니다. 아까 API 호출하는 서비스를 정의할 때 onDone에서 purchased라는 액션을 등록했었는데요. 이 액션은 머신을 만들 때는 정의하지 않았지만 이렇게 머신을 사용할 때 정의해서 이미 정의된 머신에 액션을 추가할 수 있습니다. 기존에 정의되어 있는 액션도 덮어쓸 수 있으니 일종의 오버라이드라고 생각하시면 될 것 같습니다. 액션뿐 아니라 가드도 동일한 방법으로 확장하거나 주입할 수 있습니다.

마무리

지금까지 XState의 기본 개념들을 이용해서 간단한 장바구니를 만들어봤습니다. XState는 데이터와 상태를 명확히 구분하고 상태의 흐름과 상태의 전이 조건, 사이드이팩트의 실행 구조 등이 한눈에 보이도록 코드를 작성할 수 있습니다. 여기서의 예제는 정말 간단한 예시였지만 복잡한 구조의 어플리케이션이나 UI에서 사용한다면 그 강점이 더 잘 드러날 것 같습니다. 우선 전체 어플리케이션에서 사용하기보다는 일부 UI에서 XState로 복잡한 UI 상태를 관리하고 외부에서는 XState의 사용을 모르는 정도로만 시도해 본다면 그 가능성을 확인하고 여차하면 롤백 하기 좋지 않을까 생각해 봅니다. 제가 정리한 내용은 굉장히 기초적인 내용들입니다. XState는 유용한 기능들을 많이 갖고 있습니다. 타입스크립트 지원도 훌륭합니다.

XState는 그 유용성에 비해 아직 덜 알려진 도구인 것 같습니다. 이번 기회에 한 번 검토해 보시길 바랍니다.

지금까지 작성한 예제 코드는 이렇습니다.

import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';

import { useEffect } from 'react';

const postPurchase = (cart) =>
  // fetch()로 API호출 했다 치고...
  new Promise((res) => {
    setTimeout(() => {
      res(true);
    }, 2000);
  });

const cartMachine = createMachine(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    states: {
      empty: {
        on: {
          ADD_ITEM: {
            target: 'hold',
            actions: ['addItem'],
          },
        },
      },
      hold: {
        always: {
          target: 'empty',
          cond: 'isEmpty',
        },
        on: {
          ADD_ITEM: {
            actions: ['addItem'],
          },
          RESET_ITEMS: {
            target: 'empty',
            actions: ['resetItems'],
          },
          REMOVE_ITEM: {
            actions: ['removeItem'],
          },
          PURCHASE: {
            target: 'purchasing',
          },
        },
      },
      purchasing: {
        invoke: {
          id: 'purchasing',
          src: (context) => postPurchase(context.items),
          onDone: {
            target: 'done',
            actions: ['purchased'],
          },
        },
      },
      done: {
        type: 'final',
        entry: ['resetItems'],
      },
    },
  },
  {
    actions: {
      addItem: assign({
        items: ({ items }, event) => [...items, event.item],
      }),
      resetItems: assign({
        items: [],
      }),
      removeItem: assign({
        items: ({ items }, event) => items.filter((item) => item !== event.name),
      }),
    },
    guards: {
      isEmpty: ({ items }) => items.length === 0,
    },
  }
);

const Cart = () => {
  const [state, send, service] = useMachine(cartMachine);

  useEffect(() => {
    const listener = () => console.log('done');

    service.onDone(listener);

    return () => service.off(listener);
  }, []);

  return (
    <div>
      <p>{state.value}</p>
      <ul>
        {state.context.items.map((name, index) => (
          <li key={index}>
            {name}
            <button onClick={() => send('REMOVE_ITEM', { name })}>X</button>
          </li>
        ))}
      </ul>
      <button
        onClick={() => {
          send('ADD_ITEM', { item: `item${Date.now()}` });
        }}
      >
        Add Item
      </button>
      <button
        onClick={() => {
          send('RESET_ITEMS');
        }}
      >
        Reset Item
      </button>
      <button
        onClick={() => {
          send('PURCHASE');
        }}
      >
        Purchase Items
      </button>
    </div>
  );
};

export default Cart;
♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2023