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;
    }
  };
})();
Advertisements

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) 함수는 많은 것을 할 수 있다”라는 사실입니다.

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 바로 알기” 시리즈를 블로그에 연재하려고 합니다. 많은 관심 부탁드립니다.

자바스크립트의 약속(Promise): 1부 의문점

앞서 게임 서버: node.js의 장점과 단점이라는 글에서 node.js의 문제점으로 불편한 비동기 프로그래밍 모델을 꼽았습니다. 글의 말미에 비동기 프로그래밍의 불편함을 극복하기 위한 노력으로 ES6의 Promise와 (아직 논의 중인) ES7의 async 함수에 대한 이야기를 잠깐 언급했었습니다. 이 시리즈에서는 ES6 Promise에 대해 좀 더 자세히 설명하겠습니다.

일단 한 가지 의문점으로 시작하겠습니다. 이미 웹브라우저나 node.js에서 모두 콜백 방식으로 API를 제공하고 있고, 자바스크립트 개발자라면 당연히 이런 방식에 익숙할 수밖에 없는데, 왜 ES6에서는 이와는 다른 Promise라는 스타일을 표준으로 만든 걸까요? 물론 PromiseJS, Q, RSVP, Bluebird, when.js 등의 자바스크립트 Promise 라이브러리가 많이 나와있지만, 일부 개발자만이 사용할뿐 대세와는 거리가 멀었던 게 사실입니다.

ES6 Promise를 소개하고 있는 글들은 대부분 ES6 Promise가 새로운 스타일의 비동기 프로그래밍 모델을 제공하고 있고, 기존의 콜백 방식에 비해서 더 좋다고 주장하고 있습니다. 하지만 ES6 Promise를 실제로 사용해 보신 분들은 이 지점에서 고개를 갸웃거릴 수밖에 없습니다. 기존 콜백 방식에서도 async 모듈 등을 사용해서 나름대로 불편함을 해결해왔기 때문에 말 그대로 “스타일의 차이” 외에 ES6 Promise 확실히 더 좋은 비동기 프로그래밍 모델이라고 주장할 근거가 약하기 때문입니다.

특히, HTML5 Rocks에 올라온 JavaScript Promises There and back again와 같은 글은 비교 방식에 문제가 있습니다. ES6 Promise에는 then(), map(), foreach() 등 sequencing과 parallelism을 표현하는 함수가 존재하고 콜백 방식에는 없는 것처럼 설명하고 있는데, 콜백 방식에서도 이미 async 모듈의 series, parallel, map 등을 사용해 같은 수준의 추상화를 하고 있기 때문입니다.

물론 ES6 Promise의 장점은 분명히 존재합니다. 특히, err 인자를 모든 함수에 넘기는 방식에 비해 ES6 Promise의 에러 처리 방식은 분명히 개선된 점이 있습니다. 하지만 ES6 Promise를 단순히 기존 콜백 방식의 문제점을 약간 개선한 새로운 비동기 프로그래밍 스타일 정도로 설명해서는 안 됩니다. ES6 Promise 꿈은 더 원대합니다. ES6 Promise는 불편한 “비동기 프로그래밍” 세상을 떠나 다시 우리가 살던 낙원인 “동기 프로그래밍”으로 돌아가기 위한 노력이기 때문입니다.

2부에서 이어집니다.