본문 바로가기

리액트

Should have a queue. This is likely a bug in React. Please file an issue 해결하기

문제 개요

백엔드 API 4개를 한 화면에서 모두 처리해야하는 상황이 생겼다..!

심지어 모두 병렬적으로 실행하는 것이 아니라 동기적으로 처리해야 한다는게 고민이 되었다..

 

그 중에서 한 API는 백엔드에서 분석하는 시간이 오래걸릴 수도 있기 때문에 분석이 완료되었는지 주기적으로 호출하는 Polling 작업도 필요했다.

 

정리해보자면 다음과 같다.

 

  1. API 4개를 한 화면에서 처리
  2. 첫 번째 API는 Polling 처리

 

고민

우리는 react-query를 사용하지 않기 때문에 의존성 분리를 위해 hook을 만들어서 사용하는 방식을 채택하고 있다.

다행히(?)도 4개의 API는 모두 같은 request payload를 가지기 때문에 하나의 모듈 형태로 만들어 사용하기로 하였다. 

 

// useCreateVectorData.tsx

const requestHandler = async (api: string, requestBody: any) => {
  try {
    if (!navigator.onLine) {
      throw new Error(ERR_NOT_ONLINE);
    }
    
    ...
    
    
    const res = await fetch(api, {
      headers: {
        ...JSON_CONTENT_TYPE,
        'User-Agent': navigator.userAgent,
        ...
      },
      body: JSON.stringify({
        ...requestBody
      }),
      method: 'POST'
    });
    const response = await res.json();
    return response;
  } catch (error: Error) {
    throw error;
  }
};

const useRequestHandler = (api: string) => {

  const [isLoading, setIsLoading] = useState(true);
  const { files } = useAppSelector(filesSelector);
  const dispatch = useAppDispatch();
  const [data, setData] = useState<any>(null);
  useEffect(() => {
    const fetchData = async () => {
      try {
        const sendData = {
          fileId: files[0].fileId,
          fileRevision: files[0].fileRevision
        };

        const res = await requestHandler(api, sendData);
        setIsLoading(false);
        setData({ code: 'success', data: res });
      } catch (error: Error) {
        setIsLoading(false);
        setData({ code: 'fail', data: error });
      }
    };

    fetchData();
  }, []);

  return {
    isLoading,
    data
  };
};

const usePollingExtractText = () => {
  const timerIdRef = useRef<any>(null);
  const [isPollingEnabled, setIsPollingEnabled] = useState(true);
  const dispatch = useAppDispatch();
  const { data, isLoading } = useRequestHandler(EXTRACT_TEXT);
  const { files } = useAppSelector(filesSelector);
  
  useEffect(() => {
    const pollingCallback = async () => {
      let fail = false;
      try {
        const res = await requestHandler(GET_EXTRACT_TEXT_STATUS, {
          taskId: data.data.taskId
        });
        if (res.resultCode === 0 && res.status === 'completed') {
          dispatch(
            setFiles({
              isLoading: false,
              isSuccsess: true,
              files: [...files],
              fileStatus: 'TEXT_DONE'
            })
          );
          setIsPollingEnabled(false);
        }
      } catch (error: any) {
        console.log(error);
        fail = true;
        setIsPollingEnabled(false);
      }

      if (fail) {
        console.log('Polling failed. Stopped polling.');
      }
    };

    const startPolling = () => {
      timerIdRef.current = setInterval(pollingCallback, 1000);
    };

    const stopPolling = () => {
      clearInterval(timerIdRef.current);
    };

    if (isPollingEnabled && data?.code === 'success') {
      startPolling();
    } else {
      stopPolling();
    }

    return () => {
      stopPolling();
    };
  }, [isPollingEnabled, isLoading]);

  return {
    data,
    isLoading: !isPollingEnabled && !isLoading
  };
};

const useCreateVectorData = () => {
  const { fileStatus } = useAppSelector(filesSelector);
  let ref = null;

  switch (fileStatus) {
    case null: {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      ref = usePollingExtractText();
      break;
    }
    case 'ANALYZE_DONE': {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      ref = useRequestHandler(CREATE_VECTOR_DATA);
      break;
    }
    case 'DOCINFO_DONE': {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      ref = useRequestHandler(MAKE_SUMMARY);
      break;
    }
    case 'USECREDIT_REQUIRED': {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      ref = useRequestHandler(ALL_COMPLETE_ANALYZING);
      break;
    }
  }

  return ref;
};

export default useCreateVectorData;

이처럼 인증로직이 들어간 requsetHandler를 만들고 데이터를 가져오는 hook인 useRequestHandler와 usePollingExtract hook을 생성했다. 그리고 useCreateVectorData라는 hook을 통해 status 값에 따라 또 다른 hook을 리턴하는 hook을 생성하였다.

한 컴포넌트에서 자동으로 status 값에 따라 업데이트를 할 것이라는 예상에 eslint 에러가 더럽게 되어져 있는 부분이 맘에 걸리긴 하지만... 일단 실행해보기로 하였다.

// /page/ProgressAnalysisDoc.tsx
export const ProgressAnalysisDoc = () => {
  const { t } = useTranslation();
  const [progress, setProgress] = useState(0);
  const data = useCreateVectorData();

  return (
    <Wrapper background={false}>
      <GuideMessage>
        <h1>{t('Step4.MainText')}</h1>
      </GuideMessage>
      <Loading>{t('Step4.LoadingText')}</Loading>
      <Footer>
        <ProgressBar progressPer={progress} />
      </Footer>
    </Wrapper>
  );
};

export default ProgressAnalysisDoc;

 

역시 불길한 예감은 적중한다고 다음과 같은 에러가 발생하였다..!

 

react-dom.development.js:16572 Uncaught Error: Should have a queue. This is likely a bug in React. Please file an issue.
대기열이 있어야합니다. 이는 React의 버그일 가능성이 높습니다. 문제를 제기해 주세요.

 

번역기를 돌려보니 다음과 같은 에러가 출력되었다.

대기열이 있어야 한다고 하니 아무래도 useCreateVectorData hook이 문제인게 확실하다.

하지만 조금 더 확증을 얻기 위해 이곳 저곳 찾아보기 시작했다.

 

가장 좋은 곳은 역시 공식문서..!

공식 문서에서 다음과 같은 힌트를 얻을 수 있었다.

 

https://ko.legacy.reactjs.org/docs/hooks-rules.html

 

Hook의 규칙 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

 

  1. 최상위에서만 Hooks를 호출해야 한다.
  2. 오직 React 함수 내에서 Hook을 호출해야 한다.

여기서 주목할 것은 최상위에서만 Hooks를 호출해야하고, 조건문이나 반복문에서 hook을 호출하면 안된다는 것이다!

 

하나의 Hook을 제작해 최종 커스텀 훅에서는 switch문을 통해 Status 값에 따라 보여주는 상태를 변경 하려고 하였으나 

Redux의 상태 값을 의존함에 따라 Hook을 변경하는 로직에서 Redux의 상태를 변경하는 곳이 곳곳에 있고,

Polling 하는 동안에 Redux의 상태 값이 변경될 시 hook의 동작 순서가 바뀔 수 있다는 것이다.

 

결국 하나의 hooks에서 많은 것을 처리하려는 것이 문제였다는 것이다ㅠㅠ(Lint 오류 무시부터 이상하더라니...)

 

 

해결과정

최대한 각 파일당 하는 일을 하나로 줄이기로 결정 하였다.

 

또한 페이지에 프로그래스 바가 있는 관계로 프로그래스의 퍼센테이지마다 API를 호출 하도록 변경하기로 했다.

 

//페이지
export const ProgressAnalysisDoc = () => {
  const { t } = useTranslation();

  const { fileStatus } = useAppSelector(filesSelector);

  const [step, setStep] = useState<IFileStatus | 'ready'>(fileStatus);

  return (
    <Wrapper background={false}>
      <GuideMessage>
        <h1>{t('Step4.MainText')}</h1>
      </GuideMessage>
      <Loading>{t('Step4.LoadingText')}</Loading>
      <Footer>
        {step === 'TEXT_DONE' && <CreateVector onNext={setStep} />}
        {step === 'ANALYZE_DONE' && <Keyword onNext={setStep} />}
        {step === 'DOCINFO_DONE' && <PreAsk onNext={setStep} />}
      </Footer>
    </Wrapper>
  );
};

 

const Keyword = ({ onNext }: Props) => {
  const { data, isLoading } = useAskDocRequestHandler(MAKE_SUMMARY);
  useErrorHandler(data);
  const { percentage } = usePercentage(isLoading, data?.code === 'success', 45, 90);

  useEffect(() => {
    if (!isLoading && data?.code === 'success' && percentage === 90) {
      onNext('DOCINFO_DONE');
    }
  }, [isLoading, data, onNext, percentage]);
  return <ProgressBar progressPer={percentage} />;
};

 

이렇게 퍼센테이지마다 호출할 API를 정해놓고 해당 API 호출이 끝나면 프로그래스를 올리고 다음 컴포넌트에서 다른 API를 호출하도록 수정하였다!

 

그렇게 되면 사용자 눈에는 프로그래스가 오르는 것만 보이기 때문에 시각적으로도 더 안정되게 처리할 수 있었다.

 

 

결론

hook에 너무 많은 일을 위임하지 말자..