본문으로 바로가기

5. 실행 컨텍스트와 클로저 (3)

category language/JavaScript 2019. 11. 28. 12:51

5.4 클로저

5.4.1 클로저의 개념


예제 5-7

function outerFunc() {
    var x = 10;
    var innerFunc = function() { console.log(x); }
    return innerFunc;
}

var inner = outerFunc();

/* outerFunc 실행 컨텍스트 종료 */

inner();    // 10

 

innerFunc()scopeouterFunc 변수 객체와 전역 객체를 가진다.

그런데 여기서 innerFunc()outerFunc()의 실행이 끝난 후 실행된다. 그렇다면 outerFunc 실행 컨텍스트가 사라진 이후에 innerFunc 실행 컨텍스트가 생성되는 것인데, innerFunc()의 스코프 체인은 outerFunc 변수 객체를 여전히 참조할 수 있을까?

outerFunc 실행 컨텍스트는 사라졌지만, outerFunc 변수 객체는 여전히 남아 있고, innerFunc의 스코프 체인으로 참조되고 있다.

이것이 바로 자바스크립트에서 구현한 클로저라는 개념이다.


자바스크립트의 함수는 일급 객체로 취급된다.

이는 함수를 다른 함수의 인자로 넘길 수도 있고, return으로 함수를 통째로 반환받을 수도 있음을 의미한다. 이러한 기능으로 앞 예제와 같은 코드가 가능하다. 여기서 최종 반환되는 함수가 외부 함수의 지역변수에 접근하고 있다는 것이 중요하다. 이 지역변수에 접근하려면, 함수가 종료되어 외부 함수의 컨텍스트가 반환되더라도 변수 객체는 반환되는 내부 함수의 스코프 체인에 그대로 남아있어야만 접근할 수 있다. 이것이 바로 클로저!


클로저? —> 이미 생명 주기가 끝난 외부 함수의 변수를 참조하는 함수

따라서 앞 예제에서는 outerFunc에서 선언된 x를 참조하는 innerFunc가 클로저가 된다. 그리고 클로저로 참조되는 외부 변수 즉, outerFuncx와 같은 변수를 자유 변수(Free variable)라고 한다. closure라는 이름은

함수가 자유 변수에 대해 닫혀 있다 (closed, bound)

는 의미인데, 우리말로 의역하면

’자유 변수에 엮여 있는 함수’

라는 표현이 맞을 것이다.


자바스크립트로 클로저를 구현하는 전형적인 패턴

  1. 외부 함수의 호출
  2. 이 외부 함수에서 새로운 함수가 반환됨
  3. 반환된 함수가 클로저이고 이 클로저는 자유 변수를 묶고 있다.
  4. 반환된 클로저는 새로운 함수로 사용된다.

—> 이러한 특성을 바탕으로 자바스크립트를 이용한 함수형 프로그래밍이 가능하다. (~7장)


클로저나 상속 같은 것들이 자바스크립트에 “있다” 를 넘어 “이렇게 구현하고 있다고 설명한다. 즉, 자바스크립트에 상속 기능이 있다는 것이 중요한 게 아니라 자바스크립트가 그를 어떻게 구현하는지가 중요하다고 말한다. 이는 이 언어가 어떻게 이루어져 있고, 어떤 목적을 가지고 만들어졌기 때문에 어떻게 사용하면 될 것이라고 하는 insight를 준다. 한편으로는 독자들로 하여금 이러한 것들이 자바스크립트만 가지고 있는 특별한 기능이라고 착각하지 않게끔 하고, 어디서나 이런 것들이 가능할 수 있다는 문을 열어 준다. 그야말로 언어는 tool에 불과하며, tool을 잘 사용하기 위해서는 이 tool이 어떤 목적으로 만들어졌는가를 알아야 한다. 그리고 그 목적을 달성하기 위해 어떤 방식을 사용하고 있는지!


cf) 클로저는 자바스크립트에만 있는 개념은 아니고, 여러 언어에서 차용되고 있는 특성이다. 특히 함수를 일급 객체로 취급하는 언어 (보통 이를 함수형 언어functional language라고 한다)에서 주요하게 사용되는 특성이다. 자바스크립트뿐만 아니라, 여러 가지 함수형 언어를 배워보고자 한다면 이 클로저라는 개념을 반드시 이해해야 한다.


예제 5-8

function outerFunc(arg1, arg2) {
    var local = 8;
    function innerFunc(innerArg) {
        console.log((arg1 + arg2)/(innerArg + local));
    }
    return innerFunc;
}

var exam1 = outerFunc(2, 4);
exam1(2);

 

앞 예제에서는 outerFunc() 함수를 호출하고 반환되는 함수 객체인 innerFunc()exam1으로 참조된다. 이것은 exam1(n)의 형태로 실행될 수 있다.

여기서 outerFunc()가 실행되면서 생성되는 변수 객체가 스코프 체인에 들어가게 되고, 이 스코프 체인은 innerFunc의 스코프 체인으로 참조된다. 즉, outerFunc() 함수가 종료되었지만, 여전히 내부 함수(innerFunc())의 scope으로 참조되므로 가비지 컬렉션의 대상이 되지 않고, 여전히 접근 가능하게 살아 있다. 따라서 이후에 exam1(n)을 호출하여도, innerFunc()에서 참조하고자 하는 변수 local에 접근할 수 있다.

클로저는 이렇게 만들어진다.

outerFunc 변수 객체의 프로퍼티값은 여전히 (심지어 실행 컨텍스트가 끝났음에도) 읽기 및 쓰기까지 가능하다.


exam1(2)을 호출하면, arg1, arg2, local 값은 outerFunc 변수 객체에서 찾고, innerArginnerFunc 변수 객체에서 찾는다.

—> 결과는 ((2+4)/(2+8))


cf) innerFunc()에서 접근하는 변수 대부분이 스코프 체인의 첫 번째 객체가 아닌 그 이후의 객체에 존재하는데, 이는 성능 문제를 유발시킬 수 있는 여지가 있다. 대부분의 클로저에서는 스코프 체인에서 뒤쪽에 있는 객체에 자주 접근하므로, 성능을 저하시키는 이유로 지목되기도 한다. 게다가 앞에서 알아본 대로 클로저를 사용한 코드가 그렇지 않은 코드보다 메모리 부담이 많아진다.

위의 예제에서 innerFunc 내부 함수가 없었다면, outerFunc 함수는 매번 실행시 local 변수를 생성하지만, 최신 자바스크립트 엔진에서는 이전에 호출된 outerFunc이 사용되지 않음을 파악하고 이전 값을 메모리 해제하여 일정 메모리 사용량을 유지시켜 준다. 하지만 위의 코드에서는 innerFunc 내부 함수 때문에 outerFunc을 참조하게 되고 비록 innerFunc가 사용되지 않더라도 이 코드가 반복적으로 실행될 때마다 메모리 사용량이 꾸준히 증가할 것이다. (최신 자바스크립트 엔진은 1 depth의 미사용 클로저에 대한 스코프는 가비지 컬렉팅 해주지만 2depth부턴 안된다.) GC가 실행되더라도 메모리 사용량이 줄어들지 않게 되는 것이다. (자바스크립트에서 메모리 누수의 4가지 형태)

그렇다고 클로저를 쓰지 않는 것은 자바스크립트의 강력한 기능 하나를 무시하고 사용하는 것과 다름이 없다. 따라서 클로저를 영리하게 사용하는 지혜가 필요하며, 이를 위해선 많은 프로그래밍 경험을 쌓아야 한다.


5.4.2 클로저의 활용

클로저는 성능적인 면과 자원적인 면에서 약간 손해를 볼 수 있으므로 무차별적으로 사용해서는 안 된다. 사실 클로저를 잘 활용하려면 경험이 가장 중요하게 작용한다. 여기서는 아주 전형적인 클로저의 예제 코드를 소개한다.


5.4.2.1 특정 함수에 사용자가 정의한 객체의 메서드 연결하기


예제 5-9

function HelloFunc(func) {
    this.greeting = "hello";
}

HelloFunc.prototype.call = function(func) {
    func ? func(this.greeting) : this.func(this.greeting);
}

var userFunc = function(greeting) {
    console.log(greeting);
}

var objHello = new HelloFunc();
objHello.func = userFunc;
objHello.call(); // hello

 

함수 HelloFuncgreeting 변수가 있고, func 프로퍼티로 참조되는 함수를 call() 함수로 호출한다. 사용자는 func 프로퍼티에 자신이 정의한 함수를 참조시켜 호출할 수 있다.

다만, 자신의 지역 변수인 greeting만을 인자로 사용자가 정의한 함수에 넘긴다. 사용자는 userFunc() 함수를 정의하여 objHello.func()에 참조시킨 뒤, HelloFunc()의 지역 변수인 greeting을 화면에 출력시킨다.


이 예제에서 HelloFunc()greeting만을 인자로 넣어 사용자가 인자로 넘긴 함수를 실행시킨다. 그래서 사용자가 정의한 함수도 한 개의 인자를 받는 함수를 정의할 수밖게 없다. 여기서 사용자가 원하는 인자를 더 넣어서 HelloFunc()를 이용하여 호출하려면?


예제 5-10

function HelloFunc(func) {
    this.greeting = "hello";
}

HelloFunc.prototype.call = function(func) {
    func ? func(this.greeting) : this.func(this.greeting);
}

var userFunc = function(greeting) {
    console.log(greeting);
}

var objHello = new HelloFunc();
objHello.func = userFunc;
objHello.call(); // objHello의 prototype link가 가리키는 HelloFunc.prototype로부터 상속받은 것

function saySomething(obj, methodName, name) {
    return (function(greeting) {
        return obj[methodName](greeting, name);
    });
}

function newObj(obj, name) {
    obj.func = saySomething(this, "who", name);
    return obj;
}

newObj.prototype.who = function(greeting, name) {
    console.log(greeting + " " + (name || "everyone"));
};

var obj1 = new newObj(objHello, "zzoon");
obj1.call();

 

이 부분은 이해하기 좀 어려워서 종이에 직접 로직을 써 가면서 이해했다…


첫 번재 인자 objfunc 프로퍼티에 saySomething() 함수에서 반환되는 함수를 참조하고, 반환한다. 결국 obj1은 인자로 넘겼던 objHello 객체에서 func 프로퍼티에 참조된 함수만 바뀐 객체가 된다.

따라서 objHello.prototype으로부터 상속받은 함수 call()을 호출할 수 있다. 이 코드의 실행 결과, newObj.prototype.who 함수가 호출되어 사용자가 원하는 결과인 “hello zzoon”을 출력한다.


그렇다면 saySomething() 함수 안에서 어떤 작업이 수행되는가?

  • 첫 번재 인자 : newObj 객체 —> obj1
  • 두 번째 인자 : 사용자가 정의한 메서드 이름 —> ”who"
  • 세 번째 인자 : 사용자가 원하는 사람 이름 값 —> ”zzoon"
  • 반환 : 사용자가 정의한 newObj.prototype.who() 함수를 반환하는 HelloFunc()func 함수

이렇게 반환되는 함수가 HelloFunc이 원하는 function(greeting) {} 형식의 함수가 되는데, 이것이 HelloFunc 객체의 func로 참조된다. obj1.call()로 실행되는 것은 실질적으로 newObj.prototype.who()가 된다.


이와 같은 방식으로 사용자는 자신의 객체 메서드인 who 함수를 HelloFunc에 연결시킬 수 있다.

여기서 클로저는 saySomething()에서 반환되는 function(greeting) {}이 되고, 이 클로저는 자유 변수 obj, methodName, name을 참조한다.


앞 예제는

정해진 형식의 함수를 콜백해주는 라이브러리가 있을 경우, 그 정해진 형식과는 다른 형식의 사용자 정의 함수를 호출할 때 유용하게 사용된다.

예를 들어 브라우저에서는 onclick, onmouseover와 같은 프로퍼티에 해당 이벤트 핸들러를 사용자가 정의해 놓을 수 있는데, 이 이벤트 핸들러의 형식은 function(event) {}이다. 이를 통해 브라우저는 발생한 이벤트를 event 인자로 사용자에게 넘겨주는 방식이다. 여기서 event 외의 원하는 인자를 더 추가한 이벤트 핸들러를 사용하고 싶을 때, 앞과 같은 방식으로 클로저를 적절히 활용해줄 수 있다.


5.4.2.2 함수의 캡슐화

다음과 같은 함수를 작성한다고 가정해보자.


”I am XXX. I live in XXX. I’am XX years old”라는 문장을 출력하는데, XX 부분은 사용자에게 인자로 입력 받아 값을 출력하는 함수


가장 먼저 생각할 수 있는 것은 앞 문장 템플릿을 전역 변수에 저장하고, 사용자의 입력을 받은 후, 이 전역 변수에 접근하여 완성된 문장을 출력하는 방식으로 함수를 작성하는 것이다.


예제 5-11

var buffAr = [
    'I am ',
    '',
    '. I live in ',
    '',
    '. I\'am ',
    '',
    ' years old.'
];

function getCompletedStr(name, city, age) {
    buffAr[1] = name;
    buffAr[3] = city;
    buffAr[5] = age;
    
    return buffAr.join('');
}

var str = getCompletedStr('zzoon', 'seoul', 16);
console.log(str);

 

하지만 이 방법은 단점이 있다. 바로 buffAr이라는 배열은 전역 변수로서, 외부에 노출되어 있다는 점이다.


이는 다른 함수에서 이 배열에 쉽게 접근하여 값을 바꿀 수도 있고, 실수로 같은 이름의 변수를 만들어 버그가 생길 수도 있다. 이는 특히 다른 코드와의 통합 혹은 이 코드를 라이브러리로 만들려고 할 때, 까다로운 문제를 발생시킬 가능성이 있다. 실제로 다른 사람이 사용할 라이브러리를 만들려고 하는 개발자는 이러한 충돌 가능성을 충분히 대비해서 라이브러리를 작성해야만 한다. 앞 예제의 경우, 클로저를 활용하여 buffAr을 추가적인 스코프에 넣고 사용하게 하면, 이 문제를 해결할 수 있다.


예제 5-12

var getCompletedStr = (function() {
    var buffAr = [
        'I am ',
        '',
        '. I live in ',
        '',
        '. I\'am ',
        '',
        ' years old.'
    ];
    return (function(name, city, age) {
        buffAr[1] = name;
        buffAr[3] = city;
        buffAr[5] = age;
    });
})();

var str = getCompletedStr('zzoon', 'seoul', 16);
console.log(str);

 

여기서 가장 먼저 주의해서 봐야 할 점은 변수 getCompletedStr에 익명의 함수를 즉시 실행시켜 반환되는 함수를 할당하는 것이다. 이 반환되는 함수가 클로저가 되고, 이 클로저는 자유 변수 buffAr을 스코프 체인에서 참조할 수 있다.


5.4.2.3 setTimeout()에 지정되는 함수의 사용자 정의

setTimeout 함수는 웹 브라우저에서 제공하는 함수인데, 첫 번째 인자로 넘겨지는 함수 실행의 스케쥴링을 할 수 있다. 두 번째 인자인 밀리 초 단위 숫자만큼의 시간 간격으로 해당 함수를 호출한다. setTimeout()으로 자신의 코드를 호출하고 싶다면 첫 번째 인자로 해당 함수 객체의 참조를 넘겨주면 되지만, 이것으로는 실제 실행될 때 함수에 인자를 줄 수 없다. 그렇다면 자신이 정의한 함수에 인자를 넣어줄 수 있게 하려면 어떻게 해야 할까? 클로저로 해결 가능.


예제 5-13

function callLater(obj, a, b) {
    return (function() {
        obj["sum"] = a+b;
        console.log(obj["sum"]);
    });
}

var sumObj = {
    sum : 0
}

var func = callLater(sumObj, 1, 2);
setTimeout(func, 500);

 

사용자가 정의한 함수 callLatersetTimeout 함수로 호출하려면, 변수 func에 함수를 반환받아 setTimeout() 함수의 첫 번째 인자로 넣어주면 된다. 반환받는 함수는 당연히 클로저이고, 사용자가 원하는 인자에 접근할 수 있다.


5.4.3 클로저를 활용할 때 주의사항

5.4.3.1 클로저의 프로퍼티값이 쓰기 가능하므로 그 값이 여러 번 호출로 항상 변할 수 있음에 유의해야 한다


예제 5-14

function outerFunc(argNum){
    var num = argNum;
    return function(x) {
        num += x;
        console.log('num: ' + num);
    }
}

var exam = outerFunc(40);
exam(5);
exam(-10);

 

exam 을 호출할 때마다, 자유 변수 num의 값은 계속해서 변화한다.


5.4.3.2 하나의 클로저가 여러 함수 객체의 스코프 체인에 들어가 있는 경우도 있다


예제 5-15

function func() {
    var x = 1;
    return {
        func1 : function() {console.log(++x); },
        func2 : function() {console.log(-x); }
    };
};

var exam = func();
exam.func1();
exam.func2();

 

예제에서 반환되는 객체에는 두 개의 함수가 정의되어 있는데, 두 함수 모두 자유 변수 x를 참조한다. 그리고 각각의 함수가 호출될 때마다 x 값이 변화하므로 유의해야 한다.


5.4.3.3 루프 안에서 클로저를 활용할 때는 주의하자


예제 5-16

function countSeconds(howMany) {
    for (var i = 1; i <= howMany; i++) {
        setTimeout(function () {
            console.log(i);
        }, i*1000);
    }
};

countSeconds(3);

 

이 예제는 1, 2, 3을 1초 간격으로 출력하는 의도로 만든 예제이지만, 결과는 4가 연속 3번 1초 간격으로 출력된다.

setTimeout 함수의 인자로 들어가는 함수는 자유 변수 i를 참조한다. 하지만 이 함수가 실행되는 시점은 countSecondes() 함수의 실행이 종료된 이후이고, i 값은 이미 4가 된 상태이다. —> setTimeout()로 실행되는 함수는 모두 4를 출력하게 된다.


원하는 결과를 얻으려면 루프 i값의 복사본을 함수에 넘겨야 한다. 그리고 즉시 실행 함수를 사용했다.


function countSeconds(howMany) {
    for (var i = 1; i <= howMany; i++) {
        (function (currentI) {
            setTimeout(function () {
                console.log(currentI);
            }, currentIi*1000);
        }(i));
    }
};

countSeconds(3);

 

즉시 실행 함수를 실행시켜 루프 i 값을 currentI에 복사해서 setTimeout()에 들어갈 함수에서 사용하면, 원하는 결과를 얻을 수 있다.


#책/인사이드자바스크립트