람다에 대한 부끄러운 에피소드가 하나 있다. 어떤 분이 페이스북 그룹에 JavaScript 콜백과, 람다함수, 클로저에 대해 물어보는 글을 올렸다. 난 당시 콜백과 클로저에 대해서는 어느정도 알고 있었지만 람다에 대해서는 구체적으로 아는 바가 없었다. 그래서 구글에 JavaScript Lambda를 검색해보니 어째 Arrow function 관련 내용만 나오기에 단순히 Arrow function에 대해 물어보는 것이로구나, 라고 생각하고 답변을 달았다. 다행히도 다른 분께서 람다에 대해서 지적을 해주셔서 해당 토픽에 대해 찾아보는 계기가 되었다. 이 글은 해당 페이스북 게시물을 참고하여 스스로 찾아본 람다에 대한 개념을 정리한 글이다.


람다 대수(Lambda calculus)

현재 프로그래밍 언어에서 사용되고 있는 람다에 대하여 깊은 이해를 하고자 한다면 역시 이론적인 배경을 한 번쯤 짚어보는 것도 좋겠다. 현재 사용되고 있는 람다의 근간은 수학과 기초 컴퓨터과학 분야에서의 람다 대수이다. 람다 대수는 간단히 말하자면 수학에서 사용하는 함수를 보다 단순하게 표현하는 방법이다.

람다 대수는 다음과 같은 특징이 있다.

  1. 람다 대수는 이름을 가질 필요가 없다. (익명 함수)
  2. 두 개 이상의 입력이 있는 함수는 최종적으로 1개의 입력만 받는 람다 대수로 단순화 될 수 있다. (커링)

더 많은 정보에 대해서는 위키백과에서 보다 자세한 정보를 찾아볼 수 있다.

익명 함수(Anonymous function)

익명 함수는 람다 대수로부터 영향을 받아 만들어진 프로그래밍에서 함수를 표현하는 방식의 일종이다. 비교적 최근부터 대부분의 프로그래밍 언어가 익명 함수를 지원한다. 흔히 Java가 8버전 부터 람다를 지원하게 되었다고 하는데 이때 람다가 익명 함수(Lambda abstraction)를 말한다.

각 언어별로 익명 함수를 표현하는 법은 제각기 다르지만, 공통적으로 가지는 특징이 있다. 바로 일급 객체(First-class citizen)라는 점이다.

일급 객체는 함수의 인자로 넘겨받을 수도 있으며, 함수의 결과값으로 리턴할 수도 있고, 변수에 값을 할당할 수도 있다는 것을 말한다. 프로그래밍 언어에서 익명 함수는 애초에 다른 함수에 인자로 넘기거나 함수의 결과 값으로 리턴할 용도(이를테면 map()에 인자로 넘긴다던지, 클로저를 리턴한다던지)로 만들어지기 때문에 이런 특징을 갖게 되는 것이다. 또한 함수가 일급 객체가 되면 일급 함수(First-class function)이라고 부를 수 있다.

나는 익명함수이면서 일급함수라면 람다라고 부를 수 있다고 생각한다. 어떤 분은 람다는 순수함수여야 한다는 생각을 가지고 있기도 하지만, 이것은 위에서 다룬 수학적 의미의 람다에 가까운 것으로 프로그래밍 언어에서의 람다와는 거리가 있다는 게 내 생각이다. 물론 함수형 프로그래밍 패러다임에서는 순수함수여야겠지만 함수형 프로그래밍 패러다임에서는 람다가 아니라 모든 함수가 순수함수여야 한다.

아까 전에 각 언어별로 익명 함수를 표현하는 법이 제각기 다르다고 했는데, 그 차이가 가장 두드러지는 언어가 파이썬이다. 파이썬의 익명함수는 문맥(Statement)이 아닌 표현식(Expression)만을 지원한다. 즉, 파이썬의 익명함수는 블록을 가지지도 않고, 리턴하는 값만 표현하는 식 하나로만 구성된 다는 것이다. Quora의 어떤 질문의 답변에서 파이썬은 람다 함수(Lambda function)가 아니라 람다 표현식(Lambda expression)을 지원하는 것이라고 하는데 아주 적절한 표현이라고 생각한다.

이 글에서는 언어별로 람다를 설명하지는 않고, JavaScript에서의 람다에 대해서만 다룬다.

JavaScript의 익명 함수

위의 정의에 의하면 JavaScript의 모든 함수는 일급 객체이므로 JavaScript에서는 익명 함수이기만 하면 람다라고 볼 수 있겠다. 이렇게만 보면 사실 전혀 특별한 점이 없다. 왜냐하면 JavaScript에서 익명 함수는 일상적으로 사용하는 것이기 때문이다.

가장 흔한 용례로는 콜백을 꼽지 않을 수 없다. 콜백으로 Named function을 넘길 수도 있지만, 대개의 경우 일회용 함수를 넘기기 위해서 익명 함수를 사용하게 된다. 클로저도 마찬가지로 익명함수를 사용한다. 너무나 많은 용례가 있어 굳이 하나하나 꼽을 필요도 없을 것 같다.

ES2015에서는 Arrow function이 도입되면서 익명함수를 보다 람다스럽게(…) 표현할 수 있게 되었는데, 이 부분 문법 변화만 짚고 넘어가려고 한다.

먼저 ES5의 람다식이다.

// ES5.1
[0, 1, 2, 3, 4].map(function(n) {
  return n * n;
});

Arrow function을 사용하면 다음과 같이 매우 간결하게 표현이 가능하다.

// ES2015
[0, 1, 2, 3, 4].map(n => n * n);

람다와 클로저(Closure)

전에 썼던 게시물에서 이미 JavaScript 클로저에 대해서 다룬 바 있지만, 클로저는 JavaScript에만 있는 개념이 아니다. 또한 클로저는 람다로부터 파생된 개념이다. 일단 다음의 예제를 보자.

function adder(a) {
  return function(b) {
    return a + b;
  }
}

var add5 = adder(5);
add5(10); // 15

add5라는 함수의 입장에서 생각해볼 때, 자신의 스코프 내에 있는 b라는 변수는 인자로 받은 변수이고 해당 스코프 내에 갇혀있지만, a라는 변수는 대체 어디서 와서 사용되고 있는지 알 수가 없다. 이때의 a자유 변수(Free variable), b묶인 변수(Bound variable)라고 부른다.

위의 람다식에서는 자유 변수와 묶인 변수를 하나씩 사용하고 있다. 람다식은 사용하는 변수의 종류에 따라 두 종류로 나눌 수 있다. 바로 닫힌 람다식(Closed expression)열린 람다식(Opened expression)이다.

람다 표현식에서 사용하는 변수들이 모두 묶인 변수일 때 닫힌 람다식이라고 부른다. 그리고 람다 표현식에서 사용하는 변수들 중 하나라도 자유 변수가 있을 때 열린 람다식이라고 부른다.

자, 이제 클로저를 아주 간단하게 설명할 수 있다. 클로저는 바로 열린 람다식을 닫아(Close) 버리는 함수다. 클로저의 이름이 어떻게 유래되었는지도 예상이 될 것이다. 클로저는 람다식 내의 모든 자유 변수를 스코프 내로 가져와 묶는다. 그렇기 때문에 클로저는 만들어진 환경을 기억하는 것처럼 보인다.

JavaScript의 클로저

하지만 JavaScript의 클로저는 이 정의와는 약간 다른 모습을 보인다. 다음의 예제를 보자.

var a = 1;
function freeAdder(b) {
  return function(c) {
    return a + b + c;
  }
}

var add2 = freeAdder(2);
add2(3); // 6

a = undefined;
add2(3); // NaN

위의 예제에서 자유 변수는 a, b이다. 따라서 클로저의 정의대로 ab값을 클로저 스코프 내로 데려와서 묶었다면 바깥의 a 변수를 undefined로 정의했다고 한들, add2(3)은 똑같은 6을 리턴해야 하는데 실제로는 바뀐 a값의 영향을 받아 NaN을 리턴했다. 실제로 JavaScript의 클로저는 클로저가 정의된 그 스코프보다 상위 스코프의 자유 변수들은 바인딩하지 않는다. b같이 해당 스코프의 변수는 바인딩 된다.

이것은 JavaScript의 클로저가 제너럴한 클로저의 정의대로는 구현되지 않았다는 것을 의미한다. 왜 그렇게 구현되었는지, 이유까지는 찾아보지 않았지만 당연히 성능 문제(상위 스코프 탐색비용, 변수 저장비용)로 예상된다.

참고링크