본문 바로가기

FrontEnd

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 together multiple asynchronous tasks. Let's take a look…

www.thisdot.co

Danger!!

Javascript에 async/await가 도입되면서 여러 비동기 작업을 함께 묶는 복잡한 워크플로를 쉽게 표현할 수 있습니다.

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

  useEffect(() => {
    (async () => {
      const clientAuthToken = await fetchClientToken(user);
      const connection = await createWebsocketConnection();
      const client = await createClient(connection, clientAuthToken);
      setClient(client);
    })();
  }, [user]);

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

  return client;
};

위 코드를 보고 보고 모든 것이 장밋빛이라고 생각하기 쉽습니다.

user를 전달했을 때 클라이언트를 생성하고,

user 변경이나 컴포넌트 언마운트를 통해 클라이언트가 폐기될 때마다 클라이언트의 연결을 끊습니다.

 

그러나 첫 번째 useEffect의 비동기 워크플로가 다른 Effect및 사용자 작업에 독립적으로 응답하는 다른 응용 프로그램과 동시에 실행되고 있다는 점은 고려하지 않았습니다.

이러한 다른 효과 중 하나는 언제든지 컴포넌트를 마운트 해제할 수 있습니다!
컴포넌트가 이전에 마운트 해제된 경우 setClient가 호출되어 클라이언트가 계속 생성됩니다.
 
호출자가 더 이상 존재하지 않는다고 해서 Promise가 취소되지는 않습니다만,
이후 상태 설정 또는 cleanUp을 관리하는 컴포넌트가 없으면 연결을 끊을 수가 없습니다.
(주 : 프로미스가 완료되었는데 unmount된 상태면 연결을 끊을 수가 없다.)
 
어떻게 해야 할까요? 다음과 같이 고치면 될까요?
const useClient = (user) => {
	// no state
  useEffect(() => {
    let client;

    (async () => {
      const clientAuthToken = await fetchClientToken(user);
      const connection = await createWebsocketConnection();
      client = await createClient(connection, clientAuthToken);
    })();

    return () => {
      client?.disconnect(); // here
    };
  }, [user]);

  return client;
};

이제 클라이언트가 생성되면 컴포넌트 상태에 저장하지 않습니다.

하지만 여전히 연결 끊기는 컴포넌트가 언마운트 되기 전에 Promise가 resolve되어야 동작합니다.

useEffect 내에서 비동기 워크플로를 안전하게 사용하려면 언제든지 워크플로를 취소할 수 있도록 만들어야 합니다.
또한 중단이 발생했을 때 워크플로가 어떤 단계에 있었는지에 따라 정리해야 할 사항을 추론해야 합니다. 다음은 예입니다.
const useClient = (user) => {
  const [client, setClient] = useState(null);

  useEffect(() => {
    let cancelled;
    (async () => {
      const clientAuthToken = await fetchClientToken(user); // resolve
      // if cancelled before we get to creating resources
      // it's ok, just don't create them 
      // then
      if (cancelled) return;

      const connection = await createWebsocketConnection(); // resolve
      // if cancelled before we get to the client, we need
      // to make sure our connection isn't left hanging
      
      // then
      if (cancelled) {
        connection.close();
        return;
      }
	  
      const client = await createClient(connection, clientAuthToken); // resolve
      // if cancelled after the client has been created, we
      // need to clean it up
      
      // then
      if (cancelled) {
        client.disconnect();
        return;
      }

      setClient(client);
    })();

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

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

  return client;
};​
 

취소 핸들러를 어디에 둘 것인지 이해하는 데 어려움을 겪고 있다면, 

async / await 대신 Promise를 사용하여 이 코드를 작성한다고 상상해 보십시오.

모든 .then 콜백 시작 시 취소를 처리해야 합니다.

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

  useEffect(() => {
    let cancelled;
    fetchClientToken(user).then((clientAuthToken) => {
      if (cancelled) return;

      createWebsocketConnection().then((connection) => {
        if (cancelled) {
          connection.close();
          return;
        }

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

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

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

  return client;
};
 
위의 이유 때문에 UI 코드에서 async/await을 최대한 피하려 하는 경우가 있습니다.
async/await 구문은 동기(인터럽트 불가) 코드와 비동기(인터럽트 가능) 코드 사이의 경계를 흐리게 합니다. 그것이 요점입니다!
 
선형 워크플로를 실행하는 백엔드 서버와 같이 동기 및 비동기 코드를 유사하게 처리해야 하는 상황에서는 매우 유용하지만
중단이 일반적이고 명시적으로 처리해야 하는 프론트엔드와 같은 상황에서는 위험할 정도로 오해의 소지가 있습니다.
 
물론 암시적 상태 시스템을 보다 명확하고 제어 가능하게 만드는 리소스 관리 문제를 처리하는 보다 정교한 방법이 있습니다.
독자를 위한 연습으로 xstate의 구현을 남겨두겠지만,
이는 이러한 다단계 중단 가능한 프로세스를 추론하고 모델링하는 데 유용한 도구의 한 예입니다.
 
그러나 프로젝트에서 예기치 않게 위험한 Promise에 직면하게 될 경우를 대비하여 백 포켓에 위와 같은 간단한 리액트 솔루션을 가지고 있는 것이 좋습니다.

XState 구현은 2부에서...

반응형