Tech

Finite state machine & statecharts – XState

2021. 09. 17

안녕하세요. 프론트엔드 플랫폼 박제훈입니다. XState

 

플랫폼 내 리액트 상태 관리에 대한 기술 흐름 파악으로 조사를 진행했던 XState에 대한 내용을 공유드리려 합니다.

 

들어가며

우리가 상태를 관리할 때 boolean으로 여러 상태를 관리하는 보편적인 케이스에 대해서 이야기해보려 합니다. 예를 들어 회원가입 페이지 개발 요건을 아래와 같이 전달받았다고 가정해 보겠습니다.

 

XState 화해

  • id, password 입력을 받음
  • password는 8자 이상
  • input 에러일 때 에러 메시지 보여줌
  • OK 버튼을 누르면 서버에 요청
  • 서버에 요청하는 동안 로딩 메시지 보여주고 OK 버튼 비활성화
  • 서버 요청 성공했을 때 성공 메시지 보여줌
  • 서버 요청 실패했을 때 실패 메시지 보여줌

 

먼저 아이디와 암호를 입력받고 submit 요청하도록 구현해보겠습니다. 요청할 때 버튼이 비활성화되도록 isLoading이 true면 버튼이 비활성화되도록 구현했습니다.

 

 

모든 예제 코드는 리액트로 작성했습니다.

// App
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    const resp = await signUp({ id, password });
  } catch (e) {
  }
};

const handleChangeId = (e) => {
  setId(e.target.value);
};

const handleChangePassword = (e) => {
  setPassword(e.target.value);
};

return (
  <div className='app'>
    <form onSubmit={handleSubmit}>
      <label>id</label>
      <input id='id' onChange={handleChangeId} />
      <label htmlFor={'password'}>password</label>
      <input id='password' type='password' onChange={handleChangePassword} />
      <button disabled={isLoading} type='submit'>OK</button>
    </form>
  </div>
);

이후 로딩 메시지를 보여주도록 추가하고 성공 메시지와 에러 메시지도 추가합니다.

// 생략...
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState(null);

const handleSubmit = async (e) => {
  e.preventDefault();
  setIsSuccess(false);
  setIsLoading(true);
  setError(null);
  try {
    const resp = await signUp({ id, password });
    setIsSuccess(true);
  } catch (e) {
    setError(e);
  }
  setIsLoading(false);
};

return (
  <div className='app'>
    // ...생략
    { isLoading && <p>Loading...</p> }
    { isSuccess && <p>회원가입에 성공했습니다.</p>}
    { error && <p>{error}</p>}
  </div>
);

여기까진 복잡하지 않은 구현 과정을 거쳤는데 이후 기획이 수정이 되어 확장을 해야 한다고 가정해봅시다. 서버에 OK 버튼 비활성화 처리를 요청하기 전에 인풋의 유효성을 체크하자고 논의가 되었습니다. 관련 내용을 추가하여 구현해보겠습니다.


// ...생략
const [invalidPassword, setInvalidPassword] = useState(false);

const handleChangePassword = (e) => {
  const pwd = e.target.value;
  setInvalidPassword(pwd.length < 8);
  setPassword(pwd);
};

const isDisabled = isLoading || invalidPassword;

return (
	<div className='app'>
    <button disabled={isDisabled} type='submit'>OK</button>
  // ...
  </div>
);

이번 기능을 구현하면서 조건 처리를 로딩 상태는 isLoading, 서버 요청 성공 시에는 isSuccess, 에러를 노출해야 할 때는 error, 패스워드 유효성 검사 실패 시에는 invalidPassword 변수를 이용하여 여러 상태 처리를 하였습니다. isDisabledisLoadinginvalidPassword를 조합한 상태인데 이런 식으로 추가적인 조건이 boolean의 조합으로 생성되며 늘어날 수 있습니다. boolean은 2개의 상태를 가지며 조건의 개수에 따라 2의 n(boolean의 수) 승의 조합이 생기게 됩니다. 수학에서는 이것을 Combinatorial explosion(조합적 폭발)이라고 부릅니다.

 

여기서 한 가지 고민해볼 포인트는 16(2의 4승) 개의 조합이 기능을 구현할 때 신경 써야 하는 요소인지입니다. 16개의 상태는 로직상 불가능하거나 표현할 필요가 없는 경우가 대부분입니다. 예를 들어 위의 조건이 모두 true가 되는 경우입니다.


const isAwesomeState = isLoading && isSuccess && invalidPassword && !!error; // ??

기획상으로 놓친 조건은 없는지 꼼꼼히 챙기겠지만 조건이 늘어날수록 명시적이지 않은 숨은 조합은 잠재적인 버그를 포함할 수도 있습니다. 위 예제와 같은 조건들은 boolean이 아닌 하나의 상태로서 명확하게 표현할 수 있고, 상태와 동작을 도식화하여 표현할 수도 있습니다. 이런 접근은 Finite State Machine(유한 상태 기계, 이하 FSM)과 Statecharts로 설계하고 javascript 및 typescript로 구현한 XState를 활용하여 구현할 수 있습니다. 아래는 XState를 활용하여 최종적으로 완성한 예제입니다. 요건과 조금 다르게 후술 할 직교 / 병렬 상태를 설명하기 위해 id와 password에 빈 문자열 에러를 추가하였습니다.

 

이제 위의 예제를 XState로 변경하면서 알아가고자 합니다.

 

 

 

FSM

FSM(Finite State Machine, 유한 상태 기계)은 유한한 상태의 전이를 표현하는 수학적인 모델입니다. 많이 쓰이는 분야는 게임과 논리 회로 설계 등이며 전구 동작으로 간단한 예시를 들 수 있습니다. 아래 꺼짐, 켜짐의 상태는 스위치 온/오프 이벤트를 통해 상태를 전이시킵니다.

 

XState 화해

 

일반적으로 FSM은 다섯 부분으로 구성됩니다. (문서영상에서 FSM에 대한 설명을 더 확인할 수 있습니다.)

  • 하나의 초기 상태 (an initial state)
  • 유한 개의 상태 (a finite number of states)
  • 유한 개의 이벤트 (a finite number of events)
  • 현재 상태와 이벤트로 다음 상태를 결정하는 전이 함수 (A transition function that determines the next state given the current state and event)
  • 유한 개의 최종 상태 (a finite final states)

처음 예제로 활용했던 회원가입 서버 요청 부분을 상태 머신으로 표현하면 아래 그림과 같이 표현할 수 있습니다.

 

XState 화해

 

위 그림은 아래 목록과 같이 설명할 수 있습니다.

  • 초기 상태: idle
  • 상태: idle, loading, resolved, rejected
  • 이벤트: fetching, success, failure
  • 전이 함수:
    • fetching: idle → loading
    • success: loading → resolved
    • failure: loading → rejected
  • 최종 상태: resolved

 

 

위 FSM을 Xstate를 활용하여 구현해보겠습니다.


const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCHING: {
          target: 'loading',
        }
      }
    },
    loading: {
      on: {
        SUCCESS: {
          target: 'resolved',
        },
        FAILURE: {
          target: 'rejected',
        }
      }
    },
    resolved: {
      type: 'final',
    },
    rejected: { }
  }
});

XStatecreateMachine 팩토리 함수를 통해 FSM 및 statecharts를 정의할 수 있으며 내부에선 StateNode 객체로 관리가 됩니다. on 속성을 통해 전이에 대한 이벤트 매핑을 할 수 있고 { FETCHING: { target: 'loading' } 처럼 어느 이벤트가 어떤 상태로 전이되는지 표현 가능합니다. 또한 상태 type을 final로 지정해서 최종 상태 노드임을 표현할 수 있습니다.

 

XState는 이러한 machine을 시각화해주며 간단한 동작 테스트를 할 수 있는 헬퍼 툴인 visualizer를 제공해 줍니다.

 

 

이제 boolean 상태 체크를 해제하고 Xstate의 상태를 활용해 다시 구현해보겠습니다. React에 Xstate를 사용할 때는 hook으로 사용할 수 있도록 래핑 된 @xstate/react 패키지를 지원해 줍니다.


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

useMachine hook은 상태 객체인 state(StateNode)와 interpreter 그리고 interpreter의 send 함수로 구성되어 있습니다. useMachine의 초기값으로 설정한 상태는 StateNode 객체로 생성이 되고 이 StateNodestates 속성에 계층적으로 구성됩니다. (이 계층 구조는 statecharts에서 다시 다룹니다.) StateNode에는 다른 상태로 전이하기 위한 transition 함수가 있는데 기능을 상태와 전이 만으로 구현하는 방법도 있지만 더 유용하게 활용할 수 있도록 StateNode를 해석(interpret) 하여 아래 기능을 사용하기 쉽게 제공하는 객체가 있습니다.

  • 상태 전이
  • 액션(혹은 side-effects) 실행
  • 지연 / 다중 이벤트
  • 상태 전이, context 변경 등 다중 이벤트 리스너
  • 이 외 많음

 

아래 예제와 같이 machine을 생성하고 transition으로 전이를 실행시킬 수도 있고 interpreter 객체를 활용하여 send 함수로 이벤트를 전이시킬 수도 있습니다.

const machine = createMachine({
  states: {
    // state node
    idle: {
      on: {
        FETCH: {
          target: 'pending';
        }
      }
    },
    pending: { }
  }
});

// transition 함수는 현재의 상태와 전이할 이벤트를 명시합니다.
const nextState = machine.transition('idle', { type: 'FETCH'});
// State { value: { 'pending' } ... }

// 위 동작이랑 같음.
const service = interpret(machine);
service.start();
service.send('FETCH');
// State { value: { 'pending' } ... }

isLoadingisSuccess는 machine의 state로 대체하고 현재 상태에 대한 확인은 state.matches() 메서드를 이용할 수 있으며, 상태를 값으로 넘겨줍니다.


state.matches('loading')

이제 아래와 같이 전체적으로 수정해보겠습니다. 변경되지 않은 부분이 많이 있어서 이상하지만 아직 다음이 남아있습니다!


const [state, send] = useMachine(fetchMachine);
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [invalidPassword, setInvalidPassword] = useState(false);
const [error, setError] = useState(null);

const handleSubmit = async (e) => {
  e.preventDefault();
  send('FETCHING');
  if (error) {
    setError(null);
  }
  try {
    await signUp({ id, password });
    send('SUCCESS');
  } catch (e) {
    setError(e);
    send('FAILURE');
  }
};

const handleChangeId = (e) => {
  setId(e.target.value);
};

const handleChangePassword = (e) => {
  const pwd = e.target.value;
  setInvalidPassword(pwd.length < 8);
  setPassword(pwd);
};

const isDisabled = state.matches('loading') || invalidPassword;

return (
  <div className='app'>
    <h1>회원가입</h1>
    <form onSubmit={handleSubmit}>
      <label>id</label>
      <input id='id' onChange={handleChangeId} />
      <label htmlFor={'password'}>password</label>
      <input id='password' type='password' onChange={handleChangePassword} />
      <button disabled={isDisabled} type='submit'>OK</button>
    </form>
    { state.matches('loading') && <p>Loading...</p> }
    { state.matches('resolved') && <p>회원가입에 성공했습니다.</p>}
    { state.matches('rejected') && <p>{error}</p>}
  </div>
);

 

 

 

Statecharts

state explosion

가장 처음 예제와 FSM을 비교해 보면 원래 문제를 제시했던 combinatorial explosion 현상과 유사한 문제점이 남아있습니다. FSM은 단일 상태만을 허용하기 때문에 상태 간의 조합은 다른 상태로서 새롭게 관리하는 방법으로 접근해야 합니다.

 

예를 들어 위의 boolean 플래그와 별도로 조합한 변수인 isDisabled를 FSM의 상태로서 관리하려면 or 연산인 loading 상태이거나 invalidPassword 상태를 모두 하나의 상태로서 관리해야 합니다. 이는 아래와 같이 표현할 수 있을 것입니다.

 

XState 화해

 

만약 여기서 disabled에 관여하는 상태를 하나 더 추가한다면 2배의 상태가 더 필요해집니다.

 

XState 화해

이미지 출처 State Machine: State Explosion – Statecharts

 

 

선택적으로 조건을 분리하는 boolean 상태 관리와 달리 이 경우 모든 상태가 이벤트로 전이되어야 합니다. 이렇게 된다면 모든 상태에 대한 변화를 인지해야 해서 관리가 더 어려워지고 동시에 전이 조합이 복잡해져 도식화를 통한 이점마저 사라집니다. 이와 같은 문제를 State Explosion이라고 부릅니다.

 

 


 

 

1987년 컴퓨터 과학자인 David Harrel은 FSM이 가지고 있는 문제 중 특히 상태 머신의 규모가 커짐에 따라 발생하는 state explosion 문제를 해결하고 FSM을 보다 유용하게 사용하고자 Statecharts를 제안합니다.

 

Statecharts는 FSM을 확장하여 아래의 개념을 추가하였습니다.

  • Extended state (context)
  • Actions (entry/exit/transition)
  • Guards (conditional transitions)
  • Hierarchical (nested) states
  • Orthogonal (parallel) states
  • History

 

이제 단계별로 회원가입 코드를 개선하면서 statecharts를 훑어보겠습니다.

 

 

extended state (context)

XState에서는 context로 표현되는 확장된 상태가 있습니다. 회원가입 예제에서 사용되는 id, password, error는 statecharts의 내부 값으로 관리가 가능합니다. machine 내부에서 assign이라는 메서드를 통해서 context를 업데이트할 수 있으며, 외부에서 context를 변경하는 것은 금지하고 있습니다.


export const fetchMachine = createMachine({
  // ...
  context: {
    id: '',
    password: '',
    error: null
  },
  idle: {
    on: {
      UPDATE_ID: {
        actions: assign((context, event) => ({
          id: event.data
        }))
      },
      UPDATE_PASSWORD: {
        actions: assign((context, event) => ({
          password: event.data
        }))
      },
      // ...
    },
  },
  loading: { // loading에 초기 진입했을 때 error null로 초기화.
    entry: assign((context, event) => { error: null });
  }
// 후략 ...

컴포넌트 내부도 아래와 같이 변경합니다.


const [state, send] = useMachine(fetchMachine);
// 아래 state로 관리되는 부분 제거
// const [id, setId] = useState('jehoon');
// const [password, setPassword] = useState('password1');

const handleSubmit = async (e) => {
  e.preventDefault();
  send('FETCHING');
};

const handleChangeId = (e) => {
  send('UPDATE_ID', { data: { id: e.target.value }});
};

const handleChangePassword = (e) => {
  const pwd = e.target.value;
  setInvalidPassword(pwd.length > 8);
  send('UPDATE_PASSWORD', { data: { password: pwd }});
};


return (
  // ...
  <input id='id' onChange={handleChangeId} />
  <input id='password' type='password' onChange={handleChangePassword} />
  // ... 생략
  {state.matches('rejected') && <p>{error}</p>}
);

Xstate의 visualizer로 아래처럼 확인할 수 있습니다.

 

 

actions

FSM은 상태와 이벤트의 전이만으로 구성되어 있기 때문에 FSM만으로 구현하려면 상태와 함께 발생하는 side-effect는 별도로 처리를 해줘야 합니다. 예를 들어 state의 상태 변화를 감지하여 useEffect에서 fetch를 실행시키는 방식으로 기존의 코드를 아래와 같이 변경할 수 있습니다. (다만 아래 예제는 XState를 이용해 machine으로 생성할 경우 사용할 일이 거의 없는 설명을 위한 예제입니다.)


// 원래 코드
// const handleSubmit = async (e) => {
//   e.preventDefault();
//   send('FETCHING');
//   if (error) {
//     setError(null);
//   }
//   try {
//     await signUp({ id, password });
//     send('SUCCESS');
//   } catch (e) {
//     setError(e);
//     send('FAILURE');
//   }
// };

const handleSubmit = async (e) => {
  e.preventDefault();
  send('fetching');
};

useEffect(() => {
  if (state.matches('loading')) {
    const doFetch = async () => {
      if (error) {
        setError(null);
      }
      try {
        await signUp({ id, password });
        send('success');
      } catch (e) {
        setError(e);
        send('failure');
      }
    };
    doFetch();
  }
}, [state]);

FSM은 상태와 이벤트로만 구성되기 때문에 상태에 반응하려면 위와 같이 코드를 작성해야 합니다. 이벤트를 통한 상태 전이가 발생할 때 다음 상태인 loading으로 변경되면 signUp 함수를 호출합니다. 이와 같이 이벤트를 발생시켜 상태를 전이시킬 때는 다음 상태와 함께 어떤 동작이 같이 발생할 수도 있다는 것을 알 수 있습니다. 이에 대해 XState 메인테이너는 ‘전이는 effects를 수반한다’라고 표현했습니다.

  • state + event -> next state + effects

이러한 effects가 발생하는 타이밍은 상태에 진입/이탈 순간(enter/exit)과 전이(transition) 중 3가지 발생 시점으로 정리할 수 있습니다. statecharts에서 정의한 actions와 매칭 되는 부분입니다.

 

 

XState 화해

 

위 코드는 Xstate로 아래처럼 수정할 수 있습니다. actions에는 동기 / 비동기 함수를 모두 호출할 수 있는데 우리는 비동기 동작에 대한 액션이 필요합니다. invokeXstate의 statecharts에서 비동기 동작을 발생시키기 위한 방법 중 하나이며 여기에서 예제를 확인할 수 있습니다. invoke를 통해 실행된 비동기 함수는 각각 onDone과 onError 속성에 promise의 결괏값을 리턴해주고 기존에 작성한 SUCCESS와 FAILURE 이벤트와 동일한 역할을 수행합니다.


const toggleMachine = createMachine({
  // ...
  loading: {
    entry: assign((context, event) => ({
      error: null
    })),
    invoke: {
      id: 'signUpForm',
      src: (context, event) => signUp(event.data),
      onDone: {
        target: 'resolved'
      },
      onError: {
        target: 'rejected',
        actions: assign((context, event) => ({
          error: evt.data
        }))
      }
    }
  }
  // ...
});

아래 visualizer를 확인하면 loading 상태에 invoke가 추가된 것을 확인할 수 있습니다.

 

 

Guards (conditional transitions)

상태의 전이가 발생하기 전에 특정 조건들을 만족해야 전이 가능하도록 설계도 가능합니다. 아래처럼 cond 속성을 활용하여 첫 예제에서 invalidPassword 부분을 수정해 보겠습니다. fetchMachine.transition 함수의 changed 속성은 cond를 다음 단계로 넘어갈 수 있도록 positive 조건으로 설정하고 machine이 미래에 변경이 되는지 확인하는 방식으로 체크 가능합니다.


// machine
export const fetchMachine = createMachine({
  // ...
  FETCHING: {
    target: 'loading',
    cond: (context, event) => {
      return context.password.length > 7;
    }
  }
  // ...
});

// validation 용 필드는 삭제합니다.
// const [invalidPassword, setInvalidPassword] = useState(false);

// disabled 조건에 machine이 다음 Transition이 변화되었는지를 판단합니다.
const isDisabled = state.matches('loading') || fetchMachine.transition(state, 'FETCHING').changed;

return (
// ...
  <button disabled={isDisabled} type='submit'>OK</button>
)

또한 cond 속성에 사용한 함수는 machine의 옵션으로 guards 속성에 함수화하여 사용할 수도 있습니다.


// machine
export const fetchMachine = createMachine({
  // ...
  FETCHING: {
    target: 'loading',
    cond: 'isAvaliablePasswordLength', // 함수의 이름이어야 합니다.
  }
  // ...
}, { // 두번째 인자값입니다.
  guards: {
    isAvaliablePasswordLength: (context, event) => {
      return event.data?.password.length > 7;
    }
  }
});

 

 

Hierarchical states

처음 FSM의 문제점에서 언급했던 state explosion을 방지하기 위해서 계층/병렬 상태 구조를 statecharts에서 활용할 수 있습니다. 계층적 구조의 특징은 아래와 같습니다.

  • state를 세분(refinement)할 수 있음
  • 유사한 전이를 그룹화할 수 있음
  • 상태의 격리(isolation) 가능
  • 컴포저블(composability)을 권장

 

이제 guard를 적용하면서 수정했던 코드를 다시 수정해보겠습니다.


export const fetchMachine = createMachine({
  // ...
  states: {
    idle: {
      // idle과 계층 구조로 하위에 에러 관리를 위한 state를 추가합니다.
      states: {
        noError: {},
        errors: { // 계층 구조가 필요하면 한 번 더 nested 할 수 있습니다.
          states: {
            tooShort: {}
          }
        }
      },
      // ...
      on: {
        UPDATE_PASSWORD: [{
          target: '.errors.tooShort',
          cond: 'isPasswordShort',
          actions: 'cachePassword',
        }, {
          target: '.noError',
          actions: 'cachePassword',
        }],
        FETCHING: {
          target: 'loading',
        }]
      },
  }
}, {
  actions: { // actions에 호출되는 함수들을 config로 관리하도록 변경했습니다.
    cachePassword: assign((context, event) => ({
      password: event.data?.password,
    }))
  },
  guards: {
    isPasswordShort: (context, event) => {
      return event.data?.password.length < 8;
    }
  }
}

});
// ... 후략

states를 보면 idle 내부에 noErrorerrors로 구성된 계층 상태를 생성했고 errors 내부는 tooShort으로 한 번 더 계층구조로 감쌌습니다. XState에서 표현하기론 무한대로 계층구조 생성이 가능하다고 합니다.

 

이전 코드에서는 FETCHING 이벤트 일 때 조건 체크하던 것을 UPDATE_PASSWORD 이벤트가 발생하면 조건 체크하도록 변경했습니다. XState에서는 UPDATE_PASSWORD와 같이 액션을 연속으로 호출하면서 cond로 조건을 추가해 줄 수 있는데, 먼저 true를 만나는 액션에서 행동이 종료됩니다. 그래서 password 조건 체크에서 에러 발생 상황을 먼저 체크하도록 위와 같이 코드를 변경하였습니다.

 

컴포넌트에 적용되는 코드도 아래와 같이 수정합니다.


// component.tsx

// const isDisabled = state.matches('loading') || fetchMachine.transition(state, 'FETCHING').changed;
const isDisabled = [{ idle: 'errors' }, 'loading' ].some(state.matches);

return(
  // ...
  {state.matches('idle.errors') &&
  (<>
    {state.matches('idle.errors.tooShort') && <p>비밀번호는 8자 이상이어야 합니다.</p>}
  </>)}
);

기존의 isDisabled 체크는 matches와 transition을 혼합하여 사용했었는데, 상태를 계층 구조로 변경하면서 idle의 내부 상태로서 idle.errors를 포함한 상태 이거나 loading 상태이거나로 조건을 변경할 수 있었습니다. 두 가지 이상의 상태 중 하나를 포함하는지를 판단하는 방법으로는 some을 사용하도록 공식 문서에서 가이드하고 있습니다.

 

errors 메시지는 errors 상태를 활용해 코드와 같이 활용할 수 있다는 예시로 추가했습니다.

 

 

 

Orthogonal states

병렬 상태는 여러 직교(orthogonal) 상태의 노드를 나타냅니다. 병렬 상태는 동시에 모든 하위 상태에 있습니다. 그리고 자식 상태로서 존재하고 서로 직접적으로 종속되지 않으며 병렬 상태 노드 간에 전환이 없어야 합니다. XState에서는 type: parallel로 선언합니다. 선언부를 확인해보면 initial 값을 갖지 않는데 초깃값 선언이 불가능하며 상태 자체가 병렬 상태를 감싸고 있는 방식으로 구성됩니다.


const fileMachine = createMachine({
  // ...
  type: 'parallel',
  // initial: '??' <- 병렬 상태의 값의 초기화는 각각의 병렬 상태에서 진행
  states: {
    upload: {
      inital: 'idle',
    },
    download: {
      inital: 'idle',
    },
  },  
});

에러 상태에 대한 코드를 병렬 상태로 변경해보겠습니다. id 속성에 빈 값을 허용하지 않도록 추가하고 password도 빈 값을 허용하지 않음을 표현하기 위해서 error 필드를 병렬로 구성합니다.


export const fetchMachine = createMachine({
  states: {
    idle: {
      type: 'parallel', // 추가 및 하위 id, password 에러 상태를 병렬로 구성
      states: {
        id: {
          initial: 'noError',
          states: {
            noError: {},
            errors: {
              states: {
                empty: {}
              }
            }
          }
        },
        password: {
          initial: 'noError',
          states: {
            noError: {},
            errors: {
              states: {
                empty: {},
                tooShort: {}
              }
            }
          }
        }
      }
    },
    on: {
      UPDATE_ID: [
        { // 상태는 idle 내부의 id, password 병렬 상태로 구성되기 때문에 .id로 표현
          target: '.id.errors.empty',
          cond: 'isInputIdEmpty',
          actions: 'cacheId'
        },
        {
          target: '.id.noError',
          actions: 'cacheId'
        }],
      UPDATE_PASSWORD: [
        {
          target: '.password.errors.empty',
          cond: 'isInputPasswordEmpty',
          actions: 'cachePassword'
        },
        {
          target: '.password.errors.tooShort',
          cond: 'isInputPasswordShort',
          actions: 'cachePassword'
        },
        {
          target: '.password.noError',
          actions: 'cachePassword'
        }
      ],
      FETCHING: [
        { // 유효성 검사를 fetching 이벤트가 발생할 때도 추가하였습니다.
          target: '.id.errors.empty',
          cond: 'isContextIdEmpty'
        },
        {
          target: '.password.errors.empty',
          cond: 'isContextPasswordEmpty'
        },
        {
          target: '.password.errors.tooShort',
          cond: 'isContextPasswordShort'
        },
        {
          target: 'loading'
        }]
    },
  }
},{
  guards: { // guards에는 context와 event를 구분 짓게 나눠보았습니다.
    isContextIdEmpty: (context, event) => {
      return context.id?.length === 0;
    },
    isInputIdEmpty: (context, event) => {
      return event.data?.id.length === 0;
    },
    // ... 위 함수들을 비슷하게 추가
  }
});

// component

// 에러에 대한 구분을 'id.errors'와 'password.errors'로 세분화합니다.
const isDisabled = [{ idle: 'id.errors' }, { idle: 'password.errors' },
 'loading'].some(state.matches);
}

return (
  // ...
  // 에러는 아래처럼 추가합니다.
  {state.matches('idle.id.errors') &&
  (<>
    {state.matches('idle.id.errors.empty') && <p>아이디 값이 없습니다.</p>}
   </>)}
   {state.matches('idle.password.errors') &&
   (<>
     {state.matches('idle.password.errors.tooShort') && <p>비밀번호는 8자 이상이어야 합니다.</p>}
     {state.matches('idle.password.errors.empty') && <p>비밀번호 값이 없습니다.</p>}
    </>)
  }
)

이제 visualizer에서 확인해보면 가장 처음 소개한 도식 화면과 같이 표현됩니다. 예제로 변경하는 과정은 여기서 마무리하겠습니다.

 

 

History

위의 statecharts 소개에 설명했지만 예제에는 적용하지 않은 history는 statecharts의 주요 기능이라 간단하게 소개하려 합니다. 이벤트 발생으로 상태가 다른 상태로 전이되어 이전 상태로 다시 되돌아갈 때 활용할 수 있습니다.

 

아래 예제는 egghead XState 강좌의 예제로 참고하였습니다.


// deep history
const spaceHeaterMachine = Machine({
  id: 'spaceHeater',
  initial: 'poweredOff',
  states: {
    poweredOff: {
      on: { TOGGLE_POWER: 'poweredOn.hist' },
    },
    poweredOn: {
      on: { TOGGLE_POWER: 'poweredOff' },
      type: 'parallel',
      states: {
        heated: {
          initial: 'lowHeat',
          states: {
            lowHeat: {
              on: { TOGGLE_HEAT: 'lowHeat' },
            },
            highHeat: {
              on: { TOGGLE_HEAT: 'highHeat' },
            },
          },
        },
        oscillation: {
          initial: 'disabled',
          states: {
            disabled: {
              on: { TOGGLE_OSC: 'enabled' },
            },
            enabled: {
              on: { TOGGLE_OSC: 'disabled' },
            },
          },
        },
        hist: {
          type: 'history',
          history: 'deep',
        },
      },
    },
  },
})

예제를 보면 poweredOff 상태에서 poweredOn.hist 상태로 전이시키는데 상태의 type을 보면 history 타입임을 확인할 수 있습니다. poweredOff 상태에서 벗어날 때 직전의 상태를 history 타입에서 기억하고 있습니다. 내부에서 아무런 상태 업데이트되지 않았다면 초기 상태를 반환하고 변경이 있었다면 그 변경된 상태를 기억합니다. history에는 shallow, deep 두 가지 기록 유형이 있는데 최상위 기록 값만 정할지 자식 state도 기록할지 여부에 따라 달라집니다. 1-depth 일 때는 동작의 차이가 없는데 계층 / 병렬 상태일 때 차이를 확인할 수 있습니다.

 

아래의 shallow 타입의 history는 복원 시점에서 heated / oscillation의 내부 상태를 가져오지 못합니다.

 

 

아래의 deep 타입의 history는 복원 시점에 내부 상태까지 전부 복원 가능합니다.

 

 

 

Appendix

 

Delayed event and transitions

XState에 대한 콘셉트나 기능은 이 문서에서 확인이 가능합니다. 최대한 예제에 XState의 기능을 담아내 보려고 했는데 설명하지 못한 Delayed Event and Transitions만 추가로 설명하고 넘어가려 합니다. 아래의 예제는 Cypress – realworld에서 참조하였습니다.


export const snackbarMachine = Machine(
  {
    id: "snackbar",
    initial: "invisible",
    context: {
      severity: undefined,
      message: undefined,
    },
    states: {
      invisible: {
        entry: "resetSnackbar",
        on: { SHOW: "visible" },
      },
      visible: {
        entry: "setSnackbar",
        on: { HIDE: "invisible" },
        after: {
          // after 3 seconds, transition to invisible
          3000: "invisible",
        },
      },
    },
  },
  {
    actions: {
      setSnackbar: assign((ctx, event: any) => ({
        severity: event.severity,
        message: event.message,
      })),
      resetSnackbar: assign((ctx, event: any) => ({
        severity: undefined,
        message: undefined,
      })),
    },
  }
);

visualizer에서 확인해보면 전이에 delay가 3초가 적용되어 다음 상태로 넘어감을 확인할 수 있습니다.

 

 

 

Example

화해팀은 JIRA를 활용하여 업무 상태를 관리하고 있습니다. 각각의 프로젝트마다 업무 성격에 맞는 워크플로우가 필요합니다. 플로우를 애플리케이션에서 시각화한다면 내 업무가 backlog 상태인지 top priority 상태인지 확인도 필요하지만 각각의 상태가 어떤 상태로 이동할 수 있는지 조건들도 체크해야 합니다.

아래는 화해팀 특정 프로젝트의 워크 플로우를 시각화한 화면입니다.

 

 

XState 화해

 

 

backlog 상태는 top priority 상태로만 이동할 수 있으며, top prioritybacklogpending 그리고 inprogress로 상태를 이동할 수 있습니다. top priority 상태에서 pending으로 이동하면 inprogress , top priority으로 이동하며 상태를 전환할 수 있습니다. 어떤 상태이든 drop 상태가 될 수 있습니다. 이런 복잡한 상태의 전환을 구현할 때 우리는 상태 머신으로 효율적으로 접근할 수 있습니다.

 

 

마치며

FSM / statecharts는 리액트의 상태 관리 만을 위한 개념이 아니라 여러 공학 분야에서 사용할 수 있는 수학적 모델이지만 이번 블로그에서는 특히 리액트에서 사용하는 방법에 대해서 초점을 맞춰서 이야기를 풀어나가 보았습니다. 내용을 조사하면서 복잡한 상태 변화를 갖는 애플리케이션을 구현하거나 상태 흐름을 flowchart로 도식화하면 동료 개발자뿐만 아니라 비개발자와의 협업에서도 효율적으로 커뮤니케이션하기 좋을 것 같다는 생각이 들었습니다.

 

위에서 언급한 내용들을 보았을 때 state machine이 좋은 점만 있는 것 같지만 아직은 적극적으로 사용되지 않는 이유에 대해 “statecharts.dev 에서 1999년 발간한 책의 내용이 User Interface에 statecharts를 사용하거나 일반적인 복잡성의 문제를 해결하려는 개발자들에게는 도움되지 않는다”는 의견도 있습니다. 저 역시 다양한 상태 관리의 방법을 도입할 때 ‘이런 방법도 있다’라는 관점으로 글을 작성해봤습니다. 훗날 실제 애플리케이션에 XState를 도입하려 할 때 작은 도움이 되길 바라는 마음으로 글을 마무리하려 합니다.

감사합니다.

 

 

 

Reference:

 

 


 

✅ 제훈님의 또다른 콘텐츠 Atomic state management – Jotai도 확인해보세요!

  • 상태관리
  • statechart
  • 프론트엔드
  • Finite State Machine
avatar image

박제훈 | Front-end Developer

화해팀에서 Front-end 리딩을 맡고 있습니다.

연관 아티클