이전 프로젝트 중, 검색 필터 컴포넌트의 옵션 항목들을 불러오기 위해 API 호출을 해야 하는 작업이 있었다.
그때는 프라미스 문법에 대한 이해도가 낮아서 기존 방식을 그대로 가져왔었는데, Promise all의 결과에 다시 한 번 Promise all로 map을 돌려 모든 데이터를 수집하는 무거운 로직이었다.
const fetchData = () => {
return Promise.all([
fetchMainData()
.then(resList => {
return Promise.all(
resList.map(res =>
Promise.all([
fetchSubData({offset, count}),
fetchSubData({offset + count, count}),
fetchSubData({offset + count * 2, count}),
]).then(result => {
return { main: res, sub: result.flat() };
})
)
);
}),
fetchMainData2(),
]).catch(err => {
console.error(err);
});
};
정신이 혼미해지는 비동기다.
type fetchDataResultType = {
main: mainDataType,
sub: subDataType[],
}
위와 같은 로직으로, 페이지 마운트 직후 약 250개의 요청을 주루룩 보냈다. 이로 인해 로딩이 2초 가량 걸려 UX가 떨어졌다. map 을 사용한 덕분에 각 요청을 기다리는 동안 컴포넌트 로드가 pending 되는 waterfall 문제는 얼떨결에 피했지만, 최초 로딩 시간이 2초나 걸린다는 건 굉장히 마음에 걸리는 이슈로 남았다.
당시 서버 구조 상 저런 로직이 유일한 방법이었기 때문에, UX적으로 로딩 화면이 보이는 시간을 최대한 줄이는 방법을 찾아야 했다.
저 로직을 통해 불러오는 것은 필터 항목에 포함될 카테고리였다. 하지만 어차피 최초 로딩 시에는 아무 필터도 적용되지 않은 전체 데이터가 보여지므로, 우선 데이터 리스트를 포함한 전체 페이지를 비동기 없이 빠르게 마운트시키고 나서 필터가 마운트될 때 그 안에서 필터 데이터를 부르려고 했다. 우선 필터를 모두 닫은 채로 마운트시킨 뒤, 각 필터가 최초로 열릴 때 그 안의 필터 항목들을 fetch 해오려는 계획이었다.
하지만 결국 리스트 정보를 보여줄 때 필터 데이터를 필요로 하는 부분이 생겨 이 방법도 불가능해졌고, 결국 2초의 로딩을 그냥 둔 채로 프로젝트는 완전히 마무리되어버렸다.
이 작업 당시에만 해도 비동기 로직을 여러 개, 그것도 중첩시켜 여기저기서 다루는 태스크 자체만으로 꽤나 어려움을 겪었던 터라 성능 개선을 신경쓰기가 부담스러웠다. 일단 이 실력에 필터가 제대로 동작하는 게 어디냐는 감지덕지의 심정으로 매일매일 잔뜩 긴장한 채 개발했던 것 같다.
그리고 시간이 지나 최근 프로젝트에서 나는 다시 여러 번의 API 호출을 다루어야 하는 태스크를 만나게 되었다.
예를 들어, 어떤 학교의 데이터를 API로 불러온다고 생각해 보자. 그럼 필요한 정보는 얼추 아래와 같다.
이와 같이 각 수준에서 기본 정보 / 상세 정보로 엔드포인트가 분리되어 있는 API를 사용해 대시보드 페이지를 개발하는 것이 내게 주어진 작업이었다.
API를 여러 번 부른다는 건 여러 형태로 작동할 수 있다.
위의 예시 케이스처럼 각 수준에서 관련 데이터를 한 번에 불러오지 못하고 여러 엔드포인트로 나누어 불러와야 하는 경우, 이 세 가지 형태를 모두 마주치게 될 가능성이 높다.
나는 위의 3가지 case 중 Parallel API Calls를 구현하는 과정에서 문제를 마주쳤다. 여러 API를 동시에 부른다는 것은, 바꾸어 생각하면 동시에 부르지 못할 경우 로딩 시간이 급격히 늘어날 위험성을 안고 있는 것과 같다.
우선, 코드가 길고 복잡해지기 십상이다. 프라미스 체이닝이든, async-await이든 비동기 처리를 위한 구문은 조금 더 신경써서 작성해야 하고, 처리해야 할 부분이 늘어난다.
로직의 형태(효율성)에 따라 로딩 시간이 불필요하게 늘어날 수도 있다. 앞서 서론에서 언급한 케이스처럼 200개 이상의 API가 순차적으로 호출되는 경우가 그렇다. 또는 React Suspense와 같이 프라미스를 처리하는 동안 하위 컴포넌트의 렌더링을 중단시키는 경우가 발생할 수도 있다.
또 API 호출 횟수가 늘어나는 경우 에러 핸들링 범위에 신경쓰지 않으면 에러로 인해 어플리케이션 자체가 뻗거나, 잘못된 에러 핸들러가 작동할 우려도 있다.
여러 종류의 데이터가 필요하지만 딱히 순서가 상관없는 경우, 다수의 API 호출 로직을 단순 나열하는 것이 기본적이다. 이 경우 보통은 커스텀 Hook 또는 React-Query의 useQuery 등을 사용해 컴포넌트 최상단에서 병렬적으로 호출할 수 있다.
로직이 아예 컴포넌트 안에 co-located 되어 있는 경우에는 한 개의 async function 안에서 Promise.all을 사용해 여러 개의 API 호출을 한 개의 프라미스로 합쳐서 돌려받을 수도 있다.
Suspense를 사용하던 중 문제를 마주쳤다. 프라미스를 던졌을 때 Suspense의 작동은, await 키워드를 만난 async 함수의 작동과 유사하다.
const getData = async () => {
const school = await fetchSchool();
const grade = await fetchGrade();
const student = await fetchStudent();
return {school, grade, student}
}
getData();
위 코드는 await
키워드로 인해 각 API 호출을 만날 때마다 함수가 suspended될 것이다. await 키워드를 만나면 async 함수는 일시중지되고, call stack에서 뽑혀 나와 microtask queue에 들어간다. (참고) 따라서 각 API 호출에 걸리는 시간의 총합 만큼이 로딩 시간이 된다.
물론 보통 async-await 문법은 비동기 작업을 동기적으로 처리하기 위해 사용하므로, 여기서 await 키워드로 인해 각 API가 순차적으로 처리된다는 점이 예상치 못한 문제가 되지는 않을 것이다.
반면 React Suspense 사용 시의 waterfall 문제는 내가 예상치 못했던 문제였다.
다른 글에서 설명했듯, Suspense는 자식 컴포넌트가 던진 pending 상태의 프라미스를 만나면 그 프라미스가 이행(또는 에러)될 때까지 해당 컴포넌트의 마운트를 지연(suspend)시키고, fallback UI를 보여준다. 문제는 자식 컴포넌트에서 프라미스를 여러 개 던질 경우다.
위 async 함수가 await 키워드를 만날 때마다 suspend되고 나머지 line들이 먼저 실행되듯, Suspense 역시 자식 컴포넌트가 프라미스를 '던질 때마다' 마운트를 suspend 시키고 나머지 컴포넌트들의 렌더링으로 넘어갈 것이다.
대표적으로 한 컴포넌트 안에서 React-Query의 useQuery를 여러 개 병렬로 작성하며 suspense 속성을 사용하는 경우가 이런 문제를 야기한다.
function App () {
// parallel
const usersQuery = useQuery({ queryKey, queryFn })
const teamsQuery = useQuery({ queryKey, queryFn })
const projectsQuery = useQuery({ queryKey, queryFn })
// sequential
const usersQuery = useQuery({ queryKey, queryFn, suspense: true })
const teamsQuery = useQuery({ queryKey, queryFn, suspense: true })
const projectsQuery = useQuery({ queryKey, queryFn, suspense: true })
...
}
위 코드의 parallel한 case처럼, useQuery를 여러 개 나열하는 것은 분명 나란히 비동기적으로 동작한다. 즉 순차적으로 ‘실행’될 뿐, 프라미스의 이행을 기다려주지 않는다.
하지만 sequential한 case를 보자. suspense 속성을 사용했다. 즉 각 Query가 실행 시점에 프라미스를 throw하게 된다. 따라서 각 Query가 실행되고, 던진 프라미스가 처리되는 동안 Suspense가 컴포넌트의 렌더링을 지연시키므로 아래의 다른 Query들은 실행되지 않는다. 즉 동기적 동작과 같아진다.
위에서 예시를 들었듯, 나는 각 수준에서 기본 정보 / 상세 정보로 엔드포인트가 분리되어 있는 API를 사용해서 작업하고 있었다. 즉 학생의 ‘모든 정보’를 알기 위해서는 기본 정보 API와 상세 정보 API를 반드시 함께 호출해야 하는 상황이었다. API 호출은 거의 모든 컴포넌트에서 2개 이상일 수밖에 없었다. 하지만 Suspense의 간편함도 포기하고 싶지 않았다.
이러한 문제를 해결한 다른 글들을 보면, 컴포넌트를 쪼개어 각 컴포넌트 당 한 개의 useQuery만 사용하도록 분리한 경우가 많았다. Suspense는 프라미스를 던진 해당 컴포넌트의 마운트를 일시중지시킬 뿐, 다른 컴포넌트의 마운트는 계속 진행시킨다. 한 컴포넌트에서 여러 번 API를 호출하는 대신, 각 컴포넌트 당 한 개의 API만 호출하도록 수정하고 상위에서 여러 컴포넌트를 병렬적으로 합성하면 된다.
그 대신 모든 컴포넌트를 1개의 Suspense로 감싼 상태라면, 모든 컴포넌트의 프라미스가 resolve된 시점에 fallback UI가 끝난다. 즉, 빨리 이행된 데이터부터 순차적으로 보여주지 않고 모든 프라미스의 이행을 기다렸다가 한꺼번에 보이게 된다. 따라서 데이터가 도착하는대로 최대한 빨리 보이길 원한다면 컴포넌트를 각각의 Suspense로 감싸줘야 한다.
하지만 나는 API에 따라 각기 다른 컴포넌트로 쪼개는 것이 불가능했다. 그러려면 컴포넌트가 너무 많아져야 했다. 거의 대부분의 컴포넌트에서 2~3개의 API를 호출하는데, 이를 다 각각의 API 별로 쪼개려면 컴포넌트 양이 2~3배로 늘어나는 것이다. waterfall 해결의 요지가 결국 한 컴포넌트 당 한 개의 프라미스만 던져서 병렬적으로 실행될 수 있게 하는 거라면, API를 몇 개를 호출하든 1개의 프라미스로 합쳐지도록 하기만 하면 되는 것 아닐까?
그래서 컴포넌트를 쪼개는 대신, 한 개의 useQuery 안에서 Promise.all을 사용해 여러 개의 API를 병렬적으로 호출했다.
Promise.all
은 요소 전체가 프라미스인 배열(엄밀히 따지면 이터러블 객체)을 받고 새로운 프라미스를 반환한다. 배열 안 프라미스가 모두 처리되었을 때, 그 결괏값들을 담은 배열이 새로운 프라미스의 result
가 된다. 따라서 useQuery의 queryFn이 Promise.all() 결과를 리턴하도록 하면, 최종적으로 컴포넌트에서는 한 개의 프라미스만 처리하는 셈이 된다.
// problem
const {data: studentBasicsData} = useQuery(queryKey, () => fetchStudentBasics())
const {data: studentDetailsData} = useQuery(queryKey, () => fetchStudentDetails())
// fixed
const {data: studentTotaldata} = useQuery({
queryKey,
queryFn: () => Promise.all([fetchStudentBasics(), fetchStudentDetails()])
})
그럼 몇 개의 API 호출을 하든, 무조건 Promise.all로 감싸서 사용하기만 하면 걱정이 없어지는 걸까?
여기서 조금만 더 멀리 내다본다면, 하나의 아주 사소한 복병이 더 있다는 걸 알 수 있다. HTTP/1.1 프로토콜에서는 각 브라우저마다 endpoint 당 한 번에 부를 수 있는 max connection count가 정해져 있는데, 이 개수가 생각보다 적다는 것이다.
Firefox 2: 2
Firefox 3+: 6
Opera 9.26: 4
Opera 12: 6
Safari 3: 4
Safari 5: 6
IE 7: 2
IE 8: 6
IE 10: 8
Edge: 6
Chrome: 6
그럼 예를 들어, promise.all 로 약 10개의 객체를 가진 배열에 대해 map을 돌면서 HTTP/1.1 기반의 API를 호출한 결과를 받아온다면?
async () => {
return Promise.all(arrayWithTenObjects.map(obj => fetchData(obj))
};
Promise.all은 10개의 fetchData()
를 병렬적으로 처리하려고 하겠지만, max connection(ex. 크롬에서 6개)을 넘긴 4개의 요청은 앞선 6개의 요청이 처리될 때까지 기다려야 한다.
이 때에는 fetchData()
의 데이터를 필요로 하는 부분을 별도 컴포넌트로 분리해서, 그 안에서 호출되도록 하는 편이 낫다.
const Child = props => {
const { obj } = props;
const [ data, setData ] = useState(null);
useEffect(() => {
fetchData(obj).then(res => setData(res));
}, [])
}
const Parent = () => {
return <>{ arrayWithTenObjects.map(obj => <Child obj={obj} /> }</>
}
어쨌든 10개의 Child 컴포넌트가 마운트되며 각각의 fetchData() 요청을 날리므로 HTTP max connection을 초과하는 건 똑같은 것 아닌가?
조금 다르다. 모든 프라미스가 이행될 때까지 기다렸다가 리턴시키는 promise.all 과 달리 각 컴포넌트 안에서 독립적인 API call 이 이루어지므로 먼저 로딩된 컴포넌트는 바로 보일 수 있게 된다.
(물론 먼저 로딩된 순으로 바로바로 보이는 게 무조건 좋은 건 아니다. 드르륵 로딩되는 것이 오히려 사용자에게는 한 번에 로딩되는 것보다 더 불편하게 느껴질 수도 있다..!)
다수의 프라미스를 처리하는 방법을 찾다가. promise pool이라는 기술을 접했다.
꼭 브라우저의 max connection count를 고려해서가 아니라도, Node.js에서 조회 쿼리를 날리는 등 수백개의 Promise 작업을 하는 경우, 한번에 Promise.all을 수행하지 않고 적당량의 chunk로 분할한다고 한다. 이 경우 각 프라미스 배열 안에서 가장 오래 걸리는 프라미스의 수행 시간이 chunk의 실행 시간이 된다.
문제는, 가장 긴 프라미스가 끝나기 전까지 다음 프라미스들은 작업을 하지 않는 비효율이 생길 수 있다는 것이다. chunk로 나누었다 해도, 한 시점에 한 개의 chunk만 수행되므로 특정 chunk의 수행이 오래 걸리면 뒤의 다른 chunk들은 밀린 채로 기다려야 한다.
Promise pool이란, 프라미스를 chunk 단위로 묶는 대신 컨베이어 벨트처럼 계속 돌아가는 레일을 깔아두는 것이다. 각 레일별로 프라미스가 끝나는 대로 다음 프라미스가 빈 레일에서 작업을 수행한다.
프론트엔드에서는 몇백 개의 리스트 아이템 하나하나에서 API 호출을 하지 않는 이상 이만큼의 프라미스 작업을 할 일은 잘 없을 것 같지만, 성능을 고려해야 할 때 이처럼 프라미스 요청을 chunk 단위로 나누는 관점으로 고민해보는 것은 좋은 방향일 것 같다.
대부분의 모던 프론트엔드는 h2/h3 위에서 작동하므로 위의 요인들까지 세세하게 고려할 필요는 거의 없어졌다. 그럼에도 불구하고, 여전히 HTTP/1.1을 사용하는 사이트들도 있긴 하지만 말이다.
결국 이렇게까지 성능 저하 요인을 고려해보며 내가 배운 것은,
이번 작업은 서버의 성능 부담을 낮추기 위해 엔드포인트가 여러 개로 분리되며, 쾌적한 UX가 유지되도록 프론트엔드 로직을 개선하는 것이 주된 이슈였다. API 호출이 늘어날수록, loading UI가 자주 보일 수 있어 UX가 나빠지기 쉽다. 즉 서버 구조가 UX에 어쩔 수 없는 영향을 끼치기도 한다.
그래서 React.lazy를 통해 불필요한 모듈 로드를 최소화하고, Suspense를 적용했다. Suspense는 fallback UI를 보여줌으로써 비동기 처리로 인한 Loading 상태에 대응할 수 있다. 또 개발자의 입장에서 loading case를 고려할 필요 없이 선언형으로 UI를 작성할 수 있게 해 주는 뛰어난 도구다. 하지만 렌더링 성능 관점에서 여태껏 본 바와 같은 waterfall 문제를 발생시킬 수도 있다.
React-Query의 useQuery 역시 API 데이터 및 호출 상태 관리를 손쉽게 할 수 있게 해주는 강력한 도구다. 하지만 공식문서의 Parallel queries 예제를 그대로 가져와서 사용해도, suspense라는 환경을 만나면 의도와 다르게 작동하기도 한다.
이번에는 Promise.all을 활용하여 문제를 개선했지만, 만약 Promise.all로도 해결이 어려울 만큼 무겁거나, 대량의 프라미스 작업을 해야 한다면 그 때는 어떻게 하게 될까? 위에서 짧게 언급한 Promise Pool을 프론트에서도 적용할 수 있는지 고려해볼 수도 있고, 다른 방안을 모색할 수도 있다.
등등 여러 가지 관점에서의 고려가 필요하다.
웬만하면 프론트엔드 개발 단에서 해결되는 문제가 많지만, 어쨌든 최종 목적은 정상 작동하는 프로덕트를 빠르게 사용자에게 딜리버리하는 것이므로, 기능의 임팩트에 비해 개발 코스트가 너무 크다면 꼭 내가 어떻게든 해결하겠다고 붙들고 있는 것이 최선이 아닐 수도 있다는 개인적인 깨달음도 얻었다.