Back
Featured image of post JavaScript생태계의 유한 상태기계, XState 도입기

JavaScript생태계의 유한 상태기계, XState 도입기

유한 상태 머신에 대한 간략한 개념과 UI 핸들링을 위해 사용한 경험을 공유합니다.

XState 도입기

최근 프로젝트를 진행하면서 복잡한 UI 상태를 제어할 수 있는 클라이언트 기반 상태 관리 라이브러리를 찾게 되었습니다. 현재 프로젝트는 도메인을 기반으로 컴포넌트 디렉토리 구조를 나누고 있어 도메인 별로 Context를 구성하는 방식과 Redux과 같은 중앙 저장 방식을 고민하였습니다. 그러다 FSM 기반의 상태 제어 라이브러리인 XState를 알게 되어 프로젝트에 적용해보았고 간략한 사용 방법과 후기를 한 번 남겨보도록 하겠습니다.

UI와 유한 상태 머신

XState는 자바스크립트 환경에서 유한 상태 기계(FSM) 모델을 정의할 수 있는 라이브러리입니다. 유한 상태 기계(FSM)는 위키에서 아래와 같이 설명하고 있습니다.

수학적 모델로써, 한 번에 오직 한개의 상태만을 가질 수 있는 유한한 상태들로 이루어져 있는 모델이고 각 상태를 전환할 수 있다.

UI 입장에서 생각해보면, 사용자가 웹 페이지 내에서 취할 수 있는 각 행동들이 하나의 상태 값이 될 수 있으며, 각 상태 전환에 대한 경우의 수를 고려해야 비로소 상태 관리가 가능합니다. 이 개념을 순수 함수로 작성하는 것은 상태 값이 많을수록 복잡하고 높은 난이도를 요구하는데, 이를 해결하기 위해 XState에서 선언적으로 FSM 모델을 생성하고 이를 통한 상태 관리 기능을 제공합니다.

사용 방법

설치하기

우선 필요한 패키지들을 설치해야 합니다. 저는 React에서 FSM을 도입하였기 때문에 별도의 React 패키지까지 설치하였습니다. (XState에서는 React외에도 Svelte, Vue, Ember 등 다양한 프론트엔드 환경을 지원해주고 있습니다.)

npm install xstate @xstate/react --save
#or
yarn add xstate @xstate/react --save

상태 정의하기

이제 상태를 한 번 정의해보도록 하겠습니다. 상태를 정의하려면 createMachine 메서드를 사용하여 하나의 유한 상태 기계 모델을 만들 수 있습니다. 간단하게 구조를 먼저 설명드리자면, 상태를 정의할 states, 머신 범위에서 값을 공유하기 위한 context, 고유한 id와 시작 상태의 값을 정의할 수 있습니다.

import { createMachine } from 'xstate';

const signMachine = createMachine({
  id: 'machineId',
  initial: 'start',
  context: {},
  states: {}
});

프로젝트에서 제일 먼저 유한 상태 머신(FSM)을 적용한 UI 부분은 인증 도메인이었습니다. 모달 UI 형태의 Form으로 구성되어있는데, 아래와 같이 경우에 따라 여러 가지의 상태를 정의를 해보았습니다. 01-auth-machine

  • selction: 선택 상태 (인증 유형을 선택하는 상태)
  • email: 이메일 인증
  • oauth: 소셜 로그인
  • registry: 등록 완료 (이메일 등록 후 서버에 사용자 정보가 등록되어있는 상태)
  • clear: 인증 완료 (Done)

이제 각 상태 객체에서 어떤 이벤트를 발생시키거나 어떤 상태로 전이시킬 지 계획을 작성할 수 있습니다. 이를 위해 아래와 같이 머신을 작성하면 됩니다.

export const signMachine = createMachine({
  id: 'sign',
  initial: 'selection',
  context: {
    user: null
  },
  states: {
    // 인증 선택
    selection: {},
    // 소셜 인증
    oauth: {},
    // 이메일 인증
    email: {},
    // 등록 상태
    registry: {},
    // 상태 종료
    done: {
      type: 'final'
    }
  }
});

저는 처음 진입하는 상태를 selection 이라고 정의했습니다. 사용자가 ‘이메일 로그인’ 버튼을 클릭하면 email 상태로 전환되고, 다른 로그인 방식을 클릭하면 oauth 상태로 전환됩니다. 반대로 상태의 끝은 final type을 명시하여 해당 상태가 끝이라는 것을 머신에게 알려줄 수 있습니다. 02-sign

export const signMachine = createMachine({
  id: 'sign',
  initial: 'selection',
  predictableActionArguments: true,
  context: {
    user: null
  },
  states: {
    selection: {
      on: {
        REGISTRY: {
          target: 'registry',
          actions: 'updateUser'
        },
        OAUTH: {
          target: 'oauth'
        },
        EMAIL: {
          target: 'email'
        }
      }
    },
    oauth: {
      on: {
        CLEAR: {
          target: 'selection'
        }
      }
    },
    email: {
      on: {
        CLEAR: {
          target: 'selection'
        },
        REGISTRY: {
          target: 'registry',
          actions: 'updateUser'
        },
        DONE: {
          target: 'done'
        }
      }
    },
    // 등록 상태
    registry: {
      on: {
        CLEAR: {
          target: 'done'
        },
        BACK: {
          target: 'email'
        }
      }
    },
    // 상태 종료
    done: {
      type: 'final'
    }
  }
});

이 상태들을 가지고 실제 컴포넌트에서는 어떻게 수행을 시킬 수 있을까요? xstate/react패키지에서 친절하게 hook을 제공해주고 있습니다. 이를 통해 상태 별 UI를 표현할 수 있습니다.

import { useMachine } from '@xstate/react';
import { signMachine } from '../machines/signMachine';

const [state, send, service] = useMachine(signMachine);
const currentState = state.value; //state의 value 속성을 통해 현재 상태를 알 수 있습니다.
const onSend = () => {
  send('REGISTRY'); //send 메서들 활용해 다른 상태로 전이시킬 수 있습니다.
};

useMachine을 통해 머신의 현재 상태(state)를 확인하고 상태 전이(send)를 발생시킬 수 있습니다. service는 상태의 인스턴스 입니다. 밑에서 설명해드리겠지만 현재 상태를 기록중인 머신을 다른 컴포넌트에서도 사용하려면 service 인스턴스를 prop으로 전달하거나, context를 따로 생성해서 컴포넌트끼리 공유할 수 있습니다.

//SignModal.tsx
import { useMachine } from '@xstate/react';
import { signMachine } from '../machines/signMachine';

export const SigninModal = () => {
  const [state, send, service] = useMachine(signMachine);
  const { value: signState, history } = state;
  const openState = signState !== 'done' && !!history;
  const { isLoading: loadingGithub, mutate: signinGithub } = useSigninWithProvider('GITHUB', send);
  const { isLoading: loadingGoogle, mutate: signinGoogle } = useSigninWithProvider('GOOGLE', send);

  return (
    <Modal
      open={openState}
      hasCloseIcon
      onClose={() => {
        send('CLEAR');
      }}
    >
      <Modal.Trigger>
        <Button variant="outline" color="primary-500">
          로그인
        </Button>
      </Modal.Trigger>
      <Modal.Header>로그인</Modal.Header>
      <Modal.Body>
        {signState === 'selection' && ( //selection 상태일 때에만 로그인 선택 UI 표현
          <Stack direction="vertical">
            <Button onClick={() => send('EMAIL')}>Email로 로그인</Button>
            <Button onClick={() => signinGithub()}>GitHub 계정으로 로그인</Button>
            <Button onClick={() => signinGoogle()}>Google 계정으로 로그인</Button>
          </Stack>
        )}
        {signState === 'email' && <EmailPasswordForm signMachine={service} signup={false} />}
      </Modal.Body>
    </Modal>
  );
};

실제 로그인 form 컴포넌트 코드를 축약해서 가져와 보았습니다. state에 따라 어떤 UI를 표현할지 결정하였고, 하위 컴포넌트에 service 인스턴스를 전달하여 상태 전이가 발생해도 유지하도록 하였습니다.

액션 정의하기

이제 액션을 소개해 드리겠습니다. 액션은 지정한 특정 상태에서 발생하는 일종의 effect인데 대체로 context를 수정하기 위해서 사용합니다. actions 객체에 액션 함수들을 정의하고 상태의 actions에 호출할 액션 함수를 명시하시면 됩니다.

export const signMachine = createMachine(
  {
    id: 'sign',
    initial: 'selection',
    predictableActionArguments: true,
    context: {
      user: null
    },
    states: {
      selection: {
        on: {
          REGISTRY: {
            target: 'registry',
            actions: 'updateUser'
          },
          OAUTH: {
            target: 'oauth'
          },
          EMAIL: {
            target: 'email'
          }
        }
      },
    ...
    }
  },
  {
    actions: {
      updateUser: (context, payload) => {
        const copyContext = context;
				//REGISTRY 상태가 되면 로컬스토리지에 User 정보를 저장
        if (payload.type === 'REGISTRY') {
          const { user } = payload;
          setLocalStorageItem(STORAGE_KEYS.userAuth, user);
          copyContext.user = user;
        }
      }
    }
  }
);

export type SignMachineType = typeof signMachine;

액션 함수에서는 context와 payload를 매개변수로 받습니다. payload에는 상태 전이시에 전송한 데이터가 포함됩니다. context는 현재 관리 중인 context 값에 접근하여 값을 변경하거나 가공할 수 있습니다.

const { mutate, isLoading } = useMutation(fetchFn, {
  onSuccess: async (data) => {
    const { uid, email } = user;

    if (signup) {
      if (onSignupSuccess) {
        onSignupSuccess(uid, email);
      }

      send({ type: 'REGISTRY', user });
      return;
    }
    clearUserData();
  }
});

위의 코드는 이메일 회원가입이 완료되었을 때 REGISTRY 상태로 전이시키고, action 함수를 호출하여 스토리지에 값을 저장하는 코드입니다. send 함수의 두 번째 인자로 payload 객체에서 사용할 값을 전달할 수 있습니다.

상태 공유하기

service

위에서 잠깐 언급드렸던 service 인스턴스를 활용해 하위 컴포넌트에서도 사용 할 수 있는 방법에 대해서 알려드리고자 합니다. 머신 상태는 done 상태로 이어질 때 까지 관련된 모든 컴포넌트들이 정보를 알고 있어야 합니다. service 인스턴스를 하위 컴포넌트에 넘기면 해당 머신 context를 동기화시킬 수 있습니다.

//SignModal.tsx
import { useMachine } from '@xstate/react';
import { signMachine } from '../machines/signMachine';

export const SigninModal = () => {
  const [state, send, service] = useMachine(signMachine);
  const { value: signState, history } = state;
	...
  return (
    <Modal
      open={openState}
      hasCloseIcon
      onClose={() => {
        send('CLEAR');
      }}
    >
      <Modal.Trigger>
        <Button variant="outline" color="primary-500">
          로그인
        </Button>
      </Modal.Trigger>
      <Modal.Header>로그인</Modal.Header>
      <Modal.Body>
				...
        {signState === 'email' && <EmailPasswordForm signMachine={service} signup={false} />}
      </Modal.Body>
    </Modal>
  );
};

useActor에 전달받은 service 인스턴스를 인수로 전달하여 구독중인 머신을 참조할 수 있습니다.

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

export const ChildrenComponent = (machine) => {
  const [refState, refSend] = useActor(machine);
  const currentState = machine.value;
  return (
    <button
      onClick={() => {
        refSend({ type: 'CLEAR' });
      }}
    >
      Clear
    </button>
  );
};

하위 컴포넌트에서의 전체 코드 입니다. 정상적으로 회원 가입이 완료된다면 send 메서드를 통해 완료 상태로 전이됩니다.

import { Text, TextInput, Button, Modal } from '@jdesignlab/react';
import { useForm } from 'react-hook-form';
import { useActor } from '@xstate/react';
import { Flex } from '../styles/Profile';
import { useAccountEmailWithPassword } from '../hooks/useAccountEmailWithPassword';
import { useCreateUserMutation } from '../hooks/useCreateUserMutation';
import type { InterpreterFrom } from 'xstate';
import type { SignMachineType } from '../machines/signMachine';

interface Props {
  signMachine: InterpreterFrom<SignMachineType>;
  signup: boolean;
}

export const EmailPasswordForm = (props: Props) => {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm();
  const { signMachine, signup } = props;
  const [, refSend] = useActor(signMachine);
  const { mutate: createUser } = useCreateUserMutation();
  const { mutate: registry, isLoading } = useAccountEmailWithPassword(signup, refSend, (uid, email) => {
    createUser({ uid, email });
  });

  return (
    <form
      onSubmit={handleSubmit((userInfo) => {
        registry(userInfo as EamilPasswordField);
      })}
    >
      <TextInput
        {...register('email', {
          required: '이메일을 입력해주세요.',
          pattern: {
            value: /[a-z0-9]+@[a-z]+.[a-z]{2,3}/,
            message: '이메일 형식에 맞지 않습니다.'
          }
        })}
        size="md"
        clearable
      >
        <TextInput.Label>Email</TextInput.Label>
      </TextInput>
      {errors.email && <Text color="red-base">{errors.email.message as string}</Text>}
      <TextInput
        {...register('password', {
          required: '비밀번호를 입력해주세요.',
          minLength: {
            message: '비밀번호는 최소 8자 이상으로 입력해주세요.',
            value: 8
          }
        })}
        size="md"
        type="password"
        clearable
      >
        <TextInput.Label>Password</TextInput.Label>
      </TextInput>
      {errors.password && <Text color="red-base">{errors.password.message as string}</Text>}
      <Modal.Footer>
        <Flex>
          {!signup && (
            <Button
              variant="outline"
              color="red-lighten2"
              onClick={() => {
                refSend({ type: 'CLEAR' });
              }}
            >
              뒤로가기
            </Button>
          )}
          <Button type="submit" variant="outline" color="primary-500" disabled={isLoading}>
            {signup ? '회원가입' : '로그인'}
          </Button>
        </Flex>
      </Modal.Footer>
    </form>
  );
};

context

하지만 context의 영역이 점차 커지면 prop을 통해 전달하는 방식도 한계가 올 수 있습니다. 이런 상황에서는 ActorContext를 생성하여 경계를 구축하실 수 있습니다. React에서 제공하는 ContextAPI 구조와 많이 닮아있어 쉽게 사용하실 수 있습니다.

import { createActorContext } from '@xstate/react';
import { signMachine } from './signMachine';

export const signMachineContext = createActorContext(signMachine);

생성한 ActorContext의 Provider로 공유 대상을 구성합니다.

import { useMemo } from 'react';
import { SigninModal } from './SigninModal';
import { SignupModal } from './SignupModal';
import { signMachineContext } from '../machines/signMachineContext';

const SignPage = (props) => {
  return (
    <signMachineContext.Provider>
      <SigninModal />
      <SignupModal />
    </signMachineContext.Provider>
  );
};

export default SignPage;

하위 컴포넌트에서는 context의 useActor 메서드를 통해 state, send를 사용하실 수 있습니다.

import { signMachineContext } from '../../machines/signMachineContext';

export const SigninModal = () => {
  const [state, send] = signMachineContext.useActor();
  return <>...</>;
};

타입 정의하기

XState는 타입스크립트를 지원합니다. 이를 이용해 context와 event의 타입을 정의할 수 있습니다. 또한 머신 자체를 typeof를 통해 값으로 사용할 수 있어, 컴포넌트 간 인스턴스 공유에도 타입을 맞출 수 있습니다.

createMachine 타입 정의

import { STORAGE_KEYS, setLocalStorageItem } from '@shared/storage';
import { createMachine } from 'xstate';
import type { User } from 'firebase/auth';

interface UserContext {
  user: User | null;
}

export type UserEvent =
  | { type: 'OAUTH' }
  | { type: 'EMAIL' }
  | { type: 'EMAIL' }
  | { type: 'CLEAR' }
  | { type: 'REGISTRY'; user: User }
  | { type: 'DONE' }
  | { type: 'BACK' };

export const signMachine = createMachine<UserContext, UserEvent>(
  {
    id: 'sign',
    initial: 'selection',
    predictableActionArguments: true,
    context: {
      user: null
    },
    states: {
      selection: {
        on: {
          REGISTRY: {
            target: 'registry',
            actions: 'updateUser'
          },
          OAUTH: {
            target: 'oauth'
          },
          EMAIL: {
            target: 'email'
          }
        }
      },
      oauth: {
        on: {
          CLEAR: {
            target: 'selection'
          }
        }
      },
      email: {
        on: {
          CLEAR: {
            target: 'selection'
          },
          REGISTRY: {
            target: 'registry',
            actions: 'updateUser'
          },
          DONE: {
            target: 'done'
          }
        }
      },
      // 등록 상태
      registry: {
        on: {
          CLEAR: {
            target: 'done'
          },
          BACK: {
            target: 'email'
          }
        }
      },
      // 상태 종료
      done: {
        type: 'final'
      }
    }
  },
  {
    actions: {
      updateUser: (context, payload) => {
        const copyContext = context;
        if (payload.type === 'REGISTRY') {
          const { user } = payload;
          setLocalStorageItem(STORAGE_KEYS.userAuth, user);
          copyContext.user = user;
        }
      }
    }
  }
);

export type SignMachineType = typeof signMachine;

machine 인스턴스 타입 정의

import { useActor } from '@xstate/react';
import type { InterpreterFrom } from 'xstate';
import type { SignMachineType } from '../machines/signMachine';

interface Props {
  signMachine: InterpreterFrom<SignMachineType>;
  signup: boolean;
}

export const EmailPasswordForm = (props: Props) => {
  const { signMachine, signup } = props;
  const [, refSend] = useActor(signMachine);
  return <>...</>;
};

마치며

프로젝트 전반적으로 XState를 적용하지는 않았지만, 회원 관리에서 사용해본 결과 국소적으로 상태 관리를 간편하게 적용할 수 있다는 생각이 들었습니다. 작은 상태를 쪼개서 관리하는 관점에서는 Recoil 라이브러리를 사용하는 것과 유사한 느낌을 받았습니다. 그러나 프로젝트의 구조가 도메인 별로 분리된 경우에 더욱 효율적일 것 같습니다. 상태가 많을수록 각 상태에서 정의해야하는 경우의 수도 증가하기 때문입니다.

공식 문서를 살펴보니 비동기 동작(invoke)과 상태 머신 간 공유도 가능한 것 같은데 이러한 부분을 추후에 좀 더 세밀하게 사용하게 된다면 2편으로 공유해보도록 하겠습니다. 읽어주셔서 감사합니다.

참고 자료