[코어 자바스크립트] - 클로저

이예슬·2023년 2월 19일
1
post-thumbnail

클로저의 의미 및 원리 이해

💡 클로저란 함수와 그 함수가 선언될 당시의 Lexical Environment의 상호관계에 따른 현상을 말한다.
function outerFunc() {
  var x = 10;
  var innerFunc = function () { console.log(x); };
  innerFunc();
}

outerFunc(); // 10

innerFunc는 outerFunc 내에서 선언되고 호출되었다. 이때 내부함수 innerFunc는 자신을 포함하고 있는 외부함수 outerFunc의 변수 x에 접근할 수 있다. innerFunc는 자신이 속한 렉시컬 스코프를 참조할 수 있기 때문이다.

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

/**
 *  함수 outerFunc를 호출하면 내부 함수 innerFunc가 반환된다.
 *  그리고 함수 outerFunc의 실행 컨텍스트는 소멸한다.
 */
var inner = outerFunc();
inner(); // 10

함수 outerFunc는 내부함수 innerFunc를 반환하고 종료되었다. 즉 함수 outerFunc는 실행된 이후 콜스택(실행 컨텍스트 스택)에서 제거되었으므로 outerFunc의 변수 x 또한 더 이상 유효하지 않게 되어 변수 x 또한 더 이상 유효하지 않게 되어 변수 x에 접근할 수 있는 방법은 달리 없어 보인다. 하지만 inner() 실행시 10이 출력된다. 이는 가비지 컬렉터의 동작과 관련이 있는데 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있다면 그 값은 수집 대상에 포함시키지 않기 때문이다. 즉 어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상이란 외부 함수의 LexicalEnvironment가 가비지 컬렉팅되지 않는 현상을 말한다.

쉽게 말해서 클로저란 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상을 말한다.

이 때 외부로의 전달이란 return만을 의미하는 것은 아니다.

(function(){
	var a = 0;
	var intervalId = null;
	var inner = function () {
		if(++a >= 10){
			clearInterval(intervalId);
		}
		console.log(a)
	}
	intervalId = setInterval(inner, 1000);
})()

별도의 외부 객체인 window의 메서드에 전달할 콜백 함수 내부에서 지역변수를 참조한다.

(function(){
	var count = 0;
	var button = document.createElement('button');
	button.innerText = 'click';
	button.addEventListner('click', function(){
		console.log(++count, 'times clicked');
	});
	document.body.appendChild(button);
})()

별도의 외부객체인 DOM의 메서드에 등록할 handler 함수 내부에서 지역변수를 참조한다. 두 상황 모두 지역변수를 참조하는 내부함수를 외부에 전달했기 때문에 클로저이다.

클로저와 메모리 관리

클로저는 어떤 필요에 의해 의도적으로 함수의 지역 변수를 메모리를 소모하도록 함으로써 발생한다. 메모리를 관리하기 위해서는 그 필요성이 사라진 시점에는 더는 메모리를 소모하지 않게 해주면 된다. 참조 카운트를 0으로 만들면 GC가 수거해가고 이때 소모됐던 메모리가 회수된다.

var outer = (function(){
	var a= 1; 
		var inner = function() {
			return ++a;
		};
	return inner;
})();
console.log(outer())
console.log(outer())
outer = null 

참조 카운트를 0으로 만드는 방법으로는 식별자에 참조형이 아닌 기본형 데이터(null or undefined)를 할당하면 된다.

function getClosure() {
  let count = 0;
  let interval;
  let inner = function () {
    count++;
    console.log(count);
    if (count >= 5) {
      clearInterval(interval);
      inner = null;
    }
  };
  interval = setInterval(inner, 1000);
}
getClosure();

setInterval로 실행되어 count라는 외부 변수를 참조하고 있으나 count가 5가 되었을 때 메모리를 해제하도록 하였다.

function getClosure() {
  const btnElem = document.querySelector('.my-button');
  let count = 0;

  let onClick = function () {
    count++;
    console.log(count);
    if (count >= 5) {
      btnElem.removeEventListener('click', onClick);
      onClick = null;
    }
  };
  btnElem.addEventListener('click', onClick);
}
getClosure();

메모리에서도 동일한 방식을 적용할 수 있다. count가 5가 되었을 때 버튼에서 핸들러를 제거하고 함수를 null로 바꾸면서 메모리를 해제하였다.

function getClosure() {
  const obj = { count: 0 };
  const weakCount = new WeakRef(obj);
  return function () {
    console.log(weakCount.deref().count++);
  };
}
const closure = getClosure();
const interval = setInterval(closure, 100);

위 방법은 약한 참조를 활용한 방식이다. 약한 참조는 ES2021에 새로 등장한 문법으로 가비지 컬렉터가 돌 때 참조를 유지하지 않겠다는 의미로 사용한다.

WeekRef는 객체타입만 받을 수 있으므로 count를 객체로 만들고 그 후 약한 참조 객체를 새로 만들었다. 내부 함수는 obj가 아닌 weekCount를 참조하고 있다. 그러므로 obj는 사라져야 하지만 weekCount가 참조하고 있으므로 당장은 사라지지 않는다.

단 이 방법의 경우 가비지 컬렉션이 동작하는 시간이나 방식이 환경에 따라 다를 수 있으므로 예측이 불가능하다.

클로저 활용 사례

콜백 함수 내부에서 외부 데이터를 사용하고자 할 때

접근 권한 제어(정보 은닉)

외부에 제공하고자 하는 정보들을 모아서 return하고 내부에서만 사용할 정보들은 return하지 않는 것으로 접근 권한 제어가 가능하다. 이때 return한 변수들은 공개멤버(public member)가 되고 그렇지 않은 변수들은 비공개 멤버(private member가 된다.)

// 함수를 인자로 전달받고 함수를 반환하는 고차 함수
// 이 함수가 반환하는 함수는 클로저로서 카운트 상태를 유지하기 위한 자유 변수 counter을 기억한다.
function makeCounter(predicate) {
  // 카운트 상태를 유지하기 위한 자유 변수
  var counter = 0;
  // 클로저를 반환
  return function () {
    counter = predicate(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

함수 makeCounter는 보조 함수를 인자로 전달받고 함수를 반환하는 고차 함수이다. 함수 makeCounter가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 함수 makeCounter의 스코프에 속한 변수 counter를 기억하는 클로저다. 함수 makeCounter는 인자로 전달받은 보조 함수를 합성하여 자신이 반환하는 함수의 동작을 변경할 수 있다. 이 때 주의해야 할 것은 함수 makeCOunter를 호출해 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖는다는 것이다. 함수를 호출하면 그때마다 새로운 렉시컬 환경이 생성되고 increaser와 decreaser는 각각의 렉시컬 환경을 가지므로 counter 변수를 공유하지 않아 증감이 연동되지 않는다.

이러한 문제를 해결하기 위해서는 렉시컬 환경을 공유하는 클로저를 만들어야 한다.

function Counter() {
  // 카운트를 유지하기 위한 자유 변수
  var counter = 0;

  // 클로저
  this.increase = function () {
    return ++counter;
  };

  // 클로저
  this.decrease = function () {
    return --counter;
  };
}

const counter = new Counter();

console.log(counter.increase()); // 1
console.log(counter.decrease()); // 0

생성자 함수 Counter는 increase, decreae 메소드를 갖는 인스턴스를 생성한다. 이 메소드들은 모두 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 스코프에 속한 변수 counter를 기억하는 클로저이며 렉시컬 환경을 공유한다.

function Counter() {
  // 카운트를 유지하기 위한 자유 변수
  var counter = 0;

  // 클로저
  this.increase = function () {
    return ++counter;
  };

  // 클로저
  this.decrease = function () {
    return --counter;
  };
}

const counter = new Counter();

console.log(counter.increase()); // 1
console.log(counter.decrease()); // 0

전역 변수의 사용 억제

// 카운트 상태를 유지하기 위한 전역 변수
var counter = 0;

function increase() {
	return ++counter;
}

위 예제에서 count는 전역변수로 사용되고 있다. 따라서 언제든지 count 변수에 접근하여 변경할 수 있으며 increse() 함수가 호출되기 전에 counter의 값이 변경될 가능성이 있으므로 이는 안전한 방식이 아니다. 이 경우에는 변수 counter는 외부에서 접근할 수 없도록 막고 counter를 사용하는 increse 함수가 이를 관리해야 한다.

function increase(){
	var counter = 0; 
	return ++counter;
}

counter를 increase() 함수 내에서 사용하는 지역 변수로 변경하였다. 하지만 이럴 경우 increase 함수가 호출될 때마다 지역변수 counter를 0으로 초기화하므로 변경된 이전 상태를 기억하지 못한다.

var increase = (function(){
	var counter = 0; 
	return function(){
		return ++counter;
	};
}());

해당 코드가 실행될 경우 즉시실행함수가 호출되고 변수 increase에는 함수 function(){return ++counter;} 가 할당된다. 이 함수는 자신이 생성됏을 때의 렉시컬 환경을 기억하는 클로저이다. 즉시실행함수는 호출된 이후 소멸되지만 즉시실행 함수가 반환한 함수는 변수 increase에 할당되어 increase 함수 호출시 호출된다. 이 때 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 counter를 기억한다. 따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때까지 유지된다.

상태 유지

클로저가 가장 유용하게 사용되는 상황은 현재 상태를 기억하고 변경된 최신 상태를 유지하는 것이다.

<!DOCTYPE html>
<html>
<body>
  <button class="toggle">toggle</button>
  <div class="box" style="width: 100px; height: 100px; background: red;"></div>

  <script>
    var box = document.querySelector('.box');
    var toggleBtn = document.querySelector('.toggle');

    var toggle = (function () {
      var isShow = false;

      // 1. 클로저를 반환
      return function () {
        box.style.display = isShow ? 'block' : 'none';
        // 3. 상태 변경
        isShow = !isShow;
      };
    })();

    // 2. 이벤트 프로퍼티에 클로저를 할당
    toggleBtn.onclick = toggle;
  </script>
</body>
</html>
  1. 즉시실행함수는 함수를 반환하고 즉시 소멸한다. 즉시실행함수가 반환한 함수는 자신이 생성됐을 때의 Lexical Environment에 속한 변수 isShow를 기억하는 클로저이다. 클로저가 기억하는 변수 isShow는 box의 표시 상태를 나타낸다.
  2. 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했다. 이벤트 프로퍼티에서 이벤트 핸들러인 클로저를 제거하지 않는 한 클로저가 기억하는 렉시컬 환경 변수 isShow는 소멸하지 않는다. 즉 현재 상태를 기억한다.
  3. 버튼을 클릭하며 이벤트 프로퍼티에 할당한 이벤트 핸들러인 클로저가 호출된다. 이때 .box 요소의 표시 상태를 나타내는 변수 isShow의 값이 변경된다. isShow는 클로저에 의해 참조되고 있으므로 유효하며 자신의 변경된 최신 상태를 계속해서 유지한다.

이처럼 클로저는 현재 상태(위 예제의 경우 .box 요소의 표시 상태를 나타내는 isShow 변수)를 기억하고 이 상태가 변경되어도 최신 상태를 유지해야 하는 상황에 매우 유용하다. 만약 자바스크립트에 클로저라는 기능이 없다면 상태를 유지하기 위해 전역 변수를 사용할 수 밖에 없다. 전역 변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 한다.

부분적용함수(Partial application)

원하는 만큼의 인자를 미리 넘겨놓고 나중에 추가할 인자를 전달해서 실행한다.

일부 인자를 미리 넘겨두어 기억하게끔하고 추후 필요한 시점에 기억했던 인자들까지 함께 실행하게 한다는 개념은 클로저의 정의에 부합한다.

var plus = function(a, b, c) {
  return a + b + c;
};
Function.prototype.partial = function() {
  var args = [].slice.apply(arguments);
  var self = this;
  return function() {
    return self.apply(null, args.concat([].slice.apply(arguments)));
  };
};

args는 arguments를 복사한 것으로 함수를 return할 때 args는 클로저의 변수로 저장된다.

그리고 새로운 함수에 인자가 들어왔을 때 기존에 있던 args와 concat한다.

var plusa = plus.partial(1);
plusa(2, 3); // 6
var plusb = plusa.partial(2);
plusb(4); // 7
var plusab = plus.partial(1, 3);
plusab(5); // 9

// bind 메서드를 사용해도 된다! 
var plusa = plus.bind(null, 1);
plusa(2, 3); // 6
var plusb = plusa.bind(null, 2);
plusb(4); // 7
var plusab = plus.bind(null, 1, 3);
plusab(5); // 9

커링함수(Currying)

수학과 컴퓨터 과학에서 커링(Currying)이란 다중인수를 갖는 함수를 단일 인수를 갖는 함수들의 함수열로 바꾸는 것을 말한다.

여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성한 것을 말한다.

한번에 하나의 인자만 전달하는 것이 원칙이며 중간 과정상의 함수를 실행한 결과는 그 다음 인자를 받기 위해 대기만 할 뿐 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않는다.

⇒ 함수형 프로그래밍에서의 지연실행(lazy execution)

Function.prototype.curry = function(one) {
  var origFunc = this;
  var target = origFunc.length;
  var args = [];
  function next(nextOne) {
    args = args.concat(nextOne);
    if (args.length === target) {
      return origFunc.apply(null, args);
    } else {
      return function(nextOne) { return next(nextOne) };
    }
  }
  return next(one);
}
function multiplyFour(w, x, y, z) {
  return w * x * y * z;
}
multiplyFour.curry(2)(3)(4)(5); // 120
// 커링 변환을 하는 curry(f) 함수 (일반함수 ver)
function curry(f) {
  return function(a) {
    return function(b) {
      return f(a, b);
    };
  };
}

// 커링 변환을 하는 curry(f) 함수 (화살표함수 ver)
const curry = f => a => b => f(a, b);

// f에 전달된 함수
const sum = (a, b) => a + b;

const curriedSum = curry(sum);

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

커링은 해당 함수가 고정된 개수의 인수를 가지고록 요구하므로 고정된 길이의 함수들만 사용하다.


<코어 자바스크립트> 정재남, 위키북스(2019)

profile
꾸준히 열심히!

0개의 댓글