자바스크립트 API 호출 성능 개선기

문제

나는 현재 회사에서 주로 사내 그룹웨어 개발일을 해왔었다. 해당 그룹웨어의 Front-End(이하 FE) 는 Vue3 로 구성되어있으며, 내가 입사할때 쯤 어느정도 뼈대가 만들어져있고 이제 막 기능들이 추가되고 있는 작은 프로그램이었다. 초기에는 프로젝트의 크기가 작아서 초기페이지 로딩속도에 문제가 없었다. 하지만 프로젝트에 초기 구상에 없던 기능이 계속해서 추가되며 크기가 커졌고 그에 따라 첫 페이지 진입시 실행되는 로직과 API 호출이 많아졌다.

현재 페이지의 Home 으로 접근시 API 호출 Network

개선된 현재는 Home으로 처음 접근시 소켓 연결 호출을 제외하면 거의 15개가량의 통신이 일어난다.

개선 하기전의 상태를 글로 설명하자면 (스크린샷으로 찍어놨어야하는데 깜빡했다..)

개선 되기 전에는 초기 페이지 접근시 필요한 대부분의 API 호출들이 async await 로 전부 감싸져있어서 해당 Waterfall 그래프가 약간 극단적으로 아래의 사진처럼 계단식으로 하나씩 호출이 완료되고 다음 호출이 이뤄지는 상태였다.

극단적으로 표현하자면 대충 이런느낌이었다.

해결시도

나는 문제를 해결하기 위해 이미 퇴사했거나 다른 프로젝트에 투입되어 개발 중간에 빠지게된 팀원의 코드를 뜯어보기 시작했다. 대부분의 초기 API 호출은 Vue 의 전역상태를 관리하는 Store 에서 일어나고 있었다. 좀 더 자세히 얘기하자면 해당 사용자가 Token 을 가지고 있으면 해당 Token 으로 사용자 정보를 먼저 조회한다. 그리고 조회된 사용자에 따라 출근관련 API, 메뉴관련 API, 채팅 API, 알림 API 등등 다수의 API 호출이 일어나고 있었다.

하나의 Request 로 묶기 

나는 문제를 해결하기위한 첫번째 방법으로 묶을 수 있는 API 들을 한번의 Request 로 묶어서 서버에 요청을 보내 한번의 Response 를 받는것이었다. 실제로 서버 시간을 조회해 Javascript의 시간을 초기화 하는것 같은 사용자에 엮인것이 아닌 API 들을 하나의 API 로 묶어서 호출하게끔 바꿨다. 이 방법의 문제는 API 호출시 서버에 전달해야할 Data 가 있다면 각각을 정제해서 모두 보내주는 코드가 많이 존재했다는 점이었다. 그래서 모두를 묶을 수는 없었지만 최대한 묶을 수 있는것들을 묶어 어느정도 성능향상에 도움이 되었다.

Promise.all 을 통해 비동기 호출

가장 큰 문제는 대부분의 호출은 특정 API 호출 후 FE 에서 로직적으로 무언가 검증하거나 처리 하는 단계를 거친 뒤 다음 API 호출이 일어나는 코드들 이었다. 예를 들자면 호출시 FE 에서 정제된 Data 를 사용한다던가, 소켓 연결 시도 후 연결이 되고 나면 채팅, 알림 등등 소켓과 관련있는 각자의 Store 의 init 메서드를 호출해 각각 API 들을 호출하는것 등등이 있었다. 프로젝트 초기에는 문제가 없었지만 프로젝트가 계속해서 커지면서 하나하나 추가되며 결국 문제를 일으킨것이다.

이 역시 위의 "하나의 Request 로 묶기" 로 모든 Store 를 한번의 API 로 호출한 뒤 초기화하고 싶었지만 이미 수 많은 코드들이 들어있던 FE 코드를 만지는것은 쉽지 않은 일 이었다. 실제로 시간을 들여 한번의 호출로 바꿔보았지만 지속적으로 error 가 나며 결국 Rollback 해버리고 말았다... 그래서 차선책으로 선택한것은 async await 로 전부 쓰이고 있던 Store 의 init 메서드들을 Javascript 의 Promise.all 메서드를 통해 비동기로 바꿔보는것 이었다.

const notificationInitResult = await dispatch(STORE.NOTIFICATION.INIT);
const chatInitResult = await dispatch(STORE.CHAT.INIT);
const kanbanInitResult = await dispatch(STORE.KANBAN.INIT);
...
const [notificationInitResult, chatInitResult, kanbanInitResult] = await Promise.all([
	dispatch(STORE.NOTIFICATION.INIT),
	dispatch(STORE.CHAT.INIT),
	dispatch(STORE.KANBAN.INIT),
	...
]);

대충 이런 느낌으로 변경

모든 init 메서드를 하나로 묶을 수 는 없었다. 특정 호출은 다른 호출의 결과에 따라 호출 여부나 넘어가는 파라미터가 달랐기 때문이다. 하지만 최대한 서로 연관이 없는 Store 의 init 메서드를 하나로 묶었다.

그 결과 초기 로딩시 총 평균 1.5초 걸리던 초기페이지 로딩을 평균 800ms 까지 줄일 수 있었다.

현재 페이지 로딩시 걸리는 시간

한계

우선 당연하겠지만 최소한의 HTTP 연결을 통해 필요한 정보를 가져오는게 제일 좋은 방법이라고 생각한다. 하지만 수많은 Javascript 코드들 앞에서 나는 제한된 시간 안에 해결해내지 못했다.

Promise.all 로 처리했을때는 크게 두가지의 한계가 있다. 우선 첫번째로는 브라우저마다 HTTP 최대 연결 개수가 정해져 있다는것이다. 아무리 HTTP Request 를 병렬로 처리한다 한들 크롬 브라우저, HTTP/1.1 기준으로 최대 6개까지 밖에 한번에 처리하지 못한다. 관련된 좋은 블로그글 이 있으니 한번 읽어보자.

 

브라우저 HTTP 최대 연결수 알아보기

이전 회사에서 개발할 때 있었던 일이었는데 나름대로 정리한 글이다. 우선 사건의 발단은 이러했다.

medium.com

두번째 한계는 Promise.all 같은 경우 배열로 넘긴 Promise 가 하나라도 실패하면 모두가 reject 된다. 즉 묶었던 API 호출들이 서로 결합하여 하나의 실패가 App 의 장애로 이어질 수 있는것이다.

나중에 알게되었지만 Promise.all 대신 ES2020에 추가된 Promise.allSettled 를 이용하면 성공과 실패의 결과를 각각 따로 처리할 수 있다.

결론

처음 이 프로젝트가 시작되었을때는 다들 가벼운 마음으로 시작했다고 들었기에 그룹웨어가 이만큼 커질것이라고 아무도 생각하지 않았을 것이다. 하지만 프로젝트의 요구사항은 지속적으로 추가되고 변경된다. 이번일을 계기로 초기화 로직을 하나로 묶기 편하게끔 처음부터 신경써서 만들었다면 이런 문제를 해결하기 쉽지 않았을까 생각했다.

해당 문제도 좀 더 시간을 들인다면 Promise.all 이 아닌 훨씬 작은 횟수의 API 호출로 변경이 가능하다고 생각한다. 현재는 그룹웨어 개발이 아닌 다른 프로젝트를 하고있어 재시도 하지 못하고 있지만 기회가 된다면 한번 실패했던 API 묶기방식으로 리팩토링 해보고 싶다.