const x = 1;

function outerFunc() {
    const x = 10;
    
    function innerFunc() {
        console.log(x);  // 10
    }
    innerFunc();
}

outerFunc();

outerFunc 함수 내부에서 중첩함수 innerFunc가 정의되고 호출되었다.
이 때 중첩함수 innerFunc의 상위스코프는 외부함수 outerFunc의 스코프이다.
따라서 중첩함수 innerFunc 내부에서 자신을 포함하고 있는 외부함수 outerFunc의 x변수에 접근가능하다.
만약 innerFunc함수가 outerFunc함수 내부에 정의된 중첩함수가 아니면 innerFunc함수를 outerFunc함수의 내부에서 호출한다 하더라도 outerFunc함수의 변수에 접근불가능하다.
이러한 현상이 발생하는 이뉴는 자바스크립트가 렉시컬 스코프를 따르는 프로그래밍 언어이기 때문이다.


렉시컬 스코프

  • 렉시컬 스코프 : 자바스크립트 엔진이 함수를 어디서 호출했는지가 아니라 어디에 정의했는지에 따라 상위스코프를 결정하는것
  • 함수를 어디서 호출하는지는 함수의 상위스코프 결정에 어떠한 영향을 주지 못한다.
    즉, 함수의 상위스코프는 함수를 정의한 위치에 의해 정적으로 결정되고 변하지 않는다.
  • 상위 스코프에 대한 참조는 함수정의가 평가되는 시점에 함수가 정의된 환경(위치)에 따라 결정되고 이것이 바로 렉시컬 스코프다.

함수 객체의 내부 슬롯 [[Environment]]

  • 함수가 정의된 환경과 호출되는 환경(위치)는 다를 수 있다.
    따라서 렉시컬 스코프가 가능하려면 함수는 자신이 정의된 환경, 즉 상위 스코프를 기억해야한다.
    이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다.
    자신의 내부슬롯 [[Environment]]에 저장된 상위 스코프의 참조는 현재 실행중인 실행컨텍스트의 렉시컬 환경을 가리킨다.
    왜냐하면 함수정의가 평가되어 함수객체를 생성하는 시점은
    함수가 정의된 환경(상위 함수 또는 전역코드)가 평가 또는 실행되고 있는 시점이며, 이 때 현재 실행중인 실행 컨텍스트는 상위함수(또는 전역코드)의 실행 컨텍스트이기 떄문이다.
    -> 함수 객체의 내부슬록 [[Environment]]에 저장된 현재 실행중인 실행컨텍스트의 렉시컬 환경의 참조가 바로 상위 스코프다.
    함수객체는 내부슬록 [[Environment]]에 저장한 상위스코프를 자신이 존재하는한 기억한다.
  • ex)
    const x = 1;
    
    function foo() {
       const x = 10;  
      
       bar();  
    }
    
    function bar() {
      console.log(x);
    }
    foo();    // 1
    bar();    // 1
    

    foo함수와 bar함수는 모두 전역에서 함수 선언문으로 정의되었다.
    따라서 foo함수와 bar함수는 모두 전역코드가 평가되는 시점에 평가되어 함수 객체를 생성하고 전역객체 window의 메서드가 된다.
    이때 생성된 함수 객체의 내부슬롯 [[Environment]]에는 전역 렉시컬 환경의 참조가 저장된다.
    함수가 호출되면 함수내부로 코드의 제어권이 이동되고, 함수 코드를 평가하기 시작한다. (아래 순서로 진행됨)
    1) 함수 실행컨텍스트 생성
    2) 함수 렉시컬 환경 생성
    2.1) 함수 환경 레코드 생성
    2.2) this 바인딩
    2.3) 외부 렉시컬 환경에 대한 참조 결정
    함수 렉시컬 환경의 구성요소인 외부 환경에 대한 참조에는 함수 객체의 내부슬록 [[Environment]]에 저장된 렉시컬 환경의 참조가 할당된다.
    즉, 함수 객체의 내부슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조는 바로 함수의 상위 스코프를 의미한다.


클로저와 렉시컬 환경

const x = 1;

function outer() {
    const x = 10;
    const inner = function() {   console.log(x);  }
    return inner;                             
}
const innerFunc = outer();
innerFunc();     // 10

outer 함수를 호출하면 outer함수는 중첩함수 inner를 반환하고 생명주기를 마감한다.
(실행컨텍스트 스택에서 outer함수의 실행컨텍스트가 제거됨)
이 때 outer함수의 지역변수 x와 변수 값 10을 저장하고 있던 outer함수의 실행컨텍스트가 제거되었으므로 outer함수의 지역변수 x또한 생명주기를 마감한다.
따라서 outer함수의 지역변수 x는 더는 유효하지 않은것처럼 보이지만 위 코드를 실행하면 10이 콘솔에 출력된다.
이처럼 외부함수보다 중첩함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명주기가 종료한 외부함수의 변수를 참조할 수 있다.
이러한 중첩함수를 클로저(closure) 라고 부른다.

  • 클로저에 대한 MDN의 정의는 다음과 같다.
    “클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합이다.”
    “그 함수가 선언된 렉시컬 환경의 환경”이란 함수가 정의된 위치의 스코프, 즉 상위 스코프를 의미하는 실행컨텍스트의 렉시컬 한경을 말한다.
    위 예제에서 중첩함ㅅ후 inner는 자신의 [[Environment]] 내부슬롯에 outer함수의 렉시컬 환경을 상위 스코프로서 저장한다.
    outer함수의 실행이 종료하면 실행컨텍스트의 스택에서 outer함수의 실행컨텍스트가 제거되지만 outer함수의 렉시컬 환경까지 소멸하는 것은 아니다.
    outer함수의 렉시컬 환경은 inner함수의 [[Environment]] 내부슬롯에 의해 참조되고 있고, inner 함수는 전역변수 innerFunc에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않기 때문이다.
    (가비지 컬렉터는 누군가에 의해 참조중인 메모리공간을 함부로 해제하지 않는다.)
    outer함수가 반환한 inner함수를 호출하면 inner함수의 실행 컨텍스트가 생성되고 실행 컨텍스트에 푸시된다.
    그리고 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 inner 함수 객체의 [[Environment]] 내부 슬롯에 저장되어 있는 참조값이 할당된다.
  • 외부 함수보다 더 오래 생존한 중첩함수는 외부 함수의 생존여부(실행 컨텍스트의 생존여부)와 관계없이 자신이 정의된 위치에 의해 결정된 상위스코프를 기억한다.
    이처럼 중첩함수 inner 내부에서는 상위 스코프를 참조가능하므로 상위스코프의 식별자를 참조할 수 있고 값을 변경할수도 있다.
  • 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저이다.
    하지만 일반적으로 모든 함수를 클로저라고 하지는 않는다.
    ex1)
    function foo() {
        const x = 1;
        const y = 2;   
         
        function bar() {
            const z = 3;
            console.log(z);    
        }
        return bar;
    }
      
    const bar = foo();
    bar();
    

    위 예제에서 중첩함수 bar는 외부함수 foo보다 더 오래 유지되지만 상위 스코프의 어떤 식별자도 참조하지 않았다.
    이처럼 상위스코프의 어떤 식별자도 참조하지 않는 경우 대부분의 모든 브라우저는 최적화를 통해 상위스코프를 기억하지 않는다.
    (참조하지도 않은 식별자를 기억하는것은 메모리 낭비이기 때문이다.)
    따라서 bar함수는 클로저라고 할 수 없다.

    ex2)

    function foo() {
        const x = 1;
        function bar() {  
            // 상위스코프의 식별자를 참조함  
            console.log(x);
        }
        bar();
    }
    foo();
    

    위 예제의 경우 외부함수 foo보다 중첩함수 bar의 생명주기가 짧다.
    이런 경우 중첩함수 bar는 상위 스코프의 식별자를 참조하고 있으므로 클로저였지만 외부함수보다 일찍 소멸되기 때문에 생명주기가 종료된 외부함수의 식별자를 참조할 수 있다는 클로저의 본질에 부합하지 않는다.
    따라서 일반적으로 중첩함수 bar는 클로저라고 하지 않는다.

    ex3)

    function foo() {
        const x = 1;
        const y = 2;
    
        function bar() {
            console.log(x);
        }
        return bar;
    }
    const bar = foo(); 
    bar(); 
    

    클로저는 중첩함수가 상위스코프의 식별자를 참조하고 있고,중첩함수가 외부함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적이다.
    ex3)에서 클로저인 중첩함수 bar는 상위 스코프의 x,y 식별자 중 x만 참조하고 있다.
    이런 경우 대부분의 모던 브라우저는 최적화를 통해 상위 스코프의 식별자중에서 클로저가 참조하고 있는 식별자만 기억한다.
    클로저에 의해 참조되는 상위스코프의 변수를 자유변수(free variable)라고 부른다.
    클로저(closure)란 “함수가 자유변수에 닫혀있다라”는 의미다.
    자바스크립트엔진은 최적화가 잘 되어 있어서 상위스코프의 식별자중에서 기억해야 할 식별자만 기억한다.


클로저의 활용

  • 클로저는 상태(state)를 안전하게 변경하기 위해 사용한다.
    -> 상태가 의도치않게 변경되지 않도록 은닉(information hiding)하고 특정함수에게만 상태변경을 허용하기 위해 사용한다.

ex) 함수가 호출될때마다 호출된 횟수를 누적하여 출력하는 카운터

 let num = 0;   // 카운트 상태변수

 const increase = function() { // 카운트 상태변경 함수
    return ++num;
 };

 console.log(increase());   // 1
 console.log(increase());   // 2
 console.log(increase());   // 3
  • 위 코드는 잘 동작하지만 오류를 발생시킬 여지가 많다.
    위 예제가 의도한대로 바르게 동작하려면 다음의 전제조건이 지켜져야 한다.
    1) 카운트 상태(num 변수값)은 increase함수가 호출되기 전까지 변경되지 않고 유지되어야함
    2) 이를 위해 카운트상태(num 변수값)은 increase함수만이 변경할 수 있어야함.
    하지만 카운트 상태는 전역변수이기 때문에 누구나 접근해서 변경할 수 있다.
    이는 의도치않게 상태가 변경될 수 있다는 것을 의미한다.
    따라서 카운트 상태를 안전하게 변경하고 유지하기 위해 클로저를 사용해볼 수 있다.

ex) 클로저를 이용한 카운터구현

// 카운트 상태변경 함수  
const increase = (function() {
  let num = 0; // 카운트 상태 변수
  
  // 클로저  
  return function() {
      return ++num; // 카운트 상태를 1만큼 증가  
  };
}());

console.log(increase());    // 1
console.log(increase());    // 2
console.log(increase());    // 3
  • 위 코드가 실행되면 즉시실행 함수가 호출되고, 즉시실행 함수가 반환한 함수가 increase 변수에 할당된다.
    increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위스코프인 즉시 실행함수의 렉시컬 환경을 기억하는 클로저다.
  • 즉시실행 함수는 호출된 이후 소멸하지만, 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출된다.
    따라서 이 클로저는 카운트 상태를 유지하기 위한 자유변수 num을 언제 어디서 호출하든지 참조하고 변경할 수 있다.
  • 즉시실행함수는 한번만 실행되므로 increase가 호출될 떄마다 num변수가 다시 초기화될 일은 없다.
    또한 num변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수를 사용했을떄처럼 의도치않은 변경을 걱정할 필요가 없어서 안정적인 프로그래밍이 가능함!

아래 예제는 위예제에서 감소카운트 기능을 추가한것이다.
ex)

counst counter = (function() {
    let num = 0;

    return {
        increase() {
            return ++num;
        },
        decrease() {
            return num > 0 ? num : 0;
        }
    };
} ());

console.log(counter.increase());    // 1
console.log(counter.increase());    // 2

console.log(counter.decrease());    // 1
console.log(counter.decrease());    // 0
  • 위 예제의 즉시실행함수가 반환하는 객체리터럴은 즉시 실행함수의 실행단계에서 평가되어 객체가 된다.
    이 때 객체의 메서드도 함수 객체로 생성된다.
    객체리터럴의 중괄호는 코드블록이 아니므로 별도의 스코프를 생성하지 않는다.

자주 발생하는 실수

var funcs = [];

for (var i = 0; i < 3; i++) {
    // 함수가 funcs배열의 요소로 추가됨  
    funcs[i] = function() { return i };   
}

for (var j = 0; j < funcs.length; j++) {
    console.log(funcs[j]());           
}
  • for문의 변수선언문에서 var키워드로 선언한 i변수는 블록레벨스코프가 아닌 함수레벨스코프를 갖기 때문에 전역변수다.
    전역변수 i에는 0, 1, 2가 순차할당된다.
    따라서 funcs배열의 요소로 추가한 함수를 호출하면 전역변수 i를 참조하여 i의 값 3이 출력된다.
var funcs = [];

for (var i = 0; i < 3; i++) {
    // 즉시실행 함수는 전역변수 i에 현재 할당되어 있는 값을 인숙로 전달받아
    // 매개변수 id에 할당한 후 중첩함수를 반환하고 종료한다.  
    // 즉시실행 함수가 반환한 함수는 funcs배열에 순차적으로 저장된다. 
    funcs[i] = (function(id){
        return function() {
            return id;
        };
    }(i));
}

for (var j = 0; j < funcs.length; j++) {
    console.log(funcs[j]());
}
  • 즉시실행함수의 매개변수 id는 즉시실행함수가 반환한 중첩함수의 상위스코프에 존재한다.
    즉시 실행함수가 반환한 중첩함수는 자신의 상위스코프를 기억하는 클로저이고,
    매개변수 id는 즉시실행함수가 반환한 중첩함수에 묶여있는 자유변수가 되어 그 값이 유지된다.
    위 예제는 자바스크립트의 함수레벨스코프의 특정으로 인해 for문의 변수 선언문에서 var 키워드로 선언한 변수가 전역변수이기때문에 발생하는 현상이다.
    ES6의 let키워드를 사용하면 이같은 번거로움이 깔끔하게 해결된다.
const funcs = [];

for (let i = 0; i < 3; i++) {
    funcs[i] = function() { return i; };
}
for (let i = 0; i < funcs.length; i++ ){
    console.log(funcs[i]());    // 0 1 2 
}
  • for문의 변수선언문에서 let키워드로 선언한 변수를 사용하면 for문의 코드블록이 반복실행될때마다 for문 코드블록의 새로운 렉시컬 환경이 생성된다.
    만약 for문의 코드블록내에서 정의한 함수가 있다면 이 함수의 상위스코프는 for문의 코드블록이 반복될때마다 생성된 for문 코드블록의 새로운 렉시컬 환경이다.
    이 때 함수의 상위스코프는 for문의 코드블록이 반복실행될대마다 식별자의 값을 유지해야한다.
    이를 위해 for문이 반복될때마다 독립적인 렉시컬 환경을 생성하여 식별자의 값을 유지한다.
  • let이나 const 키워드를 사용하는 반복문(for문, for … in 문, for … of 문, while 문 등)은 코드블록을 반복실행할때마다 새로운 렉시컬 환겨을 생성하여 반복할 당시의 상태를 마치 스냅숏찍는것처럼 저장한다.
    단, 이는 반복문의 코드블록 내부에서 함수를 정의할 떄만 의미가 있다.
    반복문의 코드블록 내부에 함수 정의가 없는 반복문이 생성하는 새로운 렉시컬 환경은 아무도 참조하지 않기 때문에 가비지 컬렉션의 대상이된다.
  • 또다른 방법으로 함수형 프로그래밍 기법인 고차함수를 사용하는 방법이 있다. (ex) Arrays.from)
    이 방법은 변수와 반복문의 사용을 억제할 수 있기 때문에 오류를 줄이고 가독성을 좋게 만든다.

참조(모던 자바스크립트 Deep Dive)