Dynamic Import Module Issue 개선 시도 여정 기록

zena·2024년 10월 25일
2

Front-end

목록 보기
10/12

서론

지하철 와이파이를 활용해 재능교육 온라인진단 서비스를 이용하시면서 불안정한 네트워크로 인해 로드에 실패해 에러 페이지를 빈번하게 마주치셨다는 분의 제보로 인해 해당 이슈를 대응할 수 있는 로직을 고안해보기로 했습니다 ㅋㅋㅋㅋ ㅜㅜ

첫 번째 시도

현재 문제점은 기본적으로 lazy를 적용한 라우터에서 모듈 로드에 실패 시 바로 에러를 던져 error boundary로 직속 직결되어 사용자 경험을 해치고 있는 상황이었습니다

이를 해결하기 위한 첫 번째 방안은 Failed to fetch dynamically imported module 특정 에러 문구를 활용해 retry 하는 로직을 갖는 함수를 작성하는 것이었습니다 (retry 기회는 3번 고정)

export const lazyWithRetries: typeof React.lazy = (importer) => {
  const retryImport = async () => {
    try {
      return await importer();
    } catch (error: any) {
     
      for (let i = 0; i < 3; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
         
         const url = new URL(
          error.message
            .replace("Failed to fetch dynamically imported module: ", "")
            .trim()
        );
        
        url.searchParams.set("t", `${+new Date()}`);

        try {
          return await import(url.href);
        } catch (e) {
          console.log("retrying import");
        }
      }
      throw error;
    }
  };
  return React.lazy(retryImport);
};

크롬 환경에서 실제로 느린 3G → 오프라인 → 제한 없음 순으로 네트워크를 조작했을때, 시간 차를 두고 로드를 retry 하였으며 주어진 기회 내에 모듈 렌더에 성공한 경우 error-log 출력 없이 서비스가 정상 작동하였습니다

그러나… 다른 문제 상황이 있었습니다 ㅠ

크롬 기준 에러 메세지이며, 이외 브라우저에서는 메세지가 다르거나 메세지 내 전체 url이 제공되지 않는 등…. 크로스 브라우징에 대응되지 않는 크롬 친화적인 방식이었던 것입니다

‼️ 하지만, 수확은 있었습니다 ‼️

바로 다음 코드에 관련된 흥미로운 사실인데요,

url.searchParams.set("t", `${+new Date()}`);

이는 바로 url에 임의의 수를 쿼리 스트링 값에 부여해 캐싱을 피할 수 있도록 하는 것입니다!

‘cache-busting(캐시 무효화)‘ 라고 불리우는데, 이를 사용하지 않으면 캐시된 응답을 활용하기 때문에 이전과 현재 시도가 독립적이라는 것을 알 수 없어 재시도에도 곧 바로 실패 응답이 나타나게 됩니다 (싱기방기)

두번째 시도

첫 번째 시도의 안타까운 약점을 인식한 후, 과장님과의 토론을 통해 세운 두 번째 방안입니다 ㅋ..ㅋ

어쨌든 무슨 에러던 lazy 로드를 하면서 에러가 발생하면 catch 내부 로직을 탈 것이고 → 해당 파일 경로만 알고 있다면 → 어떤 브라우저 환경에도 영향받지 않고 → 기회 내 로드를 재시도 해볼 수 있을 것이다

임포터를 통째로 넘겨받던 기존의 방식 대신 페이지(파일) 경로 자체를 문자열 인자로 넘겨 받아 url 생성자를 활용해 정적 에셋의 url을 반환해 활용하였습니다

/** retry 함수 */
const lazyWithRetries = (url: string) => {
  const retryImport = async () => {
    const workerUrl = new URL(url, import.meta.url);

    try {
      return await import(workerUrl.href);
    } catch (error: unknown) {
      for (let i = 0; i < 3; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
        workerUrl.searchParams.set('t', `${+new Date()}`);

        try {
          return await import(workerUrl.href);
        } catch (e) {
          console.log('retrying import error: ', e);
        }
      }
      throw error;
    }
  };

  return lazy(retryImport);
};

결론

retry 진행중일 경우

기회 내 성공한 경우

기회 내 실패한 경우

개발자의 실수로 인해 유효하지 않은 경로를 제공하는 경우와 같이 굳이 로직을 타지 않아도 되는 상황 속에서도 동일하게 catch문에 걸리기 때문에 retry 로직이 실행된다는 단점은 분명히 존재합니다

하지만.. 그 경우가 아니라면 특히나.. 지하철의 불안정한 네트워크를 활용하며 다양한 브라우저를 통해 서비스를 이용할 때와 같은 상황이라는 가정 하에, 모듈 로드 실패 시 매정하게 에러 페이지를 만나는 극악의 사용자 경험은 피할 수 있을 것 같다는 결론을 내리게 되었습니다!

추가적인 대응 로직을 고민하고 또 테스트를 거치고 있기에 아직까지 온라인 진단 서비스는 매정한 에러 페이지를 제공합니다..ㅎ.....ㅎ

빌드 후 실패..ㅠ 새로운 방안

빌드 후 동일한 환경을 테스트했을 때 Failed to fetch dynamically imported module 를 동일하게 내뿜는 예상치 못한 에러를 만나게 되었습니다 ㅠ

viteConfig 설정 내 modulePreload (기본 값 true) 와 관련있어 보였습니다

하나의 script 파일이 아닌, 쪼개진 n개의 module script를 가지고 있었기에 이들을 처리하는 부분이 빠져있는 것이 원인으로 추측하였고, 새로운 방안을 모색해야 했습니다

(모든 script파일 로드를 실행하도록 로직을 짜보기도 했고… 많은 시도를 거듭했지만 modulePreload를 끄지 않는 이상 결국 같은 에러가 발생하더라구요)

새로운 방안은 다음과 같습니다 근데 이제 진짜 마지막인

1. 서버 응답 이전 네트워크로 인한 script 로드 에러임을 모든 브라우저에서 동일하게 파악하기 힘들다
2. 현재 로직의 경우 어떠한 에러 코드도 없는 에러가 발생한다면 알 수 없는 에러 페이지로 이동해, 기존 요청 페이지로 재시도 하지 않고 일괄적으로 메인 페이지로 이동하도록 처리되어 있다
3. 알 수 없는 에러인 경우, 현재 네트워크 연결 상태를 확인 해 ‘알 수 없음 에러’ 가 아닌 ‘네트워크 에러’로 분기 처리를 추가한다
4. 기존에 router에서 처리하려던 retry(reload)로직을 error boundary에서 실제 error-log를 서버에 제공하기 전에 처리하도록 변경해 관심사를 확실하게 분리한다

const [errorParams, setErrorParams] = useState<TErorrUI>();
	const [onlineStatus, setOnlineStatus] = useState(false);
  
  useEffect(() => {
    if (!error || errorParams) return;

    error instanceof AxiosError && console.log(error.response);

    if (error instanceof AxiosError && error.response) {
      switch (error.response.status) {
        // axios 에러 코드 별 분기 처리
      }
    }
    
		// 예외 에러 코드 별 분기 처리
    if (['Network Error', 'timeout exceeded', 'Request aborted'].includes(error.message)) {
      return setErrorParams(errorUIData.networkError);
    } else {
	    // (unknown error) 네트워크 연결 상태에 따라 error param 추가 분기 처리
      if (!onlineStatus) {
        return setErrorParams(errorUIData.networkError);
      }
      setErrorParams(errorUIData.unknownError);
    }
    
    /**
	    에러 로그 post 로직
    */
  }, [error, errorParams, onlineStatus, storeProfileId, token]);

  useEffect(() => {
	  // 현재 네트워크 (온, 오프라인) 상태 체크
    const handleOnlineStatus = () => {
      setOnlineStatus(window.navigator.onLine);
    };

    window.addEventListener('online', handleOnlineStatus);
    window.addEventListener('offline', handleOnlineStatus);

    return () => {
      window.removeEventListener('online', handleOnlineStatus);
      window.removeEventListener('offline', handleOnlineStatus);
    };
  }, []);

처리 전


처리 후


찐 결론

원하던 방향과는 조금 다른 결론이 도달되었지만 그래도 탐구해보면서 사용자의 입장, 개발자의 입장, 서비스 자체의 입장에서 가장 최적이 방법이 무엇일지 생각해보면서 학습해볼 수 있는 아주아주 좋은 시간이 되었습니다ㅎㅎ

참고

https://medium.com/@alonmiz1234/retry-dynamic-imports-with-react-lazy-c7755a7d557a
https://github.com/vitejs/vite/issues/11804

profile
🐤 FE developer 🎧

2개의 댓글

comment-user-thumbnail
2024년 10월 25일

재능이 넘치시는거 같아요!

답글 달기
comment-user-thumbnail
2025년 1월 14일
const isGosu = ({ developer }: { developer: string }) => {
  if (developer === 'zena') return true;

  return false;
};

isGosu('zena');

👀

답글 달기