로딩 방식 개선으로 더 나은 UX 만들기

ChaeChae·2022년 11월 2일
0
post-thumbnail

재활 운동 파트를 개발하던 중 UX 측면에서 개선이 필요한 부분이 있었다. 영상이 많아 로딩이 너무 빈번하게 발생한다는 점이었다. 운동 시작 버튼을 클릭하면 다음과 같은 flow로 운동 세트가 실행된다.

운동 세트 미리보기 → 운동 1 튜토리얼 영상 → 운동 1 영상 → 운동 2 튜토리얼 영상 → 운동 2 영상 → …

그런데 기존에는 각 페이지에서 필요한 영상 소스를 하나씩 받아오고 있었다. 사용자 관점에서 보자면 다음 페이지로 넘어갈 때마다 영상 로딩을 기다려야 하는 셈이었다. 그래서 이러한 딜레이를 최소화할 수 있는 방법에 대해 고민하게 되었다.

로딩을 줄이기 위한 고민

방법 1. 이전 페이지에서 다음 페이지에 필요한 영상을 미리 받아오면 어떨까?

같은 페이지 내에서 영상만 바뀌는 경우라면 괜찮은 방법일 것 같다. 하지만 지금 flow는 튜토리얼 페이지와 운동 실행 페이지가 번갈아 가면서 나오기 때문에 작업이 번거로워질 수 있다. 그리고 다음 영상을 다 받아오기 전에 유저가 현재 운동을 건너뛰고 바로 다음 운동으로 넘어가는 경우 딜레이가 발생하게 된다.

방법 2. 운동 세트 미리보기 페이지에서 모든 영상을 한꺼번에 받아오면 어떨까?

이 방법을 사용하면 미리보기 페이지에서 수 초를 기다려야 할 것이다. 하지만 세트에 관한 설명을 화면에 먼저 보여주고, 운동 시작 버튼 안에 작은 로딩 스피너를 넣어준다면 괜찮은 방법이 될 것 같았다. 운동 세트 설명을 읽어보면서 한 번만 기다리면 이후 운동을 실행하는 동안은 딜레이가 발생하지 않으므로 딜레이가 줄어든 것처럼 느껴질 것이다. 다음 운동으로 넘어갈 때마다 썰렁한 로딩 화면을 보는 것보단 이 방법이 훨씬 나을 거란 생각이 들었다.

Promise.all로 영상을 순서대로 fetch 하기

지난 글에서 언급한 Promise.all을 활용하여 운동 목록의 순서대로 영상 URL을 받아오는 코드를 구현했다.

  1. 첫 번째 useEffect에서 운동 세트 정보와 해당 세트 내 운동 목록을 받아온다.
  2. 운동 목록이 업데이트되면 두 번째 useEffect에서 모든 영상 소스를 받아온다.
    2-1. exerciseList를 순회하며 각 운동의 id로 영상 소스를 받아와 promises 배열을 생성한다.
    2-2. Promise.all로 blobs 배열을 받고 createObjectURL 메서드를 사용해 Blob 객체를 URL로 변경한다. callback으로 받은 setState 함수를 실행해 URL 배열을 저장한다.
    2-3. 튜토리얼 영상과 운동 영상을 동일한 로직으로 받아오므로 getFiles라는 함수로 묶고 각각 호출한다.
const [setInfo, setSetInfo] = useRecoilState(setInfoState);
const [exerciseList, setExerciseList] = useRecoilState(exerciseListState);
const [, setTutorialSources] = useRecoilState(tutorialSourcesState);
const [, setVideoSources] = useRecoilState(videoSourcesState);

useEffect(() => {
  getData(url).then((json) => setSetInfo(json));
  getData(url).then((json) => setExerciseList(json));
}, []);

useEffect(() => {
  const getFiles = (fileName, callback) => {
    const promises = exerciseList.map((exercise) =>
      fetch(url, {
        body: JSON.stringify({
          exerciseId: exercise.id,
          fileName,
        }),
      }).then((res) => res.blob())
    );

    Promise.all(promises).then((blobs) => {
      const urlList = blobs.map(window.URL.createObjectURL);
      callback(urlList);
    });
  };

  getFiles("tutorial.mp4", setTutorialSources);
  getFiles("video.mp4", setVideoSources);
}, [exerciseList]);

로딩 상태 관리하기

영상을 모두 받아올 동안 로딩 스피너를 보여주기 위해 isLoading state를 추가했다. fetch 요청을 한 번만 보낸다면 isLoading의 값을 단순히 boolean으로 설정하면 되지만, 두 번의 fetch가 모두 끝난 시점을 나타낼 수 있는 방법이 필요했다. 여러 가지 방법이 있겠지만, 간단하게 두 개의 boolean이 담긴 array를 사용해보았다. Promise.all 작업이 완료될 때마다 slice로 true를 하나씩 제거하고, isLoading.includes(true)의 값이 true일 때 로딩 스피너가 보이도록 했다.

const [isLoading, setIsLoading] = useState([true, true]);

useEffect(() => {
  const getFiles = (fileType, callback) => {
    // fetch 코드 생략

    Promise.all(promises)
      .then((blobs) => {
        const urlList = blobs.map(window.URL.createObjectURL);
        callback(urlList);
      })
      .finally(() => {
        setIsLoading((prev) => prev.slice(0, -1));
      });
  };

  setIsLoading([true, true]);
  getFiles("tutorial.mp4", setTutorialSources);
  getFiles("video.mp4", setVideoSources);
}, [exerciseList]);

AbortController로 요청 취소하기

영상 소스를 받아오고 로딩 스피너까지 띄웠지만, 아직 고려해야 할 부분이 남아있었다. 만약 사용자가 로딩을 기다리지 않고 다른 페이지로 이동해버리면 어떻게 될까? 운동 개수의 두 배에 달하는 다수의 요청을 보내놓은 상태인데 해당 데이터가 필요하지 않게 되어 불필요한 자원 낭비가 발생할 것이다다. 이러한 문제를 방지하기 위해 AbortController를 사용하여 fetch 요청을 취소하는 로직을 추가했다.

  1. AbortController 인스턴스를 생성하고 AbortSignal을 fetch 요청에 담아 보낸다.
  2. useEffect의 cleanup function에 controller.abort()를 넣어주면 컴포넌트가 unmount될 때 fetch 요청을 취소하게 된다.
  3. 2번까지가 일반적인 AbortController 사용법이지만, undefined에 createObjectURL을 수행할 수 없다는 에러가 발생했다. 그래서 요청이 중단되어 blobs에 undefined가 포함되어 있다면 다음 코드를 실행하지 않도록 예외 처리를 해주었다.
useEffect(() => {
  const controller = new AbortController(); // 1
  const signal = controller.signal; // 1

  const getFiles = (fileName, callback) => {
    const promises = exerciseList.map((exercise) =>
      fetch(url, {
        signal, // 1
      }).then((res) => res.blob())
    );

    Promise.all(promises)
      .then((blobs) => {
        if (blobs.includes(undefined)) return; // 3
        const urlList = blobs.map(window.URL.createObjectURL);
        callback(urlList);
      })
      .finally(() => {
        setIsLoading((prev) => prev.slice(0, -1));
      });
  };

  setIsLoading([true, true]);
  getFiles("tutorial.mp4", setTutorialSources);
  getFiles("video.mp4", setVideoSources);

  return () => controller.abort(); // 2
}, [exerciseList]);

이제 페이지마다 영상을 받아오느라 발생하던 딜레이가 사라지고, 로딩 과정을 한 페이지에서 효율적으로 처리할 수 있게 되었다. 앞으로도 사용자 경험을 개선하기 위해 끊임없이 고민하는 개발자가 되어야겠다.

profile
정리 장인 && FE 개발자

0개의 댓글