sandwe 2022. 10. 12. 01:01

클로저 정의

  • 클로저는 자바스크립트 고유의 개념은 아니다.
  • MDN - "함수와 그 함수가 선언된 렉시컬 환경의 조합"

 

렉시컬 스코프

  • 함수를 어디서 정의했는지에 따라 함수의 상위 스코프를 결정하는 것
  • 함수의 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정되어 변하지 않으므로 정적 스코프라고도 불린다.
const x = 1; 

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

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

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

자바스크립트 엔진은 렉시컬 스코프를 따르므로 foo와 bar 함수의 상위 스코프는 전역이 된다.

 

클로저란?

outer 함수를 호출하면 outer 함수가 실행되어 중첨 함수 inner를 반환하고 outer 함수의 생명 주기는 끝이 난다. 따라서 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다.

outer 함수가 종료되면서 outer 함수의 지역변수 x도 생명 주기를 마감한다. 따라서 outer 함수의 지역변수 x도 더이상 유효하지 않아 x 변수에 접근 불가능할 것 같아 보인다.

const x = 1;

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

하지만 실제로는 중첩 함수 inner가 실행되면서 이미 종료된 외부 함수의 지역 변수 x의 값 10을 출력한다.

 

이게 왜 되지?

  • 각 함수가 평가될 때 [[Environment]] 내부 슬롯에 현재 실행 중인 실행 컨텍스트의 렉시컬 환경을 상위 스코프로 저장한다.
  • outer 함수가 평가되어 함수 객체를 생성할 때의 실행 컨텍스트의 렉시컬 환경은 전역으로 outer 함수 객체의 [[Environment]] 내부 슬롯에 상위 스코프로 저장한다.
  • inner 함수가 평가되어 함수 객체를 생성할 때의 실행 컨텍스트의 렉시컬 환경은 outer로 inner 함수 객체의 [[Environment]] 내부 슬롯에 상위 스코프로 저장한다.
  • 따라서, 내부함수가 유효한 상태에서 외부함수가 종료해 외부함수의 실행 컨텍스트가 반환되어도 외부함수 실행 컨텍스트 내의 활성 객체는 내부함수에 의해 참조되는 한 유효하여 내부함수가 스코프 체인을 통해 참조할 수 있는 것이다.

따라서, 자신이 생성될 때의 환경을 기억해 이미 생명 주기가 끝난 외부 함수의 변수를 참조하고 있는 중첩 함수를 클로저라고 한다.

자유변수: 클로저에 의해 참조되는 상위 스코프의 변수

 

모든 함수를 클로저라고 할 수 있을까?

  • 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저다.
  • 하지만 모든 함수를 클로저라고 하지는 않는다.

다음의 두가지 예제는 클로저라고 할 수 없다.

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

    function bar() {
        const z = 3;
        console.log(z);
    }

    return bar;
}

const bar = foo();
bar();
console.dir(bar);

  • 위 예제에서 중첩 함수 bar는 외부 함수 foo보다 더 오래 유지되지만 상위 스코프의 어떤 식별자도 참조하지 않는다.
  • 상위 스코프의 식별자를 참조하지 않으면 대부분의 브라우저는 최적화를 하여 상위 스코프를 기억하지 않는다.
  • 따라서 bar함수는 클로저라고 할 수 없다.

 

function foo() {
    const x = 1;

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

foo();
  • 위의 예제에서 중첩 함수 bar는 상위 스코프의 식별자를 참조하고 있다.
  • 하지만 중첩 함수 bar는 foo함수 외부로 리턴되지 않아 foo보다 bar 함수가 더 일찍 소멸된다.
  • 이는 생명주기가 짧은 외부 함수의 식별자를 참조하는 클로저의 본질에 적합하지 않다.

 

클로저에서 참조하지 않는 상위 식별자가 있다면?

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

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

    return bar;
}

const bar = foo();
bar();
console.dir(bar);

  • 자바스크립트 엔진은 클로저가 참조하지 않는 식별자는 기억하지 않고, 기억해야할 식별자만 기억한다. (알아서 가비지 콜렉터에 의해 수거된다)
  • 따라서 클로저의 메모리 점유는 필요한 것을 기억하기 위함으로 메모리 낭비에 대한 걱정을 하지 않아도 된다.

 

클로저의 활용

  • 클로저를 사용하면 어떤 상태가 의도치 않게 변경되지 않도록 안전한 은닉이 가능하다.
  • 특정 함수에게만 특정 상태 변경을 허용해 상태를 안전하게 변경하고 유지할 수 있다.

예시) 함수가 호출될 때마다 호출 횟수를 누적하는 카운터 함수

let count = 0;

const increase = function () {
  return ++count;
};

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

위의 함수가 목적에 맞게 잘 작동하려면 다음이 전제되어야 한다.

1. 카운트 상태(변수 count)이 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 한다.

2. 카운트 상태는 increase 함수만 변경해야 한다.

 

하지만 변수 count는 전역 변수로 언제든지 접근 가능하다. 따라서 의도치 않게 변경될 가능성이 있다.

따라서 변수 count를 increase 함수만이 참조 가능하도록 지역 변수로 변경해보자.

const increase = function () {
  let count = 0;
  return ++count;
};

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

count가 increase 함수의 지역 변수가 되어 increase 함수만이 변수 count를 접근할 수 있게 되었다. 하지만 increase 함수를 실행할 때마다 count의 값을 0으로 초기화하므로 누적 횟수를 구할 수가 없다.

 

이전 상태를 유지할 수 있도록 클로저를 사용해보자.

const increase = (function () {
  let count = 0;
  
  return function() {
    return ++count;
  };
}());

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

즉시 실행 함수가 실행되면서 반환되어 increase 변수에 할당된 함수는 상위 스코프인 즉시 실행 함수가 호출된 이후 소멸되어 실행 컨텍스트에서 제거되어도 즉시 실행 함수의 렉시컬 환경을 기억한다. 상위 스코프인 즉시 실행 함수의 변수 count를 참조하고 변경하고 있기 때문이다.

 

이제, 카운트 상태를 증가시킬 수도 있고 감소시킬 수도 있게 만들어보자.

const counter = (function () {
  let count = 0;
  
  return {
    increase() {
      return ++count;
    },
    decrease() {
      return count > 0 ? --count : 0;
    }
  };
}());

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

console.log(counter.decrease()); // 1
console.log(counter.decrease()); // 0
  • 즉시 실행 함수가 반환하는 객체 리터럴은 즉시 실행 함수가 실행될 때 평가되어 객체가 되고, 이때 객체의 메서드도 함수 객체로 생성된다.
  • 객체 리터럴의 중괄호 ≠ 코드블록 ➡️ 코드 블록이 아니므로 별도의 스코프를 생성하지 않는다!
  • 따라서 increase/decrease 메서드의 상위 스코프는 increase/decrease 메서드가 평가되는 시점, 즉 실행 중인 실행 컨텍스트인 즉시 실행 함수 실행 컨텍스트의 렉시컬 환경이다.
  • 그러므로 increase/decrease 메서드는 즉시 실행 함수의 스코프의 식별자인 count를 참조할 수 있다.

 

위의 예제를 생성자 함수로 구현해보자.

const Counter = (function() {
  let count = 0;
  
  function Counter() {
    // this.count = 0;
  }
  
  Counter.prototype.increase = function () {
    return ++count;
  };
  
  Counter.prototype.decrease = function() {
    return count > 0 ? --count : 0;
  };
  
  return Counter;
}());

// 생성자 함수로 객체 생성
const counter = new Counter();

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

console.log(counter.decrease()); // 1
console.log(counter.decrease()); // 0
  • 만약 count가 생성자 함수 Counter가 생성할 인스턴스의 프로퍼티라면 인스턴스를 통해 외부에서 접근 가능한 public 프로퍼티가 된다. 이는 의도치 않게 count 값이 변경될 가능성을 만든다.
  • increase, decrease 메서드는 함수 자신의 정의가 평가되어 함수 객체가 될 때 실행 중인 실행 컨텍스트인 즉시 실행 함수 실행 컨텍스트의 렉시컬 환경을 기억한다. 상위 스코프의 count 값을 참조하여 변경하기 위해서다.
  • 즉 increase, decrease만이 클로저로 폐쇄된 공간의 count 변수에 접근 가능하고, 변경 가능하다.
변수 값은 언제든지 누군가에 의해 변경될 수 있어 오류 발생의 원인이 될 수 있다.
✅ 함수형 프로그래밍은 외부 상태 변경이나 가변 데이터를 피하고 불변성을 지향한다.
✅ 따라서 함수형 프로그래밍에서 오류를 최대한 피하고 안정성을 높이기 위해 클로저가 적극적으로 사용된다!

 

함수형 프로그래밍에서 클로저를 활용한 예제를 보자.

위의 예제처럼 증가/감소가 둘다 가능한 카운터를 만들어보자.

function makeCounter(aux) {
  let counter = 0;

  return function () {
    counter = aux(counter);
    return counter;
  };
}

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

const increaser = makeCounter(increase);
const decreaser = makeCounter(decrease);

console.log(increaser()); // 1
console.log(increaser()); // 2
console.log(decreaser()); // -1
console.log(decreaser()); // -2
  • makeCounter는 보조 함수를 인자로 전달받고 함수를 반환하므로 고차 함수이다.
  • makeCounter 함수가 반환한 함수는 자신이 생성될 때의 렉시컬 환경인 makeCounter 함수의 스코프에 속한 counter 변수를 기억하는 클로저이다.

위의 예제는 증가 감소 카운터가 연동되지 않는 문제가 발생한다. 동작 과정을 살펴보자.

1. increase 함수를 인자로 넣은 makeCounter 함수가 호출되면 makeCounter 함수의 실행 컨텍스트가 생성된다.

2. makeCounter 함수는 함수 객체를 생성하여 반환한 후 소멸되어 실행 컨텍스트에서 pop 된다. 반환된 함수는 전역 변수 increaser에 할당된다. 이때, 반환된 함수는 makeCounter 함수의 렉시컬 환경을 상위 스코프로서 기억하는 클로저이다.

3. decrease 함수를 인자로 넣은 makeCounter 함수가 호출되면 makeCounter 함수의 실행 컨텍스트가 생성된다.

4. makeCounter 함수는 함수 객체를 생성하여 반환한 후 소멸되어 실행 컨텍스트에서 pop된다. 반환된 함수는 전역 변수 decreaser에 할당된다. 이때, 반환된 함수는 makecounter 함수의 렉시컬 환경을 상위 스코프로서 기억하는 클로저이다.

 

여기서, 전역 변수 increaser와 decreaser에 할당된 함수는 각각 자신만의 독립적인 렉시컬 환경을 갖는다. 그래서 자유변수 counter를 공유하지 않아 증가/감소가 연동되지 않는 것이다.

 

따라서, makeCounter 함수를 두 번 호출하지 않고, 렉시컬 환경을 공유하는 클로저를 만들어야 한다.

const counter = (function () {
  let counter = 0;

  return function (aux) {
    counter = aux(counter);
    return counter;
  };
}());

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

console.log(counter(increase));
console.log(counter(increase));
console.log(counter(decrease));
console.log(counter(decrease));

 

캡슐화와 정보 은닉

자바스크립트는 다른 객체지향 프로그래밍 언어처럼 public, privarte, protected 접근 제한자를 제공하지 않는다.

자바스크립트 객체의 모든 프로퍼티와 메서드는 기본적으로 외부에 공개되어 있다. 즉 public하다.

 

객체의 프로퍼티로 어떤 값을 직접 접근하는 것을 막기 위해 클로저를 사용한다.

const Person = (function () {
  let _age = 0;

  // 생성자 함수
  function Person(name, age) {
    this.name = name;
    _age = age;
  }

  Person.prototype.sayHi = function () {
    return `안녕! 내 이름은 ${this.name}이고, 나이는 ${_age}살이야`;
  };

  return Person;
})();

const me = new Person('길동', 20);

즉시 실행 함수가 반환하는 Person 생성자 함수와 Person.prototype.sayHi 메서드는 즉시 실행함수가 종료된 이후에 실행된다. 이때, Person 생성자 함수와 sayHi 메서드는 즉시 실행 함수의 렉시컬 환경을 기억하여 지역 변수 _age를 참조할 수 있다.

 

위의 코드는 정보 은닉이 잘되는 것처럼 동작하지만 문제가 있다.

const you = new Person('동길', 22);
you.sayHi(); // '안녕! 내 이름은 동길이고, 나이는 22살이야'
me.sayHi(); // '안녕! 내 이름은 길동이고, 나이는 22살이야'

여러 인스턴스를 생성하였더니 _age 변수의 상태가 유지되지 않는다. 이는 프로토타입 메서드가 생성되는 방식과 연관되어 있다.

  • Person.prototype.sayHi 메서드는 즉시 실행 함수가 처음 호출될 때 딱 한번 생성된다.
  • 이때, Person.prototype.sayHi 메서드는 자신의 상위 스코프인 즉시 실행 함수의 실행 컨텍스트의 렉시컬 환경의 참조를 [[Environment]] 에 저장한다.
  • 따라서, Person 생성자 함수로 생성된 어떤 인스턴스든 sayHi 메서드를 호출하면 하나의 동일한 상위 스코프를 사용하게 된다.

그러므로 Person 생성자 함수가 여러 개의 인스턴스를 생성할 경우 _age 변수의 상태가 유지되지 않는다.

 

따라서 위의 방법은 완전한 정보 은닉이라고 할 수 없다.

인스턴스 메서드를 사용한다면 자유 변수를 통해  private을 흉내낼 수 있지만 프로토타입 메서드를 사용하면 이마저도 불가능하다. ES6의 Symbol 또는 WeakMap 을 사용하여 private한 프로퍼티를 흉내내기도 했으나 근본적인 해결책은 아니다.
클래스에서 private 필드를 정의할 수 있는 표준 사양이 현재 최신 브라우저(Chrome 74 이상)와 Node.js(버전 12 이상)에 이미 구현되어 있다. 이는 클래스의 private 필드에서 살펴보자.

 

자주 발생하는 실수

1. 반복문에서 클로저 사용하기

funcs이라는 배열 안에 반복문의 순차적인 값(0, 1, 2)을 반환하는 함수를 넣어 실행하도록 해보자.

var funcs = [];

for (var i = 0; i < 3; i++) {
  funcs[i] = function () { return i; };
}

for (var j = 0; j < funcs.length; j++) {
  console.log(funcs[j]()); // 3 3 3
}

위의 코드는 모두 3을 출력한다.

  • var 키워드로 선언한 변수 i는 함수 레벨 스코프를 갖는다. 따라서 i는 전역 변수이다.
  • funcs의 배열에 추가한 함수를 순차적으로 호출하면 모두 전역 변수 i를 참조하여 3이 출력된다.

 

클로저를 사용해 0, 1, 2를 반환하게 만들어보자.

var funcs = [];

for (var i = 0; i < 3; i++) {
  funcs[i] = (function (id) {
    return function () {
      return id;
    };
  })(i);
}

for (var j = 0; j < funcs.length; j++) {
  console.log(funcs[j]()); // 0 1 2
}
  • 즉시 실행 함수는 전역 변수 i의 값을 인자로 받아 매개변수 id에 할당한 후 중첩 함수를 반환하고 종료된다.
  • 즉시 실행 함수가 반환한 중첩 함수는 자신의 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저이다.
  • 매개변수 id는 즉시 실행 함수가 반환한 중첩 함수에 묶여있는 자유변수가 되어 그 값이 유지된다.

 

하지만 이는 ES6의 블록 스코프를 갖는 let 키워드를 사용하여 더 깔끔하게 해결할 수 있다.

const funcs = [];

for (let i = 0; i < 3; i++) {
  funcs[i] = (function (id) {
    return function () {
      return id;
    };
  })(i);
}

for (let i = 0; i < funcs.length; i++) {
  console.log(funcs[i]()); // 0 1 2
  console.dir(funcs[i]);
}
  • for 문의 코드 블록이 반복 실행될 때마다 for문 코드 블록의 새로운 렉시컬 환경이 생성된다.
// i = 0 일때
{
  let i = 0;
   funcs[i] = (function (id) {
    return function () {
      return id;
    };
   })(i);
}
 
 // ---------------------------------------------
 // i = 1 일때
{
  let i = 1;
   funcs[i] = (function (id) {
    return function () {
      return id;
    };
   })(i);
}

 // ---------------------------------------------
 // i = 2 일때
{
  let i = 2;
   funcs[i] = (function (id) {
    return function () {
      return id;
    };
   })(i);
}
  • 위처럼 for문의 코드 블록이 반복 실행될 때마다 새로운 렉시컬 환경을 생성하고, 각 중첩 함수는 독립적인 렉시컬 환경을 기억한다.

 

2. 고차 함수 사용하기

고차 함수를 사용하면 변수와 반복문 사용을 억제하여 오류를 줄이고 가독성을 좋게 만든다.

// Array.from(obj, mapFn)는 중간에 다른 배열을 생성하지 않는다는 점을 제외하면
// Array.from(obj).map(mapFn);으로 볼 수 있다.

const funcs = Array.from(new Array(3), (_, i) => () => i);

// (_, i) => () => i를 함수 선언식으로 변형하면 다음과 같다.
/* 
function (_, i) {
  return function () {
    return i;
  }
} 
*/

funcs.forEach((f) => console.log(f()));

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/from