함수형 자바스크립트를 위한 기초

함수형 자바스크립트를 잘 익히기 위해서는 무엇보다 함수를 다루는 다양한 방법들을 잘 익히는 것이 중요하다. 함수를 잘 다루려면 함수와 관련된 개념들과 관련된 몇 가지 기능들에 대해 잘 알아야 하는데 이를테면 일급 함수, 클로저, 고차 함수, 콜백 패턴, 부분 적용, arguments 객체 다루기, 함수 객체의 메서드 등이 있다.

일급 함수

자바스크립트에서 함수는 일급 객체이자 일급 함수다. 자바스크립트에서 객체는 일급 객체다. 여기서 일급은 값으로 다룰 수 있다는 의미로, 아래와 같은 조건을 만족해야 한다.

  • 변수에 담을 수 있다.
  • 함수나 메서드의 인자로 넘길 수 있다.
  • 함수나 메서드에서 리턴할 수 있다.

자바스크립트에서 모든 값은 일급이다. 자바스크립트에서 모든 객체는 일급 객체이며 함수도 객체이자 일급 객체다.
일급 함수는 아래와 같은 추가적인 조건을 더 만족한다.

  • 아무 때나(런타임에서도) 선언이 가능하다.
  • 익명으로 선언할 수 있다.
  • 익명으로 선언한 함수도 함수나 메서드의 인자로 넘길 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
function f1() { }
var a = typeof f1 === 'function' ? f1 : function () { };

function f2() {
return function () { };
}

(function (a, b) { return a + b; })(10, 5);

function callAndAdd(a, b) {
return a() + b();
}
callAndAdd(function () { return 10; }, function () { return 5; })
  • f1은 함수를 값으로 다룰 수 있음을 보여준다.
  • f2는 함수를 리턴한다.
  • a와 b를 더하는 익명 함수를 선언하였으며, a와 b에 각각 10, 5를 전달하여 즉시 실행했다.
  • callAndAdd를 실행하면서 익명 함수들을 선언했고 바로 인자로 사용되었다. callAndAdd는 넘겨받은 함수 둘을 실행하여 결과들을 더한다.

클로저

스코프에 대한 개념을 잘 알고 있다면 이 글을 읽는 데 더욱 도움이 될 것 이다. 스코프란 변수를 어디에서 어떻게 찾을지를 정한 규칙으로, 여기에서 다루는 스코프는 함수 단위의 변수 참조에 대한 것이다.
함수는 변수 참조 범위를 결정하는 중요한 기준이다. 함수가 중첩되어 있다면 스코프들 역시 중첩되어 생겨난다.

클로저는 자신이 생성될 때의 환경을 기억하는 함수다.

이 말을 보다 실용적으로 표현해 보면 클로저는 자신의 상위 스코프의 변수를 참조할 수 있다고 할 수 있다.

클로저는 자신이 생성될 때의 스코프에서 알 수 있었던 변수를 기억하는 함수다.

자바스크립트의 모든 함수는 글로벌 스코프에 선언되거나 함수 안에서 선언된다. 자바스크립트의 모든 함수는 상위 스코프를 가지며 모든 함수는 자신이 정의되는 순간(정의되는 곳)의 실행 컨텍스트 안에 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function parent() {
var a = 5;
function myfn() {
console.log(a);
}
}

function parent2() {
var a = 5;
function parent1() {
function myfn() {
console.log(a);
}
}
}

parent와 parent2의 myfn에서는 a라는 변수를 선언하지 않았지만 사용하고 있다. parent의 변수 a는 myfn을 생성하는 스코프에서 정의되었고 parent2의 변수 a는 myfn을 생성하는 스코프의 상위 스코프에 정의되었다.

위와 같은 조건을 충족시키지 않는다면 그 함수가 아무리 함수 안에서 선언되었다고 하더라도 일반 함수와 전혀 다를 바가 없다. 클로저가 기억할 환경이라는 것은 외부의 변수들밖에 없기 때문이다. 또한 자신의 상위 스코프에서 알 수 있는 변수를 자신이 사용하고 있지 않다면 그 환경을 기억해야 할 필요가 없다.
글로벌 스코프를 제외한 외부 스코프에 있었던 변수 중 클로저 혹은 다른 누군가가 참조하고 있지 않는 모든 변수는 실행 컨텍스트가 끝난 후 가비지 컬렉션 대상이 된다. 어떤 함수가 외부 스코프의 변수를 사용하지 않았고, 그래서 외부 스코프의 환경이 가비지 컬렉션 대상이 된다면 그렇게 내비려 두는 함수를 클로저라고 보기 어렵다.

클로저는 자신이 생성될 때의 스코프에서 알 수 있었던 변수 중 언젠가 자신이 실행될 때 사용할 변수들만 기억하여 유지시키는 함수다.

1
2
3
4
5
var a = 10;
var b = 20;
function f1() {
return a + b;
}

f1은 클로저처럼 외부 변수를 참조하여 결과를 만든다. 게다가 상위 스코프의 변수를 사용하고 있으므로 앞서 강조했던 조건을 모두 충족시키고 있다. 그런데 왜 클로저가 아닐까?
글로벌 스코프에서 선언된 모든 변수는 그 변수를 사용하는 함수가 있는지 없는지와 관계없이 유지된다. a와 b 변수가 f1에 의해 사라지지 못하는 상황이 아니므로 f1은 클로저가 아니다.
그렇다면 클로저는 ‘함수 안에서 함수가 생성될 때’만 생성된다고 할 수 있을까? 그렇지 않다. 웹 브라우저에서는 함수 내부가 아니라면 모두 글로벌 스코프지만, 요즘 자바스크립트에서는 함수 내부가 아니면서 글로벌 스코프도 아닌 경우가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
function f2() {
var a = 10;
var b = 20;
function f3(c, d) {
return c + d;
}

return f3;
}

var f4 = f2();
console.log(f4(5, 7));

위 코드에서는 클로저가 있을까? 특히 f3처럼 함수 안에서 함수를 리턴하면 클로저처럼 보인다. 하지만 이 코드의 f4에 담긴 f3도 클로저가 아니다. f3은 f2 안에서 생성되었고 f3 바로 위에는 a, b라는 지역 변수도 있다. 하지만 f3 안에서 사용하고 있는 변수는 c,d이고 두 변수는 모두 f3에서 정의되었다. 자신이 생성될 때의 스코프가 알고 있는 변수 a, b는 사용하지 않았다. 그러므로 f3이 기억해야 할 변수는 하나도 없다. 그러므로 f3이 기억해야 할 변수는 하나도 없다. 자신이 스스로 정의한 c, d는 f3이 실행되고 나면 없어진다. 다시 실행되면 c, d를 다시 생성하고 리턴 후에 변수는 사라진다.

1
2
3
4
5
6
7
8
9
10
11
function f4() {
var a = 10;
var b = 20;
function f5() {
return a + b;
}

return f5();
}

console.log(f4());

그렇다면 위 코드에서는 클로저가 있을까? 정확한 표현은 있었다이다. 결과적으로는 클로저는 없다고 볼 수 있다. f4가 실행되고 a, b가 할당된 후 f5가 정의된다. 그리고 f5에서는 a, b가 사용되고 있으므로 f5는 자신이 생성된 환경을 기억하는 클로저가 된다. 그런데 f4의 마지막 라인을 보면 f5를 실행하여 리턴한다. 결국 f5를 참조하고 있는 곳이 어디에도 없기 때문에 f5는 사라지고, f5가 사라지면 a, b도 결국 사라질 수 있기에 클로저는 f4가 실행되는 사이에만 생겼다가 사라진다.

1
2
3
4
5
6
7
8
9
10
11
12
function f6() {
var a = 10;
function f7(b) {
return a + b;
}

return f7;
}

var f8 = f6();
console.log(f8(20));
console.log(f8(10));

드디어 클로저 코드를 사용했다. f7은 진짜 클로저다. 이제 a는 사라지지 않는다. f7이 a를 사용하기에 a를 기억해야 하고 f7이 f8에 담겼기 때문에 클로저가 되었다. 원래대로라면 f6의 지역 변수는 모두 사라져야 하지만 f6 실행이 끝났어도 f7이 a를 기억하는 클로저가 되었기 때문에 a는 사라지지 않으며, f8을 실행할 때마다 새로운 변수인 b와 함께 사용되어 결과를 만든다.

혹시 위 상황에 메모리 누수가 있다고 볼 수 있을까? 그렇지 않다. 메모리가 해제되지 않는 것과 메모리 누수는 다르다. 메모리 누수는 메모리가 해제되지 않을 때 일어나는 것은 맞지만, 위 상황을 메모리 누수라고 할 수는 없다. a는 한 번 생겨날 뿐, 계속해서 생겨나거나 하지 않는다. 메모리 누수란 개발자가 의도하지 않았는데 메모리가 해제되지 않고 계속 남는 것을 말하며, 메모리 누수가 지속적으로 반복될 때는 치명적인 문제를 만든다. f8이 아무리 많이 실행되더라도 이미 할당된 a가 그대로 유지되기 때문에 메모리 누수는 일어나지 않는다.

1
2
3
4
5
6
7
8
9
10
11
function f9() {
var a = 10;
var f10 = function (c) {
return a + b + c;
}
var b = 20;
return f10;
}

var f11 = f9();
console.log(f11(30));

위 코드는 에러없이 정상 동작한다. 클로저는 자신이 생성될 때의 스코프에서 알 수 있었던 변수를 기억하는 함수라고 했었는데, 여기서 ‘때’는 생각하는 것보다 조금 길다고 했었다.
f10에는 익명 함수를 담았다. f10이 생성되기 딱 이전 시점에는 b가 20으로 초기화되지 않았다. 클로저는 자신이 생성되는 스코프의 모든 라인, 어느곳에서 선언된 변수든지 참조하고 기억할 수 있다. 그리고 그것은 변수이기에 클로저가 생성된 이후 언제라도 그 값은 변경될 수 있다.

클로저는 자바스크립트에서 절차지향 프로그래밍, 객체지향 프로그래밍, 함수형 프로그매일 모두를 지탱하는 매우 중요한 기능이자 개념이다. 분명 클로저는 메모리 누수 같은 위험성을 가지고 있다. 그러나 메모리 누수나 성능 저하의 문제는 클로저의 단점이나 문제가 아니다.

고차 함수

고차 함수란, 함수를 다루는 함수를 말한다.

  1. 함수를 인자를 받아 대신 실행하는 함수
  2. 함수를 리턴하는 함수
  3. 함수를 인자를 받아서 또 다른 함수를 리턴하는 함수

고차 함수을 시작하기전 정의를 보고 클로저와의 차이점이 궁금해져 검색해봤다.

“Closures are also functions. But when a function captures state upon its creation, we call it a closure.”
즉, 클로저 역시 함수지만, 함수가 생성될 때 state를 점유하고 있다면 우리는 그것을 클로저라고 합니다.

출처: https://azsha.tistory.com/100 [Azsha’s Forge]

사실상 함수형 프로그래밍의 절반은 고차 함수를 적극적으로 활용하는 프로그래밍이라고도 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function callWith10(val, func) {
return func(10, val);
}

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

function sub(a, b) {
return a - b;
}

console.log(callWith10(20, add));
console.log(callWith10(5, sub));

여기서 add와 sub는 일반 함수다. 함수를 인자로 받거나 함수를 리턴하지 않기 때문이다. callWith10은 고차 함수다. 함수를 받아 내부에서 대신 실행하기 때문이다.

1
2
3
4
5
6
7
8
9
10
function constant(val) {
return function () {
return val;
}
}

var always10 = constant(10);
console.log(always10()); // 10
console.log(always10()); // 10
console.log(always10()); // 10

constant 함수는 실행 당시 받았던 10이라는 값을 받아 내부에서 익명 함수를 클로저로 만들어 val를 기억하게 만든 수 리턴한다. 리턴된 함수에는 always10 이라는 이름을 지어주었다. always10을 실행하면 항상 10을 리턴한다.

1
2
3
4
5
6
7
8
9
10
function callWith(val1) {
return function (val2, func) {
return func(val1, val2);
}
}
var callWith10 = callWith(10);
console.log(callWith10(20, add)); // 30

var callWith5 = callWith(5);
console.log(callWith5(5, sub)); // 0

callWith는 함수를 리턴하는 함수다. val1을 받아서 val1을 기억하는 함수를 리턴했다. 리턴된 함수는 이후에 val2와 func를 받아 대신 func를 실행해 준다. 함수를 리턴하는 함수를 사옹할 경우 다음처럼 변수에 담지 않고 바로 실행해도 된다.

1
2
console.log(callWith(30)(20, add));
console.log(callWith(5)(5, sub));

인자는 숫가자 아닌 값도 활용이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
_.get = function (list, index) {
return list[index];
}

var users = [
{ id: 2, name: "HA", age: 25 },
{ id: 4, name: "PJ", age: 28 },
{ id: 5, name: "JE", age: 27 },
];

var callWithUsers = callWith(users);
console.log(callWithUsers(2, _.get));

콜백 함수라 잘못 불리는 보조 함수

콜백 함수를 받아 자신이 해야 할 일을 모두 끝낸 후 결과를 되돌려 주는 함수도 고차 함수다. 보통은 비동기가 일어나는 상황에서 사용되며 콜백 함수를 통해 다시 원래 위치로 돌아오기 위해 사용되는 패턴이다. 콜백 패턴은 클로저 등과 함께 사용할 수 있는 매우 강력한 표현이자 비동기 프로그래밍에 있어 없어서는 안 될 매우 중요한 패턴이다. 콜백 패턴은 끝이 나면 컨텍스트를 다시 돌려주는 단순한 협업 로직을 가진다.

button.click(function() {})과 같은 코드의 익명 함수도 콜백 함수라고 표현되는 것을 많이 보았지만, 이 익명 함수는 ‘이벤트 리스너’라고 칭하는 것이 적합하다. 함수가 고차 함수에서 쓰이는 역할의 이름으로 불러주면 된다. _.each([1,2,3], function() {})에서의 익명 함수는 callback이 아니라 iteratee이며 _.filter(users, function() {})에서의 익명 함수는 predicate다. callback은 종료가 되었을 때 단 한 번 실행되지만 iterateepredicate, listener등은 종료될 때 실행되지 않으며 상황에 따라 여러 번 실행되기도 하고 각각 다른 역할을 한다.

함수를 리턴하는 함수와 부분 적용

앞서 곳곳에서 미리 필요한 인자를 넘겨 두고 그 인자를 기억하는 클로저를 리턴하는 함수들을 확인했다. 클로저로 만들어진 함수가 추가적으로 인자를 받아 로직을 완성해 나가는 패턴을 갖는다. 이와 유사한 기법들로 bind, curry, partial 등이 있다. 이런 기법들을 통틀어 칭하는 특별한 용어는 없지만 다음과 같은 공통점을 갖는다.

기억하는 인자 혹은 변수가 있는 클로저를 리턴한다.

bind는 this와 인자들이 부분적으로 적용된 함수를 리턴한다. bind의 경우 인자보다는 주로 함수 안에서 사용될 this를 적용해 두는데 많이 사용한다. 그 이유는 아마 this 적용을 스킵할 수 없다는 점과 인자의 부분 적용을 왼쪽에서 부터 순서대로 할 수 있는 점 때문일 것이다.

1
2
3
4
5
function add(a, b) {
return a + b;
}
var add10 = add.bind(null, 10);
console.log(add10(20)); // 30

bind는 첫 번째 인자로 bind가 리턴할 함수에서 사용될 this를 받는다. 두 번째 인자부터 함수에 미리 적용될 인자들이다. 인자를 미리 적용해 두기 위해 this로 사용될 첫 번째 인자에 null을 넣은 후 10을 넣었다. add10과 같이 this를 사용하지 않는 함수이면서 왼쪽에서 부터 순서대로만 인자를 적용하면 되는 상황에서는 원하는 결과를 얻을 수 있다. bind의 아쉬운 점은 두 가지다. 인자를 왼쪽에서 부터 순서대로만 적용할 수 있다는 점과 bind를 한 번 실행한 함수의 this는 무엇을 적용해 두었든 앞으로 바꿀 수 없다는 점이다.
bind는 왼쪽에서 부터 원하는 만큼의 인자를 지정해 둘 수 있지만 원하는 지점을 비워 두고 적용할 수는 없다. 예를 들어 어떤 함수가 필요로 하는 인자가 3개가 있는데 그 중 두 번째 인자만을 적용해 두고 싶다면 bind로는 이것을 할 수 없다. 이러한 점을 개선한 방식이 있는데 바로 partial 이다.

존 레식의 partial

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Function.prototype.partial = function () {
var fn = this, args = Array.prototype.slice.call(arguments); // 1
return function () { // 2
var arg = 0;
for (var i = 0; i < args.length && arg < arguments.length; i++) // 5
if (args[i] === undefined) args[i] = arguments[arg++]; // 6

return fn.apply(this, args);
}
}

function abc(a, b, c) {
console.log(a, b, c);
}

var ac = abc.partial(undefined, 'b', undefined); // 3
ac('a', 'c'); // 4
// a, b, c
  1. 우선 partial이 실행되면 fn에 자기 자신인 this를 담는다. 여기서 자기 자신은 abc 같은 함수다. args에는 partial이 실행될 때 넘어온 인자들을 배열로 변경하여 args에 담아 둔다.
  2. fn과 args는 리턴된 익명 함수가 기억하게 되므로 지워지지 않는다.
  3. abc.partial을 실행할 때 첫 번째 인자와 세 번쩨 인자로 넘긴 undefined 자리는 나중에 ac가 실행될 때 채워질 것이다.
  4. ac를 실행하면서 넘긴 ‘a’와 ‘c’는
  5. 리턴된 익명 함수의 arguments에 담겨 있다.
  6. for를 돌면서 미리 받아 두었던 args에 undefined가 들어 있던 자리를 arguments의 앞에서 부터 꺼내면서 모두 채운다. 다 채우고 나면 미리 받아 두었던 fn을 apply로 실행하면서 인자들을 배열로 넘긴다.

사실 partial은 구현이 잘 된 것은 아니다. 함수의 인자로 undefined를 사용하고 싶을 수도 있는데 undefined가 인자를 비워 두기 우한 구분자로 사용되고 있기 때문에, undefined를 미리 적용하고 싶다면 방법이 없다. 또한 초기에 partial을 실행할 때 나중에 실제로 실행될 함수에서 사용할 인자의 개수만큼 꼭 미리 채워 놓아야만 한다. 이 처럼 partial이 가진 제약은 ‘인자 개수 동적으로 사용하기’나 ‘arguments 객체 활용’과 같은 자바스크립트의 유연함을 반영하지 못한다는 점에서 특히 아쉽다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function add() {
var result = 0;
for (var i = 0; i < arguments.length; i++) {
result += arguments[i];
}
return result;
}

console.log(add(1, 2, 3, 4, 5));

var add2 = add.partial(undefined, 2);
console.log(add2(1, 3, 4, 5)); // 3

var add3 = add.partial(undefined, undefined, 3, undefined, undefined);
console.log(add3(1, 2, 4, 5)); // 15

console.log(add3(50, 50, 50, 50)); // 15 (bug)
console.log(add3(100, 100, 100, 100)); // 15 (bug)

위 상황에서 add2는 3, 4, 5 인자를 무시하게 된다. add3처럼 하면 1, 2, 4, 5를 모두 사용할 수 있게 되지만 undefined로라도 인자 개수를 채워놔야 해서 코드가 깔끔하지 못하고 partial 이후에는 역시 4개 이상의 인자를 사용할 수 없다는 단점이 생긴다.
위 코드에는 치명적인 문제가 있다. 의도한 것인지는 모르겠지만 그가 만든 partial 함수로 만든 함수는 재사용이 사실상 불가능하다. 한번 partial을 통해 만들어진 함수를 실행하고 나면 클로저로 생성된 args의 상태를 직접 변경하기 때문에, 다음번에 다시 실행해도 같은 args를 바라보고 이전에 적용된 인자가 남는다. 결과적으로 partial로 만들어진 함수는 단 한 번만 정상적으로 동작한다.

1
2
3
4
5
6
7
8
9
10
11
Function.prototype.partial = function () {
var fn = this, _args = arguments;
return function () {
var args = Array.prototype.slice.call(_args);
var arg = 0;
for (var i = 0; i < args.length && arg < arguments.length; i++)
if (args[i] === undefined) args[i] = arguments[arg++];

return fn.apply(this, args);
}
}

다음과 같이 두 줄만 변경하면 두 번 이상 실행해도 정상적으로 동작한다. 클로저가 기억할 변수에는 원본을 남기고 리턴된 함수가 실행될 때마다 복사하여 원본을 지키는 방식을 사용한다.


Conclusion

오늘은 본격적인 함수형 프로그래밍 시작에 앞서 기본이 될 기능들을 확인해 봤다. 일급 함수 같은 처음 듣는 용어도 있었고, 고차 함수 같은 경우는 내가 실무에서도 사용하고 있지만 정확한 용어도 모르고 사용하고 있었다. 또한 클로저, callback의 존재?는 알고 있었지만 정확한 개념이 부족했다는 것을 느꼈다. 오늘 내용의 모든 기능들의 대한 설명이 이 포스트로는 부족하지만 실력 향상에 많은 도움이 될 것같다. 기록해 두고 자주 보면서 내 것으로 만들어야 할 것 같다.

partial 같은 함수를 보고 들었던 생각은 함수형 프로그래밍의 끝은 어디 일까가 궁금해졌다. 정말 나는 생각도 못했던 기능이고 저게 그래서 실무에서 필요할까? 라는 생각을 했다. 하지만 partial과 같은 helper 함수들을 구현해 놓고 사용하면 생산성이 눈에 띄게 좋아질 것 같다고 생각한다.

이제 1장의 내용이 끝났는데 다음 장의 내용은 함수형 자바스크립트를 위한 문법 다시보기 이다. 이미 알고 있던 내용일 지더라도 기초를 더 다지기 위해 소홀히 보지 않아야겠다.

참조: 함수형 자바스크립트 프로그래밍

댓글

You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.