
재활 운동 파트를 개발하던 중 UX 측면에서 개선이 필요한 부분이 있었다. 영상이 많아 로딩이 너무 빈번하게 발생한다는 점이었다. 운동 시작 버튼을 클릭하면 다음과 같은 flow로 운동 세트가 실행된다.
운동 세트 미리보기 → 운동 1 튜토리얼 영상 → 운동 1 영상 → 운동 2 튜토리얼 영상 → 운동 2 영상 → …
그런데 기존에는 각 페이지에서 필요한 영상 소스를 하나씩 받아오고 있었다. 사용자 관점에서 보자면 다음 페이지로 넘어갈 때마다 영상 로딩을 기다려야 하는 셈이었다. 그래서 이러한 딜레이를 최소화할 수 있는 방법에 대해 고민하게 되었다.
방법 1. 이전 페이지에서 다음 페이지에 필요한 영상을 미리 받아오면 어떨까?
같은 페이지 내에서 영상만 바뀌는 경우라면 괜찮은 방법일 것 같다. 하지만 지금 flow는 튜토리얼 페이지와 운동 실행 페이지가 번갈아 가면서 나오기 때문에 작업이 번거로워질 수 있다. 그리고 다음 영상을 다 받아오기 전에 유저가 현재 운동을 건너뛰고 바로 다음 운동으로 넘어가는 경우 딜레이가 발생하게 된다.
방법 2. 운동 세트 미리보기 페이지에서 모든 영상을 한꺼번에 받아오면 어떨까?
이 방법을 사용하면 미리보기 페이지에서 수 초를 기다려야 할 것이다. 하지만 세트에 관한 설명을 화면에 먼저 보여주고, 운동 시작 버튼 안에 작은 로딩 스피너를 넣어준다면 괜찮은 방법이 될 것 같았다. 운동 세트 설명을 읽어보면서 한 번만 기다리면 이후 운동을 실행하는 동안은 딜레이가 발생하지 않으므로 딜레이가 줄어든 것처럼 느껴질 것이다. 다음 운동으로 넘어갈 때마다 썰렁한 로딩 화면을 보는 것보단 이 방법이 훨씬 나을 거란 생각이 들었다.
지난 글에서 언급한 Promise.all을 활용하여 운동 목록의 순서대로 영상 URL을 받아오는 코드를 구현했다.
Promise.all로 blobs 배열을 받고 createObjectURL 메서드를 사용해 Blob 객체를 URL로 변경한다. callback으로 받은 setState 함수를 실행해 URL 배열을 저장한다.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를 사용하여 fetch 요청을 취소하는 로직을 추가했다.
AbortController 인스턴스를 생성하고 AbortSignal을 fetch 요청에 담아 보낸다.controller.abort()를 넣어주면 컴포넌트가 unmount될 때 fetch 요청을 취소하게 된다.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]);
이제 페이지마다 영상을 받아오느라 발생하던 딜레이가 사라지고, 로딩 과정을 한 페이지에서 효율적으로 처리할 수 있게 되었다. 앞으로도 사용자 경험을 개선하기 위해 끊임없이 고민하는 개발자가 되어야겠다.