Tech
Finite state machine & statecharts – XState
2021. 09. 17안녕하세요. 프론트엔드 플랫폼 박제훈입니다. XState
플랫폼 내 리액트 상태 관리에 대한 기술 흐름 파악으로 조사를 진행했던 XState
에 대한 내용을 공유드리려 합니다.
들어가며
우리가 상태를 관리할 때 boolean으로 여러 상태를 관리하는 보편적인 케이스에 대해서 이야기해보려 합니다. 예를 들어 회원가입 페이지 개발 요건을 아래와 같이 전달받았다고 가정해 보겠습니다.
- id, password 입력을 받음
- password는 8자 이상
- input 에러일 때 에러 메시지 보여줌
- OK 버튼을 누르면 서버에 요청
- 서버에 요청하는 동안 로딩 메시지 보여주고 OK 버튼 비활성화
- 서버 요청 성공했을 때 성공 메시지 보여줌
- 서버 요청 실패했을 때 실패 메시지 보여줌
먼저 아이디와 암호를 입력받고 submit 요청하도록 구현해보겠습니다. 요청할 때 버튼이 비활성화되도록 isLoading
이 true면 버튼이 비활성화되도록 구현했습니다.
✅ 모든 예제 코드는 리액트로 작성했습니다.
이후 로딩 메시지를 보여주도록 추가하고 성공 메시지와 에러 메시지도 추가합니다.
여기까진 복잡하지 않은 구현 과정을 거쳤는데 이후 기획이 수정이 되어 확장을 해야 한다고 가정해봅시다. 서버에 OK 버튼 비활성화 처리를 요청하기 전에 인풋의 유효성을 체크하자고 논의가 되었습니다. 관련 내용을 추가하여 구현해보겠습니다.
이번 기능을 구현하면서 조건 처리를 로딩 상태는 isLoading
, 서버 요청 성공 시에는 isSuccess
, 에러를 노출해야 할 때는 error
, 패스워드 유효성 검사 실패 시에는 invalidPassword
변수를 이용하여 여러 상태 처리를 하였습니다. isDisabled
는 isLoading
과 invalidPassword
를 조합한 상태인데 이런 식으로 추가적인 조건이 boolean의 조합으로 생성되며 늘어날 수 있습니다. boolean은 2개의 상태를 가지며 조건의 개수에 따라 2의 n(boolean의 수) 승의 조합이 생기게 됩니다. 수학에서는 이것을 Combinatorial explosion(조합적 폭발)이라고 부릅니다.
여기서 한 가지 고민해볼 포인트는 16(2의 4승) 개의 조합이 기능을 구현할 때 신경 써야 하는 요소인지입니다. 16개의 상태는 로직상 불가능하거나 표현할 필요가 없는 경우가 대부분입니다. 예를 들어 위의 조건이 모두 true가 되는 경우입니다.
기획상으로 놓친 조건은 없는지 꼼꼼히 챙기겠지만 조건이 늘어날수록 명시적이지 않은 숨은 조합은 잠재적인 버그를 포함할 수도 있습니다. 위 예제와 같은 조건들은 boolean이 아닌 하나의 상태로서 명확하게 표현할 수 있고, 상태와 동작을 도식화하여 표현할 수도 있습니다. 이런 접근은 Finite State Machine(유한 상태 기계, 이하 FSM)과 Statecharts로 설계하고 javascript 및 typescript로 구현한 XState를 활용하여 구현할 수 있습니다. 아래는 XState
를 활용하여 최종적으로 완성한 예제입니다. 요건과 조금 다르게 후술 할 직교 / 병렬 상태를 설명하기 위해 id와 password에 빈 문자열 에러를 추가하였습니다.
이제 위의 예제를 XState
로 변경하면서 알아가고자 합니다.
FSM
FSM(Finite State Machine, 유한 상태 기계)은 유한한 상태의 전이를 표현하는 수학적인 모델입니다. 많이 쓰이는 분야는 게임과 논리 회로 설계 등이며 전구 동작으로 간단한 예시를 들 수 있습니다. 아래 꺼짐, 켜짐의 상태는 스위치 온/오프 이벤트를 통해 상태를 전이시킵니다.
일반적으로 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)
처음 예제로 활용했던 회원가입 서버 요청 부분을 상태 머신으로 표현하면 아래 그림과 같이 표현할 수 있습니다.
위 그림은 아래 목록과 같이 설명할 수 있습니다.
- 초기 상태: idle
- 상태: idle, loading, resolved, rejected
- 이벤트: fetching, success, failure
- 전이 함수:
- fetching: idle → loading
- success: loading → resolved
- failure: loading → rejected
- 최종 상태: resolved
위 FSM을 Xstate
를 활용하여 구현해보겠습니다.
XState
는 createMachine
팩토리 함수를 통해 FSM 및 statecharts를 정의할 수 있으며 내부에선 StateNode
객체로 관리가 됩니다. on
속성을 통해 전이에 대한 이벤트 매핑을 할 수 있고 { FETCHING: { target: 'loading' }
처럼 어느 이벤트가 어떤 상태로 전이되는지 표현 가능합니다. 또한 상태 type을 final
로 지정해서 최종 상태 노드임을 표현할 수 있습니다.
XState
는 이러한 machine을 시각화해주며 간단한 동작 테스트를 할 수 있는 헬퍼 툴인 visualizer를 제공해 줍니다.
이제 boolean 상태 체크를 해제하고 Xstate
의 상태를 활용해 다시 구현해보겠습니다. React에 Xstate
를 사용할 때는 hook으로 사용할 수 있도록 래핑 된 @xstate/react
패키지를 지원해 줍니다.
useMachine
hook은 상태 객체인 state(StateNode
)와 interpreter 그리고 interpreter의 send 함수로 구성되어 있습니다. useMachine
의 초기값으로 설정한 상태는 StateNode
객체로 생성이 되고 이 StateNode
는 states
속성에 계층적으로 구성됩니다. (이 계층 구조는 statecharts에서 다시 다룹니다.) StateNode
에는 다른 상태로 전이하기 위한 transition
함수가 있는데 기능을 상태와 전이 만으로 구현하는 방법도 있지만 더 유용하게 활용할 수 있도록 StateNode
를 해석(interpret) 하여 아래 기능을 사용하기 쉽게 제공하는 객체가 있습니다.
- 상태 전이
- 액션(혹은 side-effects) 실행
- 지연 / 다중 이벤트
- 상태 전이, context 변경 등 다중 이벤트 리스너
- 이 외 많음
아래 예제와 같이 machine을 생성하고 transition
으로 전이를 실행시킬 수도 있고 interpreter 객체를 활용하여 send
함수로 이벤트를 전이시킬 수도 있습니다.
isLoading
과 isSuccess
는 machine의 state
로 대체하고 현재 상태에 대한 확인은 state.matches()
메서드를 이용할 수 있으며, 상태를 값으로 넘겨줍니다.
이제 아래와 같이 전체적으로 수정해보겠습니다. 변경되지 않은 부분이 많이 있어서 이상하지만 아직 다음이 남아있습니다!
Statecharts
state explosion
가장 처음 예제와 FSM을 비교해 보면 원래 문제를 제시했던 combinatorial explosion 현상과 유사한 문제점이 남아있습니다. FSM은 단일 상태만을 허용하기 때문에 상태 간의 조합은 다른 상태로서 새롭게 관리하는 방법으로 접근해야 합니다.
예를 들어 위의 boolean 플래그와 별도로 조합한 변수인 isDisabled
를 FSM의 상태로서 관리하려면 or 연산인 loading 상태이거나 invalidPassword 상태를 모두 하나의 상태로서 관리해야 합니다. 이는 아래와 같이 표현할 수 있을 것입니다.
만약 여기서 disabled에 관여하는 상태를 하나 더 추가한다면 2배의 상태가 더 필요해집니다.
이미지 출처 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를 변경하는 것은 금지하고 있습니다.
컴포넌트 내부도 아래와 같이 변경합니다.
Xstate
의 visualizer로 아래처럼 확인할 수 있습니다.
actions
FSM은 상태와 이벤트의 전이만으로 구성되어 있기 때문에 FSM만으로 구현하려면 상태와 함께 발생하는 side-effect는 별도로 처리를 해줘야 합니다. 예를 들어 state의 상태 변화를 감지하여 useEffect
에서 fetch를 실행시키는 방식으로 기존의 코드를 아래와 같이 변경할 수 있습니다. (다만 아래 예제는 XState
를 이용해 machine으로 생성할 경우 사용할 일이 거의 없는 설명을 위한 예제입니다.)
FSM은 상태와 이벤트로만 구성되기 때문에 상태에 반응하려면 위와 같이 코드를 작성해야 합니다. 이벤트를 통한 상태 전이가 발생할 때 다음 상태인 loading으로 변경되면 signUp
함수를 호출합니다. 이와 같이 이벤트를 발생시켜 상태를 전이시킬 때는 다음 상태와 함께 어떤 동작이 같이 발생할 수도 있다는 것을 알 수 있습니다. 이에 대해 XState
메인테이너는 ‘전이는 effects를 수반한다’라고 표현했습니다.
- state + event -> next state + effects
이러한 effects가 발생하는 타이밍은 상태에 진입/이탈 순간(enter/exit)과 전이(transition) 중 3가지 발생 시점으로 정리할 수 있습니다. statecharts에서 정의한 actions와 매칭 되는 부분입니다.
위 코드는 Xstate
로 아래처럼 수정할 수 있습니다. actions에는 동기 / 비동기 함수를 모두 호출할 수 있는데 우리는 비동기 동작에 대한 액션이 필요합니다. invoke
는 Xstate
의 statecharts에서 비동기 동작을 발생시키기 위한 방법 중 하나이며 여기에서 예제를 확인할 수 있습니다. invoke를 통해 실행된 비동기 함수는 각각 onDone과 onError 속성에 promise의 결괏값을 리턴해주고 기존에 작성한 SUCCESS와 FAILURE 이벤트와 동일한 역할을 수행합니다.
아래 visualizer를 확인하면 loading
상태에 invoke
가 추가된 것을 확인할 수 있습니다.
Guards (conditional transitions)
상태의 전이가 발생하기 전에 특정 조건들을 만족해야 전이 가능하도록 설계도 가능합니다. 아래처럼 cond
속성을 활용하여 첫 예제에서 invalidPassword
부분을 수정해 보겠습니다. fetchMachine.transition
함수의 changed
속성은 cond
를 다음 단계로 넘어갈 수 있도록 positive 조건으로 설정하고 machine이 미래에 변경이 되는지 확인하는 방식으로 체크 가능합니다.
또한 cond
속성에 사용한 함수는 machine의 옵션으로 guards
속성에 함수화하여 사용할 수도 있습니다.
Hierarchical states
처음 FSM의 문제점에서 언급했던 state explosion을 방지하기 위해서 계층/병렬 상태 구조를 statecharts에서 활용할 수 있습니다. 계층적 구조의 특징은 아래와 같습니다.
- state를 세분(refinement)할 수 있음
- 유사한 전이를 그룹화할 수 있음
- 상태의 격리(isolation) 가능
- 컴포저블(composability)을 권장
이제 guard를 적용하면서 수정했던 코드를 다시 수정해보겠습니다.
states를 보면 idle
내부에 noError
와 errors
로 구성된 계층 상태를 생성했고 errors 내부는 tooShort
으로 한 번 더 계층구조로 감쌌습니다. XState
에서 표현하기론 무한대로 계층구조 생성이 가능하다고 합니다.
이전 코드에서는 FETCHING
이벤트 일 때 조건 체크하던 것을 UPDATE_PASSWORD
이벤트가 발생하면 조건 체크하도록 변경했습니다. XState
에서는 UPDATE_PASSWORD
와 같이 액션을 연속으로 호출하면서 cond
로 조건을 추가해 줄 수 있는데, 먼저 true를 만나는 액션에서 행동이 종료됩니다. 그래서 password 조건 체크에서 에러 발생 상황을 먼저 체크하도록 위와 같이 코드를 변경하였습니다.
컴포넌트에 적용되는 코드도 아래와 같이 수정합니다.
기존의 isDisabled
체크는 matches와 transition을 혼합하여 사용했었는데, 상태를 계층 구조로 변경하면서 idle의 내부 상태로서 idle.errors
를 포함한 상태 이거나 loading
상태이거나로 조건을 변경할 수 있었습니다. 두 가지 이상의 상태 중 하나를 포함하는지를 판단하는 방법으로는 some
을 사용하도록 공식 문서에서 가이드하고 있습니다.
errors 메시지는 errors
상태를 활용해 코드와 같이 활용할 수 있다는 예시로 추가했습니다.
Orthogonal states
병렬 상태는 여러 직교(orthogonal) 상태의 노드를 나타냅니다. 병렬 상태는 동시에 모든 하위 상태에 있습니다. 그리고 자식 상태로서 존재하고 서로 직접적으로 종속되지 않으며 병렬 상태 노드 간에 전환이 없어야 합니다. XState
에서는 type: parallel
로 선언합니다. 선언부를 확인해보면 initial
값을 갖지 않는데 초깃값 선언이 불가능하며 상태 자체가 병렬 상태를 감싸고 있는 방식으로 구성됩니다.
에러 상태에 대한 코드를 병렬 상태로 변경해보겠습니다. id
속성에 빈 값을 허용하지 않도록 추가하고 password
도 빈 값을 허용하지 않음을 표현하기 위해서 error 필드를 병렬로 구성합니다.
이제 visualizer에서 확인해보면 가장 처음 소개한 도식 화면과 같이 표현됩니다. 예제로 변경하는 과정은 여기서 마무리하겠습니다.
History
위의 statecharts 소개에 설명했지만 예제에는 적용하지 않은 history는 statecharts의 주요 기능이라 간단하게 소개하려 합니다. 이벤트 발생으로 상태가 다른 상태로 전이되어 이전 상태로 다시 되돌아갈 때 활용할 수 있습니다.
아래 예제는 egghead XState 강좌의 예제로 참고하였습니다.
예제를 보면 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에서 참조하였습니다.
visualizer에서 확인해보면 전이에 delay가 3초가 적용되어 다음 상태로 넘어감을 확인할 수 있습니다.
Example
화해팀은 JIRA를 활용하여 업무 상태를 관리하고 있습니다. 각각의 프로젝트마다 업무 성격에 맞는 워크플로우가 필요합니다. 플로우를 애플리케이션에서 시각화한다면 내 업무가 backlog
상태인지 top priority
상태인지 확인도 필요하지만 각각의 상태가 어떤 상태로 이동할 수 있는지 조건들도 체크해야 합니다.
아래는 화해팀 특정 프로젝트의 워크 플로우를 시각화한 화면입니다.
backlog
상태는 top priority
상태로만 이동할 수 있으며, top priority
는 backlog
와 pending
그리고 inprogress
로 상태를 이동할 수 있습니다. top priority
상태에서 pending
으로 이동하면 inprogress
, top priority
으로 이동하며 상태를 전환할 수 있습니다. 어떤 상태이든 drop
상태가 될 수 있습니다. 이런 복잡한 상태의 전환을 구현할 때 우리는 상태 머신으로 효율적으로 접근할 수 있습니다.
마치며
FSM / statecharts는 리액트의 상태 관리 만을 위한 개념이 아니라 여러 공학 분야에서 사용할 수 있는 수학적 모델이지만 이번 블로그에서는 특히 리액트에서 사용하는 방법에 대해서 초점을 맞춰서 이야기를 풀어나가 보았습니다. 내용을 조사하면서 복잡한 상태 변화를 갖는 애플리케이션을 구현하거나 상태 흐름을 flowchart로 도식화하면 동료 개발자뿐만 아니라 비개발자와의 협업에서도 효율적으로 커뮤니케이션하기 좋을 것 같다는 생각이 들었습니다.
위에서 언급한 내용들을 보았을 때 state machine이 좋은 점만 있는 것 같지만 아직은 적극적으로 사용되지 않는 이유에 대해 “statecharts.dev 에서 1999년 발간한 책의 내용이 User Interface에 statecharts를 사용하거나 일반적인 복잡성의 문제를 해결하려는 개발자들에게는 도움되지 않는다”는 의견도 있습니다. 저 역시 다양한 상태 관리의 방법을 도입할 때 ‘이런 방법도 있다’라는 관점으로 글을 작성해봤습니다. 훗날 실제 애플리케이션에 XState
를 도입하려 할 때 작은 도움이 되길 바라는 마음으로 글을 마무리하려 합니다.
감사합니다.
Reference:
- https://xstate.js.org/docs
- https://statecharts.dev
- https://en.wikipedia.org/wiki/UML_state_machine
- https://kyleshevlin.com/enumerate-dont-booleanate
- https://css-tricks.com/using-react-and-xstate-to-build-a-sign-in-form/
- https://egghead.io/courses/introduction-to-state-machines-using-xstate
- https://www.youtube.com/watch?v=hiT4Q1ntvzg
- https://www.youtube.com/watch?v=GuzcWkVrqLg
- https://www.youtube.com/watch?v=Hv_PhrfwerQ
- https://github.com/cypress-io/cypress-realworld-app
✅ 제훈님의 또다른 콘텐츠 Atomic state management – Jotai도 확인해보세요!