자바스크립트 이론 - 클로저

클로저

시작

이어서 클로저에 대해 알아보겠습니다.

클로저

클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합입니다.

js
const x = 1;

function outerFunc() {
  const x = 10;
  function innerFunc() {
    console.log(x); // 10 스코프 체인에 의해 innerFunc내(현재 record) x가 없으므로
                    // outer 즉, outerFunc의 x를 참조한다.
  };

  innerFunc;
}

outerFunc();

단 다음과 같은 경우는 선언 부분에 x는 1이므로 1이 출력된다. 자바스크립트는 렉시컬 프로그래밍을 따르기 때문이다.

js
const x = 1;

function outerFunc() {
  const x = 10;
  innerFunc();
}

function innerFunc() {
  console.log(x) // 1
}

outerFunc();

렉시컬 스코프

JS엔진은 함수를 어디서 호출했는지가 아닌 함수를 어디에 정의했는지에 따라 상위 스코프가 결정된다.

js
const x = 1;

function foo() {
  const x = 10;
  bar();
}

function bar() {
  console.log(x)
}

foo(); // 1
bar(); // 1

다시 말해 외부 렉시컬 환경에 대한 참조에 저장할 참조값, 즉, 스코프에 대한 참조는 함수 정의가 평가되는 시점 함수가 정의된 환경(위치)에 의해 결정된다. 이것이 바로 렉시컬 스코프이다. 정의된 환경에 대한 정보는 reocrd가 아닌 outer에 저장된다.

클로저와 렉시컬 환경과

외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 여전히 참조할 수 있다. 즉 이 개념에서 중첨 함수가 바로 클로저이다.

js
const x = 1;

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

const innerFunc = outer();
// outer는 콜 스택에서 제거된다. 단, 렉시컬 환경은 잔존하여 inner가 실행될 때 이 환경을 참조한다.
innerFunc();

// outer 함수를 호출하면 중첩 함수 inner를 반환
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스탭에서 팝되어 제거(역할을 마쳤으므로)
// inner 함수는 런타임에 평가된다. 함수 표현식이므로.
// inner 함수가 innerFunc에 전달되었는데, 이는 outer 함수의 렉시컬환경을 참조하고 있다.
// 즉, outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아니다.

가비지 콜렉터

가비지 콜렉터는 안쓰는 즉 참조되지 않는 메모리 공간은 언젠가 비워둔다. 그럼 위 코드는 어떨가 outer함수의 x도 삭제할까? 아니다. inner가 outer의 렉시컬 환경을 참조하기 때문에 x는 사라지지 않는다.

클로저와 아닌 것의 구분이

js
function foo() {
    const x = 1;
    const y = 2;

    // 일반적으로 클로저라고 하지 않는다.
    function bar () {
        const z = 3;

        //상위 스코프의 식별자를 참조하지 않는다.
        console.log(z);
    }

    return bar;
}

const bar = foo();
bar(); // 
js
function foo() {
    const x = 1;

    // bar 함수는 클로저였지만 곧바로 소멸한다.
    // 외부로 나가서 따로 호출되는게 아니라, 선언 후 바로 실행 + 소멸
    // 이러한 함수는 일반적으로 클로저라고 하지 않는다.
    function bar() {
        debugger;
        //상위 스코프의 식별자를 참조한다.
        console.log(x);
    }
    bar(); // 외부에서 사용하진 않는다.
}

foo();
js
function foo() {
    const x = 1;
    const y = 2;

    // 클로저
    // 중첩 함수 bar는 외부 함수보다 더 오래 유지되며 상위 스코프의 식별자를 참조한다.
    function bar() {
        debugger;
        console.log(x);
    }
    return bar;
}

const bar = foo();
bar();

클로저의 활용

클로저는 주로 상태를 안전(은닉)하게 변경하고 유지하기 위해 사용합니다. 또한 의도치 않는 상태 변경을 막기 위해 사용합니다.

다음은 카운트 예제입니다.

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

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

// 카운트 상태 변경 함수
const increase = function () {
    // 카운트 상태를 1만큼 증가시킨다.
    return ++num;
};

// 아래와 같이 잘 동작하지만 값이 은닉되지 않아 즉, 상태가 은닉되지 않아 중간에 변경이 가능하다.
console.log(increase()); // 1
num = 100; // 치명적인 단점이 있다.
console.log(increase()); // 100
console.log(increase()); // 101

// 보완해야 할 사항
// (1) 카운트 상태(num 변수의 값)는 increase 함수가 호출되기 전까지 변경되지 않고 유지돼야 한다.
// (2) 이를 위해 카운트 상태(num 변수의 값)는 increase 함수만이 변경할 수 있어야 한다.
// 전역변수인 num이 문제다 문제!! -> 지역변수로 바꿔볼가...?

위 코드의 경우 상태가 은닉되지 않는다. 아래 코드를 보자. 아래 코드는 은닉이 된다.

js
// 카운트 상태 변경 함수
const increase = (function () { // 즉시 실행 함수 {}()를 붙이게 되면 즉시 실행 함수 {}가 increase에 담긴다. 즉 increase 호출 시 즉시 {}구문이 실행된다.
    // 카운트 상태 변수
    let num = 0;

    // 클로저 이 구문이 반환되어 increase에 즉시 실행 함수로 담기게 됨.
    // 실행할 때마다 상위 스코프의 렉시컬 환경을 저장하기 때문에 상태를 유지하면서 은닉할 수 있다.
    return function () {
        return ++num;
    }
}());

// 이전 상태값을 유지
console.log(increase()); //1
console.log(increase()); //2
console.log(increase()); //3

// 코드 설명
// 1. 위 코드가 실행되면 즉시 실행 함수가 호출되고 즉시 실행 함수가 반환한 함수가
//    increase 변수에 할당된다.
// 2. increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인
//    즉시 실행 함수의 렉시컬 환경을 기억하는 클로저다.
// 3. 즉시 실행 함수는 호출된 이후 소멸되지만, 즉시 실행 함수가 반환한 클로저는 increase
//    변수에 할당되어 호출된다.
// 4. 이때 즉시 실행 함수가 반환한 클로저는 자신이 정의된 위치에 의해 결정된 상위 스코프인
//    즉시 실행 함수의 렉시컬 환경을 기억하고 있다.
// 5. 따라서 즉시 실행 함수가 반환한 클로저는 카운트 상태를 유지하기 위한 자유 변수 num을
//    언제 어디서 호출하든지 참조하고 변경할 수 있다.
// 6. num은 초기화되지 않을 것이며, 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로,
// 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없다.

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

아래코드는 감소 기능도 추가한 코드이다.

js
// 클로저 카운트 기능 확장(값 감소 기능 추가)
const counter = (function () { // 카운터 변수에는 객체 형태로 값이 저장된다.
    //카운트 상태 변수
    let num = 0;

    // 클로저인 메서드(increase, decrease)를 갖는 객체를 반환한다.
    // property는 public -> 은닉되지 않는다.
    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

// 8번째 줄에서 { 가 있는데, 별도의 스코프를 생성하진 않나요?

지금까지 했던 프로그래밍은 함수형 프로그래밍 기법이다.
이 기법은 불변성을 지향하며 클로저를 활용해 예상치 못한 외부 영향 오류를 피하고 안정성을 높일 수 있다. 아래는 함수형 프로그래밍에서 클로저를 활용하는 예시 코드이다.

js
// 함수를 인수로 전달받고 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.
function makeCounter(aux) {
    // 카운트 상태를 유지하기 위한 자유 변수
    let counter = 0;

    // counter를 기억하는 클로저를 반환
    return function () {
        //인수로 전달받은 보조 함수에 상태 변경을 위임한다.
        counter = aux(counter); // 즉시 실행 함수를 저장해 반환
        return counter;
    };
}

// 보조 함수
function increase(n) {
    return ++n;
}

// 보조 함수
function decrease(n) {
    return --n;
}

// 함수로 함수를 생성
// makeCounter함수는 보조 함수를 인수로 전달받아 함수를 반환한다.
const increaser = makeCounter(increase);
console.log(increaser()); // 1
console.log(increaser()); // 2

// increaser 함수와는 별개의 독립된 렉시컬 환경을 갖기 떄문에 카운터 상태가 연동되지 않는다.
const decreaser = makeCounter(decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2

// 그럼 어떻게 해야 하나요?
// -> key : makeCounter 함수를 1번만 호출할 것

2번째 코드

js
// 함수를 인수로 전달받고 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.

const counter = (function(){
    // 카운트 상태를 유지하기 위한 자유 변수
    let counter = 0;

    // 클로저를 반환
    return function (aux) {
        //인수로 전달받은 보조 함수에 상태 변경을 위임한다.
        counter = aux(counter);
        return counter;
    };    
}());

// 보조 함수
function increase(n) {
    return ++n;
}

// 보조 함수
function decrease(n) {
    return --n;
}

// 함수로 함수를 생성
// makeCounter함수는 보조 함수를 인수로 전달받아 함수를 반환한다.
console.log(counter(increase)); // 1
console.log(counter(increase)); // 2

// 자유 변수를 공유
console.log(counter(decrease)); // 1
console.log(counter(decrease)); // 0

캡슐화(enCapsulation)와 정보 은닉

캡슐화는 프로퍼티와 메서드를 하나로 묶고 객체의 특정 프로퍼티, 메서드를 감출 목적으로 사용한다. 다음 생성자 함수를 보자.

js
function Person(name, age) {
    this.name = name; //public 외부 참조 가능.
    let _age = age;   //private 외부 참조 불가. 클로저내에서만 제어 가능

    //인스턴스 메서드
    //따라서, Person 객체가 생성될 때 마다 중복 생성됨 : 해결방법 -> prototype
    this.sayHi = function () {
        console.log(`Hi! My name is ${this.name}. I am ${_age}.`);
    };
}

const me = new Person('Choi', 33);
me.sayHi();            // Hi!, My name is Choi. I am 33.
console.log(me.name);  // Choi
console.log(me._age);  // undefined

const you = new Person('Lee', 30);
you.sayHi();           // Hi! My name is Lee. I am 30.
console.log(you.name); // Lee
console.log(you.age);  // undefined

다음과 같이 블록 레벨 스코프를 지원하는 let을 사용한다면 해당 블록 스코프의 렉시컬 환경을 참조하면서 원하는 값. 1 2 3을 출력하는 코드이다. 함수 레벨 스코프를 지원하는 var를 사용한다면 아래와 같은 출력은 기대할 수 없다.

js
var 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
}