Javascript - 클로저 (Closure)

B__m·2023년 11월 18일
0

코어자바스크립트

목록 보기
2/3

클로저의 의미

  • 클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성입니다.
  • 클로저는 함수와 함수가 선언된 Lexical Environment 의 조합입니다.
  • 클로저는 함수가 선언될 때의 스코프를 기억하고, 함수가 해당 스코프 외부에서 호출될 때에도 그 스코프에 접근할 수 있도록 하는 개념입니다.
  • 클로저는 어떤 함수에서 선언한 변수를 참조하는 내부 함수에서만 발생하는 현상입니다.

A closure is the combination of a function and the lexical environment within which that function was declared - MDN

"클로저는 함수와 그 함수가 선언될 당시의 lexical environment 의 상호관계에 따른 현상"

const outer = function() {
  const a = 1;
  
  const inner = function() {
    return ++a;
  };
  return inner;
};

const outer2 = outer();
console.log(outer2());		// 2
console.log(outer2());		// 3

위의 코드에서 outer 함수는 inner 함수를 반환하고 있습니다. 이렇게 반환된 inner 함수는 외부 변수 a를 참조하고 있고, outer 함수의 실행이 끝난 이후에도 호출됩니다.

이런 경우, outer 함수가 실행될 때 생성된 Lexical Environment가 inner 함수의 스코프 체인에 포함되어 있어서 inner 함수는 외부 변수 a에 접근할 수 있습니다. 그래서 outer2를 호출할 때마다 a가 유지되어 2와 3 같은 결과를 얻을 수 있습니다.

함수의 실행 컨텍스트가 종료된 이후에도 Lexical Environment가 가비지 컬렉터의 수집 대상에서 제외되는 상황입니다. 이는 지역 변수를 참조하는 내부 함수가 외부로 전달되었기 때문에 발생합니다.

이처럼 함수의 실행 컨텍스트가 종료된 후에도 LexicalEnvironment 가 가비지 컬렉터의 수집 대상에서 제외되는 경우는 지역변수를 참조하는 내부함수가 외부로 전달된 경우가 유일합니다.
그러니까 "어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상"이란 "외부 함수의 LexicalEnvironment가 가비지 컬렉팅 되지 않는 현상"을 말하는 것입니다.


메모리 관리

메모리 누수의 위험을 이유로 클로저 사용을 조심해야 한다거나 심지어 지양해야 한다고 주장하는 사람들도 있지만 메모리 소모는 클로저의 본질적인 특성일 뿐입니다. 오히려 이러한 특성을 정확히 이해하고 잘 활용하도록 노력해야 합니다.

'메모리 소모'에 대한 관리법만 잘 파악해서 적용해야겠습니다.

관리 방법은 간단합니다.

클로저는 어떤 필요에 의해 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생합니다. 그렇다면 그 필요성이 사라진 시점엔 더 이상 메모리를 소모하지 않게 해주면 됩니다. 참조 카운트를 0으로 만들면 언젠가 가비지 컬렉터가 수거해 갈 것이고, 이때 소모됐던 메모리가 회수될 것입니다.

방법은 식별자에 참조형이 아닌 기본형 데이터(null 이나 undefined)를 할당하면 됩니다.

const outer (function() {
  const a = 1;
  const inner = function() {
    return ++a;
  }
  return inner;
})();

console.log(outer());
console.log(outer());
outer = null; 					// outer 식별자의 inner 함수 참조를 끊음

클로저 활용 사례

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

const fruits = ['apple', 'banana', 'peach'];
const $ul = document.createElement('ul');

fruits.forEach(function(fruit) {				// (A)
  const $li = document.createElement('li'));
  $li.innerText = fruit;
  $li.addEventListiener('click', function() {	// (B)
    alert('your choice is' + fruit);
  });
  $ul.appendChild($li);
});
document.body.appendChild($ul);

콜백 함수 (A)는 그 내부에서 외부 변수를 사용하지 않고 있으므로 클로저가 없지만, addEventListener에 넘겨준 콜백함수 (B)는 fruit 이라는 외부 변수를 참조하고 있으므로 클로저가 있습니다. (A)는 fruits의 개수만큼 실행되며, 그때마다 새로운 실행 컨텍스트가 활성화될 것입니다. A의 실행 종료 여부와 무관하게 클릭 이벤트에 의해 각 컨텍스트의 (B)가 실행될 때는 (B)의 outerEnvironmentReference가 (A) LexicalEnvironment를 참조하게 됩니다. 따라서 최소한 (B)함수가 참조할 예정인 변수 fruit 에 대해서는 (A)가 종료된 후에도 GC 대상에서 제외되어 계속 참조 가능할 것입니다.

그런데 (B)의 함수의 쓰임새가 콜백 함수에 국한되지 않는 경우라면 반복을 줄이기 위해 (B)를 외부로 분리하는 편이 낫겠습니다.

const fruits = ['apple', 'banana', 'peach'];
const $ul = document.createElement('ul');

const alertFruit = function (fruit) {
  alert('your choice is' + fruit);
};

fruits.forEach(function (fruit) {
  const $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', alertFruit.bind(null,fruit));
  $ul.appendChild($li);
});

document.body.appendChild($ul);
alertFruit(fruits[1]);

bind 메서드로 값을 직접 넘겨주고 외부로 분리 (클로저x)
그냥 alertFruit를 넘기면 인자에 대한 제어권을 addEventListener가 가지게 되고, 첫번째 인자에 이벤트 객체를 주입하기 때문에 [object MouseEvent] 가 출력됩니다. 따라서 bind 메서드를 사용했습니다. 다만 이렇게 하면 이벤트 객체가 인자로 넘어오는 순서가 바뀌는 점 및 함수 내부에서의 this가 원래의 그것과 달라지는 점은 감안해야 합니다. 이런 점을 보완하기 위해 고차함수 를 활용할 수 있습니다.

const fruits = ['apple', 'banana', 'peach'];
const $ul = document.createElement('ul');

const alertFruitBuilder = function (fruit) {
	return function () {
      alert('your choice is' + fruit);
    }
};

fruits.forEach(function (fruit) {
  const $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', alertFruitBuilder(fruit)
  $ul.appendChild($li);
});

document.body.appendChild($ul);
alertFruit(fruits[1]);

alertFruitBuilder 함수를 실행하면서 fruit 값을 인자로 전달했습니다.

그러면 이 함수의 실행 결과가 다시 함수가 되며, 이렇게 반환된 함수를 리스너에 콜백 함수로써 전달할 것입니다. 이후 언젠가 클릭 이벤트가 발생하면 비로소 이 함수의 실행 컨텍스트가 열리면서 alertFruitBuilder의 인자로 넘어온 fruit를 outerEnvironmentReference에 의해 참조할 수 있겠죠.

alertFruitBuilder의 실행 결과로 반환된 함수에는 클로저가 존재합니다.

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

정보 은닉은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서 모듈간의 결합도를 낮추고 유연성을 높이고자 하는 현대 프로그래밍 언어의 중요한 개념중 하나입니다.

흔히 접근 권한에는 public, private, protected 세 종류가 있습니다.
public은 외부에서 접근 가능한 것이고, private 는 내부에서만 사용 가능하며 외부에 노출되지 않는 것을 의미합니다.

클로저를 이용하면 함수 차원에서 public한 값과 private한 값을 구분하는 것이 가능합니다.
바로 return 을 활용하면 됩니다.

외부에 제공하고자 하는 정보들을 모아서 return 하고, 내부에서만 사용할 정보들은 return 하지 않는 것으로 접근 권한 제어가 가능한 것입니다.

function createCounter() {
  // private 변수
  let count = 0;

  // public 함수
  function increment() {
    count++;
    console.log(count);
  }

  // 클로저: 외부에서는 increment만 접근 가능
  return increment;
}

const counter = createCounter();
counter(); // 출력 결과: 1
counter(); // 출력 결과: 2

이 예제에서 createCounter 함수는 클로저를 반환합니다. 클로저에는 count라는 private한 변수와 increment라는 public한 함수가 포함되어 있습니다. 외부에서는 count에 직접 접근할 방법이 없으며, 오직 increment 함수만을 통해서만 count를 조작할 수 있습니다. 이로써 count는 함수 외부로부터 감춰진(private) 상태가 되고, 함수 외부에서는 모듈의 일부로서 필요한 부분만을 노출할 수 있게 됩니다.

부분 적용 함수

부분 적용 함수란 n개의 인자를 받는 함수에 미리 m개의 인자만 남겨 기억시켰다가, 나중에 (n-m)개의 인자를 넘기면 비로소 원래 함수의 실행 결과를 얻을 수 있게끔 하는 함수입니다.

this를 바인딩해야 하는 점을 제외하면 앞서 살펴본 bind 메서드의 실행 결과가 바로 부분 적용 함수입니다.

bind 메서드를 활용한 부분 적용 함수

const add = function () {
  const result = 0;
  for (var i = 0; i < arguments.length; i++) {
    result += arguments[i];
  }
  return result;
};

const addPartial = add.bind(null, 1, 2, 3, 4, 5);
console.log(addPartial(6, 7, 8, 9, 10));			// 55

부분 적용 함수 구현

const partial = function () {
  const originalPartialArgs = arguments;
  const func = originalPartialArgs[0];
  if(typeof func !== 'function') {
    throw new Error('첫 번째 인자가 함수가 아닙니다.');
  }
  return function () {
    const partialArgs = Array.prototype.slice.call(originalPartialArgs, 1);
    const restArgs = Array.prototype.slice.call(arguments);
    return func.apply(this,partialArgs.concat(restArgs));
  };
};

const add = function () {
  const result = 0;
  for(let i = 0; i < arguments.length; i++) {
    result += arguments[i];
  }
  return result;
};

const addPartial = partial(add, 1, 2, 3, 4, 5);
console.log(addPartial(6, 7, 8, 9, 10)); 				// 55

const dog = {
  name: '강아지',
  greet: partial(function(prefix, suffix) {
    return prefix + this.name + suffix;
  }, '멍멍, ')
};
dog.greet('입니다 !');									// 멍멍, 강아지입니다.

첫 번째 인자에는 원본 함수를, 두 번째 인자 이후부터는 미리 적용할 인자들을 전달하고, 반환할 함수 부분 적용 함수 에서는 다시 나머지 인자들을 받아 이들을 한데 모아 concat 원본 함수를 호출apply 합니다. 또한 실행 시점의 this를 그대로 반영함으로써 this에는 아무런 영향을 주지 않게 됐습니다.

디바운스

디바운스는 짧은 시간 동안 동일한 이벤트가 많이 발생할 경우 이를 전부 처리하지 않고 처음 또는 마지막에 발생한 이벤트에 대해 한 번만 처리하는 것으로, 프런트엔드 성능 최적화에 큰 도움을 주는 기능 중 하나입니다. scroll, wheel, mousemove, resize 등에 적용하기 좋습니다.

const debounce = function (eventName, func, wait) {
  const timeoutId = null;
  return function (event) {
    const self = this;
    console.log(eventName, 'event발생');
    clearTimeout(timeoutId);
    timeoutId = setTimeout(func.bind(self,event),wait);
  };
};

const moveHandler = function(e) {
  console.log('move event 처리');
};

const wheelHandler = function(e) {
  console.log('wheel event 처리');
};

document.body.addEventListener('mousemove', debounce('move', moveHandler,500));

document.body.addEventListener('mousewheel', debounce('wheel', wheelHandler,700));

디바운스 함수에서 클로저로 처리되는 변수에는 eventName, func, wait, timeoutId가 있습니다.

커링 함수

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

커링은 한 번에 하나의 인자만 전달하는 것을 원칙으로 합니다. 또한 중간 과정상의 함수를 실행한 결과는 그다음 인자를 받기 위해 대기만 할 뿐으로, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않습니다.

부분 적용 함수와는 다르게 여러 개의 인자를 전달할 수 있고, 실행 결과를 재실행 할 때 원본 함수가 무조건 실행됩니다.

const curry3 = function (func) {
  return function (a) {
    return function (b) {
      return func(a, b);
    };
  };
};

const getMaxWith10 = curry3(Math.max)(10);
console.log(getMaxWith10(8));				// 10
console.log(getMaxWith10(25));				// 25

const get getMinWith10 = curry3(Math.min)(10);
console.log(getMinWith(8));					// 8
console.log(getMinWith(25));				// 10

부분 적용 함수와 달리 커링 함수는 필요한 상황에 직접 만들어 쓰기 용이합니다. 필요한 인자 개수만큼 함수를 만들어 계속 리턴해 주다가 마지막에 조합해서 리턴해주면 됩니다. 다만 인자가 많아질수록 가독성이 떨어진다는 단점이 있습니다.

ES6에서는 화살표 함수를 써서 한 줄에 표기할 수 있습니다.

const curry5 = func => a => b => c => d => e => func(a, b, c, d, e);

각 단계에서 받은 인자들을 모두 마지막 단계에서 참조할 것이므로 GC되지 않고 메모리에 차곡차곡 쌓였다가, 마지막 호출로 실행 컨텍스트가 종료된 후에야 비로소 한꺼번에 GC의 수거 대상이 됩니다.

커링함수가 유용할 때가 있는데, 당장 필요한 정보만 받아서 전달하고 또 필요한 정보가 들어오면 전달하는 식으로 하면 결국 마지막 인자가 넘어갈 때까지 함수 실행을 미루는 셈이 됩니다. 이를 함수형 프로그래밍에서는 지연실행이라고 칭합니다.


정리

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

내부함수를 외부로 전달하는 방법에는 함수를 return 하는 경우뿐 아니라 콜백으로 전달하는 경우도 포함됩니다.

클로저는 그 본질이 메모리를 계속 차지하는 개념이므로 더는 사용하지 않게 된 클로저에 대해서는 메모리를 차지하지 않도록 관리해줄 필요가 있습니다.

0개의 댓글