[JavaScript 정복기] 클로저란 무엇일까 (코어 자바스크립트) - 2

예흠·2020년 11월 25일
0

JavaScript 정복기!

목록 보기
2/2
post-thumbnail

클로저(closure)?

- 클로저의 의미 및 원리

  • 자신을 내포하는 함수의 컨텍스트에 접근할 수 있는 함수 - 더글라스 크록포드,<자바스크립트 핵심가이드>
  • 함수가 특정 스코프에 접근할 수 있도록 의도적으로 그 스코프에서 정의하는 것 - 에단 브라운,<러닝 자바스크립트>
  • 함수가 선언할 때 만들어지는 유효범위가 사라진 후에도 호출할 수 있는 함수 - 존 레식,<자바스크립트 닌자 비급>
  • 이미 생명 주기상 끝난 외부 함수의 변수를 참조하는 함수 - 송형주 고현준,<인사이드 자바스크립트>
  • 자유변수가 있는 함수와 자유변수를 알 수 있는 환경의 결합 - 에릭 프리먼,<Head First Javascript Programming>
  • 로컬 변수를 참조하고 있는 함수 내의 함수 - 야마다 요시히로,<자바스크립트 마스터북>
  • 자신이 생성될 때의 스코프에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하여 유지시키는 함수 - 유인동,<함수형 자바스크립트 프로그래밍>

클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성이다.
이렇게 여러 서적에서 나온 핵심들을 나열해 보았다.

MDN 에서는 클로저에 대해서 직역해보면 "클로저는 함수와 그 함수가 선언될 당시의 lexical environment의 상호관계에 따른 현상"이라고 소개한다.

클로저란 어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상 이라고 볼 수 있겠다.

예제를 한번 보자.

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

outer 함수에서 변수 a를 선언했고, outer의 내부함수인 inner 함수에서 a의 값을 1만큼 증가시킨 다음 출력한다.
=> inner 함수 내부에서는 a를 선언하지 않았기 때문에 environmentRecord에서 값을 찾지 못하므로 outerEnvironmentReference에 지정된 상위 컨텍스트인 outer의 LexicalEnvironment에 접근해서 다시 a를 찾는다.

outer 함수의 실행 컨텍스트가 종료되면 LexicalEnvironment에 저장된 식별자들(a, inner)에 대한 참조를 지운다. 그러면 각 주소에 저장돼 있던 값들은 자신을 참조하는 변수가 하나도 없게 되므로 가비지 컬렉터의 수집 대상이 된다.

내용을 좀 더 바꿔보자.

var outer = function () {
  var a = 1;
  var inner = function () {
    return ++a;
  };
  return inner
};
var outer2 = outer();
console.log(outer2()); //2
console.log(outer2()); //3

이번에는 inner 함수의 실행 결과가 아닌 inner 함수 자체를 반환해 보았다.
그러면 outer 함수의 실행 컨텍스트가 종료될 때 outer2 변수는 outer의 실행 결과인 inner 함수를 참조하게 될 것이다. 이후 outer2를 호출하면 앞에 반환된 함수인 inner가 실행된다.

inner함수의 실행 컨텍스트의 environmentRecord에는 수집할 정보가 없다.
=>outer-EnvironmentReference에는 inner 함수가 선언된 위치의 LexicalEnvirooent가 참조복사된다. inner함수는 outer 함수 내부에서 선언됐으므로, outer함수의 LexicalEnvironment가 담길 것이다.

inner 함수의 실행 시점에서 outer 함수는 이미 실행 종료된 상태인데 outer 함수의 LexicalEnvironment에 접근할 수 있는 이유는 바로 가비지 컬렉터의 동작 방식때문이다.
=> 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있다면 그 값은 수집 대상에 포함시키지 않는다.

두 번째 예제처럼 "어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상"이란 "외부 함수의 LexicalEnvironment가 가비지 컬렉팅되지 않는 현상"을 말하는 것이다.

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

- 클로저와 메모리 관리

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

'메모리 소모'에 대한 관리 방법은 정말 간단하다.
클로저는 어떤 필요에 의해 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생한다.
=> 그렇다면 그 필요성이 사라진 시점에는 더는 메모리를 소모하지 않게 해주면 된다.

참조 카운트를 0으로 만들면 언젠가 가비지 컬렉터가 수거해갈 것이고, 이때 소모됐던 메모리가 회수될 것이다.

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

여러가지 메모리 해제 코드를 추가한 예제를 보자.

// (1) return에 의한 클로저의 메모리 해제
var outer = (funtion () {
             var a = 1;
             var inner = function () {
  				return ++a;
			};
			return inner;
})();
console.log(outer());
console.log(outer());
outer = null; // outer 식별자의 inner 함수 참조를 끊음으로 메모리 해제

// (2) setInterval에 의한 클로저의 메모리 해제
(function () {
  var a = 0;
  var intervalId = null;
  
  var inner = function () {
    if (++a >= 10) {
      clearInterval(intervalId);
      inner = null; // inner 식별자의 함수 참조를 끊음으로 메모리 해제
    }
    console.log(a);
  };
  intervalId = setInterval(inner, 1000);
})();
  
// (3) eventListener에 의한 클로저의 메모리 해제
(function () {
  var count = 0;
  var button = document.createElement('button');
  button.innerText = 'click';
  
  var clickHandler = funtion () {
    console.log(++count, 'times clicked');
    if(count >= 10) {
      button.removeEventListener('click', clickHandler);
      clickHandler = null; // clickHandler 식별자의 함수 참조를 끊음으로 메모리 해제
    }
  };
  button.addEventListener('click', clickHandler);
  document.body.appendChild(button);
})();

- 클로저 활용 사례

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

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

var alertFruitBuilder = function (fruit) {
  return function () {
    alert('your choice is ' + fruit);
  };
};
fruits.forEach(function (fruit) {
  var $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListenee('click', alertFruitBuilder(fruit));
  $ul.appendChild($li);
});
document.body.appendChild($ul);
alertFruit(fruits[1]);

alertFruitBuiler 함수를 실행하면서 fruit 값을 이낮로 전달하고 이 함수의 실행 결과가 다시 함수가 되며, 반환된 함수를 리스너에 콜백 함수로써 전달할 것이다.
이후 언젠가 클릭 이벤트가 발생하면 비로소 이 함수의 실행 컨텍스트가 열리면서 alertFruitBuilder의 인자로 넘어온 fruit를 outerEnvironmentReference에 의해 참조할 수 있을 것이다.
=> 즉, alertFruitBuilder의 실행 결과로 반환된 함수에는 클로저가 존재한다.

이렇게 고차함수를 이용해서 외부변수를 참조 시켜서 클로저를 활용할 수 있다.

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

정보은닉(information hiding)은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서 모듈간의 결합도를 낮추고 유연성을 높이고자 하는 현대 프로그래밍 언어의 중요한 념 중 하나이다. (흔히 접근 권한에는 public, private, protected 세 종류가 있다.)

  • public : 외부에서 접근 가능한 것
  • private: 내부에서만 사용하며 외부에 노출되지 않는 것

예시

var createCar = function () {
  var fuel = Math.ceil(Math.random() * 10 + 10); // 연료(L)
  var power = Math.ceil(Math.random() * 3 + 2); // 연비(km / L)
  var moved = 0;
  var publicMembers = {
    get moved () { // getter
      return moved;
    },
    run: function () {
      var km = Math.ceil(Math.random() * 6);
      var wasteFuel = km / power;
      if (fuel < wasteFuel) {
        console.log('이동불가');
        return;
      }
      fuel -= wasteFuel;
      moved += km;
      console.log(km + 'km 이동 (총 ' + moved + 'km). 남은 연료: ' + fuel);
    }
  };
  Object.freeze(publicMembers); //덮어씌우는 어뷰징을 막기위함
  return publicMembers;
};
var car = createCar();

fuel, power 변수는 비공개 멤버로 지정해 외부에서의 접근을 제한했고, moved 변수는 getter만을 부여함으로써 읽기 전용 속성을 부여했다.
=> 이제 외부에서는 오직 run 메서드를 실행하는 것과 현재의 moved 값을 확인하는 두 가지 동작만 할 수 있다.

그리고 그냥 return을 한다면 run 메서드를 다른 내용으로 덮어씌우는 어뷰징이 가능하기 때문에 Object.freeze를 사용하여 변경할 수 없게끔 막아보았다.

클로저를 활용해 접근권한을 제어하는 방법

  • 함수에서 지역변수 및 내부함수 등을 생성한다.
  • 외부에 접근권한을 주고자 하는 대상들로 구성된 참조형 데이터(대상이 여럿일 때는 객체 또는 배열, 하나일 때는 함수)를 return한다.
    => return한 변수들은 공개 멤버가 되고, 그렇지 않은 변수들은 비공개 멤버가 된다.

3. 부분 적용 함수

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

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

//(1)
var add = function () {
  var result = 0;
  for (var i = 0; i < arguments.length; i++) {
    result += arguments[i];
  }
  return result;
};
var addPartial = partial(add, 1, 2, 3, 4, 5);
console.log(addPartial(6, 7, 8, 9, 10)); // 55

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

이렇게 첫 번째 인자에는 원본 함수를, 두 번째 인자 이후부터는 미리 적용할 인자들을 전달하고, 반환할 함수(부분 적용 함수)에서는 다시 나머지 인자들을 받아 이들을 한데 모아(concat) 원본 함수를 호출(apply)한다.

이 부분에서 디바운스도 관련이 있는데 디바운스에 대해서는 쓰로틀과 비교하면서 다음에 다뤄 보고자 한다.

4. 커링 함수

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

부분 적용 함수와 기본적인 맥락은 일치하지만 몇 가지 다른 점이 있다.

  • 커링은 한 번에 하나의 인자만 전달하는 것을 원칙으로 한다.
  • 중간 과정상의 함수를 실행한 결과는 그다음 인자를 받기 위해 대기만 할 뿐으로, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않는다.
    => 부분적용 함수는 여러 개의 인자를 전달할 수 있고, 실행 결과를 재실행 할때 원본 함수가 무조건 실행된다.
var curry3 = function (func) {
  return function (a) {
    return function (b) {
      return func(a, b)
    };
  };
};

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

이처럼 부분 적용 함수와 달리 커링 함수는 필요한 상황에 직접 만들어 쓰기 용이하다.
필요한 인자 개수만큼 함수를 만들어 계속 리턴해주다가 마지막에만 조합해서 리턴해주면 되기 때문이다.

그런데 인자가 많아질수록 가독성이 떨어진다는 단점이 있다.

var curry5 = function (func) {
  return function (a) {
    return function (b) {
      return function (c) {
        return function (d) {
          return function (e) {
            return func(a,b,c,d,e);
          };
        };
      };
    };
  };
};

이런식으로 줄을 너무 많이 소모 할 수 있는데 ES6에서는 화살표 함수를 써서 한 줄로 표기할 수가 있다.

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

화살표 순서에 따라 함수에 값을 차례로 넘겨주면 마지막에 func가 호출될 거라는 흐름이 한눈에 파악이 된다.

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

이 커링 함수가 유용한 경우가 있는데, 당장 필요한 정보만 받아서 전달하고 또 필요한 정보가 들어오면 전달하는 식으로 하면 결국 마지막 인자가 넘어갈 때까지 함수 실행을 미루는 셈이 된다. 이를 함수형 프로그래밍에서는 지연실행(lazy execution)이라고 칭한다.
=> 원하는 시점까지 지연시켰다가 실행하는 것이 요긴한 상황이라면 커링을 쓰기에 적합하다.

유용한 예시를 보자

var getInformation = baseUrl => path => id => fetch(baseUrl + path + '/' + id);

var imageUrl = 'http://imageAddress.com/';

//이미지 타입별 요청 함수 준비
var getImage = getInformation(imageUrl);
var getEmoticon = getImage('emoticon');
var getIcon = getImage('icon');

//실제 요청
var emoticon1 = getEmoticon(100); // http://imageAddress.com/emoticon/100;
var icon1 = getIcon(205); // http://imageAddress.com/icon/205;

- 결론 및 정리

  • 클로저란 어떤 함수에서 선언한 변수를 참조하는 내부함수를 외부로 전달할 경우, 함수의 실행 컨텍스트가 종료된 후에도 해당 변수가 사라지지 않는 현상이다.
  • 내부함수를 외부로 전달하는 방법에는 함수를 return하는 경우뿐 아니라 콜백으로 전달하는 경우도 포함된다.
  • 클로저는 그 본질이 메모리를 계속 차지하는 개념이므로 더는 사용하지 않게 된 클로저에 대해서는 메모리를 차지하지 않도록 관리해줄 필요가 있다.

자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 직접 부여하도록 설계돼 있지 않지만 우리는 클로저를 이용하여 함수 차원에서 public한 값과 private한 값을 구분하는 것이 가능하다.

var outer = function () {
  var a = 1;
  var inner = function () {
    return ++a;
  };
  return inner
};
var outer2 = outer();
console.log(outer2()); //2
console.log(outer2()); //3

outer 함수는 외부로부터 철저하게 격리된 닫힌 공간이다. 외부에서는 외부 공간에 노출돼 있는 outer라는 변수를 통해 outer 함수를 실행할 수는 있지만, outer 함수 내부에는 어떠한 개입도 할 수 없다.
=> 외부에서는 오직 outer 함수가 return한 정보에만 접근할 수 있다.

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

profile
노래하는 개발자입니다.

0개의 댓글