자바스크립트

자바스크립트와 이벤트 루프

회사 기술블로그인 Toast Meetup 에 기고했던 글을 옮겨왔다. 원문은  http://meetup.toast.com/posts/89 에서 확인할 수 있다.

자바스크립트의 큰 특징 중 하나는 ‘단일 스레드’ 기반의 언어라는 점이다. 스레드가 하나라는 말은 곧, 동시에 하나의 작업만을 처리할 수 있다라는 말이다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 많은 작업이 동시에 처리되고 있는 걸 볼 수 있다. 예를 들면, 웹브라우저는 애니메이션 효과를 보여주면서 마우스 입력을 받아서 처리하고, Node.js기반의 웹서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 어떻게 스레드가 하나인데 이런 일이 가능할까? 질문을 바꿔보면 ‘자바스크립트는 어떻게 동시성(Concurrency)을 지원하는 걸까’?

이때 등장하는 개념이 바로 ‘이벤트 루프’이다. Node.js를 소개할 때 ‘이벤트 루프 기반의 비동기 방식으로 Non-Blocking IO를 지원하고..’ 와 같은 문구를 본 적이 있을 것이다. 즉, 자바스크립트는 이벤트 루프를 이용해서 비동기 방식으로 동시성을 지원한다. 동기 방식의(Java 같은) 다른 언어를 사용하다가 Node.js 등을 통해 자바스크립트를 처음 접하게 되는 사람들은 이 ‘이벤트 루프’의 개념이 익숙하지 않아서 애를 먹는다. 뿐만 아니라 자바스크립트를 오랫동안 사용해서 비동기 방식의 프로그래밍에 익숙한 사람들조차 이벤트 루프가 실제로 어떻게 동작하는지에 대해서는 자세히 모르는 경우가 많다.

좀 지난 동영상이지만 최근에 Help, I’m stuck in an event-loop를 우연히 보게 되었는데, 내가 이벤트 루프에 대해 잘못 이해하고 있는 부분들이 많다는 것을 알게 되었다. 그래서 이번 기회에 이벤트 루프에 대해 좀더 자세히 공부해 보았는데, 정리도 할 겸 중요한 사실 몇 가지를 공유해볼까 한다.

ECMAScript에는 이벤트 루프가 없다

웬만큼 두꺼운 자바스크립트 관련 서적들을 뒤져봐도 이벤트 루프에 대한 설명은 의외로 쉽게 찾아보기가 힘들다. 그 이유는 아마, 실제로 ECMAScript 스펙에 이벤트 루프에 대한 내용이 없기 때문일 것이다. 좀더 구체적으로 표현하면 ‘ECMAScript 에는 동시성이나 비동기와 관련된 언급이 없다’고 할 수 있겠다(사실 ES6부터는 조금 달라졌지만, 나중에 좀더 설명하겠다). 실제로 V8과 같은 자바스크립트 엔진은 단일 호출 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐이다. 그렇다면 비동기 요청은 어떻게 이루어지며, 동시성에 대한 처리는 누가 하는 걸까? 바로 이 자바스크립트 엔진을 구동하는 환경, 즉 브라우저나 Node.js가 담당한다. 먼저 브라우저 환경을 간단하게 그림으로 표현하면 다음과 같다.

b1493856-379d-11e6-9c16-a9a4cf841567.png

위 그림에서 볼 수 있듯이 실제로 우리가 비동기 호출을 위해 사용하는 setTimeout이나 XMLHttpRequest와 같은 함수들은 자바스크립트 엔진이 아닌 Web API 영역에 따로 정의되어 있다. 또한 이벤트 루프와 태스크 큐와 같은 장치도 자바스크립트 엔진 외부에 구현되어 있는 것을 볼 수 있다. 다음은 Node.js 환경이다.

Bt5ywJrIEAAKJQt.jpg

(출처: http://stackoverflow.com/questions/10680601/nodejs-event-loop)

이 그림에서도 브라우저의 환경과 비슷한 구조를 볼 수 있다. 잘 알려진 대로 Node.js는 비동기 IO를 지원하기 위해 libuv 라이브러리를 사용하며, 이 libuv가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해 Node.js의 API를 호출하며, 이때 넘겨진 콜백은 libuv의 이벤트 루프를 통해 스케쥴되고 실행된다.

이제 어느 정도 감이 잡힐 것이다. 각각에 대해 좀더 자세히 알아보기 전에 한가지만 확실히 짚고 넘어가자. 자바스크립트가 ‘단일 스레드’ 기반의 언어라는 말은 ‘자바스크립트 엔진이 단일 호출 스택을 사용한다’는 관점에서만 사실이다. 실제 자바스크립트가 구동되는 환경(브라우저, Node.js등)에서는 주로 여러 개의 스레드가 사용되며, 이러한 구동 환경이 단일 호출 스택을 사용하는 자바 스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 ‘이벤트 루프’인 것이다.

단일 호출 스택과 Run-to-Completion

이벤트 루프에 대해 좀더 알아보기 전에, 먼저 자바스크립트 언어의 특징을 하나 살펴보자. 자바스크립트의 함수가 실행되는 방식을 보통 “Run to Completion” 이라고 말한다. 이는 하나의 함수가 실행되면 이 함수의 실행이 끝날 때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미이다. 앞서 말했듯이 자바스크립트 엔진은 하나의 호출 스택을 사용하며, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지는 다른 어떠한 함수도 실행될 수 없다. 다음의 예제를 보자.

function delay() {
  for (var i = 0; i < 100000; i++);
}
function foo() {
  delay();
  bar();
  console.log('foo!'); // (3)
}
function bar() {
  delay();
  console.log('bar!'); // (2)
}
function baz() {
  console.log('baz!'); // (4)
}
setTimeout(baz, 10); // (1)
foo();

자바스크립트를 경험해본 사람이라면, 아무리 delay 함수가 10ms 보다 오래 걸린다고 해도 ‘baz!’가 ‘foo!’ 보다 먼저 콘솔에 찍히는 일은 없을 거라는 것을 알 것이다. 즉, foo 내부에서 bar를 호출하기 전에 10ms이 지났다고 해도 baz가 먼저 호출되지는 않는다는 말이다. 그러므로 위의 예제를 실행하면 콘솔에는 ‘bar!’ -> ‘foo!’ -> ‘baz!’의 순서로 찍히게 된다. 위의 코드가 전역 환경에서 실행된다고 가정하고 코드내 주석으로 숫자가 적힌 각 시점의 호출 스택을 그림으로 그려보면 다음과 같을 것이다.

46cb891a-36d6-11e6-8728-231d5bce2f36.png

(전역 환경에서 실행되는 코드는 한 단위의 코드블록으로써 가상의 익명함수로 감싸져 있다고 생각하는 것이 좋다. 따라서 위의 코드의 첫 줄이 실행될 때에 호출 스택의 맨 아래에 익명 함수가 하나 추가되며, 마지막 라인까지 실행되고 나서야 스택에서 제거된다.)

setTimeout 함수는 브라우저에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거된다. 그 후에 foo 함수가 스택에 추가되고, foo 함수가 내부적으로 실행하는 함수들이 차례로 스택에 추가되었다가 제거된다. 마지막으로 foo 함수가 실행을 마치면서 호출 스택이 비워지게 되고, 그 이후에 baz 함수가 스택에 추가되어 콘솔에 ‘baz!’가 찍히게 된다.

(결과적으로 baz는 10ms보다 더 늦게 실행되게 될 것이다. 즉, 자바스크립트의 타이머는 정확한 타이밍을 보장해주지 않는데, 이와 관련해서 잘 설명된 John Resig의 글이 있으니 관심 있으신 분들은 클릭!)

태스크 큐와 이벤트 루프

여기서 하나의 궁금증이 생긴다. setTimeout 함수를 통해 넘긴 baz 함수는 어떻게 foo 함수가 끝나자 마자 실행될 수 있을까? 어디서 대기하고 있다가 누구를 통해 실행될까? 바로 이 역할을 하는 것이 태스크 큐와 이벤트 루프이다. 태스크 큐는 말 그대로 콜백 함수들이 대기하는 큐(FIFO) 형태의 배열이라 할 수 있고, 이벤트 루프는 호출 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 해 준다.

앞선 예제를 살펴보자. 코드가 처음 실행되면 이 코드는 ‘현재 실행중인 태스크’가 된다. 코드를 실행하는 도중 10ms이 지나면 브라우저의 타이머가 baz를 바로 실행하지 않고 태스크 큐에 추가한다. 이벤트 루프는 ‘현재 실행중인 태스크’가 종료되자 마자 태스크 큐에서 대기중인 첫 번째 태스크를 실행할 것이다. foo가 실행을 마치고 호출 스택이 비워지면 현재 실행중인 태스크는 종료되며, 그 때 이벤트 루프가 태스크 큐에 대기중인 첫 번째 태스크인 baz를 실행해서 호출 스택에 추가한다.

MDN의 이벤트 루프 설명을 보면 왜 ‘루프’라는 이름이 붙었는지를 아주 간단한 가상코드로 설명하고 있다.

while(queue.waitForMessage()){
  queue.processNextMessage();
}

위 코드의 waitForMessage() 메소드는 현재 실행중인 태스크가 없을 때 다음 태스크가 큐에 추가될 때까지 대기하는 역할을 한다. 이런 식으로 이벤트 루프는 ‘현재 실행중인 태스크가 없는지‘와 ‘태스크 큐에 태스크가 있는지‘를 반복적으로 확인하는 것이다. 간단하게 정리하면 다음과 같을 것이다.

  • 모든 비동기 API들은 작업이 완료되면 콜백 함수를 태스크 큐에 추가한다.
  • 이벤트 루프는 ‘현재 실행중인 태스크가 없을 때'(주로 호출 스택이 비워졌을 때) 태스크 큐의 첫 번째 태스크를 꺼내와 실행한다.

좀더 명확하게 이해하기 위해 앞의 예제를 조금 바꿔보자.

function delay() {
  for (var i = 0; i < 100000; i++);
}
function foo() {
  delay();
  console.log('foo!');
}
function bar() {
  delay();
  console.log('bar!');
}
function baz() {
  delay();
  console.log('baz!');
}

setTimeout(foo, 10);
setTimeout(bar, 10);
setTimeout(baz, 10);

이 코드를 실행하면 아무런 지연 없이 setTimeout 함수가 세 번 호출된 이후에 실행을 마치고 호출 스택이 비워질 것이다. 그리고 10ms가 지나는 순간 foobarbaz 함수가 순차적으로 태스크 큐에 추가된다. 이벤트 루프는 foo 함수가 태스크 큐에 들어오자 마자, 호출 스택이 비어있으므로 바로 foo를 실행해서 호출 스택에 추가한다. foo 함수의 실행이 끝나고 호출 스택이 비워지면 이벤트 루프가 다시 큐에서 다음 콜백인 bar를 가져와 실행한다. bar의 실행이 끝나면 마찬가지로 큐에 남아있는 baz를 큐에서 가져와 실행한다. 그리고 baz까지 실행이 모두 완료되면 현재 진행중인 태스크도 없고 태스크 큐도 비어있기 때문에, 이벤트 루프는 새로운 태스크가 태스크 큐에 추가될 때까지 대기하게 된다.

(코드는 다르지만 그림으로 표현하면 대략 다음과 같을 것이다)

event_loop.jpg

(출처: http://www.2ality.com/2014/09/es6-promises-foundations.html)

(글의 서두에 언급했던 영상을 보면 발표자가 직접 만든 인터랙션 환경을 사용해 이 과정을 정말 이해하기 쉽게 잘 설명하고 있다. 안보신 분들은 꼭 확인해 보길 바란다.)

비동기 API와 try-catch

setTimeout 뿐만 아니라 브라우저의 다른 비동기 함수들(addEventListenerXMLHttpRequest… )이나 Node.js의 IO 관련 함수들 등 모든 비동기 방식의 API들은 이벤트 루프를 통해 콜백 함수를 실행한다. 자, 그러면 다음과 아래와 같은 코드가 왜 에러를 잡아낼 수 없는지 이제는 확실히 알 수 있을 것이다.

$('.btn').click(function() { // (A)
  try {
    $.getJSON('/api/members', function (res) { // (B)
      // 에러 발생 코드
    });
  } catch (e) {
    console.log('Error : ' + e.message);
  }
});

위의 코드에서 버튼이 클릭되어 콜백 A가 실행될 때 $.getJSON 함수는 브라우저의 XMLHttpRequest API를 통해 서버로 비동기 요청을 보낸 후에 바로 실행을 마치고 호출 스택에서 제거된다. 이후에 서버에서 응답을 받은 브라우저는 콜백 B를 태스크 큐에 추가 하고 B는 이벤트 루프에 의해 실행되어 호출 스택에 추가된다. 하지만 이때 A는 이미 호출 스택에서 비워진 상태이기 때문에 호출 스택에는 B만 존재할 뿐이다. 즉 B는 A가 실행될 때와는 전혀 다른 독립적인 컨텍스트에서 실행이 되며, 그렇기 A 내부의 try-catch 문에 영향을 받지 않는다.

(마찬가지 이유로 에러가 발생했을 때 브라우저의 개발자 도구에서 호출 스택을 들여다봐도 B만 덩그라니 놓여있는 것을 볼 수 있을 것이다.)

(이런 이유로 Node.js의 비동기 API들은 중첩된 콜백 호출에 대한 에러 처리를 위해 ‘첫 번째 인수는 에러 콜백 함수’ 라는 컨벤션을 따르고 있다)

이를 해결하기 위해서는 콜백 B의 내부에서 try-catch를 실행해야 한다. (물론, 이렇게 해도 네트워크 에러나 서버 에러는 잡을 수 없다. 이를 위해서는 에러 콜백을 따로 제공해야 한다.)

$('.btn').click(function() { // (A)
  $.getJSON('/api/members', function (res) { // (B)
    try {
      // 에러 발생 코드
    } catch (e) {
      console.log('Error : ' + e.message);
    }
  });
});

setTimeout(fn, 0)

프론트엔드 환경의 자바스크립트 코드를 보다 보면 setTimeout(fn, 0)와 같은 코드를 종종 보게 된다. 관용적으로 쓰이는 코드이지만, 사실 처음 보는 사람에게는 직관적으로 이해하기 힘든 코드일 것이다. 0초 이후에 실행을 한다는 건 실제로 그냥 실행하는 것과 다를 게 없으니 말이다. 하지만 실제로 이 코드는 그냥 fn을 실행하는 것과는 상당히 다른 결과를 가져온다. 위의 예제에서도 보았겠지만 setTimeout 함수는 콜백 함수를 바로 실행하지 않고 (호출 스택이 아닌)태스크 큐에 추가한다. 그렇기 때문에 아래의 코드는 콘솔에 B -> A 순서로 출력하게 될 것이다.

setTimeout(function() {
  console.log('A');
}, 0);
console.log('B');

프론트엔드 환경에서는 렌더링 엔진과 관련해서 이런 코드가 특히 요긴하게 쓰일 때가 있다. 브라우저 환경에서는 자바스크립트 엔진뿐만 아니라 다른 여러 가지 프로세스가 함께 구동되고 있다. 렌더링 엔진도 그 중의 일부이며, 이 렌더링 엔진의 태스크는 대부분의 브라우저에서 자바스크립트 엔진과 동일한 단일 태스크 큐를 통해 관리된다. 이로 인해 가끔 예상치 못한 문제가 생길 경우가 있는데, 다음의 코드를 살펴보자.

$('.btn').click(function() {
  showWaitingMessage();
  longTakingProcess();
  hideWaitingMessage();
  showResult();
});

longTakingProcess가 너무 오래 걸리는 작업이기 때문에 그 전에 showWaitingMessage를 호출해서 로딩 메시지(‘로딩중…’과 같은)를 보여주려고 한다. 하지만 실제로 이 코드를 실행해 보면 화면에 로딩 메시지가 표시되는 일은 없을 것이다. 이유는 showWaitingMessage 함수의 실행이 끝나고 렌더링 엔진이 렌더링 요청을 보내도 해당 요청은 태스크 큐에서 이미 실행중인 태스크가 끝나기를 기다리고 있기 때문이다. 실행중인 태스크가 끝나는 시점은 호출 스택이 비워지는 시점인데, 그 때는 이미 showResult 까지 실행이 끝나 있을 것이고, 결국 렌더링이 진행되는 시점에는 hideWaitingMessgae로 인해 로딩 메시지가 숨겨진 상태일 것이다. 이를 해결하기 위해서 다음처럼 setTimeout를 사용할 수 있다.

$('.btn').click(function() {
  showWaitingMessage();
  setTimeout(function() {
    longTakingProcess();
    hideWaitingMessage();
    showResult();
  }, 0);
});

이 경우에는 longTakingProcess가 바로 실행되지 않고 태스크 큐에 추가될 것이다. 하지만 showWaitingMessage로 인해 태스크 큐에는 렌더링 요청이 먼저 추가되기 때문에 longTakingProcess는 그 다음 순서로 태스크 큐에 추가될 것이다. 이제 이벤트 루프는 태스크 큐에 있는 렌더링 요청을 먼저 처리하게 되고 로딩 메시지가 먼저 화면에 보여지게 된다.

꼭 렌더링 관련이 아니라도, 실행이 너무 오래 걸리는 코드를 setTimeout을 사용하여 적절하게 다른 태스크로 나누어 주면 전체 어플리케이션이 멈추거나 스크립트가 너무 느리다며 경고창이 뜨는 상황을 방지할 수도 있을 것이다.

한가지 짚고 넘어갈 사실은 ‘0’ 이라는 숫자가 실제로 ‘즉시’를 의미하지 않는다는 점이다. 브라우저는 내부적으로 타이머의 최소단위(Tick)를 정하여 관리하기 때문에 실제로는 그 최소단위만큼 지난 후에 태스크 큐에 추가되게 된다. 그리고 이 최소단위는 브라우저별로 조금씩 다른데, 예를 들어 크롬 브라우저의 경우 최소단위로 4ms 사용하기 때문에 크롬에서 setTimeout(fn, 0)은 setTimeout(fn, 4)와 동일한 의미를 갖게 될 것이다.

이런 문제를 해결하기 위해 setImmediate라는 API가 제안되었지만, 안타깝게도 표준의 반열에 오르지는 못하고 IE10 이상에만 포함되어 있다. 실제로 이 메소드는 setTimeout 와 같은 최소단위 지연이 없이 바로 태스크 큐에 해당 콜백을 추가한다. EsLint로 유명한 N.C.Zakas도 이 메소드가 표준화 되지 않은 것에 대해 비판하는 글을 올린 적이 있다. 비슷한 효과를 위해 postMessage 나 MessageChanel을 사용하기도 하는데, 관련된 내용은 setImmediate의 폴리필을 구현한 라이브러리 페이지에 잘 정리되어 있다.

(Node.js 에는 이런 용도를 위해 nextTick이라는 함수가 있지만 0.9버전 부터는 약간 다른 개념으로 사용된다. 다음 절에서 좀더 설명하겠다.)

프라미스(Promise)와 이벤트 루프

이런 이벤트 루프의 개념은 실제로 HTML 스펙에 정의되어 있다. 문서에서 이벤트 루프, 태스크 큐의 개념에 대해 잘 정의되어 있는 것을 볼 수 있을 것이다. 그런데 문서 중간에 마이크로 태스크(microtask) 라는 생소한 용어가 보인다. 이런… 이제 겨우 이벤트 루프에 대해 이해한 것 같은데 뭔가 상황이 더 복잡해질 것 같은 불길한 예감이 든다. 마음을 가다듬고, 다음 코드를 살펴보자.

setTimeout(function() { // (A)
  console.log('A');
}, 0);
Promise.resolve().then(function() { // (B)
  console.log('B');
}).then(function() { // (C)
  console.log('C');
});

콘솔에 찍히는 순서는 어떻게 될까? 프라미스도 비동기로 실행된다고 할 수 있으니 태스크 큐에 추가돼서 순서대로 A -> B -> C 가 될까? 아니면 프라미스는 setTimeout처럼 최소단위 지연이 없으니 B -> C -> A 일까? 체인 형태로 연속해서 호출된 then() 함수는 어떤 식으로 동작할까? 결론부터 말하자면 정답은 B -> C -> A 인데, 이유는 바로 프라미스가 마이크로 태스크를 사용하기 때문이다. 그럼 마이크로 태스크가 대체 뭘까?

마이크로 태스크는 쉽게 말해 일반 태스크보다 더 높은 우선순위를 갖는 태스크라고 할 수 있다. 즉, 태스크 큐에 대기중인 태스크가 있더라도 마이크로 태스크가 먼저 실행된다. 위의 예제를 통해 좀더 자세히 알아보자. setTimeout() 함수는 콜백 A를 태스크 큐에 추가하고, 프라미스의 then() 메소드는 콜백 B를 태스크 큐가 아닌 별도의 마이크로 태스크 큐에 추가한다. 위의 코드의 실행이 끝나면 태스크 이벤트 루프는 (일반)태스크 큐 대신 마이크로 태스크 큐가 비었는지 먼저 확인하고, 큐에 있는 콜백 B를 실행한다. 콜백 B가 실행되고 나면 두번째 then() 메소드가 콜백 C를 마이크로 태스크 큐에 추가한다. 이벤트 루프는 다시 마이크로 태스크를 확인하고, 큐에 있는 콜백 C를 실행한다. 이후에 마이크로 태스크 큐가 비었음을 확인한 다음 (일반) 태스크 큐에서 콜백 A를 꺼내와 실행한다. (이런 일련의 작업은 HTML 스펙에서 perform a microtask checkpoint 라는 항목에 명시되어 있다.)

잘 와 닿지 않는 분들은 이와 관련해서 인터랙션과 함께 아주 잘 정리된 글이 있으니 꼭 확인해 보길 바란다. 원문 글에서는 브라우저마다 프라미스의 호출 순서가 다른 문제를 지적하고 있는데, 이유는 프라미스가 ECMAScript에 정의되어 있는 반면, 마이크로 태스크는 HTML 스펙이 정의되어 있는데, 둘의 연관관계가 명확하지 않기 때문이다. (ECMAScript에는 ES6부터 프라미스를 위해 잡 큐(Job Queue)라는 항목이 추가되었지만, HTML 스펙의 마이크로 태크스와는 별도의 개념이다.) 하지만 최근에 Living Standard 상태인 HTML 스펙을 보면 자바스크립트의 잡큐를 어떻게 이벤트 루프와 연동하는지에 대한 항목이 포함되어 있다. 또한 현재는 대부분의 브라우저에서 해당 문제가 수정되어 있는 걸 확인할 수 있다.

(프라미스A+ 스펙문서의 Note를 보면 구현 시에 일반(macro) 태스크나 마이크로 태스크 둘 다 사용할 수 있다고 적혀 있다. 실제로 프라미스가 처음 자바스크립트에 도입되는 시점에는 프라미스를 어떤 순서로 실행할 것인가에 대한 논의가 꽤 있었던 것으로 보인다. 하지만 앞서 언급한 것처럼 현재는 프라미스를 마이크로 태스크라고 정의해도 무리가 없을 것 같다.)

휴우. 정리를 하고 다시 봐도 복잡해 보인다. 하지만, 실제로 마이크로 태스크이냐 일반 태스크이냐에 따라 실행되는 타이밍이 달라지기 때문에 둘을 제대로 이해하고 구분해서 사용하는 것은 중요하다. 예를 들어 마이크로 태스크가 계속돼서 실행될 경우 일반 태스크인 UI 렌더링이 지연되는 현상이 발생할 수도 있을 것이다. 관련해서 잘 정리된 스택오버플로우의 답변도 있으니 참고하면 좋을 것 같다.

마무리 하기 전에, 마이크로 태스크를 사용하는 다른 API들도 살짝 살펴보자.

  • MutationObserver는 DOM의 변화를 감지할 수 있게 해 주는 클래스이며, es6-promise와 같은 폴리필에서 마이크로 태스크를 구현하기 위해 사용되기도 한다.
  • 이전 절에서 살짝 언급했던 Node.js의 nextTick은 기존에는 일반 태스크를 이용해 구현되었지만, 0.9 버전부터 마이크로 태스크를 이용하도록 변경되었다.

마치며

이벤트 루프는 실제로 자바스크립트 언어의 명세보다는 구동 환경과 더 관련된 내용이기 때문에 다른 프로세스들(렌더링, IO 등)과 밀접하게 연관되어 있어 잘 정리된 자료를 찾기가 쉽지만은 않다. 또한 Node.js의 libuv는 HTML 스펙을 완벽히 따르지는 않기 때문에 브라우저 환경의 이벤트 루프와 상세 구현이 조금씩 다르다(심지어 브라우저 별로도 구현이 조금씩 다르다). 또한, 최근에는 ES6에 프라미스와 잡 큐라는 항목이 추가되며 마이크로 태스크의 개념과 혼동되며 이해하기가 한층 더 복잡해졌다. 여기서 끝이 아니다. 사실 이 글에서는 브라우저가 ‘단일 이벤트 루프’를 사용한다고 가정하고 설명했지만, 웹 워커(Web Worker)는 각각이 독립적인 이벤트 루프를 사용하며(Worker Event Loop라는 이름으로 구분되어 있다), 이와 관련된 내용을 추가한다면 더더욱 복잡해질 것이다. (하아…)

하지만 자바스크립트의 비동기적 특성을 잘 활용하기 위해서는 이벤트 루프를 제대로 이해하는 것이 중요하다. 특히 (이 글에서는 다루지 못했지만) 웹 워커나 Node.js의 클러스터를 사용하는 멀티 스레드 환경에서는 이벤트 루프에 대한 탄탄한 이해가 없다면 예상치 못한 버그 앞에 좌절하게 될 지도 모른다. 사실 개인적으로도 계속 스펙문서를 부분 부분 뒤져가며 글을 작성하느라 완벽하게 이해하고 정리하지는 못한 기분이다. 하지만 이 글이 조금이나마 도움이 되었기를 바라며, 여기서 만족하지 말고 관련 링크들을 짬짬이 살펴 보면서 이벤트 루프에 대해 제대로 이해하는 기회가 되었으면 좋겠다.

참고 링크

자바스크립트

ES6의 제너레이터를 사용한 비동기 프로그래밍

회사 블로그인 Toast Meetup 에 기고했던 글을 옮겨왔다. 원문은  http://meetup.toast.com/posts/73 에서 확인할 수 있다.

자바스크립트가 다른 언어들과 구분되는 큰 특징 중의 하나는 바로 싱글스레드를 기반으로 하는 비동기 방식의 언어라는 점이다. 이런 특징에 힘입어 Non-blocking IO을 사용하는 Node.js의 언어로 사용되면서 최근에는 서버사이드에서도 큰 인기를 얻고 있다. 하지만 이런 구조적 특징에서 오는 단점도 적지 않은데, 대표적인 것이 바로 연속적 전달 방식(CPS)으로 인한 콜백 지옥이다 (굳이 설명 안 해도 알 거라 믿는다. 밑에서 지옥을 한번 소환할 예정이니 혹시 몰라도 일단 넘어가자).

이 콜백 지옥을 해결하기 위해 많은 시도가 있었는데, 최근에 ES6에 프라미스(Promise)가 포함되면서 콜백 지옥의 문제를 상당 부분 완화할 수 있게 되었다. 하지만 많은 사람들이 기대하는 것과는 다르게 프라미스는 콜백 지옥을 해결하기 위해 나온 도구가 아니며, 단지 완화시킬 수 있는 방법을 제공해 줄 뿐이다. 그리고 상대적으로 주목을 덜 받고 있는 것 같지만, ES6에는 비동기 프로그래밍을 위한 더 중요한 도구가 있다. 바로 제너레이터(Generator)이다.

사실 제너레이터를 처음 접했을 때 받은 인상은 ‘좋은 기능인 거 같긴 한데… 이걸 어디다 쓰지?’ 정도의 느낌이었다. 하지만 최근에 개인 프로젝트에서 Koa를 사용하면서 제너레이터의 유용함에 대해서 눈을 뜨게 되었고, 좀 더 공부해 본 결과 비동기 프로그래밍에 있어서 아주 중요한 개념이라는 걸 알게 되었다. 심지어 이미 많은 곳에서 사용되고 있었다. (나만 몰랐던겨? ㅠㅠ) 그런 의미에서, 혹시나 아직까지 모르는 분들을 위해 최근까지 알게 된 내용을 정리해 보도록 하겠다.

제너레이터란?

제너레이터에 대해서는 예전에 FE개발팀 위클리에서 관련된 글 이터레이터와 제너레이터을 번역하여 공유한 적이 있다. 이 글에서 제너레이터의 상세 스펙에 대해서는 다루지 않을 예정이니, 제너레이터(혹은 이터레이터)에 대한 개념이 생소하신 분들은 먼저 앞의 링크를 눌러 내용을 숙지하면 좋을 것 같다.

여기서는 몇 가지 개념만 짚고 넘어가도록 하겠다. 제너레이터는 함수의 실행을 중간에 멈추었다가 필요한 시점에 다시 재개할 수 있다. 일종의 코루틴(Coroutine) 이라고 볼 수 있는데, 이 위키 페이지에도 설명되어 있듯이, 코루틴과는 다르게 멈출 때 돌아갈 위치를 직접 지정할 수 없고, 단순히 호출자에게 제어권을 넘겨주게 된다(그래서 세미-코루틴이라 불린다). 아래 예제를 보자.

function* myGen() {
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

const myItr = myGen();
console.log(myItr.next());  // {value:1, done:false}
console.log(myItr.next());  // {value:2, done:false}
console.log(myItr.next());  // {value:3, done:false}
console.log(myItr.next());  // {value:4, done:true}

myGen 제너레이터는 실행될 때 이터레이터를 반환한다. 그리고 이터레이터의 next() 함수가 호출될 때마다 호출되는 곳의 위치를 기억해둔 채로 실행된다. 그리고 함수 내부에서 yield를 만날 때마다 기억해둔 위치로 제어권을 넘겨준다. 이런 식으로 next() -> yield -> next() -> yield 라는 순환 흐름이 만들어 지고, 이 흐름을 따라 해당 함수가 끝날 때까지 (return을 만나거나 마지막 라인이 실행될 때까지) 진행된다.

여기서 중요한 점은 next()와 yield가 서로 데이터를 주고받을 수 있다는 점이다. 위의 예제에서 볼 수 있듯이 yield 키워드 뒤의 값은 next() 함수의 반환값으로 전달된다 (정확히는 value 프라퍼티의 값으로). 그럼 반대로 호출자가 제너레이터에게 값을 전달할 수도 있을까? 물론 가능하다. next()를 호출할 때 인수를 넘기면 된다. 다음의 예제를 보자.

function *myGen() {
    const x = yield 1;       // x = 10
    const y = yield (x + 1); // y = 20
    const z = yield (y + 2); // z = 30
    return x + y + z;
}

const myItr = myGen();
console.log(myitr.next());   // {value:1, done:false}
console.log(myitr.next(10)); // {value:11, done:false}
console.log(myitr.next(20)); // {value:22, done:false}
console.log(myitr.next(30)); // {value:60, done:true}

next()를 호출할 때 인수로 값을 지정하면 yield 키워드가 있는 대입문에 값이 할당되는 것을 볼 수 있다. 이런 식으로 제너레이터와 호출자는 서로 제어권 뿐만 아니라 데이터까지 주고받을 수 있다. 자, 여기까지 알고 나서 다시 myGen 내부를 들여다보자. 분명 함수의 내부에서는 콜백도 없고 프라미스도 없지만, 비동기적으로 데이터를 주고받으며 실행되고 있다. 이 무슨 비동기인 듯 비동기 아닌 비동기 같은 코드인가.

왠지 이쯤 되면 느낌이 오시는 분들이 있을 것이다. 뭔가… 이걸 잘 활용하면 콜백 지옥을 넘어서 신세계를 경험할 수 있을 것만 같은 느낌적 느낌말이다. (음… 나만 그런가?)

콜백 지옥 소환

이 느낌적 느낌을 직접 동작하는 코드로 승화시키기 위해 아주 간단하고 비효율적인 커피 주문 시스템을 만들어 보기로 하자. 우선 이 시스템은 굉장히 비효율적이라서, 핸드폰 번호를 알아야 아이디를 알 수 있고, 아이디를 알아야 이메일을 알 수 있고, 이메일을 알아야 이름을 알 수 있고, 이름을 알아야만 주문을 할 수 있다(뭐 이런 슬픈 시스템이…ㅠㅠ). 슬프지만 그냥 예제니까 가벼운 마음으로 코드를 작성해 보자.

function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }

function orderCoffee(phoneNumber) {
    const id = getId(phoneNumber);
    const email = getEmail(id);
    const name = getName(email);
    const result = order(name, 'coffee');
    return result;
}

이렇게 간단하게 할 수 있으면 얼마나 좋을까. 하지만 상황을 더 슬프게 만들기 위해 각각의 데이터들을 외부 네트워크에 있는 서버에서 받아와야 한다고 가정해 보자(아… 왜… ㅠㅠ). 싱글 스레드인 자바스크립트에서 네트워크 요청을 위해 이런 코드를 짠다면 하루에 커피 100잔도 못팔고 망하는 수가 있다. 지금이 바로 비동기 방식의 진가를 발휘하기 위해 콜백 지옥을 소환할 때다.

function getId(phoneNumber, callback) { /* … */ }
function getEmail(id, callback) { /* … */ }
function getName(email, callback) { /* … */ }
function order(name, menu, callback) { /* … */ }

function orderCoffee(phoneNumber, callback) {
    getId(phoneNumber, function(id) {
        getEmail(id, function(email) {
            getName(email, function(name) {
                order(name, 'coffee', function(result) {
                    callback(result);
                });
            });
        });
    });
}

쨘. 지옥을 소환하는 게 이렇게 쉽다니 (지옥인데 친숙하다는 게 더 슬프다). 참고로 여기서 콜백의 문제점은 사실 단순히 들여쓰기와 가독성의 문제만은 아니다. 더 중요한 문제점은 콜백함수를 다른 함수로 전달하는 순간 그 콜백함수에 대한 제어권을 잃는 점이다. 즉, 내가 제공한 콜백이 언제 실행되는지, 몇 번 실행되는지 등에 대해 신뢰할 수가 없게 된다. 그리고 위의 코드에서 보다시피 내가 처음에 제공한 콜백 함수는 한없이 위임되어 저 지옥 구멍의 끝에 파묻혀 있다 (왜 거기있니 얘야…ㅠㅠ). 이로 인해 프로그램이 더 예측하기 어렵게 되고 에러가 발생하기 쉽게 되며, 디버깅 또한 만만치 않게 된다.

프라미스의 구원

하지만 알다시피, 프라미스의 등장으로 인해 이러한 문제는 상당 부분 완화되었다. 위의 슬픈 예제를 달래기 위해 프라미스로 보듬어 보자. 일단 모든 getXXX 함수에서 콜백 파라미터를 제거하고, 실행 결과로 프라미스를 반환한다고 가정하자.

function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }

function orderCoffee(phoneNumber) {
    return getId(phoneNumber).then(function(id) {
        return getEmail(id);
    }).then(function(email) {
        return getName(email);
    }).then(function(name) {
        return order(name, 'coffee');
    });
}

일단 가독성이 한결 나아 보인다. 뿐만 아니라, 이제 해당 함수가 처리를 성공적으로 완료했을 경우 항상 then()에 넘겨진 함수가 단 한번 실행될 거라는 신뢰가 생겼다. 어마어마한 발전이다. 여기서 만족하지 말자. 프라미스를 쓸 수 있다면 ES6의 시대에 살고 있을 테니 Arrow 함수를 써서 좀 더 세련되게 만들어 보도록 하겠다.

function orderCoffee(phoneNumber) {
    return getId(phoneNumber)
        .then(id => getEmail(id))
        .then(email => getName(email))
        .then(name => order(name, 'coffee'));
}

후후. 이 맛에 Arrow 함수를 쓴다. 이제 기존의 콜백을 사용한 코드와 비교하면 훨씬 나아 보인다. 오, 프라미스. 콜백의 구원자여!

자, 이 정도면 된 것 같은데… 과연 더 이상 나아질 곳이 있는 걸까?

비동기 코드를 동기식 코드처럼 작성하기

잠시 마음을 가라앉히고, 제일 처음에 우리가 작성했던 코드를 보자.

function orderCoffee(phoneNumber) {
    const id = getId(phoneNumber);
    const email = getEmail(id);
    const name = getName(email);
    const result = order(name, 'coffee');
    return result;
}

그리고 다시 프라미스로 만든 세련된(?) 코드를 살펴보자. 자, 둘을 비교했을 때 어떤 코드가 더 이해하기 쉬울까? 당연하게도 바로 위의 코드가 훨씬 직관적이고 알아보기 쉽다. 잠시 프라미스의 능력에 감복하고 있었지만 냉정하게 말하면 이렇게 할 수가 없으니깐 차선을 선택했을 뿐이다(아… 프라미스 어쩔 ㅠㅠ). 그리고 이렇게 할 수 없는 이유는 아까도 말했듯이 자바스크립트가 싱글-스레드 기반의 언어이기 때문이다. 당연한 얘기지만, 각각의 네트워크 요청이 값을 반환하기 전까지 프로그램 전체가 멈춰서 대기를 해야 한다면 이 프로그램은 너무 느려서 사용할 수가 없을 것이다.

하지만 여기서 제너레이터를 활용한다면 어떨까? 아까 보았던 “비동기인 듯 비동기 아닌 비동기 같은 코드”를 떠올려 보자. 제너레이터는 함수를 실행 도중에 멈추고 제어권을 다른 곳으로 넘겨줄 수 있고 값도 전달할 수 있다. 그렇다면 전체 프로그램을 멈추지 않고도 이런 방식의 코드를 작성할 수 있지 않을까?

한번 시도해 보자. 우선 간단하게 기존의 함수 선언에 * 을 추가해서 제너레이터로 변경하고, 각 할당문에 yield를 추가해 보도록 하자. 다음과 같은 모습이 될 것이다.

function* orderCoffee(phoneNumber) {
    const id = yield getId(phoneNumber);
    const email = yield getEmail(id);
    const name = yield getName(email);
    const result = yield order(name, 'coffee');
    return result;
}

오!! 그럴듯하다. 딱 원하던 그대로이다. 정말 이게 되는 걸까?

하지만 세상에 쉽게 얻어지는 것어 어디 있으랴. yield를 통해 실행을 멈추고 제어권을 넘겨준 것 까지는 좋았지만, getId()가 작업을 완료하는 순간 다시 반환값과 함께 제어권을 가져오려면 누군가가 이터레이터의 next() 함수를 호출해 주어야만 할 것이다. 현재로서는 작업을 완료하는 시점을 알고 있는 getId() 함수 내부에서 직접 호출할 수 밖에 없는데, 그렇게 되면 이터레이터와의 밀접한 의존도가 생기게 된다. 즉 다음과 같이 데이터를 반환하는 모든 함수의 마지막에 next() 를 호출하는 코드가 추가되어야 할 것이다.

const iterator = orderCoffee('010-1234-1234');
iterator.next();

function getId(phoneNumber) {
    // …
    iterator.next(result);
}

function getEmail(id) {
    // …
    iterator.next(result);
}

function getName(email) {
    // …
    iterator.next(result);
}

function order(name, menu) {
    // …
    iterator.next(result);
}

(실제로 iterator.next()는 등의 함수 내부에서 의존하고 있는 사용하는 라이브러리에 따라 콜백 형식으로 호출되겠지만, 여기서는 설명을 위해 단순하게 제일 아랫줄에 추가했다.)

아… 이래서는 제너레이터에서 범용적인 함수를 사용할 수가 없다. 또한 콜백 방식과는 또 다른 의미로 제너레이터가 본인의 제어권을 상실했다(제어권을 넘겨드릴게요. 하지만… 돌려주실 건가요? ㅠㅠ). 왠지 콜백보다 더 나빠진 기분이다. 대실망. 구원자인 줄 알았건만. 제너레이터, 넌 결국 계륵이었니.

제너레이터와 프라미스의 만남

하지만 여기서 끝이 아니다. 아까 아주 잠깐 홀대했던 프라미스를 좀 달래서 도움을 요청해 보자(프라미스, 아깐 미안… 도와줘). 만약 모든 함수가 프라미스를 반환한다면 각각의 함수가 제어권을 직접 다루지 않고, 제3자에게 위임할 수 있지 않을까? 시도해 보자. 우선 프라미스의 예제에서처럼 모든 getXXX 함수는 프라미스를 반환한다고 가정하자. 이제 누군가가 이터레이터를 생성해서 함수가 끝날때까지 반복해서 실행시켜 주면 된다.

const iterator = orderCoffee('010-1010-1111');
let ret;

(function runNext(val) {
    ret = iterator.next(val);

    if (!ret.done) {
        ret.value.then(runNext);
    } else {
        console.log('result : ', ret.value);
    }
})();

코드가 살짝 복잡해 보이니 잠깐 살펴보겠다. 이터레이터를 생성해서 next()를 실행하면 결과의 value 값으로 프라미스를 반환하고, 프라미스의 then() 메서드에서 다시 이터레이터의 next() 함수를 실행한다. 이런 식으로 이터레이터가 done:true를 반환할 때까지 순환하면서 호출하게 된다. 즉,  next() -> yield -> then() -> next()의 순환흐름에 따라 실행되는 것이다.
(runNext() 함수가 재귀적으로 호출되고 있다. 만약 흐름이 잘 이해가 안 가면 위의 제너레이터 예제를 참고해 가며 살펴보길 바란다.)

자, 결과는?? 된다!! 프라미스와 제너레이터를 함께 사용하면 각각의 함수에서 제너레이터를 신경 쓰지 않고도 외부에서 제어할 수가 있다. 이제 제너레이터를 활용하여 비동기인 코드를 마치 동기식 코드인 것처럼 작성할 수 있는 길이 열린 것이다!! (제너레이터, 아깐 미안…)

여기서는 코드를 단순하게 만들기 위해 예외처리와 같은 작업들이 생략되었지만, 여기서 조금만 더 코드를 발전시키면 범용적으로 쓸 수 있는 함수를 만들어 낼 수 있을 것이다. 좀 더 욕심을 부려서 제너레이터의 실행결과로 프라미스를 반환하도록 하면, 좀 더 유용하게 사용할 수도 있을 것 같다 (위의 코드를 보면 알겠지만, 지금 상태에선 반환값을 전달할 수가 없다). 하지만 신이 난 김에 계속 달려가지 말고 잠깐 생각을 해 보자. 이렇게 좋은 기능이라면 당연히 누군가가 이미 만들어 놓은 라이브러리가 있지 않을까? 바야흐로 오픈소스의 시대. 어디든 금손이가 존재하는 시대. 외쳐보자 npm!

co

(npm이 응답하셨다.)

$ npm install co

당연한 얘기지만, 이미 이러한 기능을 구현해 놓은 라이브러리가 여럿 있다. 그중 가장 널리 쓰이는(것 같은…) co를 살펴보자. co는 200 라인 정도 밖에 안되는 아주 작은 라이브러리인데(이름도 귀엽다. 코-라니), 제너레이터를 쉽게 사용할 수 있는 아주 편리한 두 가지 함수를 제공한다. 먼저, 다음과 같이 co 함수에 제너레이터를 인수로 넘기면 제너레이터를 마지막까지 실행하고, 실행결과로 프라미스를 반환해 준다.

co(function* () {
    const id = yield getId('010-1234-5678');
    const email = yield getEmail(id);
    const name = yield getName(email);
    return yield order(name, 'coffee');
}).then(result => {
    console.log(result);
});

이렇게 쉽다니. 굳이 번거롭게 제너레이터를 직접 제어하지 않아도 된다. 한걸음 더 나아가 wrap 함수를 사용하면 제너레이터 함수를 프라미스를 반환하는 함수로 변환할 수도 있다.

const orderCoffee = co.wrap(function *() {
    const id = yield getId('010-1234-5678');
    const email = yield getEmail(id);
    const name = yield getName(email);
    return yield order(name, 'coffee');
});

orderCoffee.then(result => {
    console.log(result);
});

이제 이렇게 생성된 함수를 또 다른 제너레이터가 yield로 사용할 수 있을 것이다. 바야흐로 프라미스로 대동단결! 또한 co 에서는 프라미스뿐만 아니라 함수, 배열, 객체, 제너레이터 등을 모두 yield 할 수 있다 (자세한 내용은 co의 README 페이지에서 확인하자).

한가지 더 보너스가 있다. 에러 핸들링은? 서… 설마? 그렇다. 그 설마다. 콜백의 세계에서 소외받고 있던 그리운 옛 친구 try/catch를 다시 불러올 수 있다. (안녕. 오랜만이야 ㅠㅠ)

co(function* () {
    let result = false;
    try {
        const id = yield getId('010-1234-5678');
        const email = yield getEmail(id);
        const name = yield getName(email);
        result = yield order(name, 'coffee');
    } catch(err) {
        console.log('이 또한 지나가리라…', err); // 에러처리 로직
    }
    return result;
}).then(result => {
    console.log(result);
});

co 이외에도 프라미스를 확장한 bluebird 의 coroutine() 혹은 node-fibers를 사용한 asyncawait 같은 라이브러리도 있으니, 관심 있으신 분들은 한 번씩 둘러보길 바란다.

Koa

사실 글의 첫 부분에서 말했듯이, 이 글을 처음 쓰게 된 동기는 바로 Koa였다. Koa는 Express의 업그레이드 버전이라고 보면 되는데, Express와 동일한 기능이 제너레이터 기반으로 작성되었기 때문에 아주 편하게 비동기 코드를 작성할 수 있다. 예를 들어 koa-router를 사용해서 특정 URL을 핸들링 할 때 제너레이터를 사용해서 다음과 같이 작성할 수 있다.

router.post('/login', function*() {
    const {email, password} = this.request.body;
    const user = yield userDB.get(email);
    const valid = yield crypter.compare(password, user.password);
  //     …
});

저기 저 yield의 자태를 보라. Koa가 제너레이터를 기반으로 미들웨어에 대한 제어를 해 주기 때문에, 우리는 굳이 다른 것 신경 쓸 필요 없이 yield 를 사용해서 아주 간편하게 비동기 코드를 작성할 수 있다. 기존 Express의 router를 사용할 때와 비교해 보면 차이를 알 수 있을 것이다.

router.post('/login', function(req, res, next) {
    const {email, password} = req.body;
    return userDB.get(email)
        .then(user => crypter.compare(password, user.password)
        .then(valid => {
            // …
            next();
        });
});

단순히 function 뒤에 * 하나 추가했을 뿐인데 코드가 이렇게 달라진다. 여기에 예외처리를 더한다고 생각해보자(아… 아, 앙돼!). 이쯤 하면 인정. Koa 승.

Node.js의 변절자로 불리는 TJ가 바이바이- Node.js 하실 때 계속 메인테인 하겠다고 언급했던 프로젝트가 바로 Koa이다 (co도 마찬가지). 차마 버릴 수 없는 그런 매력덩어리란 의미이지 않을까.

One More Thing

사실 여기서 끝내도 멋진 마무리가 될 것 같지만, 정말 그냥 넘어가기 힘든 “원 모어 띵”이 있다. 제너레이터를 제어하는 게 번거로워서 싫다면. 근데 그냥 막 왠지 이유 없이 외부 라이브러리는 싫다면. 아님 co.wrap(function *() { 문법이 못생겨서 싫다면. C# 개발자들이 “우리는 async await하면 땡인데?” 하고 놀린다면. 이제 당당하게 말하자. 자바스크립트도 된다고.

async function orderCoffee(phoneNumber) {
    const id = await getId(phoneNumber);
    const email = await getEmail(id);
    const name = await getName(email);
    return await order(name, 'coffee')
}

orderCoffee('011-1234-5678').then(result => {
    console.log(result);
});

위의 co.wrap 을 이용한 코드에서 달라진 점은 co.wrap(function *() -> async function, 그리고 yield -> await 두 가지뿐이다. 나머지는 프라미스를 반환하는 것까지 동일하다. 이제 라이브러리 도움 없이도 이런 식의 코드를 작성할 수가 있다. 오!! (이런 것까지 바란 건 아닌데… 그래도 주신다면 감사 감사 덥썩) 이 async-await가 바로 자바스크립트 개발자들이 눈빠지게 기다리고 있는 바로 그 스펙이다. 현재 Stage 3 단계로써 아직 표준의 반열에 올라서진 못했지만, 이미 regenerator 같은 라이브러리에 구현되어 있으며, Babel이나 TypeScript 같은 트랜스파일러에서도 지원하고 있다. 그리고 위에서 Koa의 링크를 타고 문서를 확인해 보신 분들은 이미 알겠지만, Koa도 2.0에서부터 async/await 기반으로 완전히 변경될 예정이다(이미 개발은 완료되었지만 node.js가 정식 지원하기 전까지 Alpha를 달고 있을 것으로 보인다).

이미 마음만 먹으면 얼마든지 사용할 수 있다는 얘기다. 단지 아직 표준이 아니라는 것을 염두에 두고, 선택과 그에 따른 책임은 각자의 몫이다.

마치며

지금까지 자바스크립트에서 제너레이터로 비동기 프로그래밍을 하는 방법에 대해 알아보았다. 제너레이터와 프라미스를 같이 사용하면 비동기 코드를 마치 동기식 코드를 작성하는 것처럼 작성할 수 있다. 실제 사용해보면 알겠지만, 복잡한 비동기 코드를 다룰 때 이를 활용하면 이전과 비교할 수 없이 편하게 코드를 작성할 수 있을 것이다. (그리고 많은 자바스크립트 개발자들이 async-await가 하루빨리 표준스펙이 되길 기다리고 있다.)

쓰고 보니 무슨 ‘찬양하라 제너레이터’ 정도가 되어 버린 것 같지만, 프라미스가 만병통치약이 아니듯이 제너레이터도 마찬가지다. 모두가 아는 사실이지만 (그리고 자주 까먹는 사실이지만), 프로그래밍에서 만병 통치약(a.k.a 은총알)은 없다. 콜백도 잘 쓰면 약이 될 것이고, 제너레이터도 못 쓰면 독이 될 것이다. 훌륭한 도구가 주어지면 그것을 상황에 맞게 잘 활용하는 것은 프로그래머의 몫이다. 그리고 훌륭한 도구가 있는데도 사용하지 않고 방치해 둔다면 그 또한 프로그래머의 도리가 아닐 것이다. 이제, 새로운 도구를 손에 넣었으니 마음껏 활용해 보자 🙂

자바스크립트

[번역] 불변(Immutable) 데이터 구조와 자바스크립트

원문 : http://jlongster.com/Using-Immutable-Data-Structures-in-JavaScript

얼마전에 나는 현재의 내 블로그를 재작성할 것이라 말하면서, 내가 배운 것들에 대해 좀더 깊이 들어가 보기로 약속했다. 오늘 나는 자바스크립트에서의 불변 데이터 구조, 특히 immutable.js 와 seamless-immutable 라이브러리에 대해 이야기할 것이다. 다른 라이브러리들도 있지만, 개념적으로는 완전히 영구적인 데이터 구조이거나 네이티브 자바스크립트 객체를 복사한 것 중의 하나일 것이고, 당신이 어떤 라이브러리를 선택하든 간에*[1]* 이들 두 라이브러리를 비교해 보는것으로 각자의 장단점을 잘 알게 될 것이다. 또한 transit-js 에 대해서도 조금 언급할 예정인데, 이는 직렬화를 할 때에 매우 유용할 것이다.

[1] mori는 영구적 데이터 구조에 대한 또다른 구현이며(ClosureScript에서 나왔다), React’s immutablility helpers는 네이티브 자바스크립트 객체를 얇게 복사하는 또다른 라이브러리이다.

이 글의 아주 조금은 Redux에 한정된 내용일 것이다. 전반적으로는 불변 데이터 구조를 사용하는 것에 대해 이야기를 하지만, 그것을 Redux에 사용할 수 있는 방법도 조금 제공할 것이다. Redux에서는 단일 앱 상태 객체를 갖고 그것을 불변적으로(immutably) 갱신하는데, 이렇게 할 수 있는 다양한 방식이 있고 각각의 장단점이 있다. 이에 대해서는 아래에서 살펴보겠다.

Redux에 대해서 한가지 고려해야 할 것은 단일 앱 상태 요소(single app state atom)를 만들기 위해 reducer를 어떻게 결합할 것인가이다. Redux가 기본으로 제공하는 메소드인 combineReducers는 당신이 여러 값들을 하나의 자바스크립트 객체로 결합한다고 가정한다. 만약 당신이 그 값들을 하나의 Immutable.js 객체로 결합하려 한다면, 자신만의 combineReducers를 작성해야 할 것이다. 만약 당신이 앱 상태를 직렬화해야 하고, 그 결과가 온전히 Immutable.js 객체로 만들어지기 원한다면 이 과정이 필요할 것이다.

이 글의 대부분은 자바스크립트에서 불변 객체를 어떻게 사용하는가에 대해 다룰 것이다. 이는 가끔 조금 불편하게 느껴지는데, 왜냐하면 당신은 기본 문법을 극복하려 하고 있고, 그 때문에 타입을 교묘하게 다루고 있다는 기분이 들 수 있기 때문이다. 하지만 당신의 앱에 따라, 그리고 당신이 어떤 식으로 구성하는가에 따라 거기에서 빠져나올 수 있을 것이다.

최근에 자바스크립트 네이티브에 불변 데이터 구조를 추가하자는 제안이 나왔지만, 진행이 될지는 아직 확실하지가 않다. 이 제안은 분명히 현재 자바스크립트에서 불변 데이터 구조를 사용할 때 생기는 대부분의 문제들을 해결할 것이다.

Immutable.js

Immutable.js는 페이스북에서 만들었으며 불변 데이터 구조의 가장 유명한 구현중의 하나이다. 이는 굉장히 멋지다; 완전히 영구적인 데이터 구조를 직접 구현하기 위해 tries와 같은 고급 개념들을 사용해서 구조를 공유하도록 만들었다. 모든 업데이트는 새로운 값을 반환하지만, 내부적으로는 구조가 공유되어 메모리 사용량을 (또한 GC의 스레싱(thrashing)도) 현저하게 줄인다. 말인즉슨, 만약 당신이 1000개의 요소를 벡터에 추가한다 해도 실제로 1001개의 요소를 가지는 새로운 벡터를 반환하지 않는다는 뜻이다. 거의 확실하게, 내부적으로는 몇개의 작은 객체들만 메모리에 할당될 것이다.

획기적인 기초를 쌓은 Okasaki의 멋진 공헌에 힘입어 구조를 공유하는 데이터 구조를 적극적인 활용함으로써, 불변적 값들은 너무 느려서 실제 앱에서 사용할 수 없다는 미신은 대부분 사라졌다. 실제로 얼마나 많은 앱들이 이를 통해 더 빨라질 수 있는지를 알면 놀랄 것이다. (다른 누군가에 의해 변형되는 것을 피하기 위해) 빈번하게 데이터 구조를 복사하고 읽어들이는 앱들은 불변 데이터 객체에서 쉽게 이점을 얻을 수 있다. (단순히 하나의 큰 배열을 한 번 복사하는 것만으로 변형가능한 데이터의 성능 이점은 상쇄될 것이다.)

또다른 예제는 ClosureScript가 불변 데이터 구조를 UI에 사용함으로써 큰 성능 향상 효과를 얻을 수 있음을 발견한 내용에서 확인할 수 있다. 만약 당신이 UI를 변형하려 한다면 아마 일반적으로는 DOM을 필요 이상 건드리게 될 것이다(왜냐하면 값을 갱신해야 하는지 아닌지 알 수가 없기 때문이다). React는 DOM 변형을 최소화해주지만, 당신은 그 이를 위해 여전히 virtual DOM을 만들어내야 한다. 만약 컴포넌트들이 불변적이라면, 당신은 virtual DOM을 만들어낼 필요조차 없다. 단순히 === 등가 비교만으로도 갱신이 필요한지 아닌지 알 수 있을 것이다.

사실이라고 하기엔 너무 마냥 좋은 것 같은가? 아마도 당신은 이런 이점들에도 불구하고 왜 우리가 불변 데이터 구조를 항상 사용하지 않는지 궁금할 것이다. 글쎄, Elm이나 ClojureScript 같은 몇몇 언어들은 실제로 그렇게 사용하고 있다. 자바스크립트에서는 언어가 기본적으로 제공하고 있지 않기 때문에 좀더 어려우며, 그렇기 때문에 우리는 장점과 단점을 비교해볼 필요가 있다.

공간과 GC 효율성

왜 구조의 공유가 불변 데이터 구조를 더 효율적으로 만드는지는 이미 설명했다. 물론 특정 인덱스의 배열을 직접 변형하는 것보다 나은 방법은 없겠지만, 불변성의 오버헤드는 그리 크지 않다. 만약 당신이 변형을 피하고 싶다면 불변성은 객체를 직접 복사하는 것보다 더 나을 것이다.

Redux에서는 불변성이 강제된다. 당신이 새로운 값을 반환하지 않는다면 화면에서 아무것도 갱신되지 않을 것이다. 이것으로 인한 큰 이점들이 있는데, 만약 당신이 객체를 복사하는 것을 피하고 싶다면 Immutable.js를 살펴봐야 할 것이다.

참조(Reference) & 값(Value) 등가 비교

당신이 내부적으로 객체의 참조를 저장하고 있다고 가정하고 그 객체를 obj1이라 하자. 나중에 obj2가 들어온다. 만약 당신이 절대 객체를 변형하지 않으며, obj1 === obj2가 true 라면, 당신은 아무것도 변경된 것이 없다는 것을 확실하게 알 수 있다. React와 같은 많은 아키텍처에서 이는 강력한 최적화를 할 수 있게 해 준다.

이것을 “참조 등가 비교”라고 부르는데, 단순히 포인터를 비교하는 방법이다. 하지만 또다른 “값 등가 비교”라는 개념이 있으며, 이는 두 개의 객체가 실제로 동일한지를 obj1.equals(obj2)를 이용해서 검사하는 방법이다. 만약 모두가 불변적이라면 객체를 단지 값인 것 처럼 다룰 수 있다.

ClosureScript에서는 모든 것이 값이며, 심지어 (===와 같은) 기본 등가 비교 연산자조차 값 비교를 수행한다. 만약 당신이 실제로 인스턴스를 비교하고 싶다면 identical?을 사용할 수 있다. 불변 데이터 구조에 대해 값을 등가 비교하는 것의 이점은, 전체를 재귀적으로 검사하는 것보다 훨씬더 빠르게 검사할 수 있다는 점이다(만약 구조를 공유한다면 그 부분을 체크하지 않고 건너뛸 수 있다).

그러면 실제로 이것을 어떻게 사용할까? 이것으로 어떻게 React를 손쉽게 최적화 하는지에 대해서는 이미 설명했다. 단지 sholdComponentUpdate를 구현해서 상태가 동일한지 확인하고 동일하다면 렌더링을 건너뛰면 된다.

또한 나는 Immutable.js에서 === 를 사용하는 것이 값 등가 비교를 수행하지는 않지만 (당연하게도, 자바스크립트의 문법을 직접 오버라이드할 수는 없다) Immutable.js가 객체의 동일성을 위해 값 등가 비교를 이용한다는 것을 발견했다. Immutable.js는 객체가 동일한지를 검사하는 어느 곳에서든 값 등가 비교를 사용한다.

예를 들자면 Map 객체의 키들은 값 등가 비교를 사용한다. 말인즉슨, Map에 객체를 저장한 다음 나중에 똑같은 형태를 가진 객체를 제공하면 같은 값을 가져올 수 있다는 것이다.

let map = Immutable.Map();
map = map.set(Immutable.Map({ x: 1, y: 2}), "value");
map.get(Immutable.Map({ x: 1, y: 2 })); // -> "value"

이것은 정말 멋진 많은 의미를 갖는다. 예를 들어 서버로부터 데이터를 내려받기 위해 필요한 필드를 지정하는 쿼리 객체를 받는 함수가 있다고 해 보자.

function runQuery(query) {
  // pseudo-code: somehow pass the query to the server and
  // get some results
  return fetchFromServer(serialize(query));
}

runQuery(Immutable.Map({
  select: 'users',
  filter: { name: 'James' }
}));

만약 내가 쿼리를 캐싱하는 것을 구현하고 싶다면, 이렇게만 하면 될 것이다.

let queryCache = Immutable.Map();
function runQuery(query) {
  let cached = queryCache.get(query);
  if(cached) {
    return cached;
  } else {
    let results = fetchFromServer(serialize(query));
    queryCache = queryCache.set(query, results);
    return results;
  }
}

쿼리 객체를 값으로써 다룰 수 있고, 그 객체를 키로 해서 결과를 저장할 수 있다. 나중에 무언가가 동일한 쿼리를 실행한다면, 심지어 쿼리 객체가 동일한 인스턴스가 아니더라도 캐시된 결과를 돌려받게 될 것이다.

값 등가 비교가 단순화시킬 수 있는 수많은 패턴들이 있다. 사실 나는 포스트를 가져올 때 정확히 같은 기법을 사용하고 있다.

자바스크립트와의 상호 연동

Immutable.js 데이터 구조의 중요한 단점은 바로 위에서 설명한 모든 기능들을 구현할 수 있는 이유이다 : 일반 자바스크립트 데이터 구조가 아니라는 점이다. Immutable.js 객체는 자바스크립트 객체와 완전히 다르다.

즉 map.property대신에 map.get("property")를 해야 하고, array[0] 대신에 array.get(0)을 해야 한다는 의미이다. Immutable.js가 자바스크립트와 호환이 되는 수많은 API의 목록을 제공하고는 있지만, 심지어 그것들 조차 다르다(push는 기존의 인스턴스를 변형시키는 대신 반드시 새로운 배열을 반환해야 한다). 마치 엄격하게 변형을 위해(mutation-heavy) 만들어진 자바스크립트 문법과 싸우고 있는 것 처럼 느껴질 수도 있다.

이것이 문제가 되는 이유는, 만약 하드코어 하게 사용하지 않는다거나 프로젝트를 처음부터 시작하는 것이 아니라면 Immutable 객체를 모든 곳에서 쓸 수 없기 때문이다. 실제로 작은 함수들의 로컬 객체들에서는 불변성이 반드시 필요하지는 않을 것이다. 만약에 당신이 모든 객체/배열 등을 불변적으로 생성한다고 하더라도, 일반적인 자바스크립트 객체/배열 등을 사용하는 외부 라이브러리들과 함께 동작하도록 해야 할 것이다.

그 결과 당신은 자바스크립트 객체나 Immutable 객체 중 어떤 것을 이용하고 있는지 절대로 알 수 없게 된다. 이는 함수들을 이해하는 것을 어렵게 만든다. 당신이 어느곳에 불변 객체를 사용할 지를 명확하게 정할 수 있다고 하더라도, 여전히 당신은 시스템을 통해서 사용여부가 명확하지 않은 곳으로 객체들을 넘겨주게 될 것이다.

실제로, 가끔 Immutable Map 내부에 일반 자바스크립트 객체를 넣고 싶을 때가 있다. 그렇게 하지 말라. 불변한 상태와 가변 상태를 같은 객체에 섞는 것은 혼란을 줄 것이다.

나는 이것에 대해 두 가지 해결책을 알고 있다.

  1. TypeScript나 Flow와 같은 타입 시스템을 사용하라. 이것은 불변 데이터 구조가 시스템의 어느 곳에서 흐르고 있는지를 기억해야만 하는 정신적인 부하를 제거해준다. 다만, 이는 꽤나 다른 코딩 스타일을 요구하기 때문에 많은 프로젝트들에서 굳이 이 과정을 도입하려 하지는 않을 것이다.
  2. 데이터 구조에 대한 상세 내용을 숨겨라. 만약 당신이 Immutable.js를 시스템의 특정한 부분에서 사용하고 있다면, 외부에서 직접 데이터 구조에 접근할 수 있도록 만들지 말라. Redux에서의 단일 요소 앱 상태가 좋은 예이다. 만약 앱의 상태가 Immutable.js 객체라면, React 컴포넌트가 Immutable.js의 API를 직접적으로 사용하도록 강요하지 않도록 하라.여기에는 두 가지 방법이 있다. 첫번째는 typed-immutable과 같은 것을 사용해서 실제로 객체의 타입 을 지정하는 것이다. Record을 생성하면 Immutable.js 객체를 감싸주는 가벼운 랩퍼(Wrapper)를 얻을 수 있고, 이 객체는 타입이 지정된 필드에 대해 겟터(getter)를 정의하여 map.property 인터페이스를 사용할 수 있게 해 준다. 그 객체로부터 값을 읽어들이는 모든 것들은 그 객체를 일반 자바스크립트 객체처럼 다룰 수 있다. 그 객체를 변형(mutate)할 수는 없긴 하겠지만, 그건 실제로 당신이 강제하고자 하는 것이다. 두번째 방법은 객체를 쿼리할 수 있는 방법을 제공하고 값을 읽으려고 하는 모든 것들에게 그 쿼리를 수행하도록 강제하는 것이다. 이것을 일반적으로 적용할 수는 없겠지만, Redux의 경우에는 매우 잘 동작하는데, 이는 Redux가 단일 앱 상태 객체를 갖고 있으며 어떻게든 그 데이터 구조를 숨기길 원하기 때문이다. 모든 React 컴포넌트들이 그 데이터 구조에 의존하게 된다면 당신은 결코 실제 앱 상태의 구조를 변경할 수 없을 것이다. (하지만 실제로는 자주 변경할 수 밖에 없다)

    깊은 객체 쿼리를 위해 쿼리가 꼭 복잡한 엔진일 필요는 없고, 그냥 단순한 함수면 된다. 아직 내 블로그에 적용하지는 않았지만, 만약에 getPost(state, id)나 getEdittorSettings(state)와 같은 함수들이 한 묶음 있다고 가정해보라. 이들은 모두 state를 받아서 내가 “쿼리”하는 것을 단지 함수를 이용해 반환한다. 나는 그것이 상태 내부의 어디에 위치하는지 더이상 신경쓰지 않는다. 단 하나의 문제점은 여전히 immutable 객체를 반환할 수도 있다는 점인데, 그렇기 때문에 아마도 그 객체를 먼저 자바스크립트 객체로 강제 변환시키든가 위에서 말한 Record 타입을 이용해야 할 것이다.

모든 걸 종합하자면 : 자바스크립트 상호 연동은 실제 이슈이다. 절대 불변 객체로부터 자바스크립트 객체를 참조하지 말라. 상호 연동의 문제는 typed-immutable에 의해 제공되는 Record 타입에 의해 완화될 수 있는데, 이는 변형을 하거나 유효하지 않은 필드를 읽을 때 에러를 발생시키는 등의 또다른 흥미로운 이점을 갖고 있다. 마지막으로, 만약 Redux를 사용한다면 모든 것이 앱 상태 구조에 의존하도록 강제하지 말라. 왜냐하면 이후에 변경하게 될 것이기 때문이다. 데이터 구현을 추상화하면 불변성의 상호 연동과 관련된 문제들을 해결할 수 있을 것이다.

seamless-immutable

불변성을 강제할 수 있는 또다른 방법이 있다. seamless-immutable 프로젝트는 일반 자바스크립트 객체를 사용하는 훨씬 가벼운 해결책이다. 이것은 새로운 데이터 구조를 만들어내지 않으며, 그러므로 데이터 구조를 공유하지 않고, 객체를 수정할 때 그것을 복사해서 사용한다는 의미가 된다(하지만, 얇은 복사만 한다). 위에서 설명한 성능이나 값 등가 비교의 이점은 얻을 수 없다.

하지만 그 대신 훌륭한 자바스크립트 상호 연동이 훌륭하다. 모든 데이터 구조는 말 그대로 온전한 자바스크립트 데이터 구조이다. 차이점은 seamless-immutable은 Object.freeze를 호출하기 때문에 객체를 변형할 수 없다는 점이다 (ES6 에서 기본설정인 strict mode 에서는 변형 시에 에러를 발생시킬 것이다). 덧붙여서 이것은 각각의 인스턴스에 갱신을 지원하기 위해 (제공된 프라퍼티를 합쳐서 새로운 객체를 반환하는)merge와 같은 몇가지 메소드들을 추가한다.

불변 데이터 구조를 갱신할 때, 깊게 중첩된 객체들을 갱신하기 쉽게 만들어 주는 Immutable.js의 setIn 이나 mergeIn과 같은 몇가지 메소드들은 포함되어 있지 않다. 하지만 이들은 쉽게 구현될 수 있으며, 나는 이들에 대해 프로젝트에 공헌할 계획을 갖고 있다.

불변 객체와 가변 객체를 섞는 것은 불가능하다. seamless-immutable은 모든 인스턴스를 감쌀 때 객체들을 깊게 불변 객체로 변경하며, 어떤 값이 추가되든 자동적으로 감싸진다. 실제로는 Immutable.js도 매우 비슷한 방식으로 동작하는데, obj.merge와 같은 몇가지 메소드들 뿐만 아니라 Immutable.fromJS도 깊은 변환을 한다. 하지만 obj.set은 자동으로 강제 변환을 하지 않는데, 그렇기 때문에 원하는 어떤 데이터 타입이든 저장할 수 있게 된다. seamless-immutable에서는 이것이 불가능하며, 그렇기 때문에 실수로 가변 자바스크립트 객체를 저장하는 일은 발생할 수 없다.

개인적인 의견으로는, 각각의 라이브러리들이 지금 하고 있는 방식 대로 계속 진행하길 바란다. 이들은 다른 목표를 가지고 있다. 예를 들어 seamless-immutable은 자동적인 강제 변환을 하기 때문에, 알지 못하는 타입에 대해서는 저장할 수 없을 것이고, 그렇기 때문에 기본적으로 내장된 타입을 제외한 타입들과는 제대로 동작하지 않을 것이다. (실제로 지금 현재 Map이나 Set 타입도 지원하지 않고 있다)

seamless-immutable은 많은 장점을 가진 작은 라이브러리이지만, 그와 동시에 값 등가 비교와 같은 불변 데이터 구조의 중요한 장점들을 잃어버리기도 했다. 만약 자바스크립트 상호 연동이 중요한 고려대상이라면 이것은 환상적인 해결책일 것이다. 이것은 기존에 존재하는 코드를 마이그레이션 할 때 특히 유용한데, 당신은 객체들을 건드리는 모든 코드를 전부 재작성할 필요 없이 객체를 차근차근 불변한 상태로 변경할 수 있을 것이다.

빠뜨린 부분: transit-js를 이용한 직렬화

한가지 마지막으로 고려해야 할 부분이 있다. 바로 직렬화이다. 만약 커스텀 데이터 타입을 사용한다면 JSON.stringify는 더이상 선택사항이 아니다. 하지만 JSON.stringify가 썩 훌륭하지는 않는데, 심지어 ES6의 Map이나 Set 인스턴스조차 직렬화하지 못한다.

transit-js는 David-Nolen이 만든 훌륭한 라이브러리이며, 확장 가능한 데이터 전송 포맷을 정의한다. 기본적으로는 Map이나 Set 인스턴스들을 던져넣을 수 없지만, 중대한 차이점은 커스텀 타입들을 transit이 이해할 수 있는 무언가로 번역할 수 있다는 점이다. 실제로 전체 Immutable.js 타입들에 대한 직렬화와 역직렬화를 위한 전체 코드는 150 라인이 채 되지 않는다.

또한 Transit은 타입을 인코드 하는 방식을 보면 더욱 똑똑하다. 예를 들어 Transit은 맵의 키가 복잡한 타입일 것이라는 것을 알기 때문에, 어떻게 Map 타입을 직렬화 할 것인지 알려주기가 쉽다. Immutable.js를 지원하는 (위에서 언급했던) transit-immutable-js 라이브러리를 사용하면, 이런 식으로 할 수 있다.

let { toJSON, fromJSON } = require('transit-immutable-js');

let map = Immutable.Map();
map = map.set(Immutable.Map({ x: 1, y: 2 }), "value");

let newMap = fromJSON(toJSON(map));
newMap.get(Immutable.Map({ x: 1, y: 2 })); // -> "value"

값 등가 비교를 할 때 transit의 간편하고 쉬운 맵 직렬화를 사용하면, 어떤 시스템에서든 단순하고 일관된 패턴의 방식을 제공할 수 있다. 실제로 내 블로그에서 서버렌더링을 할 때 쿼리를 캐쉬한 다음에 클라이언트로 그 캐쉬를 전송하는데, 그 캐쉬는 여전히 손상되지 않고 유지된다. 이 케이스는 내가 transit으로 전환했던 실제 핵심 이유였다.

ES6 타입을 직렬화 하는 것이 물론 어렵지는 않겠지만, 만약 키값이 복잡한 구조로 되어 있다면 나는 값 등가 비교를 하지 않고 어떻게 역직렬화된 인스턴스를 사용할 수 있을 지 모르겠다.

또한 만약 일반 자바스크립트 객체와 Immutable.js 객체가 섞여서 존재한다면, transit을 이용한 직렬화는 모든 타입들을 손상되지 않게 유지시켜준다. 나는 이들을 섞는 것을 추천하지 않지만, transit은 각각의 객체를 적절한 타입으로 역직렬화 시킬 것이다. 반면 순수 JSON 객체를 사용한다면 역직렬화 할 때 모든 객체를 Immutable.js 타입으로 변환하게 될 것이다. (Immutable.fromJS(JSON.parse(str))를 한다고 생각해보라)

당신은 tranit을 확장해서 Date 인스턴스나 커스텀 타입등과 같은 어떤 것이든 직렬화할 수 있다. transit-format에서 타입을 어떻게 인코드 하는지 확인해보라.

만약 seamless-immutable을 사용한다면, 당신은 이미 스스로를 내장된 자바스크립트 (고로 JSON 변환 가능한) 타입만 사용하도록 제한하고 있는 것이기 때문에 JSON.stringify를 사용할 수 있다. 이는 단순하긴 하지만 확장성을 잃게 된다. 모든 것은 결국 트레이드오프다.

결론

불변성은 많은 이점을 제공해 주지만, Immutable.js가 제공하는 완전히 영구적인 데이터 구조를 사용할지 아닐지의 여부는 앱에 따라 다르다. 내 생각에 대부분의 앱들은 비교적 규모가 작기 때문에 객체를 복사하는 것에 별다른 문제가 없을 것이다.

하지만 단순함을 얻는 대신 기능들을 잃게 될 것이다; 제한된 API 뿐만 아니라 값 등가 비교도 할 수 없다. 덧붙여서 나중에 구조의 공유를 통한 성능의 향상을 위해 Immutable.js로 전환하려고 할 때 어려울 수도 있다.

일반적으로 나는 데이터의 상세 구조를 외부 세계로부터 숨기는 것을 추천한다. 특히 Immutable.js를 사용한다면 말이다. obj.property와 arr[0]와 같은 자바스크립트 객체나 배열의 기본 프로토콜을 따르려고 노력하라. 불변 객체들을 빠르게 이런 인터페이스들로 감싸는 것이 가능해야 하는데, 좀더 연구가 필요할 것 같다.

이는 특히 Redux의 경우 사실인데, 당신이 앱 상태의 구조를 나중에 변경하게 될 것이기 때문이다. 심지어 당신의 앱 상태가 일반 자바스크립트 객체인 경우에도 같은 문제가 있다. 앱 상태와 관련된 것들을 변경할 때 외부에서 그 상태를 사용하는 것들이 깨져서는 안된다. 대신에 앱의 상태를 쿼리하는 방법을 제공하라. 최소한 데이터 접근을 함수를 통해 할 수 있도록 추상화라도 시켜라.  Relay나 Falcor와 같은 더 복잡한 솔루션도 이를 해결할 수 있는데, 왜냐하면 쿼리 언어가 데이터에 접근하는 기본 방법이 되기 때문이다.

불변성과 관련된 내가 알고있는 현존하는 라이브러리에 대한 gist를 만들었다.

자바스크립트

[번역] 바보들을 위한 자바스크립트 엔진 가이드

원문 : http://developer.telerik.com/featured/a-guide-to-javascript-engines-for-idiots/?utm_source=javascriptweekly&utm_medium=email

참고로 말하자면, 나는 이글을 쓰는 사람이나 읽는 사람 모두 바보라고 생각하지 않는다. 하지만 가끔은 어떤 주제가 당신을 바보로 느끼도록 만들 때가 있다. 자바스크립트 코드를 구동하는 엔진이 그런 주제 중의 하나이다 (최소한 나에게는).

웹에서 코드를 작성할 때, 단지 문자들을 나열했을 뿐인데, 브라우저에서 뭔가가 나타나는 걸 보면 가끔은 약간의 마법같다는 생각이 든다. 하지만 기술 뒤에 숨겨진 마법을 이해한다면 당신의 프로그래밍 솜씨를 향상시키는 데에 도움이 될 것이다. 최소한 자바스크립트 기반의 웹이나 모바일 기술의 내부에서 일어나는 일들에 대해 설명을 할 때 바보같다는 느낌이 덜 들게 될 수는 있을 것이다.

… 중략 …

몇가지 용어 정리

‘자바스크립트 엔진’은 주로 가상머신이라 불린다. ‘가상 머신’은 특정 컴퓨터 시스템을 소프트웨어 기반으로 에뮬레이션 한 것을 뜻한다. 많은 종류의 가상 머신이 있으며, 얼마나 정확하게 실제의 물리적 머신을 에뮬레이션 할 수 있느냐에 따라 분류된다.

예를 들어, ‘시스템 가상 머신’은 운영체제가 실행될 수 있는 플랫폼의 완전한 에뮬레이션을 제공한다. 맥 사용자들은 Parallels를 잘 알텐데, 이는 당신의 맥에서 윈도우를 구동할 수 있게 해주는 시스템 가상 머신이다.

반면에 ‘프로세스 가상 머신’은 완전한 기능을 제공하지는 않지만, 하나의 프로그램이나 프로세스를 구동할 수 있다. Wine은 프로세스 가상 머신으로서 리눅스 머신에서 윈도우 어플리케이션을 실행할 수 있게 해주지만, 전체 윈도우 운영체제를 제공하지는 않는다.

자바스크립트 엔진은 자바스크립트 코드를 해석하고 실행하기 위해 만들어진 일종의 프로세스 가상 머신이다.

주의: 레이아웃 엔진과 자바스크립트 엔진은 다르다. 레이아웃 엔진은 브라우저가 웹페이지의 레이아웃을 배치할 때 사용되는 반면, 더 하위레벨인 자바스크립트 엔진은 코드를 해석하고 실행한다. 여기에 좋은 설명이 있다.

그래서 자바스크립트 엔진은 정확히 어떤 일을 하는걸까?

자바스크립트 엔진의 기본적인 역할은, 개발자가 작성한 자바스크립트 코드를 브라우저에 의해 해석되거나 어플리케이션에 임베드 될 수 있는 빠르고 최적화된 코드로 변환하는 일이다. 실제로 JavaScriptCore는 스스로를 ‘최적화 가상 머신’ 이라 부른다.

더 자세하게 말하면, 각각의 자바스크립트 엔진은 특정 버전의 ECMAScript를 구현한다. ECMAScript가 발전하는 만큼, 자바스크립트 엔진도 발전한다. 수많은 자바스크립트 엔진이 존재하는 이유는 각각의 엔진이 서로 다른 웹브라우저, 헤드리스(headless) 브라우저, Node.js와 같은 런타임 등에서 동작하도록 만들어졌기 때문이다.

아마 웹브라우저라는 단어는 익숙하겠지만, 헤드리스 브라우저는 생소할 것이다. 헤드리스 브라우저는 그래픽 유저 인터페이스(GUI)가 없는 웹브라우저이다. 이는 웹 제품에 대한 자동화된 테스트를 구동할 때 유용하다. PhantomJS가 좋은 예이다. 그러면 Node.js는 어디에 속하는 걸까? Node.js는 자바스크립트를 서버사이드에서 사용할 수 있도록 해주는 비동기, 이벤트주도 방식의 프레임워크이다. 이들은 모두 자바스크립트-주도(Javascript-driven)의 도구이기 때문에, 자바스크립트 엔진에 의해 구동된다.

위에서 말한 가상머신의 정의에 따르면, 자바스크립트 엔진을 ‘프로세스 가상 머신’이라고 부르는 게 맞을 것이다. 왜냐하면 자바스크립트 엔진의 유일한 목적은 자바스크립트 코드를 읽고 컴파일 하는 것이기 때문이다. 그렇다고 간단한 엔진이라는 뜻은 아니다. 예를 들면 JavaScriptCore는 자바스크립트 코드를 분석하고, 해석하고, 최적화하고, 가비지 콜렉팅을 하는 여섯개의 ‘빌딩 블록’을 갖고 있다.

어떤 식으로 작동할까?

당연히, 엔진에 따라 다르다. 우리가 관심을 갖고 있는 두개의 중요한 엔진은 Webkit’s의 NativeScript와 Google의 V8 엔진이다. 이들 두 엔진은 코드를 다른 방식으로 처리한다.

JavaScriptCore는 스크립트를 해석하고 최적화하기 위해 다음의 순차적인 단계를 진행한다.

  1. 어휘 분석(lexical analysis). 소스코드를 일련의 토큰 혹은 식별가능한 문자열로 분해한다.
  2. 토큰들은 Parser에 의해 분석되어, Syntax Tree로 만들어진다.
  3. 네 개의 JIT(just in time) 프로세스가, Parser에 의해 만들어진 바이트코드들을 분석하고 실행한다.

간단하게 말하면, 자바스크립트 엔진은 당신의 소스코드를 가져와서, 문자열 단위로 분해하고(어휘단위로 정리), 이들 문자열을 가져다가 컴파일러가 이해할 수 있도록 바이트 코드로 변환한 후, 이를 실행한다.

구글의 V8 엔진은 C++로 작성되었다. 이 엔진 역시 자바스크립트 코드를 컴파일하고 실행하며, 메모리를 할당하고, 가비지 컬렉팅을 한다. 이 엔진은 소스코드를 직접 머신 코드로 컴파일하는 두 개의 컴파일러로 구성되어 있다.

  1. Full-codegen: 최적화되지 않은 코드를 생성하는 빠른 컴파일러
  2. Crankshaft: 빠르고 최적화된 코드를 생성하는 느린 컴파일러

Full-codegen이 생성한 코드를 Crankshaft가 검사하여, 최적화가 필요하다고 판단되면 코드를 변경한다. 이를 ‘crankshafting’이라고 한다.

재미있는 사실: crankshaft는 자동차 공업에서 사용되는 내연기관 엔진의 필수적인 부품이다. 고성능의 자동차에 사용되는 이런 타입의 유명한 엔진이 V8이다.

컴파일 과정에서 머신 코드가 생성이 되면, 엔진은 ECMA 표준에 명시된 모든 데이터 타입, 연산자, 객체, 함수들을 추출하여, 브라우저나 런타임(NativeScript와 같은)이 사용할 수 있도록 한다.

이외에는 어떤 자바스크립트 엔진이 있을까?

당신의 클라이어트 코드를 분석하고 실행할 수 있는 자바스크립트 엔진은 어지러울 만큼 많이 있다. 각각의 브라우저 버전이 릴리즈 될때마다 자바스크립트 엔진은 최신 코드를 실행할 수 있는 상태를 유지하기 위해 변경되고 최적화 될 것이다.

이들 엔진에 붙여진 이름들로 의해 혼란을 겪기 전에, 그 밑바탕에는 많은 마케팅 압력이 있다는 사실을 알아두면 좋을 것이다. 자바스크립트 컴파일러에 대한 이 유익한 분석글에서, 저자는 빈정대듯이 이야기한다. “참고로 얘기하자면, 컴파일러는 대략 37% 정도의 마케팅으로 구성되며, 이를 위해 할 수 있는 몇 안되는 방법 중 하나는 브랜딩을 다시 하는 것이다. 그래서 이런 이름들이 줄줄이 생겨난다. SquirrelFish, Nitro, SFX, Nitro Extreme…”

네이밍에 마케팅이 강한 영향을 미친다는 것을 염두에 둔다면, 자바스크립트 엔진의 역사상 중요한 몇가지 이벤트들에 대해 주목하는 것은 유용할 것이다. 내가 간단한 표를 만들어 보았다.

Browser, Headless Browser, or Runtime JavaScript Engine
Mozilla Spidermonkey
Chrome V8
Safari** JavaScriptCore*
IE and Edge Chakra
PhantomJS JavaScriptCore
HTMLUnit Rhino
TrifleJS V8
Node.js*** V8
Io.js*** V8

*JavaScriptCore는 SquirrelFish로 재작성되었고, Nitro라고도 불리는 SquirrelFish Extreme으로 다시 브랜딩되었다. 하지만 JavaScriptCore를 WebKit의 구현(Safari와 같은)에 내장된 엔진이라고 부르는 건 여전히 타당하다.

**iOS 개발자들은 Mobile Safari가 Nitro를 기반으로 하지만 UIWebView는 JIT 컴파일을 포함하고 있지 않기 때문에 더 느리다는 것을 꼭 알아야 한다. 하지만 iOS8에서는 개발자들이 Nitro에 접근할 수 있는 WKWebView를 사용할 수 있으며, 이를 통해 상당한 속도향상을 경험할 수 있다. 하이브리드 모바일 앱 개발자들은 약간 더 숨통이 트일 수 있을 것이다.

***io.js가 Nods.js로부터 갈라져 나오기로 결정한 이유중의 하나는 프로젝트에 의해 지원될 V8버전과 관계가 있다. 여기서 정리된 것 처럼, 이 문제는 진행중이다.

왜 관심을 가져야 할까?

자바스크립트 엔진의 코드 해석 및 실행 프로세스의 목표는 가능한한 짧은 시간내에 가장 최적화된 코드를 생성하는 것이다.

요점은, 이들 엔진의 발전이 웹과 모바일 분야를 최대한 고성능으로 발전시키려는 우리의 목표와 부합한다는 것이다. 이 발전을 따라가기 위해, arewefastyet.com의 벤치마킹 그래프 등에서 엔진별 성능이 얼마나 다양한 지를 확인할 수 있다. 예를 들면, V8로 구동될 때와 Crankshafted 없는 엔진으로 구동될 때의 Chrome의 성능차이는 흥미롭다.

arewefastyet.jpg

어떤 개발자든, 우리가 열심히 노력해서 생성하고, 디버그하고, 관리하는 코드를 표현해주는 브라우저의 내부에 있는 차이에 대해 알고 있어야 한다. 왜 특정한 스크립트는 어떤 브라우저에서는 느리고, 다른 브라우저에서는 더 빠를까?

마찬가지로 모바일 개발자들, 특히 웹뷰를 사용해 컨텐츠를 표시하거나 NativeScript등의 런타임을 사용하는 하이브리드 모바일 앱을 만드는 개발자들은, 그들의 자바스크립트 코드를 어떤 엔진이 해석하고 있는지 알고 싶을 것이다. 모바일 웹 개발자들은 그들의 작은 디바이스에 있는 다양한 브라우저들의 내재된 한계와 가능성에 대해 알아야만 한다. 자바스크립트 엔진의 변화를 놓지지 않고 따라간다면 당신이 웹, 모바일, 앱 개발자로서 발전하는 데에 큰 도움이 될 것이다.