개발/삽질

React에서 어떻게 해야 Async한 Data를 잘 다룰 수 있을까?

SuYongS 2021. 4. 23. 23:20

이 블로그를 처음 만들면서 제일 쓰고싶었던 주제이다. 정말 많이 고민했었고, 꽤 괜찮은 해답이 나온거 같다. 나의 생각흐름을 천천히 적어볼태니, 독자분들도 같이 생각하시면서 따라와주시면 감사하겠습니다.

 

추가로 더 좋은 방법이 있거나 이 방법에 뭔가 문제가 있어보이면 피드백을 주시면 감사하겠습니다.


일단 첫번째 생각이다.

1. async function을 react component내에서 async하게 실행할 방법이 있는가?

정답은 불가능하다이다. 일단 react component는 async 함수가 아니기 때문에 내부에서 await를 사용하지 못한다. 그 말은 Promise의 결과를 콜백으로 받아서 사용해야 하는 것이다. 여기서 문제가 생긴다. react component는 state가 바뀌면 functional 컴포넌트를 재랜더링 한다. 한번더 호출이 된다는 말이다. 이를 도식화하면 다음과 같다.

그림 1 - React Component 도식화

 

그림 1에서 function A는 async function이고 function B는 일반적인 function이다. 이때 function B가 동작할때 동안은 blocking을 하니 Component A가 잠시 일시정지 되었다가 function B의 작업이 끝나고 blocking이 해제 되지만, function A와 같은 경우는 병렬로 실행되기 때문에 function A가 동작하는 동안 component A는 계속 실행하게 된다. 문제는 function A가 아직 작업중일때, Component A가 다시 랜더링 될 경우이다. 분명 function A는 functionB처럼 첫번째 랜더링에서만 유효해야하지만 그렇지 않다. 만약 function A에서 setState 동작을 할 경우 state변경이 순서대로 일어남을 보장할 수 없다. 따라서 우리는 async function을 가공하는 방식으로 가야한다.

 

> Deasync 라이브러리를 이용하는것은 어떨까?

더보기

Deasync 라이브러리를 이용하는것은 어떨까?

Deasync 라이브러리를 이용하면 async함수를 일반 함수로 바꿀 수 있다! 처음엔 이게 어떻게 가능한지 고민했었는데 알고보니 node의 event loop를 직접 조작하여 만들어낸 결과물이였다. 정확한 설명과 사용법은 github.com/abbr/deasync에 있으니 참고하도록 하자.

그래서 Deasync 라이브러리를 사용하면 안되는걸까에 대한 답은 안된다이다

물론 async한 함수를 편리하게 호출할 수 있는것은 맞다. 하지만 명심하자. React는 UI를 다루는 프레임워크(프레임 워크라고 하자...)고 UI/UX 디자인은 언제나 유저 친화적이여야 한다. deasync를 해버리면 함수가 끝나기 전까지 유저는 그저 밋밋한 흰색 화면만 보고 있을것이다. 안그래도 RN이여서 Native보다 느린데, 밋밋한 흰색 화면이 뜬다면 "이 개발자는 제대로 일을 안 하고 있을지도 몰라!" 와 같은 비판을 받을수 있으니 주의하자.

 

참고로 이부분에 대해서 React에서도 심도있게 고민하여 나온 결과가 있긴하다. Concurrent 모드이다. 물론 RN에선 안되는걸로 안다... 심지어 실험용이니 Production에서 쓸건 생각도 하지말자. 또한 이 글과 약간 느낌이 다르다. Concurrent모드는 async한 함수를 사용한다는 느낌보다는, 코스트가 높은 함수를 실행할때 (코스트가 높으면 시간이 오래걸리니 async function과 유사하다고 느낄 수 있다. 물론 async funciton와는 근본적으로 다르지만)blocking을 없에고 병렬로 동작하면서 사용자에게 어플리케이션이 돌아가고 있다는 느낌을 주려고 만든것 같다. 그래서 이름도 async 모드에서 concurrent모드로 바뀐것이 아닐까?

 

1번이 불가능함을 깨달았다. 그렇다면 우리는 어떻게 접근하는 것이 좋을까? 잘 생각해보니 우리가 async 함수를 가장 많이 쓰는곳이 생각났다. api 서버로 request보낼때 가장 많이 쓸것이고, 그부분이 우리가 고민중인 설계에 가장 적합한 예시라고 생각든다. 따라서 실제 api 서버로 request 보낼때 어떻게 보냈는지 생각했다. 일반적으로 다음과 같이 한다.

 

  1. state를 정의하여 기본값을 채워둔다.
  2. 데이터가 로드 되었는지 확인하는 state를 생성한다
  3. 적절히 서버로 요청을 보낸다
  4. response를 받으면 데이터가 로드되었다고 알려주고 state를 변경한다

우리는 이미 답을 가지고 있다! 2번을 주목해보면 state를 이용하여 data의 상태를 알려주고 있다. 이에 착안해서 만들게 되었다.

2. useAsyncState

useAsyncState라는 것을 만드려고 한다. 왜 굳이 훅으로 만들려고 했냐면,

react는 flux패턴이다. (검색하면 아키텍쳐라고 나오던데 MVC, MVVM같은거는 패턴이라 부르면서 flux는 왜 아키텍쳐인지는 잘 모르겠다.) flux패턴은

그림 2 - flux 패턴

다음과 같이 action -> dispatcher -> store -> view 순으로 되어있는데, 대부분 View가 Action을 발생시켜서 자신을 다시 랜더링하는 방식이다. 단방향이기 때문에 단순하기도 하다.

 

아무튼 flux패턴이 나온 이유는 우리가 쓰는 state라는 놈도 flux패턴의 산물이라고 생각한다. view에서 어떠한 action이 하고싶을때 어떤 action을 할지 setState라는 dispatcher를 실행시키면, store에서 어떠한 값을 변경할지 결정하여 View에 업데이트를 해주는 것이다. 그냥 좀 어려워 보이는데 간단해서 그냥 훅의 구조와 완전히 동일한 것 같다. 사실 redux가 저 패턴 자체인거 같기도 하다.

 

뭐... 아무튼 훅 자체가 react의 설계 흐름 그 자체니, 우리도 훅을 사용하는게 아무래도 react스러울거라고 생각해서 훅으로 promise 상태를 나타내기로 하였다

const useAsyncState = (initValue) => {
  const [state, setState] = useState(initValue === undefined ? 'initialized' : 'fulfilled');
  const [result, setResult] = useState(initValue ?? Error('not initialized'));

  const value = { state, result };
  const setValue = useCallback((newValue) => {
    if (newValue instanceof Error) {
      setResult(newValue);
      setState('rejected');
    } else {
      setResult(newValue);
      setState('fulfilled');
    }
  }, []);

  const setCustomState = useCallback((customState) => {
    setState(customState);
    if (customState === 'initialized' || customState === 'pending') {
      setResult(initValue ?? Error('not initialized'));
    }
  }, [initValue]);

  return [value, setValue, setCustomState];
}

코드는 다음과 같이 나타냈다. useState와 동일하게 value, dispatcher순으로 값을 출력했고, 특별히 3번째 값으로 state변경 함수를 넣었다. 원래 설계상으로는 Promise의 pending, fulfilled, rejected의 3가지 타입의 state를 나타내려고 하였다. 하지만 그렇게하다보면 실제로 사용할때, 현재 함수가 실행중인지, 실행중이 아닌지 판단할 수 없기때문에 initialized라는 새 state를 작성하였다.

 

또한 setValue라는 dispatcher를 리턴하는데, Error를 넣는지, result를 넣는지에 따라 state가 자동으로 바뀌는 모습을 볼 수 있다. 3번째 인자를 옵셔널로 사용하기 위한 일종의 트릭이다.

 

이제 일반 state 사용하듯 사용하면 다음과 같이 async한 data를 state와 적절히 조합해서 사용할 수 있다.

const SomeComponent = () => {
  const [data, setData, setState] = useAsyncState();
  
  useEffect(() => {
    if (data.state === 'initialized') {
      setState('pending');
      
      someFunction().then((value) => {
        setData(value);
      });
    }
  }, [data]);

  return (
    // JSX.Element
  );
}

 

그런데 코드를 보면 알겠지만, pending상태를 항상 명시해줘야하는게 문제가 생긴다. 좋은 방법은 없을까?

 

3. useAction

Redux가 아닌, 커스텀 훅의 이름이다. useAsyncFunction과 useAction중 useAction을 하기로 선택하였다. useAction은 간단히 다음의 역할을 한다.

 

  • async function을 인자로 받는다.
  • fetch를 하면 async function을 실행시킨다.
  • result 값은 AsyncState이다.

useAction의 용도는 "현재 컴포넌트에서 이러한 async function을 사용할것이다"를 명시하게 하려는 의도도 있다. 서버에 request를 날릴경우에 "해당 컴포넌트는 서버에 어떠한 정보를 가져옵니다" 정도만 알고있어야 하는게 내 생각이다. 그렇지않고, View에서 모든 request를 날리는 부분을 다 만들어버리면 뷰와 action끼리의 관계가 깊어져 유지보수하기에 매우 힘들어지기 때문이다. 어쨌든 action은 다음과 같이 처리하기로 했다.

const useAction = (func, deps = []) => {
  const [value, setValue, setState] = useAsyncState();

  const fetch = useCallback(async (...args) => {
    setState('pending');
    const result = await func(...args);
    setValue(result);
  }, [func, setState, setValue]);

  useEffect(() => {
    setValue(Error('Not initialized'));
    setState('initialized');
  }, [deps, setState, setValue]);

  return [value, fetch];
};

간단히 다음과 같이 만들었다. 설명을 좀 하자면 첫번째 인자로 실행시킬 function을 받고, 리턴값으로 asyncstate와 해당 함수를 실행시키는 function을 받는다. 따라서 실제로 써본다면 대략

const SomeComponent = () => {
  const [data, fetchData] = useAction(async () => {
    // async한 어떤 작업
    return result;
  });
  
  useEffect(() => {
    if (data.state === 'initialized') {
      fetchData();
    } else if (data.state === 'fulfilled') {
      // data.result로 값을 가져온다
    } else {
      // Promise가 reject됨
    }
  }, [data]);

  return (
    // JSX.Element
  );
}

이런 느낌일것이다. 추가로 deps는 어떠한 state가 변경되면, 해당 action의 작업이 다시 취소된다. 일단 여기까지가 현재까지의 상황이다.

어느정도 코드를 만져보신분은 문제가 하나 보일것이다.

 

deps변경에 따라 state가 초기화 되는것이 safe하지 않다

 

무슨말이냐, 위에서 고민했던 상황과 동일하다. async function이 동작중일때, 갑자기 function을 취소하라고 할 수 없다. 따라서 리셋이 되더라도, setValue가 설정됨과 동시에 자동으로 fulfilled상태가 될 수있다. 물론 if문에서 state가 pending상태일때만 result를 설정하게 하면 되지만 문제가 하나 더 있다.

 

해당 코드는 deps를 등록하면 무한히 state가 바뀌어버리는 버그가 존재한다! (하...)

원인은 아직 찾지 못했다. 찾게되면 파트2로 해당글을 작성할 듯 하다.

 

마무리가 이상하긴 한데, 여기까지 정독해주신 독자분들 감사합니다. deps등록 버그의 원인을 아시는분은 댓글 달아주시면 정말 감사드리겠습니다.

추가로 해당 코드 모두 typescript로 typesafe하게 작성되었고, 자동 타입 추론도 모두 지원하지만... 전부 공개하기 좀 꺼려져서 일단 일부만 공개하는점은 양해 부탁드립니다