재활 운동 파트를 개발하던 중 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]);
이제 페이지마다 영상을 받아오느라 발생하던 딜레이가 사라지고, 로딩 과정을 한 페이지에서 효율적으로 처리할 수 있게 되었다. 앞으로도 사용자 경험을 개선하기 위해 끊임없이 고민하는 개발자가 되어야겠다.