시간이 오래 걸리는 함수들을 병렬로 호출하여 최적화하기
함수는 호출되는 시점에 실행을 시작하고 순차적으로 코드라인을 실행한 후 종료된다. 동기함수든, 비동기함수든, 함수는 호출될 때 실행되기 시작한다. Web API 에서 제공하는 비동기함수인 setTimout 의 경우, 얼마의 시간이 지난 후에 콜백을 실행할지 미리 지정할 수가 있다. 만약 funcA 함수 안에서 setTimeout 에 funcB 콜백함수를 넘겨준다면 funcA함수는 일단 코드라인을 순차적으로 실행한 후 종료되고, 일정 시간이 지난 뒤에 콜백 funcB가 실행된다.
싱글스레드로 동작하는 자바스크립트의 콜스택 안에서는 한번에 하나의 작업만 처리할 수 있어서, 병렬처리를 지원하기 위해 크롬을 비롯한 브라우저들은 이벤트루프 기능을 제공한다. 이벤트루프는 콜스택을 주시하고 있다가 비어있는 걸 발견하면 비동기 함수의 콜백태스크들이 들어있는 콜백큐(마이크로 태스크 큐, 애니메이션 프레임즈 큐, 태스크 큐) 들을 차례대로 조회하면서 태스크를 하나씩 꺼내다가 콜스택에 추가한다.
대표적인 비동기함수인 setTimout 함수가 실행되면 Web API 가 호출되고, setTimout 의 콜백함수가 태스크큐에 들어간다. 세개의 콜백 큐 중 우선순위가 가장 낮다.
브라우저 렌더트리가 새로 그려질 때, 즉 다음 렌더링에서 실행될 콜백함수가 담기는 큐이다. 대개의 경우 마이크로태스크 큐보다 우선순위가 낮다.
ES6 규격에서 비동기 처리를 위한 Promise 객체가 공식 지원되기 시작하면서, 기존의 태스크 큐와 분리된 Job Queue 라는 개념이 추가되었고, 이 표준에 따라 크롬을 비롯해서 사파리, 파이어폭스 등의 브라우저에서는 각각 Promise 객체 처리를 위한 Job Queue 를 구현하였다. 크롬에서는 Job Queue 를 마이크로태스크큐라는 이름으로 부르고 있으며, 비동기 콜백 큐 가운데 가장 우선순위가 높도록 설계하였다.
그렇다면 이제 본론으로 들어가보자. Promise 객체로 만든 비동기 콜백함수는 대체 언제 실행이 시작되고, 얼마만큼의 시간이 지난 후에 성공/실패 처리가 완료된다고 확신할 수 있을까?
결론부터 말하자면, Promise 객체를 리턴하는 함수를 호출할 때 비동기 콜백함수는 실행되기 시작하고, 마이크로 태스크 큐에 들어가서 대기한다. 호출되는 순간 Promise 객체의 상태는 pending 상태이다. 이 콜백함수의 처리결과를 받으려면 await 키워드로 Promise 객체를 리턴하는 함수를 호출해야 한다.
setTimout 의 콜백함수와는 달리, Promise 객체의 콜백함수가 처리완료(settled)되는 시간은 알 수 없다. 하지만 정확히 처리시간을 몰라도, 반드시 성공/실패 처리가 완료된 후에 그에 따른 로직을 실행하도록 보장할 수는 있다.
await 은 Promise 객체의 처리결과를 기다렸다가 받는 키워드이다. 따라서 await 을 사용하면 Promise 객체아 완전히 이행될 때까지 로직 블로킹이 발생한다. Promise 객체의 결과를 받으려면 await이 필요한 것은 맞지만, 여러개의 Promise 가 있을 때 그 모든 이행결과를 따로 따로 기다릴 필요는 없고 병렬처리가 가능하다.
다음 코드는, 처리하는 데 2초가 걸리는 함수의 결과값을 정확히 3초뒤에 받을 수 있도록 병렬처리로직을 구현한 코드이다. Promise객체가 reject 되는 케이스는 제외하였다. 함수 실행에 걸리는 시간을 보장하기 위해 setTimout 을 활용하였지만 예제 자체는 Promise 객체에 대한 예제이다.
https://gist.github.com/yeonwooz/b526a1a7a4fbcb74039eca26c29333f0
31번째 줄부터 시작하는 즉시실행함수는 async 함수선언부와 함수호출부로 분리해도 상관없지만 몇 줄 더 간략하게 작성하려고 사용하였다. car 함수는 3초 후에 Promise 객체의 상태를 반환하고, bar 함수는 3초 후에 Promise 객체의 상태를 반환한다. Promise 객체가 호출되는 순간 콜백함수가 실행되기 시작하여 마이크로 태스크큐에 올라간다.
여기서 한가지 주목할 부분은 22번 라인이다. bar 함수는 Promise 객체를 리턴하는 함수인데 22번 라인에서resPromise 는 await 키워드 없이 호출하여 Promise 의 상태(pending)를 받고, 25번 라인에서 이 상태를 resolve 했다. 33번 라인에서 await 키워드가 없었다면 Promise 객체 자체를 받았겠지만, await 키워드로 받아서 결과값 120 (5!) 을 받을 수 있었다.
마이크로태스크 큐는 태스크 큐와는 달리, 큐 안에 있는 작업들이 모두 처리될 때까지 이벤트루프가 다른 큐로 이동하지 않는다는 특징이 있다. 즉, await car(5) 가 호출되는 순간 마이크로 태스크 큐 안에 있는 콜백함수는 car 함수의 Promise 콜백, bar 함수의 Promise 콜백이므로 이벤트루프가 큐에 방문했을 때 두개가 연달아 처리되는 것이다.
이 병렬처리 로직은 Promise 객체의 정적메소드 중 하나인 Promise.all 을 사용해서 구현할 수도 있다.
https://gist.github.com/yeonwooz/1f8fac5e28c93d7426bac795da6f24c2
앞선 코드에서는 가장 바깥의 실행컨텍스트(즉시실행함수로 만든 컨텍스트)에서 await car(5) 를 호출하도록 했지만, 이번에는 bar 와 car 모두 await 없이 호출하고 Promise.all 에서 처리하도록 해보았다.
이렇게 Promise 객체를 활용하면 비동기 콜백함수의 처리완료(성공/실패) 와 이후의 로직실행 순서를 보장할 수 있고, 또 이벤트루프의 동작을 활용하여 여러개의 Promise 객체가 있을 때 병렬처리하는 것도 가능하기 때문에 낭비되는 시간을 줄일 수 있다.
Photo by Michał Parzuchowski on Unsplash
참고링크
https://developers.google.com/web/ilt/pwa/working-with-promises
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Using_promises