JS에서 비동기 프로그래밍

Single Thread

javascript는 언어적 차원에서 단일 쓰래드만 지원합니다. 우리가 작성한 코드는 단 한줄만이 실행중인 상태입니다. 단 하나의 쓰래드에 의해서 말이죠.

var a = 10;
var b = 20;

function swap(){
    var swap = a;
    a = b;
    b = swap;
}

따라서 다른 언어와 달리 JS에서는 위의 코드에서 동시성 문제가 발생하지 않습니다. a와 b 변수에 동시에 접근하여 변경 하는 경우자체가 발생하지 않으니까요.

언어 차원에서 단일 쓰래드로 정의했기 때문에 동시성 문제를 배제하고 프로그래밍 할수 있다는 것은 나쁘지 않습니다만, 이제 성능이 문제가 됩니다.

네 최근에는 조금 달라졌습니다. ServiceWorker가 등장하며 제한적이나마 멀티 쓰래딩이 가능해졌습니다.

Performance

function f(){
  var text = fileUtil.readFile('a.txt');
  return parseCsv(text);
}

만약 a.txt의 용량이 커서 읽는데 시간이 많이 걸리면 어떻게 될까요? 사실 배치(batch) 작업에서는 크게 문제 되지 않습니다. 그냥 순서대로 실행하면 되니까요. 우리가 bash 쉘 스크립트같은 곳에서 동시성을 요구하지 않는것과 비슷합니다. 순서대로 일련의 작업을 잘 실행해주면됩니다.

하지만 JS의 대부분은 웹 브라우저에서 동작합니다.

웹 브라우저에서 사용자가 원하는 것은 배치 작업이 아닙니다. 사용자가 클릭하고, 편집하고, 스크롤하고, 저장하고…​

동시에 많은 작업을 수행하기를 원할수 있습니다. 하지만, 동시에 시간이 걸리는 작업도 수행해야 합니다.

이 두가지를 동시에 요청하면 JS만으로 는 답이 나오지 않습니다.

function click1(){
  var text = fileUtil.readFile('a.txt');
  return parseCsv(text);
}

function click2() {
  var text = fileUtil.readFile('b.txt');
  return parseCsv(text);
}

사용자가 버튼을 클릭하여 click1이 수행중인데 용량이 커서 읽어오는데 10초쯤 걸립니다. 그런데 또다른 버튼을 클릭해 click2 를 실행하려고 해도 click1이 순차적으로 시간을 소비하여 실행중이기 때문에 click2는 10초후에 실행될 겁니다.

Asynchronous

따라서 JS가 아닌 다른 수단이 필요해집니다. 언어레벨에서 단일 쓰대드라고 규정해버린 JS자체로는 이문제를 해결할수 없습니다. 그래서 js의 실행환경을 제공하는 주변(웹브라우저, node등) 에서 기능을 제공하게 됩니다.

대표적으로 웹브라우저에서는 XmlHttpRequest 을 제공합니다. js에서 xmlHttpRequest를 호출하면 웹브라우저는 이에 대한 실행을 C Posix 쓰래드로 실행합니다! JS 쓰래드가 아니고요.

var oReq = new XMLHttpRequest();
oReq.addEventListener("load", function callback(response){
    console.log('load'+response); // (1)
});
oReq.open("GET", "http://www.example.org/example.txt");
oReq.send();  // (2)
console.log('sent!');  // (3)

<2> send 함수를 호출한다고 해서 js가 동기적으로 응답이 올때까지 기다리지는 않습니다. 그냥 빨리 끝나는 함수 하나를 호출한 것 뿐입니다. 바로 <3> 으로 넘어가 실행합니다.

그럼 <1> 은 언제 실행될까요? 개념적으로 따라가보겠습니다.

  • JS 함수 <1> 을 임시로 저장해 둡니다.

  • C++로 만들어진 쓰래드가 HTTP 요청을 실행하여 서버에 요청을 보내고,

  • 응답이 와서 응답 데이터를 C++로 파싱이 한후에 저장해 둡니다.

  • 임시로 저장했던 JS 함수 <1> 을 호출하는 함수를 micro task queue에 추가 합니다.

  • JS의 유일한 쓰래드가 현재 실행중인 macro task(사용자의 클릭이나 document load같은 주요 이벤트에 대해 실행되는) 코드가 없을때

  • JS의 유일한 쓰래드를 macro task queue를 실행하게 합니다.

  • JS 쓰래드는 micro task queue에 저장되어 있던 <1> 함수를 데이터와 함께 호출합니다

이런 콜백 기반의 비동기화 방식은 다음과 같은 전제를 합니다.

  • CPU는 빠르다.

  • IO는 느리다.

  • 따라서 IO는 JS에서 하지말고, CPU에 관련된 것만 JS로 하자.

여전히 JS는 단일 쓰래드입니다. CPU를 많이 소비하는 작업을 하면 여전히 느려집니다. 하지만 IO 때문에 느려지는것은 회피할수 있게 되었습니다.

웹브라우저상에서 이런식으로 사용할수 있는 비동기 콜백은 과거에는 setTimeout, XmlHttpRequest 정도 였습니다.

과거에도 setTimeout을 통해 micro task들을 사용하기는 했습니다.

function click1() {
  setTimeout(function() {
    doSomething();
  },0); // (1)
}
1 timeout 시간을 0으로 주었습니다. 이를 통해 현재 실행중인 macro task가 종료된후에 바로 이어서 micro task를 수행할수 있게 됩니다.

이런 코드를 통해 현재 macro task가 빠르게 종료시키고, 혹시 다른 클릭등 또다른 macro task가 있으면 그쪽을 우선적으로 실행하여 사용자의 요청에 반응하게 하고, 그후에 시간이 걸리는 작업을 수행하게 할수 있었습니다. 이 과정에서 active object 패턴을 사용 할 수도 있었죠.

하지만 ES5를 거치면서 Promise가 추가 되었습니다. 이를 통해 micro task queue를 더 적극적으로 사용할수 있게 되었습니다.

Promise

개념적으로 Promise는 micro task의 선언입니다. 생성자에 인자로 준 함수는 바로 micro task queue에 추가됩니다.

new Promise(function(){
  console.log("Here");
});
console.log("Out here");

위의 코드를 실행시키면 다음처럼 실행됩니다.

Out here (1)
Here (2)
1 macro task에서 실행되었음
2 micro task에서 실행되었음

여기에서 아 그런가 보다 하고 넘어가기가 쉽습니다만..

만약 코드가 다음처럼 무한 루프에 빠진다면 micro task는 실행되지 않습니다.

new Promise(function(){
  console.log("Here");
});
console.log("Out here");
let i=0;
for(true) {
  i = i+1;
}
Out here (1)
1 macro task에서 실행되었음

왜냐하면 JS의 유일한 쓰래드가 유휴상태가 되지 않았기 때문에, macro task가 종료되지 않았기 때문에 micro task를 실행할 쓰래드가 없기 때문입니다.

이처럼 micro task의 실행에 있어 대전제는 JS의 유일한 쓰래드가 실행하는 다른 JS 코드가 없어야 합니다.

Promise.then

Promise.then은 새롭게 micro task를 추가하는 명령입니다. 일종의 팩토리 메소드 같은 역활이랄까요.

new Promise(resolve=>{
  resolve(10);
}).then(value=>{
  console.log(value);
}).catch(err=>{

});

Promise의 생성자에 넘긴 함수는 micro task에서 실행되어 다음에 실행될 함수를 지정합니다.

  • resolve 함수를 호출하면, then에 넘겨진 함수를 micro task에 추가합니다.

  • reject를 호출하면 catch에 넘겨진 함수를 micro task에 추가합니다.

여기에서 중요한것은 다음에 어떤 함수를 호출할지는 오로지 resolve, reject를 호출할때만 결정된다는 것입니다. 만약 resolve, reject 두 함수중 아무것도 호출하지 않았다면 해당 Promise는 이상 상태(Pending)로 남게 됩니다.

then에 넘긴 함수가 micro task queue를 통해 실행되고 그 결과로 promise를 반환할수 있습니다. 그럼 또 then을 반복할수 있습니다.

new Promise( function(resolve, reject) {
  resolve(10);
}).then(value=>{
  return new Promise(function(resolve, reject) {
    resolve(value +10);
  });
}).then(nextValue => {
  console.log(nextValue);
})
.catch(err=>{

});

만약 then에 넘긴 함수가 promise를 반환하지 않고 값을 반환하면 어떻게 될까요?

new Promise( function(resolve, reject) {
  resolve(10);
}).then(value=>{
  return value + 10;
}).then(nextValue => {
  console.log(nextValue);
})
.catch(err=>{

});

이렇게 호출하게 되면 promise는 리턴 받은 값을 단순히 전달만하는 task를 micro task에 추가합니다. 덕분에 그다음 then에서는 20을 받을수 있습니다.

Syncrhonized code to Promise?

왜 이런 이상한 짓을 해야 할까요? 왜 그냥 함수 호출이면 될거 같은 동기적 코드를 저렇게 micro task로 만들어야 할까요?

원인은 동일합니다. JS가 단일 쓰래드만 지원하기 때문입니다.

일련의 작업을 작은 task로 나누어서 하나의 쓰래드로 실행하는 것은 active object 패턴의 전형입니다. Active Object

일련의 작업을 promise에 넘길정도로 작은 task로 쪼개어 실행하는 것으로 JS Thread를 독점하지 않고, 중간에 사용자의 클릭이나 스크롤등의 macro task를 수행할수 있는 기회를 줄수 있습니다.

이 일련의 작업이 IO를 사용하는 작업일수도 있고, CPU를 사용하는 작업일수도 있습니다. IO는 외부 환경의 지원을 받지만, CPU는 우리가 직접 나눠주어야 합니다.

이런 작업 역시 Promise를 이용하여 코딩할수 있는 것입니다.

뭐 물론 작업이 IO/CPU를 모두 그다지 사용하지 않을것 같다면 그냥 작성하면 됩니다.

이런 작업을 나누는 기준이 되는것은 FPS입니다. 고급 애니메이션이 필요한 곳이라면 60 FPS, 아닌곳이라도 20 FPS 정도는 나올수 있게 하는게 좋습니다. 물론 웹브라우저에서 애니메이션은 CSS로 하는게 당연시 되긴 합니다만..

우리에게 주어진 시간은 1000ms/60fps = 16.6ms 또는 1000ms/20fps = 50ms 정도 입니다. 이 시간안에 모든 JS코드가 동작해야 합니다.

모든 JS 코드라는 것은 우리가 작성한 코드 말고도 vue, react등 프레임워크들도 포함 해야 합니다.

Sequence diagram

Prmise를 Sequence diagram으로 표현하면 다음과 같겠지만, 적당히만 보시기 바랍니다. 정확한 그림은 아니니까요.

diag ff8d8f7de6816cbd1fe4bbdba9c07d7b
Tags: js  promise  asynchronouse