[자바스크립트] 클로저

업데이트:

이번 시간에는 자바스크립트 클로저에 대해 알아보자.


1. 클로저란

이미 생명주기가 끝난 외부 함수의 변수를 참조하는 함수를 클로저라 한다.

아래 예제를 살펴보자.

function test(){
    var a = 1;
    var closer = function(){
        console.log(a);
    }
    // 함수를 리턴
    return closer;
}

var bono = test();
bono();  // 1 출력

위 예제의 스코프 체인을 생각해보면 다음과 같다.

1) 전역 실행 컨텍스트의 [[scope]] 는 [전역 객체]

2) test 실행 컨텍스트의 [[scope]] 는 [test 변수 객체 - 전역 객체]

3) bono 실행 컨텍스트의 [[scope]] 는 [bono 변수 객체 - test 변수 객체 - 전역 객체].

test 함수가 실행되고 종료되면서 closer 함수를 리턴한다. 이 때 test 함수는 종료되었지만 closer 함수의 [[scope]]가 test 함수의 [[scope]]를 참조하고 있어 가비지 컬렉터가 test 변수 객체 스코프를 제거하지 않는다. 따라서 실행 컨텍스트가 끝났음에도 불구하고 test 함수의 변수 a에 접근이 가능한 것이다.

test 의 a 와 같이 클로저로 참조되는 외부 변수를 자유 변수라 한다.

클로저의 가장 대표적인 패턴을 보자.

function outFunc(){
    var x = 1;  // 자유 변수
    
    return function(){
        /* x 변수와 arguments 를 활용한 로직 */
    }
}

var newFunc = outFunc();
/* outFunc 의 실행 컨텍스트 반환 */
newFunc();

1) 함수가 호출되고 새로운 함수를 반환한다.

2) 반환된 함수가 클로저이고 이 클로저가 참조하는 변수들이 자유 변수이다.

참고_클로저의 성능은?

클로저는 대부분 스코프체인의 뒤쪽 객체에 접근하기 위해 사용하는데, 이는 과정에서 클로저를 사용하지 않은 코드보다 메모리에 부담을 주게 된다. 자바스크립트의 주요 기능이면서도 사용에 주의해야 할 코드이다. 현명하게 클로저를 사용할 수 있도록 프로그래밍 경험을 많이 쌓는 것이 좋겠다.

2. 클로저의 활용

클로저는 성능, 자원에 부담이 되기 때문에 잘 활용할 수 있어야 한다. 클로저의 가장 대표적인 예제들을 살펴보면서 개발할 때 참고하기 바란다.

1_ 콜백 함수에 정의된 형식과 다른 사용자 정의 함수 호출
function BonoBono(){
    this.name = "bonobono";
}

BonoBono.prototype.call = function(func){
    func ? func(this.name) : this.func(this.name);
}
    
var bono = new BonoBono();

bono.func = function(name){
    console.log(name);
}

bono.call(function(name){console.log(name + '는 귀요미')});  // bonobono는 귀요미 출력
bono.call();  // bonobono 출력

위 예제에서는 call() 함수에 사용될 bono.func 를 정의할 때 name 하나만 인자로 받는 함수만을 정의해야 한다. 여기에 사용자가 원하는 인자를 더 넣으려면 어떻게 해야할까?

function BonoBono(){
    this.name = "bonobono";
}

BonoBono.prototype.call = function(func){
    func ? func(this.name) : this.func(this.name);
}

var bono = new BonoBono();


/* 추가된 코드 */

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

// BonoBono() 의 객체를 더 자유롭게 활용하기 위한 새로운 함수 선언
// 첫번째 인자로 받는 obj는 BonoBono()의 객체
// 두 번째 인자는 새로운 변수
// obj의 func 함수 정의 후 obj 반환
function newObj(obj, hi){
    obj.func = saySomething(this, "meet", hi);
    return obj;
}

newObj.prototype.meet = function(name, hi){
    console.log(name + " " + (hi||"hello"));
}

// newObj() 생성자 함수를 통해 새로운 객체 생성
var temp = new newObj(bono, "hi~!");

temp.call();

1) temp 객체는 newObj() 함수로 생성되었다. 이 때 this는 함수로 생성된 객체를 가리킨다. (new 로 호출되었기 때문) obj.func 에는 saySomething(this, “meet”, hi) 가 실행되어 리턴된 아래 함수가 들어간 상태이다. 이 상태로 obj 객체는 temp로 리턴된다.

// obj.func에 리턴받은 함수
function(name){
    return newObj["meet"](name, "hi~!");
}

2) call() 함수를 실행시키면 인자가 없으므로 this.func(this.name) 이 실행된다. 이 때 call() 함수를 호출한 객체가 temp 객체이므로 this는 temp 객체이다. temp 객체에는 newObj() 생성자에게 리턴받은 func() 함수가 존재하고, name 프로퍼티는 위에서 bono 객체가 생성될 때 이미 생성된 상태이다. 따라서 아래 코드가 실행된다.

function("bonobono"){
    return newObj["meet"]("bonobono", "hi~!");
}

newObj의 prototype에 meet 함수가 있었다. newObj 객체가 생성될 때 [[Proto]]에 newObj.prototype이 참조되어 프로토타입 체이닝을 통해 newObj의 meet이 실행된다. 이는 다음과 같다.

function("bonobono", "hi~!"){
    console.log("bonobono" + " " + ("hi~!"||"hello"));
}

따라서 결과는 bonobono hi~! 이 출력되게 된다.

복잡한 예제지만 천천히 이해해보자. 이미 정해진 형식의 콜백 라이브러리가 있는 경우, 그 형식과 다른 사용자 정의 함수를 호출할 때 위와 같이 사용된다.

꼭 직접 코딩을 해보고 넘어가도록 하자.

/* 위 예제를 한 번 더 해보자 */
function BonoBono(){
    this.name = "bonobono";
}

// call 함수는 이미 정해진 인자만 받도록 정의되어 있음
// 이 외에 추가 인자를 받을 수 있도록 수정
BonoBono.prototype.call = function(func){
    func ? func(this.name) : this.func(this.name);
}
    
var bono = new BonoBono();

// 클로저. 인자를 포함한 함수 반환
function closer(a, b, c){
    return function(name){
        console.log(name + a + b + c);
    }
}

// 추가 변수를 받기 위한 함수
function addArg(a, b, c, d){
    a.func = closer(b, c, d);
    return a;
}

var temp = new addArg(bono, '인자1', '인자2', '인자3');

temp.call();  // bonobono인자1인자2인자3 출력
2_ 함수의 캡슐화

아래 예제를 살펴보자

var intro = [
    '저는',
    '',
    '입니다.'
];

function letIntro(name){
    intro[1] = name;
    
    return introduce.join('');
}

var bono = letIntro('bonobono');
console.log(bono);  // 저는bonobono입니다. 출력

위 예제의 단점은 intro 변수는 전역변수로 외부에 노출되어 있다는 점이다. 다른 함수에서도 intro 배열에 접근하여 값을 변경할 수 있고 이로 인한 오류들이 발생할 수 있다.

var letIntro = (function(){
    var intro = [
    '저는',
    '',
    '입니다.'
	];
    
    return function(name){
        intro[1] = name;
        
        return intro.join('');
    };
})();

var str = letIntro('bonobono');
console.log(str);  // 저는bonobono입니다. 출력

위와 같이 익명의 함수를 즉시실행하여 반환되는 함수를 str 변수에 할당하는 방법이 있다. 반환되는 함수가 클로저가 되고 이 클로저는 자유변수 intro를 스코프 체인에서 참조할 수 있다. 반면 외부에서는 intro 변수에 접근이 불가능하게 된다.

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

setTimeout 함수는 웹 브라우저에서 제공하는 함수인데, 첫 번째 인자로 넘겨진 함수 실행을 스케쥴링 할 수 있다. 두 번째 인자로 넘긴 시간 간격으로, 밀리 초 단위 숫자만큼의 간격으로 해당 함수를 호출한다.

setTimeout() 으로 함수를 넘길 수 있지만, 이 방법으로는 실제 실행될 때 함수에 인자를 넘겨줄 수 없다.

즉 아래 예제는 오류가 발생한다.

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

setTimeout(func(3), 500);

그렇다면 자신이 정의한 함수에 인자를 넣어주려면 어떻게 해야할까?

function callLater(a){
    return (function(){
        console.log(a);
    });
}

var func = callLater(1);
setTimeout(func, 1000);

위와 같이 callLater 함수를 setTimeout 함수로 호출하려면 변수 func에 담아 setTimeout() 의 첫 번째 인자로 넣어주면 된다. 반환되는 함수가 클로저고 이는 사용자가 원하는 인자에 접근할 수 있다.

3. 클로저 활용시 주의사항

1_ 클로저의 프로퍼티 값은 항상 변할 수 있다
function sumFunc(argNum){
    var num = argNum;
    return function(x){
        num += x;
        console.log(num);
    }
}

var test = sumFunc(50);
test(5);  // 55 출력
test(5);  // 60 출력

위와 같이 test 함수가 호출될 때 마다 변수 num의 값이 계속 변할 수 있으니 주의하자.

2_ 여러 함수가 반환되는 경우
function func(){
    var x = 1;
    return{
        func1: function(a){
            x += a;
            console.log(x);
        },
        func2: function(b){
            x += b;
            console.log(x);
        }
    }
}

var test = func();
test.func1(5);  // 6 출력
test.func2(5);  // 11 출력

위와 같이 여러개의 함수가 반환되는 경우에도 동일한 스코프 체인을 사용하고 있으므로 동일한 변수를 참조한다. 따라서 각 함수를 한 번씩만 실행해도 1번 주의사항과 같이 프로퍼티 값이 계속해서 변하게 된다.

3_ 루프 안에서 클로저 활용하는 경우

이번 예제는 클로저 설명시 가장 대표적으로 소개되는 예시이다.

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 를 참조한다. 이 함수가 실행되는 시점은 countSeconds 함수가 종료된 이후이고, 이 때 i의 값은 4 이다. 그러므로 setTimeout 로 실행되는 함수는 모두 4를 출력하게 된다. (setTimeout의 첫 번째 인자만 4가 반영되고, 두 번째 인자인 i 에는 순서대로 1, 2, 3 이 들어간 상태이다.)

원래의 목적에 맞게 코드를 수정해보자.

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

countSeconds(3);

즉시실행함수를 실행시켜 i의 값을 current에 복사해서 사용하면 원하는 결과를 얻을 수 있다. 이 때에도 setTimeout이 실행되는 시점은 countSeconds 함수 종료 후라는 점을 기억하자.

카테고리:

업데이트: