본문 바로가기

Javascript/모던 자바스크립트 DeepDive

[모던 자바스크립트 Deep Dive] 24 클로저

  • 클로저는 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어 등에서 사용되는 중요한 특성
  • 클로저는 자바스크립트 고유의 개념이 아니므로 ECMAScript 사양에 들어가지 않음
  • MDN에서 정의한 클로저
    • "A closure is the combination of a function and the lexical environment within which that function was declared"
    • "클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다"
// innerFunc 함수가 outerFunc 함수의 내부에서 정의된 중첩 함수가 아니라면 
// innerFunc 함수를 outerFunc  함수의 내부에서 호출하더라라도 outerFunc 함수의 변수에 접근 불가
const x = 1;

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

function innerFunc() {
  console.log(x); // 1; innerFunc은 전역에서 정의되었기 때문
}

outerFunc();

렉시컬 스코프

  • 자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디서 정의했는지에 따라 상위 스코프를 결정
  • 스코프의 실체는 실행 컨텍스트의 렉시컬 환경. 렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조 Outer Lexical Environment Reference를 통해 상위 렉시컬 환경과 연결. 이것이 스코프 체인
  • 렉시컬 스코프: 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장할 참조값. 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정
const x = 1;

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

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

foo(); // 결과는?
bar(); // 결과는?

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

  • 함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장
  • 해당 함수가 호출되었을 때 생성될 함수 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장될 참조 값
  • 외부 렉시컬 환경에 대한 참조에는 함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조가 할당 됨

함수 객체의 내부 슬롯 [[Environment]]에는 상위 스코프가 저장

 

클로저와 렉시컬 환경

  • 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조 가능. 이러한 중첩 함수를 클로저 closure라고 부름
  • 실행 컨텍스트가 실행 컨텍스트 스택에서 제거되어도 다른 곳에서 참조되어지고 있다면 해당 렉시컬 환경은 소멸하지 않음
  • 클로저는 중첩 함수가 상위 스코프 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적 (자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로는 모든 함수는 클로저)
  • 자유 변수 free variable: 클로저에 의해 참조되는 상위 스코프의 변수, 클로저는 함수가 자유 변수에 닫혀있다라는 의미
const x = 1;

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

// outer 함수를 호출하면 중첩함수 inner를 반환
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텐스트에서 팝되어 제거됨
const innerFunc = outer();
innerFunc(); // 10

전역 함수 객체의 상위 스코프 결정

 

중첩 함수의 상위 스코프 결정
outer 함수의 실행 컨텍스트가 제거되어도 outer 함수의 렉시컬 환경은 유지
외부 함수가 소멸해도 반환된 중첩 함수는 외부 함수의 변수를 참조할 수 있음

클로저의 활용

  • 클로저는 상태 state를 안전하게 변경하고 유지하기 위해 사용. 상태를 은닉 information hiding , 특정 함수에게만 상태 변경을 허용하게 함
// 카운트 상태 변수
let num = 0;

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

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

// 오류 가능성
// 1. 카운트 상태(num변수의 값)는 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 함
// 2. 이를 위해 카운트 상태(num변수의 값)는 increase 함수만이 변경할 수 있어야 함
// 카운트 상태 변경 함수
const increase = function () {
  // 카운트 상태 변수
  let num = 0;

  // 카운트 상태를 1만큼 증가시킴
  return ++num;
};

// 이전 상태를 유지하지 못함 - 함수가 호출될 때마다 새로운 렉시컬 환경 생성
console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1
// 카운트 상태 변경 함수
const increase = (function () {
  // 카운트 상태 변수
  let num = 0;
  // 클로저
  return function () {
    return ++num;
  };
})();

// 카운트 증가 성공
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
const counter = (function () {
  // 카운트 상태 변수
  let num = 0;

  // 클로저인 메서드를 갖는 객체를 반환
  // 객체 리터럴은 스코프를 만들지 않음
  // 따라서 아래 메서드들의 상위 스코프는 즉시 실행 함수의 렉시컬 환경
  return {
    // num:0 - 프로퍼티는 public하므로 은닉되지 않음
    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
console.log(counter.decrease()); // 0
// 위 코드를 생성자 함수로 표현할 경우
const Counter = (function () {
  // 카운트 상태 변수
  let num = 0;
  function Counter() {
    // this.num = 0 - 프로퍼티는 public하므로 은닉되지 않음
  }

  Counter.prototype.increase = function () {
    return ++num;
  };

  Counter.prototype.decrease = function () {
    return num > 0 ? --num : 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
console.log(counter.decrease()); // 0
// 함수를 인수로 전달받고 함수를 반환하는 함수
// 이 함수는 카운트 상태를 유지하기 위한 counter를 기억하는 클로저를 반환
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);
console.log(increaser()); // 1
console.log(increaser()); // 2

// makeCounter 함수를 호출해 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖음
const decreaser = makeCounter(decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2

makeCounter 함수를 두 번째 호출했을 때 생성된 렉시컬 환경

// 독립된 카운터가 아니라 연동하여 증감이 가능한 카운터를 만들려면 렉시컬 환경을 공유하는 클로저를 만들어야 함
// 이를 위해서는 makeCounter함수를 두 번 호출하지 말아야 함

// 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 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;
}

// 보조 함수를 전달하여 호출
console.log(counter(increase)); // 1
console.log(counter(increase)); // 2

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

캡슐화와 정보 은닉

  • 캡슐화 encapsulation는 객체의 상태 state를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작 behavior인 메서드를 하나로 묶는 것. 이를 통해 객체의 특성 프로퍼티나 메서드를 감추는 목적(정보 은닉 information hiding)으로 사용하기도 함
  • 정보 보호 및 객체 간의 상호 의존성 (결합도 coupling)을 낮추는 효과가 있음
  • 대부분의 객체지향 프로그래밍 언어는 public, private, protectedt 같은 접근 제한자 access modifier를 선언하여 공개 범위를 한정 가능
  • 자바스크립트는 이제 private를 필드를 정의할 수 있는 새로운 사양을 도입하려는 듯? 기본적으로는 지원하지 않음
  • 흉내내는 방법은 한계가있어 근본적인 해결은 아님
function Person(name, age) {
  this.name = name; // public
  let _age = age; // private

  // 인스턴스 메서드
  this.sayHi = function () {
    console.log(`Hi! my name is ${this.name}. I am ${_age}`);
  };
}

const me = new Person("Jin", 29);
me.sayHi(); // Hi! my name is Jin. I am 29
console.log(me.name); // Jin
console.log(me._age); // undefined

// sayHi 메서드는 인스턴스 메서드이므로 Person 객체가 생성될 때마다 중복 생성됨
function Person(name, age) {
  this.name = name; // public
  let _age = age; // private
}
// 프로토타입 메서드
Person.prototype.sayHi = function() {
  // Person 생성자 함수의 지역 변수 _age를 참조할 수 없음
  console.log(`Hi! my name is ${this.name}. I am ${_age}`)
}
// 즉시 실행 함수를 사용하여 Person 생성자 함수와 prototype 메서드를 하나의 함수에 모아보자
const Person = (function () {
  let _age = 0;

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

  // 프로토타입 메서드
  Person.prototype.sayHi = function () {
    console.log(`Hi! my name is ${this.name}. I am ${_age}`);
  };

  // 생성자 함수 반환
  return Person;
})();

const me = new Person("Jin", 29);
me.sayHi(); // Hi! my name is Jin. I am 29
console.log(me.name); // Jin
console.log(me._age); // undefined

const you = new Person("Park", 20);
you.sayHi(); // Hi! my name is Park. I am 20
console.log(you.name); // Park
console.log(you._age); // undefined

// 문제점
me.sayHi(); // Hi! my name is Jin. I am 20
// Person.prototype.sayHi 메서드가 단 한번 생성되는 클로저
// Person.prototype.sayHi 메서드는 모든 인스턴스가 하나의 동일한 상위 스코프를 사용

자주 발생하는 실수

var funcs = [];

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

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

// 3
// 3
// 3

// var는 블록 레벨 스코프가 아닌 함수 레벨 스코프를 갖기 때문에 전역 변수
var funcs = [];

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

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

// 0
// 1
// 2

// let은 블록 레벨 스코프를 가져, 매 반복마다 렉시컬 스코프를 생성
// 고차 함수와 화살표 함수를 사용한 방법
const funcs = Array.from(new Array(3), (_, i) => () => i);

funcs.forEach((f) => console.log(f()));
// 0
// 1
// 2