회사에서 재활 운동 관련 서비스를 개발하고 있는데 여러 개의 운동 세트와 세트별 운동 목록을 보여주는 페이지가 있다. 세트 정보와 그 안에 포함된 운동의 정보를 한 번에 보내주는 API가 있으면 참 좋겠지만, 지난 글에 언급했던 것처럼 외주업체에서 만든 API를 사용하고 있어 원하는 API가 없었다. 세트 목록을 받아오는 API와 세트 id로 해당 세트 내 운동 목록을 받아오는 API가 분리되어 있었다. 그래서 두 API를 조합하여 다음과 같이 세트 순서대로 운동 목록이 들어있는 중첩된 배열을 만들고자 했다.
[
[운동1-1, 운동1-2, 운동1-3], // 세트 1
[운동2-1, 운동2-2, 운동2-3, 운동2-4, 운동2-5], // 세트 2
// ...
];
위와 같은 형태의 데이터를 만들기 위해 가장 먼저 떠올린 방법은 아래와 같다. 세트 목록을 먼저 받아오고, 세트 목록을 순회하며 해당 세트 내 운동 목록을 받아오는 것이다. 하지만 원하는 결과가 나오지 않았다. fetch가 비동기 함수이기 때문에 데이터가 세트 순서대로 담기지 않았다.
const [setList, setSetList] = useState([]);
const [exerciseList, setExerciseList] = useState([]);
// 세트 목록 받아오기
fetch(url)
.then((json) => {
// 세트 순서대로 순회
for (let i = 0; i < json.length; i++) {
// 세트 목록 저장
setSetList((prev) => [...prev, json[i]]);
// 현재 세트 내 운동 목록 받아오기
fetch(url)
.then((json) => {
// 세트 순서대로 운동 목록 저장
setExerciseList((prev) => [...prev, json]);
});
}
});
내가 입사하기 전에 외주업체에서 기능 개발에 참고하라고 보내준 코드가 있다. 혹시나 이 부분에 대한 해결책이 있을까 해서 찾아봤는데 아래와 같이 구현을 해놓았다. 배열을 큐처럼 사용하여 운동 세트 id를 뒤에 push하고, 맨 앞의 데이터를 꺼내 fetch 요청에 담아 보내고 있다. 무슨 이유인지 재귀함수까지 사용했는데, 이렇게까지 복잡하게 생각할 필요는 없을 것 같았다. 로직을 파악하기 어렵고 가독성이 떨어져 다른 방법을 찾아보기로 했다.
// 세트 정보 쿼리 결과 수신 대기 중 여부(true면 대기중)
const [waitQuerySet, setWaitQuerySet] = useState(false);
// 세트와 세트 내 운동 목록의 순서를 맞추기 위한 큐(reqest body에 들어갈 데이터)
const reqBodyQueue = [];
// body는 큐에 계속 쌓되 fetch 재귀 호출은 한 번만 실행
function queueBodyData(bodyData) {
reqBodyQueue.push(bodyData);
if (reqBodyQueue.length === 1) {
fetchFromQueue();
}
}
if (waitQuerySet === false && setList.length === 0) {
setWaitQuerySet(true);
fetch(`${BASE_URL}/set`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
})
.then((res) => res.json())
.then((json) => {
for (let i = 0; i < json.length; i++) {
setSetList((prev) => [...prev, json[i]]);
queueBodyData({ setId: json[i].id });
}
});
}
function fetchFromQueue() {
if (reqBodyQueue.length > 0) {
fetch(`${BASE_URL}/exercise`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqBodyQueue[0]),
credentials: "include",
})
.then((res) => res.json())
.then((json) => {
setExerciseList((prev) => [...prev, json]);
reqBodyQueue.shift();
fetchFromQueue();
});
} else setWaitQuerySet(false);
}
해답은 의외로 간단하게 Promise에 있었다. fetch는 비동기 함수라 Promise 객체를 리턴하는데, Promise 클래스에는 여러 개의 promise가 담긴 배열을 받아 병렬적으로 처리하는 유용한 메서드들이 있다(MDN - Promise concurrency 참고). 그중 Promise.all()
은 지금처럼 데이터를 모두 받아온 후 렌더링해야 하는 상황에서 유용하게 쓸 수 있다. 또한 세트별 운동 목록은 세트 목록에 대해 의존성을 가지므로 각각 useEffect를 사용하고, 운동 목록을 받아오는 useEffect의 dependency array에 setList를 추가해주었다.
useEffect(() => {
fetch(`${BASE_URL}/set`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
})
.then((res) => res.json())
.then((json) => {
setSetList(json);
})
.catch((err) => console.log(err));
}, []);
useEffect(() => {
const promises = setList.map((set) =>
fetch(`${BASE_URL}/exercise`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ setId: set.id }),
credentials: "include",
})
.then((res) => res.json())
.catch((err) => console.log(err))
);
Promise.all(promises).then((json) => {
setExerciseList(json);
});
}, [setList]);
위의 코드도 잘 작동하지만, fetch 관련 로직이 반복되어 리팩터링을 하고 싶었다. 반복되는 부분을 추출하여 별도의 파일에 getData라는 함수를 만들었다. 동료 개발자에게도 이 함수를 사용할 수 있도록 알려주었다.
const getData = ({ apiEndpoint, body }) =>
fetch(`${BASE_URL}${apiEndpoint}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
credentials: "include",
})
.then((res) => res.json())
.catch((err) => console.log(err));
getData 함수를 사용하니 불필요한 반복이 줄어들고 API 요청에 필요한 파라미터도 쉽게 확인할 수 있었다. 레거시 코드에서 49줄로 장황하게 구현했던 로직을 16줄로 정리하니 훨씬 가독성 좋고 깔끔한 코드가 되었다.
useEffect(() => {
getData({
apiEndpoint: "/set",
}).then(setSetList);
}, []);
useEffect(() => {
const promises = setList.map((set) =>
getData({
apiEndpoint: "/exercise",
body: { setId: set.id },
})
);
Promise.all(promises).then(setExerciseList);
}, [setList]);