5. 실행 컨텍스트와 클로저 #4 클로저(Closure)

2021. 11. 10. 14:23JavaScript/basic

이전글

https://yonghwankim-dev.tistory.com/163

 

5. 실행 컨텍스트와 클로저 #3 스코프 체인

이전글 https://yonghwankim-dev.tistory.com/162 5. 실행 컨텍스트와 클로저 #2 실행 컨텍스트 생성 과정 이전글 https://yonghwankim-dev.tistory.com/161 5. 실행 컨텍스트와 클로저 #1 실행 컨텍스트 개념 이..

yonghwankim-dev.tistory.com

 

본 글은 INSIDE JAVASCRIPT 도서의 내용을 복습하기 위해 작성된 글입니다.

 

요약 및 정리

  1. 클로저의 개념
    • 이미 생명주기가 끝난 외부 함수의 변수를 참조하는 함수를 클로저라고 함
    • 외부 함수의 변수를 자유 변수(Free Variable)이라고 함
  2. 클로저의 활용
    • 특정 함수에 사용자가 정의한 객체의 메서드 연결
    • 함수의 캡슐화
    • setTimeout() 함수에 지정되는 함수의 사용자 정의
  3. 클로저를 활용할 때 주의사항
    • 클로저의 프로퍼티 값이 쓰기 가능하므로 그 값이 여러 번 호출로 항상 변할 수 있음
    • 하나의 클로저가 여러 함수 객체의 스코프 체인에 들어가 있을 수 있음
    • 루프 안에서 클로저를 활용할 때는 주의해야 함

 

개요

본 글에서는 클로저(Closure)의 개념에 대해서 살펴보고 이 클로저를 어디에서 활용되고 주의해야할 사항은 무엇인지 살펴보겠습니다.

 

1. 클로저의 개념

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

var inner = outerFunc();
inner();

위의 코드를 토대로 실행 컨텍스트를 그려보면 아래 그림과 같습니다.

 

위 그림의 흐름을 보면 첫번째로 outerFunc() 함수를 수행하고 outerFunc() 함수는 innerFunc() 함수를 반환합니다. 그리고 반환된 innerFunc 함수를 inner에 저장하고 inner 함수를 호출합니다. 주목할 점은 outerFunc 함수가 실행이 끝났음에도 불구하고 내부함수였던 innerFunc 함수는 outerFunc 함수의 변수인 x(10)를 참조하여 출력한다는 점입니다. 이와 같이 내부 함수가 실행이 끝난 외부 함수의 변수를 참조할 수 있는 함수를 클로저라고 합니다.

 

클로저의 개념을 정리하면 외부 함수의 컨텍스트가 종료되어 반환되더라도 변수 객체는 반환되는 내부 함수의 스코프 체인에 그대로 남아있어야만 접근할 수 있습니다. 이것이 바로 클로저입니다.

 

위 예제에서 외부 함수의 변수 중 x와 같은 변수를 가리켜 자유 변수(Free Variable)이라고 합니다.

 

 

2. 클로저의 활용

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

// HelloFunc() 생성자 함수
function HelloFunc(func){
    this.greeting = "hello";
}

// HelloFunc 생성자 함수의 프로토타입 객체에게 call 메서드 정의
HelloFunc.prototype.call = function(func){
    func ? func(this.greeting) : this.func(this.greeting);
}

// userFunc 함수는 매개변수를 그대로 출력한다.
var userFunc = function(greeting){
    console.log(greeting);
}

var userFunc2 = function(greeting){
    console.log("hello " + greeting);
}


// 객체 생성
var objHello = new HelloFunc();

// 객체에 userFunc 메서드 동적 생성
objHello.func = userFunc;

// call 메서드의 매개변수로 함수를 넣으면 인자로 넣은 함수를 호출
// 매개변수가 없으면 objHello 객체의 func 메서드 호출
objHello.call();    // Expected Output : hello

objHello.call(userFunc2);   // Expected Output : hello hello

// 이 예제에서 HelloFunc() 생성자 함수는 greeting만을 인자로 넣어 사용자가
// 인자로 넘긴 함수를 실행시킨다. 그래서 사용자가 정의한 함수도 한 갱의 인자를 받는 함수(userFunc2)
// 를 정의할 수 밖에 없다.

함수 HelloFunc는 greeting 변수가 있고, func 프로퍼티로 참조되는 함수를 call() 함수로 호출합니다. 사용자는 func 프로퍼티에 자신이 정의한 함수를 참조시켜 호출할 수 있습니다. 위의 예제에서는 userFunc 함수가 사용자 정의 함수에 해당됩니다. 다만, HelloFunc.prototype.call()을 보면 알 수 있듯이 자신의 지역 변수인 greeting만을 인자로 사용자가 정의한 함수에 넘깁니다. 위 예제에서 사용자는 userFunc() 함수를 정의하여 objHello.func()에 참조시킨 뒤, HelloFunc()의 지역변수인 greeting을 화면에 출력시킵니다.

 

위 예제에서 HelloFunc()는 greeting만을 인자로 넣어 사용자가 인자로 넘긴 함수를 실행시킵니다. 그래서 사용자가 정의한 함수도 한 개의 인자를 받는 함수를 정의할 수 밖에 없다. 이 문제를 해결하기 위해서는 아래와 같이 해결합니다.

 

// obj => obj1
// methodName => who
// name => zzoon
function saySomething(obj, methodName, name){
    // step4 내부 함수 반환 (클로저)
    return (
      function(greeting){
          return obj[methodName](greeting,name);    // obj[methodName](greeting,name) === obj.who(greeting, name)
      }  
    );
}

// 생성자 함수
// 첫번째 인자 : objHello
// 두번째 인자 : 사용자가 출력을 원하는 사람 이름
function newObj(obj, name){
    // this는 빈 객체에 바인딩
    obj.func = saySomething(this, "who", name); // step3 saySomething 함수 실행
    return obj;
}

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

var obj1 = new newObj(objHello, "zzoon");   // step2 newObj 객체 생성
obj1.call();

위의 코드에서 newObj() 함수를 선언하였습니다. 이 함수는 HelloFunc()의 객체를 좀 더 자유롭게 활용하려고 정의한 함수입니다. 첫번째 인자로 받는 obj는 HelloFunc()의 객체, 두번째 인자는 사용자가 출력을 원하는 사람 이름이 됩니다. 그리고 obj의 func 프로퍼티에 saySomething() 함수에서 반환되는 함수를 참조하고, 반환합니다.

 

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

앞 코드로 다음 코드가 실행됩니다.

obj.func = saySomething(this, "who", name);
return obj;

첫번째 인자 obj의 func 프로퍼티에 saySomething() 함수에서 반환되는 함수를 참조하고, 반환합니다. 결국 obj1은 인자로 넘겼던 objHello 객체에서 func 프로퍼티에 참조된 함수만 바뀐 객체가 됩니다. 따라서 다음과 같이 호출할 수 있습니다.

obj1.call();

위 코드의 실행 결과, newObj.prototype.who 함수가 호출되어 사용자가 원하는 결과인 "hello zzoon"가 출력됩니다.

 

그렇다면 saySomething() 함수 안에서 어떤 작업이 수행되는지 살펴봅니다.

function saySomething(obj, methodName, name){
    return (
      function(greeting){
      		// obj[methodName](greeting,name) === obj.who(greeting, name)
          return obj[methodName](greeting,name);
      }  
    );
}
  • 첫 번째 인자 : 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 외의 원하는 인자를 더 추가한 이벤트 핸들러를 사용하고 싶을 때, 앞과 같은 방식으로 클로저를 적절히 활용해줄 수 있다.

 

2.2 함수의 캡슐화

다음과 같은 함수를 작성한다고 가정합니다.

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

가장 먼저 생각할 수 있는 것은 앞 문장 템플릿을 전역 변수에 저장하고, 사용자의 입력을 받아 출력하는 방식입니다. 이와 같은 방식으로 구현된 코드는 아래와 같습니다.

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

function getCompleteStr(name, city, age){
    buffAr[1] = name;
    buffAr[3] = city;
    buffAr[5] = age;

    return buffAr.join('');
}

var str = getCompleteStr('zzoon', 'seoul', 16);
console.log(str);   // Expected Output : I am zzoon. I live in seoul. I'am 16 years old.

하지만 위의 코드는 단점이 존재합니다. 그것은 buffAr 배열이 전역 변수로서 외부에 노출되었다는 점입니다. 이는 다른 함수에서 이 배열에 접근하여 값을 변경할 수 있고, 실수로 같은 이름의 변수를 만들어 문제를 발생시킬 수 있습니다. 이는 특히 다른 코드와의 통합 혹은 이 코드를 라이브러리로 만들려고 할 때, 까다로운 문제를 발생시킬 가능성이 있습니다. 위 예제의 경우, 클로저를 활용하여 buffAr을 추가적인 스코프에 넣고 사용하게 되면, 이 문제를 해결 할 수 있습니다.

var getCompleteStr = (function(){
    // 클로저가 외부 함수 내의 buffAr 변수 참조, 외부에서 buffAr 접근 금지
    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;

        return buffAr.join('');
    });
})();

var str = getCompleteStr('zzoon', 'seoul', 16);
console.log(str);   // Expected Output : I am zzoon. I live in seoul. I'am 16 years old.

위 코드에서 주목할점은 getCompleteStr에 익명의 함수를 즉시 실행시켜 반환되는 함수를 할당하는 것입니다. 이 반환되는 함수가 클로저가 되고, 이 클로저는 자유 변수 buffAr을 스코프 체인에서 참조할 수 있습니다. 클로저에 있는 스코프 체인을 그려보면 아래와 같습니다.

 

 

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

setTimeout 함수는 웹 브라우저에서 제공하는 함수입니다. 첫번째 인자로 넘겨지는 함수 실행의 스케쥴링을 할 수 있습니다. 두번째 인자는 밀리 초(1000ms=1초) 단위 숫자만큼의 시간 후 해당 함수를 호출합니다. setTimeout()으로 자신의 코드를 호출하고 싶다면 첫번째 인자로 해당 함수 객체의 참조를 넘겨주면 되지만, 실제 실행될 때 함수에 인자를 줄 수 없는 문제가 있습니다. 이 문제를 해결하기 위해서는 클로저를 활용할 수 있습니다.

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);   // Expected Output : 3

위의 코드에서 callLater를 수행할때 반환하는 익명 함수가 클로저이고 setTimeout 함수의 첫번째 인자로 func(반환받은 익명 함수)가 수행될때 자유변수인 obj, a, b를 참조하여 setTimeout 함수의 인자를 넣을 수 없는 문제를 해결 할 수 있습니다.

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

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

function outerFunc(argNum){
    var num = argNum;

    return function(x){
        num += x;
        console.log("num : " + num);
    }
}

var exam = outerFunc(40);
exam(5);   // Expected Output : 45
exam(-10); // Expected Output : 35

위의 예제와 같이 exam을 여러번 호출할때마다 자유 변수 num은 계속해서 변경됩니다.

 

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

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

var exam = func();
exam.func1();       // Expected Output : 2
exam.func2();       // Expected Output : -2

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

 

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

function countSeconds(howMany){
    console.log("call countSeoncds " + howMany);
    // 자유 변수 i
    for(var i=1; i<=howMany; i++){
        console.log("for i : " + i);
        setTimeout(function(){
            console.log(i); // Expected Output : 1 2 3, Real Output : 4 4 4
        },i*1000);
    }
}

위 예제의 의도는 1, 2, 3을 1초 간격으로 출력하는 의도로 만든 예제입니다. 하지만 결과는 4, 4, 4가 출력됩니다. setTimeout 함수의 인자로 들어가는 익명 함수는 자유 변수 i를 참조합니다. 하지만 이 함수가 실행되는 시점은 countSeconds() 함수의 실행이 종료된 이후입니다. i값은 이미 4가 되었기 때문에 모든 출력이 4가 나오게 됩니다.

 

위 문제를 해결하기 위해서는 루프 i 값의 복사본을 함수에 넘겨주어 해결합니다.

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

countSeconds_solution(3);   // Expected Output : 1 2 3

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

 

References

source code : https://github.com/yonghwankim-dev/javascript_study
송형주, 고현준 저, 『인사이드 자바스크립트』, 한빛미디어(2014)