JavaScript Array Comprehension

Python은 쉽게 리스트를 만들 수 있는 방법으로 list comprehension을 제공합니다.

0에서 10까지 제곱의 리스트는 list comprehension으로 다음과 같이 표현할 수 있습니다. 여기서 range(10)[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 리스트를 리턴하고, 전체 식은 각 원소 x를 제곱(x**2)하여 새로운 리스트를 리턴합니다.

>>> [x**2 for x in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

리스트 [1,2,3][3,1,4]에서 각각 xy를 뽑아 xy가 같지 않으면 튜플 (x, y)의 리스트를 리턴하는 것도 다음와 같이 간결히 표현할 수 있습니다.

>>> [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
[(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]

Python은 언어 자체가 for, in, if 등의 키워드를 인지하고, 위와 같이 list comprehension을 이용해 리스트를 쉽게 생성, 조작할 수 있게 해줍니다.

JavaScript의 배열(array)를 이용해서 비슷한 일을 할 수 없을까요? 물론 JavaScript는 comprehension을 위한 특별한 문법을 제공하지 않습니다. 하지만 고차함수를 이용하여 Python의 list comprehension과 비슷한 array comprehension을 만들 수 있습니다.

일단 편의를 위해 배열에 대해 여러 고차함수를 제공하는 lodash 라이브러리를 사용하겠습니다.

lodash는 여러 고차함수를 제공하지만 array comprehension을 구현하기 위해서는 다음 두 함수를 추가할 필요가 있습니다. lodash의 mixin() 함수를 이용해 다음과 같이 추가합니다. (참고로 이 함수들은 lodash-contrib에서 가져왔습니다.)

var concat = Array.prototype.concat;
_.mixin({
  // Concatenates one or more arrays given as arguments.  If given objects and
  // scalars as arguments `cat` will plop them down in place in the result
  // array.  If given an `arguments` object, `cat` will treat it like an array
  // and concatenate it likewise.
  cat: function() {
    return _.reduce(arguments, function(acc, elem) {
      if (_.isArguments(elem)) {
        return concat.call(acc, slice.call(elem));
      }
      else {
        return concat.call(acc, elem);
      }
    }, []);
  },

  // Maps a function over an array and concatenates all of the results.
  mapcat: function(array, fun) {
    return _.cat.apply(null, _.map(array, fun));
  },
});

여기서 cat() 함수는 여러 개의 배열을 인자로 받아 하나의 배열로 합쳐줍니다.

var s1 = _.cat([1,2,3], [4], [5,[6]]);
console.log(s2);
// [ 1, 2, 3, 4, 5, [ 6 ] ]

다음으로 mapcat() 함수는 배열의 각 원소 x에 대해 함수 f를 호출하여 결과로 리턴된 배열을 합쳐서 리턴합니다. 여기서 함수 f는 원소를 인자로 받아 새로운 배열을 리턴하는 함수입니다.

var s2 = _.mapcat([1,2,3], function (x) { return [x, x]; });
console.log(s2);
// [ 1, 1, 2, 2, 3, 3 ]

map(), filter(), mapcat() 함수만 있으면 Python의 list comprehension을 다음과 같이 기계적으로 만들어낼 수 있습니다.

// >>> [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
// [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]

var r1 = _.mapcat([1, 2, 3], function (x) {
  return _.map(_.filter([3, 1, 4], function (y) {
    return x !== y;
  }), function (y) {
      return [x,y]; // Use an array to create a tuple.
  });
});
console.log(r1);

규칙을 눈치 채셨나요? for가 한 번만 나오면 map() 함수로, for가 2번 이상 나오면 mapcat()을 사용하다가 가장 마지막에만 map()을 씁니다. 그리고 if 조건이 나오면 적절히 filter를 삽입해 주면 됩니다. (참고로 여기서 filter는 eager evaluation하기 때문에 불필요하게 두 번 루프를 돌아서 비효율적인 면이 있습니다. lazy evaluation하는 방법으로 성능 개선이 가능합니다만, 설명이 너무 복잡해져서 이 부분은 생략합니다.)

번역 규칙을 간단히 정리하면 다음과 같습니다.

* [x+1 for x in xs] ≡

_.map(xs, function (x) { return x+1; });

* [x+1 for x in xs if x != 0] ≡

_.map(_.filter(xs, function (x) { return x !== 0; }),
      function (x) { return x+1; });

* [(x, y) for x in xs for y in ys] =

_.mapcat(xs, function (x) { 
  return _.map(ys, function (y) { 
    return [x, y]; 
  }); 
});

다음은 array comprehension을 이용해 행렬을 transpose하는 코드입니다.

/*
>>> matrix = [
...     [1, 2, 3, 4],
...     [5, 6, 7, 8],
...     [9, 10, 11, 12],
... ]

>>> [[row[i] for row in matrix] for i in range(4)]
[[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]]
*/

var matrix = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]];
var r2 = _.mapcat(_.range(4), function (i) {
  return [_.map(matrix, function (row) {
    return row[i];
  })];
});
console.log(r2);

JavaScript 함수: 스코핑, 클로저

오늘은 JavaScript 함수의 두 가지 대표적인 특징인 스코핑 규칙과 클로저에 대해 설명합니다.

스코핑(scoping)

JavaScript는 정적 스코핑(static/lexical scoping)하는 언어입니다. 초기 Lisp을 제외하고 동적 스코핑(dynamic scoping)하는 언어는 거의 없기 때문에 “정적” 스코핑이란 말은 크게 의미가 없습니다. 다만, 다른 언어와 달리 블록 스코핑(blocking scoping)이 아닌 함수 스코핑(function scoping)이라는 점이 특이합니다.

예를 들어, 아래 foo() 함수 호출은 x의 값을 리턴하는데, x 값은 1이 아닌 2가 리턴됩니다. 블록 안에서 새로 변수 x를 선언하고 2를 할당했지만, 자바스크립트는 함수 스코핑이기 때문에 함수 foo()x 변수는 단 하나만 존재합니다. 따라서 var x=2는 새로운 변수를 선언이 아니라 단순히 x=2라는 할당문으로 취급됩니다.

function foo() {
    var x = 1;
    {
        var x = 2;
    }
    return x;
}

var x = foo();
console.log(x);
// 2

함수 스코핑이 블록 스코핑에 비해 가지는 장점은 없습니다. 의도한 설계라기 보다는 초기 프로그래밍 언어 설계 오류라고 보는 편이 맞습니다. 실제로 ES6에서는 이 문제를 바로 잡기 위해 let 키워드를 이용한 블록 스코핑을 새로 도입하였습니다. 아래 foo_() 함수는 var 키워드를 let으로 바꾸었을 뿐인데, 1이 리턴되는 것을 확인할 수 있습니다.

function foo_() {
    let x = 1;
    {
        let x = 2;
    }
    return x;
}

var x_ = foo_();
console.log(x_);
// 1

ES6는 이전 버전 JavaScript와 호환성 때문에 varlet 키워드를 모두 허용하지만, 새로 작성되는 코드는 모두 let으로 작성하는 것이 좋습니다.

클로저

JavaScript 함수는 1등 시민(first-class citizen)이기 때문에 string이나 number와 마찬가지로 함수의 인자로 넘기거나, 함수의 리턴값으로 돌려주거나, 변수에 저장하는 등의 일이 가능합니다. 따라서 함수 안에서 numberstring을 생성하고 사용할 수 있는 것처럼 함수 안에서 새로운 함수를 선언하고 사용하는 것도 가능합니다.

function makeAdder() {
    function add(a, b) {
        return a + b;
    }
    return add;
}

var adder = makeAdder();
console.log(adder(1, 2));
// 3

위의 makeAdder() 함수를 호출하면 새로운 함수 add()를 리턴하는데, 이 함수는 인자 a, b를 받아서 합을 리턴하는 함수입니다. 호출 결과를 변수 adder에 저장하면 adder(1,2)처럼 함수 호출도 가능함을 확인할 수 있습니다. 이 경우, 함수 add()makeAdder() 안에 선언되어 있기 때문에 함수 밖에서 접근할 수 없다는 점을 제외하면 최상단에 정의한 함수와 다를 바가 없습니다.

JavaScript는 여기서 한발 더 나아갑니다. 아래 makeAdder() 함수는 인자 a를 받아서 a를 더해주는 함수 addA를 리턴합니다. 여기서 aaddA()에 선언된 변수가 아니라 makeAdder()에 선언된 변수라는 점이 특별합니다. JavaScript 함수는 해당 함수에 참조하고자 하는 변수가 없으면 여기서 포기하지 않고 바깥 함수(enclosing function)들에 있는 변수를 찾는 것입니다.

function makeAdder(a) {
    function addA(b) {
        return a + b;
    }
    return addA;
}

var add1 = makeAdder(1);
console.log(add1(2));
// 3

따라서 makeAdder(1)이 리턴하는 함수는 인자 b를 받아서 1을 더해주는 함수가 됩니다. addA()에서 참조한 변수 amakeAdder() 함수가 리턴된 후에도 addA()에서 사용할 수 있기 때문에 가비지 콜렉션되지 않도록 JavaScript 런타임에 처리를 해줍니다.

우리는 보통 함수를 코드로만 생각합니다. 데이터는 함수 호출 시 인자로 넘기는 것이고 함수 자체는 순수한 코드라고 생각하는 경향이 있습니다. 하지만 위 예에서 볼 수 있듯이 JavaScript 함수는 단순히 코드가 아니라 코드+데이터입니다. addA() 함수는 덧셈을 하는 코드도 가지고 있지만, a의 값인 데이터도 가지고 있기 때문입니다. 이렇게 코드와 데이터를 모두 가지고 있는 함수를 클로저(closure)라고 부릅니다.

클로저가 강력한 이유는 “코드+데이터 = 프로그램”이기 때문입니다. 즉, 클로저만 있으면 모든 프로그램을 표현할 수 있다는 뜻입니다. 오브젝트도 마찬가지입니다. “오브젝트 = 코드+데이터”이고, “코드+데이터 = 클로저”이므로 “오브젝트 = 클로저”라는 공식이 성립합니다.

실제로 JavaScript에서는 클로저를 이용해 오브젝트를 만드는 일이 흔합니다. 클로저를 이용해 counter 오브젝트를 만들면 다음과 같습니다. (이 코드는 MDN 클로저 페이지에서 차용했습니다.) changeBy(), increment(), decrement(), value() 함수 모두 privateCounter 변수에 접근하는 클로저임을 확인할 수 있습니다.

var counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  };
})();

JavaScript Reduce 함수

자바스크립트에서 고차함수(higher-order function)하면 항상 언급되는 함수가 forEach, map, reduce입니다. 그 외에도 every, some, filter와 같은 함수들도 자주 사용됩니다.

이들 고차 함수의 공통점은 함수의 인자로 함수를 받거나 함수를 리턴한다는 점입니다. filter를 예로 들면, 배열의 각 원소에 대해 callback 함수를 호출하여 true가 리턴되는 경우에만 해당 인자를 리턴 값의 배열에 포함시킵니다. 아래 예제 코드를 보면, isBigEnough 함수를 통과하는 값(10보다 크거나 같은 값)만 리턴 값에 포함되는 것을 확인할 수 있습니다.

function isBigEnough(value) {
  return value >= 10;
}
var filtered = [12, 5, 8, 130, 44].filter(isBigEnough);
// filtered is [12, 130, 44]

이런 고차함수는 아주 작은 수준의 코드 재활용으로 생각할 수 있습니다. 만약 filter라는 함수가 없었다면, 우리는 필터링 조건이 달라질 때마다 새로운 함수를 작성했을 것입니다. 예를 들어, 아래 posneg 두 함수는 배열을 인자로 받아 각각 0보다 큰 숫자, 0보다 작은 숫자만 리턴하는 함수입니다.

function pos(arr)
{
    var ret = [];
    for (var i = 0; i < arr.length; i++) {
        if (arr[i] > 0) {
            ret.push(arr[i]);
        }
    }
    return ret;
}

function neg(arr)
{
    var ret = [];
    for (var i = 0; i < arr.length; i++) {
        if (arr[i] < 0) {
            ret.push(arr[i]);
        }
    }
    return ret;
}

var xs = [0, -1, 2, 3, -4, 5];
console.log(pos(xs));
console.log(neg(xs));

// [ 2, 3, 5 ]
// [ -1, -4 ]

위 두 함수는 if 문 안의 조건을 제외하고는 사실상 모든 코드가 동일합니다. 따라서 이 부분만 인자로 빼내면 filter와 유사한 범용적인 함수가 나오게 됩니다. 그리고 이렇게 일단 filter 함수가 생기면 pos, neg 함수는 filter 함수를 재활용해서 쉽게 작성할 수 있게 됩니다.

function myfilter(arr, predicate) {
    var ret = [];
    for (var i = 0; i < arr.length; i++) {
        if (predicate(arr[i])) {
            ret.push(arr[i]);
        }
    }
    return ret;
}

function pos(arr) {
    return myfilter(arr, function (x) { return x > 0; });
}

function neg(arr) {
    return myfilter(arr, function (x) { return x < 0; });
}

var xs = [0, -1, 2, 3, -4, 5];
console.log(pos(xs));
console.log(neg(xs));

재미있는 건 재활용이 여기서 그치지 않습니다. posneg 함수가 서로 동일한 구조를 공유하고 있었기 때문에 filter라는 함수를 뽑아낼 수 있었던 것처럼 filter를 포함한 다른 고차함수들도 비슷한 구조를 공유하고 있기 때문에 공통점을 다시 뽑아낼 수 있습니다.

여기서 등장하는 함수가 reduce 함수입니다. reduce는 맥가이버칼처럼 여러 고차함수를 만들어낼 수 있는 표현력을 가지고 있습니다. reduce 함수는 배열의 각 원소를 돌면서 콜백 함수를 반복적으로 적용하여 하나 값을 뽑아내는 함수입니다. 아래 예제는, 배열을 돌면서 각 원소 값의 총합을 리턴하는 코드입니다.

function add(acc, value) {
  return acc + value;
}
[1, 2, 3, 4, 5].reduce(add, 0);

이 코드의 실행 결과는 add(add(add(add(add(0, 1), 2), 3), 4), 5)와 같습니다. reduce의 두 번째 인자가 acc의 초기값이 되고, 이후 각 원소를 덧셈한 결과가 계속 acc에 누적되어 넘어가는 방식입니다. 이 과정을 그림으로 표현하면 다음과 같습니다. 참고로 JavaScript의 reduce 함수는 사실 함수 프로그래밍에서는 foldl이라는 함수명으로 더 잘 알려져 있습니다.

얼핏 보기에 reduce 함수와 filter 함수는 크게 공통점이 없어 보입니다. filter 함수는 배열을 받아서 다른 배열을 리턴하는데, reduce 함수는 배열을 받아서 원소를 리턴하는 것처럼 보이기 때문입니다. 하지만 reduce 함수를 이용해 다음과 같이 filter 함수를 구현할 수 있습니다. acc에 빈 배열 []을 넘기고 predicate(x)가 참이면 해당 원소를 acc에 추가하면 최종적으로 predicate(x)를 만족하는 원소들을 리턴하게 됩니다.

function myfilter(arr, predicate) {
    return arr.reduce(function (xs, x) {
        if (predicate(x))
            xs.push(x);
        return xs;
    }, []);
}

비슷한 방식으로 map 함수도 구현할 수 있습니다.

function mymap(arr, f) {
    return arr.reduce(function (xs, x) {
        xs.push(f(x));
        return xs;
    }, []);
}

filtermap은 별개의 함수처럼 보이지만, reduce 함수를 통해 공통점을 뽑아낼 수 있는 수준의 유사점도 가지고 있음을 확인할 수 있습니다. 두 함수 모두 배열이라는 데이터 구조를 돌면서 각 원소에 대해 어떤 처리를 하고 결과값을 리턴합니다. filter는 각 원소가 predicate를 통과하는지 확인하고, 통과하는 원소들만 다시 배열로 리턴하는 반면 map은 각 원소에 함수 f를 적용하고 결과값을 조건 없이 배열로 리턴한다는 차이점이 있지만, 결과적으로 배열이라는 데이터 구조를 돌면서 결과값을 만들어내는 방식 자체는 동일한 셈입니다.

바꿔 말해, 배열로 뭔가를 해야 하는 거의 모든 함수는 reduce 함수 (혹은 reduceRight 함수)로 만들어 낼 수 있다는 뜻입니다. filtermap 외에도 underscore.js가 제공하는 대부분의 고차 함수를 reduce로 만들어 낼 수 있습니다.

fold 함수의 표현력이 궁금하신 분은 Graham Hutton 교수의 논문 A tutorial on the universality and expressiveness of fold을 읽어보시기 바랍니다.

JavaScript 함수로 할 수 있는 일들

JavaScript 바로 알기에서 JavaScript가 많은 단점에도 불구하고 살아남은 이유는 JavaScript의 함수가 first-class이기 때문이라고 말씀드렸습니다. 비유를 하자면, first-class 함수는 무엇이든 만들어낼 수 있는 줄기 세포와 같아서 JavaScript의 부족한 부분을 채울 수 있었기 때문입니다.

블록 범위(block scoping)

아래 코드를 실행하면 콘솔에 1이 찍힐까요, 아니면 2가 찍힐까요?

var a = 1;
{
    var a = 2;
}
console.log(a);

일반적인 기대와는 다르게 JavaScript는 콘솔에 2를 출력합니다. { } 블록이 Java나 C#의 블록 범위를 연상시키지만, JavaScript는 이런 일반적인 기대와 달리 함수 범위(function scoping)만 지원하기 때문에 같은 함수 내에서는 블록과 상관 없이 변수의 범위가 하나만 존재합니다.

하지만 실망할 필요는 없습니다. 다음과 같이 JavaScript의 익명 함수(anonymous function)를 정의한 후에 곧바로 호출하면 var a = 2가 이 익명 함수 범위에 정의되므로 사실상 블록 범위를 지정한 것과 같은 효과를 낼 수 있습니다.

var a = 1;
(function ()
{
    var a = 2;
})();
console.log(a);

참고로 이 문제는 ES6에 let 키워드가 추가되면서 해결되었습니다.

비공개 필드(private field)

JavaScript는 Python, Ruby 등과 마찬가지로 동적 타이핑하는 언어이고, 동적 타이핑하는 언어는 일반적으로 메소드나 필드에 대한 접근 제한자(access modifier)를 제공하지 않습니다. 바꿔 말해, 모든 메소드나 필드가 공개(public)됩니다.

하지만 JavaScript에서는 함수를 이용하여 비공개 필드(private field)를 만들어 낼 수 있습니다. 생성자(constructor) 함수에서 지역 변수를 선언하고, 클로저(closure)로 정의한 메소드가 참조하게 만드는 방법입니다.

function Counter(init)
{
  var count = 0;
  this.inc = function () {
    count++;
    console.log(count);
  };
  this.dec = function () {
    count--;
    console.log(count);
  };
}

var c = new Counter();
c.inc();
c.dec();

위 예제에서 count 변수는 로컬 변수이기 때문에 Counter 함수가 리턴되고 나면 더 이상 접근이 불가능하지만, incdec 메소드에서 참조하고 있기 때문에 두 메소드에서는 이후에도 계속 접근이 가능합니다. inc, dec 메소드 외에는 이 변수에 접근할 방법이 없으므로 비공개 필드와 똑같은 효과를 냅니다.

참고로 이 문제는 ES6 Symbol 도입으로 해결하려고 했으나 아직 엄밀한 의미의 비공개 필드는 제공하지 않고 있습니다.

클래스

JavaScript는 일반적인 객체지향 프로그래밍 언어와 달리 프로토타입이라는 특이한 상속 모델을 가지고 있는 프로그래밍 언어입니다. 클래스 상속과 프로토타입 상속의 장단점을 차치하고, 일단 대부분의 개발자들에게 익숙치 않은 방식이라는 측면에서 프로토타입 상속 방식은 사실상 실패했다고 볼 수 있습니다. 다행인 것은 함수를 이용해 클래스 방식의 상속을 만들어낼 수 있다는 점입니다.

var __extends = this.__extends || function (d, b) {
    for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
    function __() { this.constructor = d; }
    __.prototype = b.prototype;
    d.prototype = new __();
};
var Animal = (function () {
    function Animal(name) {
        this.name = name;
    }
    Animal.prototype.move = function (meters) {
        alert(this.name + " moved " + meters + "m.");
    };
    return Animal;
})();
var Snake = (function (_super) {
    __extends(Snake, _super);
    function Snake(name) {
        _super.call(this, name);
    }
    Snake.prototype.move = function () {
        alert("Slithering...");
        _super.prototype.move.call(this, 5);
    };
    return Snake;
})(Animal);

ES6에서는 class 지원이 포함되었기 때문에 위 코드는 아래와 같이 일반적인 객체지향 프로그래밍 스타일로 표현할 수 있습니다. 하지만 ES6에 class 지원이 공식적으로 포함되기 전에도 JavaScript 개발자는 위와 같이 first-class 함수를 이용해 이미 클래스 기반의 상속 메커니즘을 사용하고 있었습니다.

class Animal {
    constructor(public name: string) { }
    move(meters: number) {
        alert(this.name + " moved " + meters + "m.");
    }
}

class Snake extends Animal {
    constructor(name: string) { super(name); }
    move() {
        alert("Slithering...");
        super.move(5);
    }
}

정리

JavaScript는 1999년에 제정된 ES3 이후로 거의 10년이 넘게 새로운 표준을 제정하지 못하고 표류했습니다. 일반적인 프로그래밍 언어라면 이미 사장되고도 남을 충분한 시간이었습니다. 하지만 웹브라우저에서 사용할 수 있는 마땅한 대안 언어가 없었고, 다행히 JavaScript가 first-class 함수를 지원한 덕분에 언어의 부족한 면을 이 글에서 소개한 다양한 기술들을 이용해 메꾸어 왔습니다. ES 5, 6, 7 표준화를 통해 이런 부분들이 채워지면 위에서 소개한 방법들도 추억 속으로 사라지겠지만, 우리가 여전히 기억해야할 것은 “(first-class) 함수는 많은 것을 할 수 있다”라는 사실입니다.

자바스크립트의 약속(Promise): 3부 관심사의 분리

지난 2부에서는 JSON 파일을 읽어 domain key 값을 다시 파일에 쓰는 코드를 sync, callback, promise, async function 총 4가지 버전으로 작성해 보았습니다.

이 과정에서 알게 된 사실은 callback을 제외한 sync, promise, async function은 코드의 구조가 거의 똑같다는 사실입니다. 그래서 promise는 단순히 스타일이 다른 비동기 프로그래밍 방식이 아니라 코드를 좀 더 sync하게 작성하기 위한 방법이고, async function으로 넘어가기 위한 중간 단계라고 설명했습니다.

2부에서 살펴본 promise 예제를 다시 한 번 살펴보겠습니다.

var fs = require('fs');
var Promise = require('promise');
 
var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);
 
readFile('config.json')
  .then(function (text) {
    try {
      var obj = JSON.parse(text);
      writeFile('domain.txt', obj.domain).then(function () {
        console.log("Done");
      }).catch(function (reason) {
        console.error('Error while writing domain text file');
      });
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  }).catch(function (reason) {
    console.error('Error while reading config file');
  });

이 코드는 sync 코드를 그대로 옮겼기 때문에 try/catch 블록과 catch() 함수가 같이 사용되어 조금 복잡해 보입니다. 코드를 좀 더 간결하게 만들기 위해 다음과 같이 에러 처리를 한 곳으로 모으고, then()을 중첩해서 부르는 대신 chaining하도록 바꾸겠습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    var obj = JSON.parse(text);
    return writeFile('domain.txt', obj.domain);
  }).then(function () {
    console.log("Done");
  }).catch(function (reason) {
    if (reason.code === 'EACCES')
      console.error('Error while writing domain text file');
    else if (reason.code === 'ENOENT')
      console.error('Error while reading config file');
    else
      console.error('Invalid JSON in file');
  });

이 코드를 다시 sync 코드로 바꾸면 다음과 같습니다.

var fs = require('fs');

try {
  var text = fs.readFileSync('config.json');
  var obj = JSON.parse(text);
  fs.writeFileSync('domain.txt', obj.domain);
  console.log("Done");
} catch (reason) {
  if (reason.code === 'EACCES')
    console.error('Error while writing domain text file');
  else if (reason.code === 'ENOENT')
    console.error('Error while reading config file');
  else
    console.error('Invalid JSON in file');
}

이 두 코드를 비교하면 then의 의미가 좀 더 명확해집니다. sync 코드에서는 암묵적으로 코드가 한줄씩 실행된다고 가정하고, 다음줄을 실행할 때 이전 계산값이 이미 존재한다고 가정합니다. async 코드에서는 이런 보장이 없기 때문에 then()을 명시적으로 사용해 이전 줄의 실행 결과가 다음 줄을 실행할 때 필요하다고 표시합니다.

sync와 async의 차이점은 ‘a’가 있냐 없냐이고, 사실 종이 한 장 차이입니다. 코드의 로직은 전혀 차이가 없고, 단순히 동기냐 비동기냐의 차이만 있기 때문에 실제 코드도 거의 차이가 없어야 당연합니다. 하지만 node.js의 콜백 스타일은 ‘비동기’라는 단 하나의 걱정거리 때문에 코드 스타일이 완전히 달라졌기 때문에 좋지 않은 프로그래밍 스타일이라고 이야기할 수 있습니다.

then()함수의 의미는 동기/비동기의 차이를 하나의 함수로 캡슐화(encapsulation)했다는데 있습니다. promise를 이용해 작성한 코드는 then()의 구현체만 바꾸면 코드 변경 없이 같은 코드를 비동기가 아닌 동기로 구동할 수도 있습니다. 즉, 관심사의 분리(Separation of Concerns)라는 측면에서 promise는 “비동기 프로그래밍”이라는 관심사를 분리해냈다고 이해할 수도 있습니다.

자바스크립트의 약속(Promise): 2부 비교

1부에서 ES 6의 Promise 도입 배경에 대해 의문을 제기했습니다. 이미 웹브라우저나 node.js에서 콜백 스타일을 많이 쓰고 있음에도 불구하고 자바스크립트 표준화 단체인 Ecma는 Promise라는 새로운 비동기 프로그래밍 방식을 표준으로 채택했기 때문입니다.

ES 6 Promise를 설명하는 대부분의 글들은 기존 콜백 스타일에 비해 ES 6가 가지는 장점을 설명하고 있습니다. 일례로, ECMAScript 6 promises (1/2): foundations라는 글은 콜백의 단점을 다음과 같이 정리하고 있습니다.

* Error handling becomes more complicated: There are now two ways in which errors are reported – via callbacks and via exceptions. You have to be careful to combine both properly.

* Less elegant signatures: In synchronous functions, there is a clear separation of concerns between input (parameters) and output (function result). In asynchronous functions that use callbacks, these concerns are mixed: the function result doesn’t matter and some parameters are used for input, others for output.

* Composition is more complicated: Because the concern “output” shows up in the parameters, it is more complicated to compose code via combinators.

  • 에러 처리가 더 복잡해진다. 에러가 보고되는 방식이 콜백과 예외 두 가지가 된다. 두 가지를 모두 적절하게 처리하기 위해서 주의를 기울여야 한다.
  • 시그너처가 아름답지 못하다. 동기 함수에서는 입력(인자)과 출력(함수 결과) 사이의 명확한 구분이 존재한다. 콜백을 쓰는 함수에서는 입력과 출력이 섞여 있다. 함수의 결과는 중요하지 않고, 일부 인자는 입력에 나머지 인자는 출력에 사용된다.
  • 합성이 더 어렵다. 출력이 인자로 표현되기 때문에, 컴비네이터를 통해 합성하는 게 더 복잡해진다.

물론 다 일리가 있는 설명이긴 하지만, 이런 몇 가지 장점만으로 이미 JavaScript 개발자들에게 익숙한 콜백 방식을 버리고 Promise 방식을 채택했다는 사실은 여전히 쉽게 납득하긴 어렵습니다.

사실 이해가 어려운 이유는 비교 대상에 있습니다. 콜백과 Promise를 비교할 것이 아니라 동기 프로그래밍 모델과 Promise를 비교해보면 콜백 대비 Promise의 장점이 명확히 보이기 때문입니다.

이해를 돕기 위해 간단한 예제로 시작하겠습니다. 아래 node.js 프로그램은 config.json 파일을 읽어 domain 키의 값을 domain.txt 파일에 쓰는 간단한 프로그램입니다.

var fs = require('fs');

try {
  var text = fs.readFileSync('config.json');
  try {
    var obj = JSON.parse(text);
    try {
      fs.writeFileSync('domain.txt', obj.domain);
      console.log("Done");
    } catch (e) {
      console.error('Error while writing domain text file');
    }
  } catch (e) {
    console.error('Invalid JSON in file');
  }
} catch (e) {
  console.error('Error while reading config file');
}

위 프로그램은 fs 모듈이 제공하는 readFile, writeFile 함수 대신에 readFileSync, writeFileSync 함수를 써서 우리게에 익숙한 동기 프로그래밍 방식으로 작성했습니다. 함수의 입력과 출력이 명확히 분리되어 있고, 모든 예외 처리는 try/catch 블록을 통해 하기 때문에 정상적인 실행 경로와 예외 처리 경로도 명확히 구분되어 있습니다. var obj = JSON.parse(text)에서 볼 수 있는 것처럼 한 함수의 출력을 다른 함수의 입력으로 넘기는 것도 간단합니다.

이 프로그램을 node.js의 콜백 방식으로 다시 작성해보면 다음과 같습니다.

var fs = require('fs');

fs.readFile('config.json',
  function (error, text) {
    if (error) {
      console.error('Error while reading config file');
    } else {
      try {
        var obj = JSON.parse(text);
        fs.writeFile('domain.txt', obj.domain, function (err) {
          if (err)    
            console.error('Error while writing domain text file');
          else
            console.log("Done");
        });
      } catch (e) {
        console.error('Invalid JSON in file');
      }
    }
  });

이 프로그램은 앞서 이야기한 장점이 상당 부분 사라졌습니다. 함수 인자에 입력과 출력이 모두 표현되고 있어, 더 이상 한 함수의 출력을 다른 함수의 입력으로 넘기는 것이 쉽지 않고, 예외 처리도 에러 콜백과 try/catch 블록 양쪽에서 모두 이루어지기 때문에 복잡해졌습니다.

자 그럼 콜백 예제는 잊고 이번에는 Promise로 짠 프로그램을 살펴보겠습니다. 참고로 node.js의 콜백 함수를 Promise 스타일로 바꾸기 위해 node의 promise 모듈이 제공하는 denodeify 함수를 사용하였습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    try {
      var obj = JSON.parse(text);
      writeFile('domain.txt', obj.domain).then(function ()
      {
        console.log("Done");
      }).catch(function (reason) {
        console.error('Error while writing domain text file');
      });
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  }).catch(function (reason) {
    console.error('Error while reading config file');
  });

이 프로그램을 콜백 방식이 아닌 동기 프로그래밍 예제와 비교해보시기 바랍니다. 뭔가 느껴지시나요? 네. 이 프로그램의 전체적인 구조는 동기 프로그래밍 예제와 상당히 닮아 있습니다. 물론 try/catch 블록 대신 catch 함수를 사용하거나 함수의 결과가 리턴값이 아닌 then 함수를 통해 넘어오긴 하지만 콜백 방식에 비해 동기 프로그래밍에 훨씬 가깝다는 사실을 느낄 수 있습니다.

이번에는 이 프로그램을 다시 ES 7 async 함수를 사용하도록 바꿔보겠습니다. (참고로 이 코드는 async 함수 지원이 필요하므로, 아직 node.js에서 동작하지 않습니다.)

async function () {
  try {
    var text = await readFile('config.json');
    try {
      var obj = JSON.parse(text);
      try {
        await writeFile('domain.txt', obj.domain);
        console.log("Done");
      } catch (e) {
        console.error('Error while writing domain text file');
      }
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  } catch(e) {
    console.error('Error while reading config file');
  }
}();

async, await 키워드만 제외하면, 이 프로그램은 사실상 동기 프로그래밍과 똑같은 걸 확인할 수 있습니다. 앞서 디슈가링(desugaring)이라는 글에서 프로그래밍 언어의 새로운 기능을 이해하기 위해서는 새로운 기능이 기존 언어로 어떻게 표현되는지를 알면 된다고 말씀드렸는데, ES 7 async 함수는 디슈가링을 통해 ES 6 Promise로 표현됩니다. async 함수는 then 함수를 통해 넘어가던 함수의 출력을 await 키워드를 이용해 함수의 출력으로 바꿔주는 것입니다.

정리하면, ES 6 Promise는 기존 콜백 스타일에 비해 더 나은 비동기 프로그래밍을 제공할 뿐만 아니라 ES 7에서 준비하고 있는 async 함수를 제공하기 위한 준비 단계이기도 합니다. 다시 말해, Promise는 단순히 더 나은 비동기 프로그래밍 스타일이 아니라 불편한 비동기 프로그래밍 방식을 버리고, 다시 편리한 동기 프로그래밍 방식으로 돌아가기 위한 중간 과정으로 이해할 수 있습니다.

JavaScript 바로 알기

JavaScript는 세상에서 가장 많이 쓰이는 언어 중 하나입니다. “시작은 미약하였으나 끝은 창대하리라”는 말이 딱 들어맞는 언어가 JavaScript인 것 같습니다. 초기 웹브라우저의 간단한 스크립트 언어로 출발한 뭔가 어설픈 언어가 인터넷의 발전과 더불어 지금은 전세계에서 가장 많은 개발자를 확보하고 있는 메인스트림 언어가 되었으니 말입니다.

전 웹브라우저 개발을 하면서 2008-2010년에 JavaScript의 표준을 정하는 Ecma의 TC39 미팅에 정기적으로 참석을 했었습니다. 당시 모질라, 구글, 야후, MS, IBM, 오페라 등이 참여하여 10년 이상 정체된 JavaScript의 다음 버전을 논의하고 있었는데, 그렇게 정리되서 나온 것이 EcmaScript 5(이하 ES5)입니다. 얼마 전에 ES6가 나왔고, ES 7을 이미 논의하기 시작했으니 벌써 시간이 많이 흘렀습니다. 당시 저는 30초반의 경험 부족 엔지니어라 세계적인 대가인 Brendan Eich, Douglas Crockford, Mark Miller, Waldemar Horwat, Allen Wirfs-Brock 등과 같은 자리에서 이야기 나누고, 같이 밥 먹는 것만으로 신기했던 기억이 납니다.

Douglas Crockford가 JavaScript: The Good Parts에서 이야기한 것처럼 JavaScript는 좋은 점도, 나쁜 점도 많은 언어입니다. 하지만 좋은 점이 나쁜 점을 모두 상쇄하고도 남을 만큼 강력하였기 때문에 다른 언어로 대체되지 않고, 지금도 계속 발전하고 있다고 생각합니다.

제가 생각하는 JavaScript의 좋은 점은 “객체지향 프로그래밍”이 아닌 “함수형 프로그래밍”에 있습니다. Java나 C#이 람다(lambda)를 도입하기 훨씬 전에, JavaScript는 이미 함수를 인자로 넘기고, 리턴값으로 돌려주고, 변수에 저장할 수 있었습니다. JavaScript는 이 first-class 함수를 이용하여 JavaScript의 많은 단점(block scoping의 부재, class의 부재 등)을 극복해 냅니다. 그렇게 때문에 JavaScript를 바로 아는 것은 JavaScript에 숨어 있는 함수형 프로그래밍을 이해하고 활용하는 방법을 아는 것이 됩니다.

JavaScript는 게임 개발자와도 분리할 수 없는 언어입니다. Unity가 지원하는 언어 중 하나(Unity JavaScript)이기도 하고, node.js 프레임워크가 많이 사용되면서 게임 서버 프로그래밍에서도 중요한 언어가 되었기 때문입니다. 그래서 앞으로 “C# 바로 알기” 시리즈와 더불어서 “JavaScript 바로 알기” 시리즈를 블로그에 연재하려고 합니다. 많은 관심 부탁드립니다.