Javascript

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

복습의 목적으로 정재남님의 저서 '코어 자바스크립트'를 읽고 정리한 글입니다.

더 자세하고 정확한 내용은 책을 참고하시길 바랍니다.

 

이 챕터를 읽고 난 후 다음의 질문에 답할 수 있어야 한다.

  1. 클로저란 무엇인가
  2. 클로저는 언제 발생하는가
  3. 클로저를 어떻게 제거할 수 있는가
  4. 클로저를 어떻게 활용할 수 있나
  5. 정보은닉이란 무엇인가
  6. 접근 권한을 어떻게 분류할 수 있는가
  7. 자바스크립트에서 어떻게 접근 권한을 구분 지어줄 수 있는가
  8. 부분 적용 함수란 무엇인가
  9. Symbol이란 무엇인가
  10. 커링이란 무엇인가?

클로저의 정의

MDN은 다음과 같이 클로저를 정의하고 있다.

클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

 

컨텍스트 A가 있고 A는 내부 함수 B를 갖는다고 가정하자. 함수 B의 컨텍스트에서는 외부 컨텍스트 A의 렉시컬 환경을 참조할 수 있다. 내부 함수가 외부 컨텍스트의 렉시컬 환경을 언제나 참조하는 것은 아니다. 함수 B에서 사용하는 변수 C가 있다고 했을 때 자신의 실행 컨텍스트에서 변수 C를 찾지 못하면, 외부 컨텍스트의 렉시컬 환경을 참조하게 된다. 만약 A에 C가 존재한다면 B는 C를 참조하게 되고, 이때 발생하는 것이 렉시컬 환경과의 조합이고 이를 클로저라고 한다. 클로저는 내부 함수에서 외부 변수를 참조할 때 발생하는 상호작용을 의미한다.

 

클로저 예시

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

var outer2 = outer();

console.log(outer2); // 2

inner 함수에서 외부 변수인 a를 참조하고 있다.

 

콜스택에는 전역 컨텍스트, outer, inner순으로 담기고 inner의 실행이 종료되면,

inner, outer, 전역 컨텍스트 순으로 콜 스택이 비워진다.

 

자신을 참조하는 변수가 더 이상 존재하지 않는 값들은 가비지 컬렉터의 수집 대상이 된다.

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 함수의 실행 결과가 아닌, 함수를 통째로 반환하게 되면 상황이 달라진다.

 

outer2에 inner 함수를 할당하면 outer2를 호출할 때마다 inner 함수가 실행된다. inner 함수는 자신의 외부 실행 컨텍스트에 있는 변수를 참조할 수밖에 없으니, outer 함수의 실행 컨텍스트가 종료되었음에도 변수 a는 그 참조 대상으로 남아있게 된다. 결론적으로 변수 a는 가비지 컬렉터의 수거 대상이 되지 않는다.

 

클로저란 내부 함수에서 외부 변수를 참조할 때 발생하는 상호작용을 의미한다고 했다. 그 상호 작용이란 외부 변수를 참조하는 내부 함수가 외부로 전달될 때 내부 함수에 의해 참조되는 변수가 자신이 선언되었던 실행 컨텍스트가 종료되었음에도 사라지지 않는 현상을 의미한다.

 

저자님은 클로저를 아래와 같의 정의하고 있다.

클로저란 어떤 함수 A에서 선언한 변수 a를 참조하는 내부 함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상을 말합니다.
(function(){
  var a = 0;
  var intervalId = null;
  var inner = function() {
    if (++a >= 10){
      clearInterval(intervalId);
    }
    console.log(a);
  };
  intervalId = setInterval(inner, 1000)
})();
(function(){
  var count = 0;
  var button = document.createElement('button');
  button.innerText = 'click';
  button.addEventListener('click', function(){
    console.log(++count, 'times clicked');
  });
  document.body.appendChild(button);
})();

외부로 전달한다는 것이 return만을 의미하는 것은 아니다. 두 예시처럼 메서드에 전달되는 콜백 함수가 외부 변수를 참조하여 클로저가 발생하기도 한다.

 

클로저 해제 방법

불필요하게 클로저가 발생한다는 것은 지역 변수가 메모리를 불필요하게 잡아먹고 있음을 의미한다. 그럴 때 메모리의 참조 카운트를 0으로 만들면 클로저를 제거할 수 있다. 만약 의도적으로 클로저를 사용하는 경우에도 필요한 만큼 클로저를 활용하고 난 후 메모리를 해제하면 메모리 누수를 방지할 수 있을 것이다. 방법은 간단하다. null을 할당하면 된다.

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

console.log(outer());
console.log(outer());

outer=null;
(function(){
  var count = 0;
  var button = document.createElement('button');
  button.innerText = 'click';

  var clickHandler = function () {
    console.log(++count, 'times clicked');
    if(count >= 10 ){
      button.removeEventListener('click', clickHandler);
      clickHandler = null;
    }
  };
  button.addEventListener('click', clickHandler);
  document.body.appendChild(button);
})();

필요가 없어지면 null 값을 할당하여 참조를 끊어준다.

 

클로저의 활용

 

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

var fruits = ['apple', 'banana', 'peach'];

var $ul = document.createElement('ul');

fruits.forEach(function (fruit) {
  var $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', function(){
    alert('your choice is '+fruit);
  });
  $ul.appendChild($li);
});

document.body.appendChild($ul);

콜백 함수에서 외부 변수를 사용하기 위해서 클로저를 활용한 사례이다.

 

함수의 의존성을 끊어주기 위해 별도로 분리한다면?

var fruits = ['apple', 'banana', 'peach'];

var $ul = document.createElement('ul');

var alertFruit = function(fruit){
  alert('your choice is ' + fruit);
  // your choice is [object PointerEvent]
}

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

document.body.appendChild($ul);

이벤트 핸들러는 첫번째 인자로 이벤트 객체를 주입하기 때문에, 원하는 값이 아니라 [object PointerEvent]가 출력된다.

 

bind 메서드를 활용하면 이를 해결할 수 있다.

var fruits = ['apple', 'banana', 'peach'];

var $ul = document.createElement('ul');

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

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

document.body.appendChild($ul);

이 방법은 바인드 메서드로 fruit를 직접 넘겨주기 때문에 클로저가 발생하지 않는다. 다만 this를 제대로 유지할 수 없다는 한계가 있다. 예시에서는 이벤트의 타겟이 되는 엘리먼트인 리스트가 this 되어야 하지만, 실제로는 전역 객체를 가리킨다. 그리고 이벤트 객체는 첫 번째가 아닌 두 번째 인자로 주입된다. this를 활용해야 하거나 인자의 순서를 지켜야 하는 상황에서는 좋은 방법이 아닐 수 있다.

 

이러한 변경 사항이 발생하지 않도록 하기 위해 고차함수를 활용하는 방법도 있다.

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.addEventListener('click', alertFruitBuilder(fruit));
  $ul.appendChild($li);
});

document.body.appendChild($ul);

이렇게 클로저를 적극 활용할 수 있다.

이벤트 객체, this, 변수 모두 정상적으로 전달되는 것을 확인해 볼 수 있다.

 

책에 나오지는 않았지만,

익명함수를 활용하여 원하는 값만 핸들러로 넘겨주는 방식도 활용해 볼 수 있을 것 같다.

var fruits = ['apple', 'banana', 'peach'];

var $ul = document.createElement('ul');

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

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

document.body.appendChild($ul);

 

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

 

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

 

접근 권한에는 세 종류가 있다.

  • public: 어디에서나 접근이 가능함
  • private: 자신이 포함된 클래스의 메서드를 통해서만 접근 가능함
  • protected: 자신이 포함된 클래스, 상속받은 클래스에서 접근 가능함

 

자바스크립트는 기본적으로 접근권한을 부여하지 않지만 클로저를 통해 이를 구분할 수 있다.

 

외부에서 내부 함수에 있는 변수에 접근하기 위해서는 내부에서 return 해주면 된다. 외부에 제공하고자 하는 정보(public)만 반환하고 나머지(private)는 반환하지 않음으로써, 접근 권한을 제어할 수 있는 것이다.

 

자동차로 가장 멀리 이동한 사람이 승리하는 게임이 있다고 가정하자.

var car ={
  fuel: Math.ceil(Math.random()*10 + 10),
  power: Math.ceil(Math.random()*3 + 2),
  moved: 0,
  run: function(){
    var km = Math.ceil(Math.random()*6);
    var wasteFuel = km/this.power;
    if(this.fuel < wasteFuel){
      console.log('이동불가');
      return;
    }
    this.fuel -= wasteFuel;
    this.moved += km;
    console.log(km + 'km 이동 (총 ' + this.moved + 'km)');
  }
};

car.run(); // 6km 이동 (총 6km)
console.log(car.fuel); // 15.5
car.run(); // 3km 이동 (총 9km)
console.log(car.fuel); // 14.75

여기서 문제는 객체의 속성을 쉽게 조작할 수 있다는 점이다.

만약 누군가가 moved의 값을 증가시키는 방법을 안다면 그 사람이 승리하게 될 것이다.

 

조작을 방지하기 위해 함수를 동원하여 원하는 객체만 리턴하도록 하자.

var createCar = function (){
  var fuel =  Math.ceil(Math.random()*10 + 10);
  var power = Math.ceil(Math.random()*3 + 2);
  var moved = 0;
  return {
    get moved (){
      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);
    }
  }
}

var car = createCar();

car.run(); // 6km 이동 (총 6km). 남은 연료: 18.5
console.log(car.fuel);  // undefined

car.moved += 1000; // 조작 안되고 6이 그대로 아래에 출력되는 것을 확인할 수 있다

console.log(car); // { moved: [Getter], run: [Function: run] }
console.log(car.moved); // 6

이제 fuel과 power는 반환되지 않기 때문에 private의 권한을 갖게 되었고, moved는 getter를 통해 리턴하여 읽기 전용 속성이라고 할 수 있다. 유저는 moved의 값을 확인할 수만 있고 직접 조작할 수는 없다.

 

그러나 여전히 run 메서드를 조작할 수 있다는 문제가 남아있다.

var createCar = function (){
  var fuel =  Math.ceil(Math.random()*10 + 10);
  var power = Math.ceil(Math.random()*3 + 2);
  var moved = 0;
  var publicMembers = {
    get moved (){
      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;
}

freeze 메서드를 활용하여 리턴하는 객체를 동결시킬 수 있다.

 

부분 적용 함수

부분 적용 함수란 n개의 인자를 받는 함수에 미리 m개의 인자를 넘겼다가, 나중에 n-m개의 인자를 넘겨 함수의 실행결과를 얻는 함수이다. 미리 인자를 넘겨주어 기억시킨 다는 점에 있어서 클로저를 활용하여 부분 적용 함수를 구현할 수 있다.

var partial = function(){
  var args = arguments;
  // console.log(args); // [Arguments] { '0': [Function: add], '1': 1, ...}
  var func = args[0];
  // console.log(func); // [Function: add]
  if (typeof func !== 'function'){
    throw new Error('첫 번째 인자는 함수여야 합니다.');
  }
  return function(){
    var a = Array.prototype.slice.call(args, 1);
    // console.log(a); // [ 1, 2, 3, 4, 5 ]
    var b = Array.prototype.slice.call(arguments);
    // console.log(b); // [ 6, 7, 8, 9, 10 ]
    // console.log(this); // <ref *1> Object [global] { ...}
    return func.apply(this, a.concat(b));
  };
};

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

이 방법은 부분 적용 함수에 넘길 인자를 앞에서부터 차례대로 주입시켜야 한다는 제약 사항이 있다.

 

이를 해결하기 위해, 전역 객체에 새로운 속성을 정의하고 이를 활용할 수 있다.

Object.defineProperty(global, '_', {
  value: 'EMPTY',
  writable: false,
  configurable: false,
  enumerable: false,
});

var partial = function(){
  var args = arguments;
  var func = args[0];
  if (typeof func !== 'function'){
    throw new Error('첫 번째 인자는 함수여야 합니다.');
  }
  return function(){
    var a = Array.prototype.slice.call(args, 1);
    var b = Array.prototype.slice.call(arguments);
    console.log(a); // [ 1, 2, 'EMPTY', 4, 5, 'EMPTY', 'EMPTY', 8, 9 ]
    console.log(b); // [ 3, 6, 7, 10 ]
        for (var i = 0; i < a.length; i++){
      if (a[i] === _){
        a[i] = b.shift();
      }
    }
    console.log(a); // [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
    console.log(b); // [ 10]
    return func.apply(this, a.concat(b));
  };
};

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, _, 4, 5, _, _, 8, 9); 

console.log(addPartial(3, 6, 7, 10)); // 55

전역 객체에 '_'라는 프로퍼티를 정의하여 빈 공간을 확보했다. 

 

ES6에서 새롭게 추가된 변경 불가능한 값인 Symbol를 활용하는 방법도 있다.

심볼은 주로 이름의 충돌 위험이 없는 유일한 객체의 프로퍼티 키를 만들기 위해 사용한다.

 

Symbol.for 메서드는 인자로 전달받은 문자열을 키로 사용하여

Symbol 값들이 저장되어 있는 전역 Symbol 공간을 먼저 탐색한다.

 

만약 검색에 실패하면 새로운 Symbol 값을 생성하여 해당 키로 전역 Symbol 공간에 저장한 후,

Symbol 값을 반환한다.

var partial = function(){
  var args = arguments;
  var func = args[0];
  if (typeof func !== 'function'){
    throw new Error('첫 번째 인자는 함수여야 합니다.');
  }
  return function(){
    var a = Array.prototype.slice.call(args, 1);
    var b = Array.prototype.slice.call(arguments);
    for (var i = 0; i < a.length; i++){
      if (a[i] === Symbol.for('EMPTY')){
        a[i] = b.shift();
      }
    }
    return func.apply(this, a.concat(b));
  };
};

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

var _ = Symbol.for('EMPTY');

var addPartial = partial(add, 1, 2, _, 4, 5, _, _, 8, 9); 

console.log(addPartial(3, 6, 7, 10)); // 55

 

부분 적용 함수가 실무에서 사용되는 대표적인 예로 디바운스가 있다.

 

디바운스는 프로그래밍 기법 중 하나로 이벤트를 그룹화하여 특정 시간이 지난 후에 하나의 이벤트만 발생하도록 하는 기술이다. 예를 들어, 브라우저에서 발생하는 mousemove, mousewheel 과 같은 이벤트는 과도한 이벤트의 발생으로 인해 성능에 무리를 줄 수 있다. 이렇게 동일한 이벤트가 많이 발생하는 경우 이를 묶어서 한 번만 처리하도록 하는 것이 디바운스이다.

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

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

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

document.body.addEventListener('mousemove', debounce('move', moveHandler, 500));
document.body.addEventListener('mousewheel', debounce('wheel', wheelHandler, 700));

이벤트 발생 후에 wait에 할당한 시간만큼 동일한 이벤트가 발생하지 않으면 핸들러를 실행하여 이벤트를 처리한다.

 

커링 함수

커링 함수는 부분 적용 함수와 다르게 한 번에 하나의 인자만 받고

마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않는다.

커링 함수란 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서
순차적으로 호출될 수 있게 체인 형태로 구성한 것을 말합니다.
var curry = function(func){
  return function (a){
    return function (b){
      return function (c){
        return function (d){
          return function (e){
            return func(a, b, c, d, e);
          }
        }
      }
    }
  }
}

var getMax = curry(Math.max);

console.log(getMax(1)(2)(3)(4)(5));

 

화살표 함수를 쓰는 것이 낫다.

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

var getMax = curry(Math.max);

console.log(getMax(1)(2)(3)(4)(5));

커링을 통한 서버 요청 예시

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

var naver = 'https://naver.com';
var kakao = 'https://kakao.com';

var getNaver = getInfo(naver);
var getKakao = getInfo(kakao);

var getImageFromNaver = getNaver('image');
var getImageFromKakao = getNaver('kakao');

// 실제 요청

var naverImage100 = getImageFromNaver(100);
var naverImage200 = getImageFromNaver(200);

var kakaoImage10 = getImageFromKakao(10);
var kakaoImage20 = getImageFromKakao(20);

이처럼 공통적인 요소를 먼저 기억시켜 두고 특정한 값으로만 서버 요청을 수행하여 편의성을 높일 수 있다.

 

redux thunk에서도 커링이 활용된다.

 

const thunk = store => next => action => {
  return typeof action === 'function'
    ? action (dispatch, store.getState)
    : next(action)
}

리덕스 내부에서 store, next 값이 결정되면 thunk에 미리 인자로 넘겨 놓고 전달되는 액션의 타입에 따라 작업을 처리한다.