본문 바로가기

FrontEnd

XState와 비동기 2편: XState Actor와 함께 비동기를 안전하게 모델링

반응형

원문 보기

 

Using XState Actors to Model Async Workflows Safely - This Dot Labs

In my previous post I discussed the challenges of writing async workflows in React that correctly deal with all possible edge cases. Even for a simple case of a…

www.thisdot.co

1편 보기

 

XState와 비동기 1편: useEffect 안의 비동기 코드는 위험합니다!

원문 보기 Async Code in useEffect is Dangerous. How Do We Deal with It? - This Dot Labs The introduction of async/await to Javascript has made it easy to express complex workflows that string togeth..

itchallenger.tistory.com

이전 게시물에서 React에서 엣지 케이스를 올바르게 처리하는 비동기 워크플로를  작성하는 문제에 대해 논의했습니다.

오류 처리가 없는 두 개의 종속성이 있는 간단한 클라이언트 로직인 경우에도 다음과 같이 장황하게 끝납니다.

const useClient = (user) => {
  const [client, setClient] = useState(null);

  useEffect(() => {
    let cancelled;
    (async () => {
      const clientAuthToken = await fetchClientToken(user);
      if (cancelled) return;

      const connection = await createWebsocketConnection();
      if (cancelled) {
        connection.close();
        return;
      }

      const client = await createClient(connection, clientAuthToken);
      if (cancelled) {
        client.disconnect();
        return;
      }

      setClient(client);
    })();

    return () => {
      cancelled = true;
    };
  }, [user]);

  useEffect(() => {
    return () => {
      client.disconnect();
    };
  }, [client]);

  return client;
};

 

 

매우 명령적인 코드 기능이 얽혀 있으며 앞으로 읽고 변경하기가 어려울 것입니다.
 
워크플로의 다양한 부분의 상태적 특성과,
미래에 무언가를 놓쳤거나, 변경이 필요할 경우 쉽게 발견할 수 있는 방식으로 상호 작용하는 표현하는 방법이 필요합니다.
여기서 상태 머신과 액터 모델이 유용할 수 있습니다.
 

State machines? Actors? (상태 기계? 액터?)

상태 머신은 상태와 ,이전 상태와 수신하는 외부 이벤트의 조합에서 다음 상태를 결정하기 위해 따라야 하는 일련의 규칙으로 구성된 엔터티입니다. 상태 머신은 어디에나 있습니다. 예를 들어, Promise는 랩핑 중인 비동기 계산에서 값을 수신할 때 pending 상태에서 resolved 상태로 전환되는 상태 머신입니다.

상태 머신과 상태 차트 알아보기

 

 

액터 모델은 액터라고 하는 자체-포함된 단위의 상호 작용으로 비동기 워크플로를 모델링하는 컴퓨팅 아키텍처입니다. 이러한 단위는 이벤트를 보내고 수신하여 서로 통신하고 상태를 캡슐화하며 상위 액터가 하위 액터를 생성하여 수명 주기를 연결하는 계층적 관계에 존재합니다.

액터 모델 간단히 알아보기

 
단일 엔티티가 액터이자 상태 머신이 되도록 두 패턴을 결합하는 것이 일반적이므로,
하위 액터가 생성되고 엔티티가 어떤 상태에 있는지에 따라 메시지가 전송됩니다.
저는 XState를 사용할 것입니다. 쉬운 선언적 스타일로 액터와 상태 머신을 생성할 수 있습니다.
 

Setting the stage (단계를 나누기)


첫 번째 단계는 워크플로를 고유한 상태로 나누는 것입니다.
프로세스의 모든 단계가 상태인 것은 아닙니다.
오히려 상태는 사용자 입력이든 외부 프로세스의 완료이든 워크플로가 어떤 일이 일어나기를 기다리는 프로세스의 순간을 나타냅니다.
우리의 경우 워크플로를 세 가지 상태로 대략적으로 나눌 수 있습니다.

 

  1. 워크플로가 처음 생성되면 즉시 연결 생성 및 인증 토큰 가져오기를 시작할 수 있습니다. 클라이언트를 생성하기 전에 해당 작업들이 완료될 때까지 기다려야 합니다. 이 상태를 "preparing"이라고 합니다.
  2. 그런 다음 클라이언트 생성 프로세스를 시작했지만 클라이언트 생성이 클라이언트를 반환할 때까지 사용할 수 없습니다. 이 상태를 "creatingClient"라고 부를 것입니다.
  3. 마지막으로 모든 것이 준비되었으며, 클라이언트를 사용할 수 있습니다. 기계는 자원을 해제하고 스스로를 파괴할 수 있도록 종료 신호만 기다리고 있습니다. 이 상태를 "clientReady"라고 부를 것입니다.
다음과 같이 시각적으로 표현할 수 있습니다.

코드는 다음과 같습니다.

export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      on: {
        "preparations.complete": {
          target: "creatingClient",
        },
      },
    },
    creatingClient: {
      on: {
        "client.ready": {
          target: "clientReady",
        },
      },
    },
    clientReady: {},
  },
});
이 코드는 다소 지나치게 단순화 한 것입니다.
"preparing" 상태에 있을 때 실제로는 두 개의 개별적이고 독립적인 프로세스가 발생하고 있으며 클라이언트 생성을 시작하기 전에 두 프로세스가 모두 완료되어야 합니다.
다행스럽게도 이것은 병렬 자식 상태 노드로 쉽게 표현됩니다. (token, connection)
병렬 상태 노드를 Promise.all처럼 생각할 수 있습니다.
각 프로세스는 독립적으로 진행되지만 이를 호출한 부모는 두 프로세스가 모두 완료되면 알림을 받습니다.
XState에서 "종료"는 다음과 같이 "final"으로 표시된 상태에 도달하는 것으로 정의됩니다. (type:final)
(final에 도달하면, 해당 머신이 종료됩니다.)
export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      // Parallel state nodes are a good way to model two independent
      // workflows that should happen at the same time
      type: "parallel",
      states: {
        // The token child node
        token: {
          initial: "fetching",
          states: {
            fetching: {
              on: {
                "token.ready": {
                  target: "done",
                },
              },
            },
            done: {
              type: "final", // 종료
            },
          },
        },
        // The connection child node
        connection: {
          initial: "connecting",
          states: {
            connecting: {
              on: {
                "connection.ready": {
                  target: "done",
                },
              },
            },
            done: {
              type: "final", // 종료
            },
          },
        },
      },
      // The "onDone" transition on parallel state nodes gets called when all child
      // nodes have entered their "final" state. It's a great way to wait until
      // various workflows have completed before moving to the next step!
      onDone: {
        target: "creatingClient",
      },
    },
    creatingClient: {
      on: {
        "client.ready": {
          target: "clientReady",
        },
      },
    },
    clientReady: {},
  },
});

 

상태 차트의 최종 모양은 다음과 같습니다.

Casting call (각 리소스를 Actor로, 즉 배우로 캐스팅 한다는 의미. 푸하하!)


지금까지는 상태 머신을 선언 할 때 암시적으로 생성된 루트 액터 단 하나만 존재합니다.
액터 사용의 진정한 이점을 얻으려면 모든 일회용 리소스를 액터로 모델링해야 합니다.
 
XState를 사용하여 이 모두를 상태 머신으로 작성할 수 있지만, 대신 XState가 아닌 코드와 상호 작용하는 액터를 정의하는 짧고 달콤한 방법인 콜백을 활용한 함수를 활용해 보겠습니다.
다음은 WebSocket을 생성하고 처리하는 연결 액터의 모습입니다.
 
send 함수를 파라미터로 받아, connection의 콜백으로 send 함수 호출을 전달합니다.
// Demonstrated here is the simplest and most versatile form of actor: a function that
// takes a callback that sends events to the parent actor, and returns a function that
// will be called when it is stopped.

// 두번째 인자로 부모에게 메세지(이벤트)를 보내는 콜백을 전달받습니다.
const createConnectionActor = () => (send) => {
  const connection = new WebSocket("wss://example.com");
	
    
  // connection 연결 성공 시 부모 액터에게 이벤트를 전달합니다.
  connection.onopen = () =>
    // We send an event to the parent that contains the ready connection
    send({ type: "connection.ready", data: connection });

  // Actors are automatically stopped when their parent stops so simple actors are a great
  // way to manage resources that need to be disposed of. The function returned by an
  // actor will be called when it receives the stop signal.
  
  // stop 신호를 받으면 호출할 함수를 전달합니다.
  return () => {
    connection.close();
  };
};
다음은 콜백 액터 내에서 프라미스의 사용을 보여주는 클라이언트를 위한 것입니다.
 
프라미스를 액터로 직접 생성할 수 있지만 이벤트에 응답하거나, 스스로 리스소를 정리하거나 "완료" 및 "오류" 이외의 이벤트를 보내는 메커니즘을 제공하지 않으므로 대부분의 경우 좋지 않은 선택입니다.
 
콜백 액터 내에서 프라미스 생성 기능을 호출하고 .then()과 같은 Promise 메서드를 사용하여 비동기 응답을 제어하는 ​​것이 좋습니다.
// We can have the actor creation function take arguments, which we will populate
// when we spawn it

// 액터 생성 함수가 인수를 가져오도록 할 수 있습니다. 
// 자식 프로세스를 스폰할 때
const createClientActor => (token, connection) => (send) => {
  const clientPromise = createClient(token, connection);
  clientPromise.then((client) =>
    send({ type: "client.ready", data: client })
  );

  return () => {
    // A good way to make sure the result of an async function is
    // always cleaned up is by invoking cleanup through .then()
    // If this executes before the promise is resolved, it will cleanup
    // on resolution, whereas if it executes after it's resolved, it will
    // clean up immediately
    
    // 비동기 함수의 결과가 제대로 정리하는 좋은 방법은
    // .then()을 통해 정리 함수를 호출하는 것입니다.
    // Promise가 resolved 되기 전에 실행되면 resolved 된 후 정리됩니다.
    // resolved 된 후면 즉시 정리됩니다.
    clientPromise.then((client) => {
      client.disconnect();
    });
  };
};

액터는 XState의 spawn action creator와 함께 스폰되지만,

실행 중인 액터에 대한 참조를 어딘가에 저장해야 하므로 일반적으로 spawn을 assign과 결합하여 액터를 만들고 부모 컨텍스트에 저장합니다.

// We put this as the machine options. Machine options can be customised when the
// machine is interpreted, which gives us a way to use values from e.g. React context
// to define our actions, although this is not demonstrated here
const clientFactoryOptions = {
  spawnConnection: assign({
    connectionRef: () => spawn(createConnectionActor()),
  }),
  spawnClient: assign({
    // The assign action creator lets us use the machine context when defining the
    // state to be assigned, this way actors can inherit parent state
    clientRef: (context) =>
      spawn(createClientActor(context.token, context.connection)),
  }),
};
주 : assign은 context(상태의 부가 정보)에 데이터를 할당하는데 사용함.

assign API 알아보기

 

Context | XState Docs

Context 🚀 Quick Reference While finite states are well-defined in finite state machines and statecharts, state that represents quantitative data (e.g., arbitrary strings, numbers, objects, etc.) that can be potentially infinite is represented as extende

xstate.js.org

 
이제 특정 상태가 입력될 때 이러한 작업을 트리거하는 것이 쉬운 작업이 됩니다.
주 : action은 해당 작업이 완료되었을 때 호출하고 싶은 다른 함수들을 의미함.
상세한 구현을 보고싶으면 맨 아래 예제 코드로 이동하기.
지금까지 한 작업은 client, token 액터들이 자신의 리소스를 관리하도록 한 것임!
export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      type: "parallel",
      states: {
        token: {
          initial: "fetching",
          states: {
            fetching: {
              // Because there's no resource to manage once it's done, we
              // can simply invoke a promise here. Invoked services are like
              // actors, but they're automatically spawned when the state node
              // is entered, and destroyed when it is exited,
              invoke: {
                src: "fetchToken",
                // Invoking a promise provides us with a handy "onDone" transition
                // that triggers when the promise resolves. To handle rejections,
                // we would similarly implement "onError"
                onDone: {
                  // These "save" actions will save the result to the machine
                  // context. They're simple assigners, but you can see them in
                  // the full code example linked at the end.
                  actions: "saveToken",
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
        // The connection child node
        connection: {
          initial: "connecting",
          states: {
            connecting: {
              // We want our connection actor to stick around, because by design,
              // the actor destroys the connection when it exits, so we store
              // it in state by using a "spawn" action
              entry: "spawnConnection",
              on: {
                // Since we're dealing with a persistent actor, we don't get an
                // automatic "onDone" transition. Instead, we rely on the actor
                // to send us an event.
                "connection.ready": {
                  actions: "saveConnection",
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
      },
      onDone: {
        target: "creatingClient",
      },
    },
    creatingClient: {
      // The same pattern as the connection actor. We spawn a  persistent actor
      // that takes care of creating and destroying the client.
      // entry: "spawnClient",
      on: {
        "client.ready": {
          actions: "saveClient",
          target: "clientReady",
        },
      },
    },
    // Even though this node can't be exited, it is not "final". A final node would
    // cause the machine to stop operating, which would stop the child actors!
    clientReady: {},
  },
});

 

성능과 관련하여

XState는 React에서 상태 머신을 사용하는 프로세스를 단순화하는 훅을 제공합니다.
const useClient = (user) => {
  const [state] = useMachine(clientFactory, clientFactoryOptions);

  if (state.matches("clientReady")) return state.context.client;
  return null;
};

물론, 상태 기계 정의와 결합하면, 액션 정의와 액터 코드는 거의 코드가 적거나 더 단순한 코드가 아닙니다.

다음과 같이 워크플로를 분해할 때의 이점은 다음과 같습니다.

 

애플리케이션에서 이러한 패턴을 자주 분리할 필요는 없습니다. 한두 번일 수도 있고 아닐 수도 있습니다.
대부분의 많은 응용 프로그램은 복잡한 워크플로와 일회용 리소스에 대해 걱정할 필요가 없습니다.
 
하지만 이러한 아이디어를 뒷주머니에 가지고 있으면 특히 UI 동작을 모델링하기 위해 상태 머신을 사용하고 있는 경우
복잡한 문제에서 쉽게 벗어날 수 있습니다.
 

XState Visualizer

Visualizer for XState state machines and statecharts

stately.ai

반응형