CoreJS - 클로저

SANGKU OH·2020년 12월 2일
1

CoreJavascript

목록 보기
8/10
post-thumbnail

클로저란?

본질을 깨닫고 나면 의외로 쉬운 개념이지만 어딘가 잘 모르겠는 그 녀석..

"어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상"

아직 잘 모르겠다..

let outer = function () {
  let a = 1;
  let inner = function() {
    console.log(++a);
  };
  inner();
};
outer();
  • outer 함수에서 변수 a를 선언했다.
  • outer의 내부함수인 inner 함수에서 a의 값을 1만큼 증가시킨 다음 출력한다.
  • inner 함수 내부에서는 a를 선언하지 않았기 때문에 environmentRecord에서 값을 찾지 못하므로 outerEnvironmentReference에 저장된 상위 컨텍스트인 outer의 LexicalEnvironment에 접근해서 다시 a를 찾는다.
  • 4번째 줄에서는 2가 출력된다.
  • outer함수의 실행 컨텍스트가 종료되면 LexicalEnvironment에 저장된 식별자들(a, inner)에 대한 참조를 지운다.
  • 그러면 각 주소에 저장돼 있던 값들은 자신을 참조하는 변수가 하나도 없게 되므로 가비지 컬렉터의 수집 대상이 된다.
let outer = function() {
  let a = 1;
  var inner = function() {
    return ++a;
  };
  return inner();
};
let outer2 = outer();
console.log(outer2); //2
  • 이번에도 inner 함수 내부에서 외부변수인 a를 사용했다.
  • 그런데 6번째 줄에서는 inner 함수를 실행관 결과를 리턴하고 있으므로 outer 함수의 실행 컨텍스트가 종료된 시점에는 a 변수를 참조하는 대상이 없어진다!
  • 이 전의 코드와 마찬가지로 a, inner 변수의 값들은 언젠가 가비지 컬렉터에 의해 소멸한다.

역시 일반적인 함수 및 내부함수에서의 동작과 차이가 없다.
위의 두 코드에서는 outer함수의 실행 컨텍스트가 종료되기 이전에 inner함수의 실행 컨텍스트가 종료돼 있으며, 이후 별도로 inner함수를 호출할 수 없다는 공통점이 있다.
그렇다면 outer의 실행 컨텍스트가 종료된 후에도 inner함수를 호출할 수 있게 만들면 어떨까?

외부 함수의 변수를 참조하는 내부함수

let outer = function() {
  let a = 1;
  let inner = function() {
    return ++a;
  };
  return inner;
};
let outer2 = outer();
console.log(outer2()); //2
console.log(outer2()); //3
  • 6번째 줄에서 inner함수의 실행 결과가 아닌 inner 함수 자체를 반환했다.
  • outer함수의 실행 컨텍스트가 종료될 때(8번째 줄) outer2 변수는 outer의 실행 결과 inner함수를 참조하게 될 것이다.
  • 이후 9번째에서 outer2를 호출하면 앞서 반환된 함수인 inner가 실행된다.
  • inner 함수의 실행 컨텍스트의 environmentRecord에는 수집할 정보가 없다. inner 함수는 outer함수 내부에서 선언됐으므로, outer함수의 LexicalEnvironment가 담긴다.
  • 스코프 체이닝에 따라 outer에서 선언한 변수 a에 접근해서 1만큼 증가시킨 후 그 값인 2를 반환하고, inner 함수의 실행 컨텍스트가 종료된다.
  • 10번째 줄에서 다시 outer2를 호출하면 같은 방식으로 a의 값을 2에서 3으로 증가시킨 후 3을 반환한다.

우리는 이상한 점을 느낄 수 있다.
inner함수의 실행 시점에는 outer함수는 이미 실행이 종료된 상태인데 outer함수의 LexicalEnvironment에 어떻게 접근하는 걸까? 이는 가비지 컬렉터의 동작 방식에 있다.
가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있으면 그 값은 수집 대상에 포함시키지 않는다!
위의 코드에서 outer함수는 실행 종료 시점에 inner함수를 반환한다.
외부함수인 outer의 실행이 종료되더라도 내부 함수인 inner함수는 언젠가 outer2를 실행함으로써 호출될 가능성이 열린 것이다. 언젠가 inner함수의 실행 컨텍스트가 활성화되면 outer함수의 LexicalEnvironment를 필요로 할 것이므로 수집대상에서 제외된다.
그 덕에 inner함수가 이 변수에 접근할 수 있는 것이다!

클로저는 어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상이라고 언급했다.
함수의 실행 컨텍스트가 종료한 후에도 LexicalEnvironment가 가비지 컬렉터의 수집 대상에서 제외되는 경우는 지역변수를 참조하는 내부함수가 외부로 전달된 경우가 유일하다.

즉, 클로저란 외부 함수의 LexicalEvironment가 가비지 컬렉팅 되지 않는 현상이라고 말할 수 있다.

좀 더 자세히는 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상이라고도 표현이 가능하다.

but, '외부로 전달'이 곧 return을 의미하는 것은 아니다!

클로저와 메모리 관리

메모리 소모는 클로저의 본질적인 특성일 뿐이다.

의도대로 설꼐한 '메모리 소모'에 대한 관리법을 잘 파악해서 적용하는 것이 클로저 사용의 핵심!
클로저는 어떤 필요에 의해 의도적으로 함수의 지역변수가 메모리를 소모하도록 함으로써 발생한다. 그렇다면 그 필요성이 사라진 시점에는 더는 메모리를 소모하지 않게 해주면 된다!
참조 카운트를 0으로 만들면 언젠간 GC가 수거해갈 것이고, 이때 메모리가 회수된다.
그렇다면 참조 카운트를 0으로 만드는 방법은...? 식별자에 참조형이 아닌 기본형 데이터(보통 null / undefined)를 할당하면 된다!

let outer = (function() {
  let a = 1;
  var inner = function() {
    return ++a;
  };
  return inner
})();;
console.log(outer());
console.log(outer());
outer = null; // outer 식별자와 inner함수 참조를 끊음

클로저 활용

그래 그러면 어떤 상황에서 클로저를 활용해서 코드를 작성하면 좋을까?

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

let fruits = ['apple', 'banana', 'peach'];
let $ul = document.createElement('ul'); // 공통코드

fruits.forEach(function(fruit) { // (A)
  var $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', function() { // (B)
    alert('your choice is' + fruit);
  });
  $ul.appendChild($li);
});
document.body.appendChild($ul);
  • fruits 변수를 순회하며 li를 생성하고, 각 li를 클릭하면 해당 리스너에 기억된 콜백 함수를 실행하도록 했다.
  • 4번째 줄의 forEach 메소드에 넘겨준 익명의 콜백함수(A)는 그 내부에서 외부 변수를 사용하지 않고 있으므로 클로저가 없다.
  • 7번째 줄의 addEventListener에 넘겨준 콜백함수(B)에는 fruit이라는 외부 변수를 참조하고 있으므로 클로저가 있다.
  • (A)는 fruits의 개수만큼 실행되며, 그때마다 새로운 실행 컨텍스트가 활성화된다.
  • (A)의 실행 종료 여부와 무관하게 클릭 이벤트에 의해 각 컨텍스트의(B)가 실행될 때는 (A)의 LexicalEnvironment를 참조하게 된다.
  • 최소한(B)함수가 참조할 예정인 변수 fruit에 대해서는 (A)가 종료된 후에도 GC의 대상에서 제외되어 계쏙 참조가 가능하다!

그러나 (B)함수의 쓰임새가 콜백 함수에 국한되지 않는 경우라면 반복을 줄이기 위해 (B)를 외부로 분리하는 편이 나을 수 있다!, 즉 fruit를 인자로 받아 출력하는 형태로 말이다.

let alertFruit = function(fruit) {
  return function() {
    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의 세 종류가 있다. 각 단어의 의미 그대로 public은 외부에서 접근 가능한 것이고, private은 내부에서만 사용하며 외부에 노출되지 않는 것을 의미한다.

자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 직접 부여하도록 설계되어 있다. 그러나 클로저를 이용하면 함수 차원에서 publicgks rkqtrhk private한 값을 구분하는 것이 가능하다.

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

outer 함수를 종료할 때 inner함수를 반환함으로써 outer함수의 지역변수인 a의 값을 외부에서도 읽을 수 있게 되었다. 이처럼 클로저를 활용하면 외부 스코프에서 함수 내부의 변수들 중 선택적으로 일부의 변수에 대한 접근 권한을 부여할 수 있다! return을 활용해서!

return 값이 외부에 정보를 제공하는 유일한 수단이다.

외부에서는 외부 공간에 노출돼 있는 outer라는 변수를 통해 outer함수를 실행할 수는 있찌만, outer함수 내부에는 어떠한 개입도 할 수 없다. 외부에서는 오직 outer함수가 return한 정보에만 접근할 수 있다.
즉, 외부에 제공하고자하는 정보들을 모아서 return하고, 내부에서만 사용할 정보들은 return하지 않는 것으로 접근 권한 제어가 가능한 것이다.

return한 변수들은 공개멤버(public)가 되고, 그렇지 않은 변수들은 비공개 멤버(private)가 되는 것

접근 권한 제어를 위해 간단한 게임을 만들어 보자.

  • 각 턴마다 주사위를 굴려 나온 숫자(km)만큼 이동한다.
  • 차량별로 연료랑(fuel)과 연비(power)는 무작위로 생성된다.
  • 남은 연료가 이동할 거리에 필요한 연료보다 부족하면 차는 이동하지 못한다.
  • 모든 유저가 이동할 수 없는 턴에 게임이 종료된다.
  • 게임 종료 시점에 가장 멀리 이동해 있는 사람이 승리!
let car = {
  fuel: Math.ceil(Math.random() * 10 + 10), // 연료(L)
  power: Math.ceil(Math.random() * 3 + 2), // 연비(km/L)
  moved: 0, // 총 이동거리
  run: function() {
    let km = Math.ceil(MAth.random() * 6);
    let wasteFuel = km / this.power;
    if(this.fuel < wasteFuel) {
      console.log('이동불가');
      return;
    }
    this.fuel -= wateFuel;
    this.moved += km;
    console.log(km + 'km 이동 (총' + this.moved + 'km)');
  }
};

car 변수에 객체를 직접 할당했다. fuel과 power는 무작위로 생성하고, moved라는 프로퍼티에 총 이동거리를 부여했으며, run 메소드를 실행할 때마다 car 객체의 fuel, moved값이 변하게 설정했다. 이런 객체를 사람 수 만큼 생성해서 각차의 턴에 run을 실행하면 게임을 즐길 수 있을 것이다.
모두가 run 메소드만 호출하면 좋겠지만 자바스크립트를 아는 사람이 있다면 연료, 연비, 이동거리 등을 마음대로 바꿀 수 있을 것이다.

car.fuel = 10000;
car.pwoer = 100;
car.moved = 1000;

치트키를 쓰는 것과 같이 말이다..! 이렇게 값을 바꾸지 못하도록 방어를 할 필요가 있다.
클로저를 활용해서 객체가 아닌 함수로 만들고, 필요한 멤버만을 return하는 것으로 바꿔보자!

let createCar = function() {
  let fuel = Math.ceil(Math.random() * 10 + 10); // 연료(L)
  let power = Math.ceil(Math.random() * 3 + 2); // 연비(km / L)
  let moved = 0;
  return {
    get moved() {
    	return moved;
    },
    run: function() {
      let km = Math.ceil(Math.random() * 6);
      let wasteFuel = km / power;
      if(fuel < wasteFuel) {
        console.log('이동불가');
        return;
      };
      fuel -= wasteFuel;
      moved += km;
	  console.log(km + 'km 이동 (총' + moved + 'km). 남은 연료: ' + fuel);
    }
  };
};
let car = createCar();

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

car.run(); // 3km 이동(총 3km). 남은 연료: 17.4
console.log(car.moved) // 3
console.log(car.fuel) // undefined
console.log(car.power) // undefined

car.fuel = 1000;
console.log(car.fuel); // 1000
car.run(); // 1km 이동(총 4km). 남은 연료: 17.2

car.power = 100;
console.log(car.power); // 100
car.run(); // 4km 이동(총 8km). 남은 연료: 16.4

car.moved = 1000;
console.log(car.moved); // 8
car.run(); // 2km 이동(총 10km). 남은 연료: 16

비록 run 메소드는 다른 내용으로 덮어씌우는 어뷰징 여전히 가능한 상태이긴 하지만 앞서의 코드보다는 후러씬 안전한 코드가 되었다. 이런 어뷰징까지 막기 위해서는 객체를 return하기 전에 미리 변경할 수 없게끔 조치해야된다..!

let createCar = function() {
  ...
  let publicMembers = {
    ....
  };
  Object.freeze(publicMembers);
  return publicMembers;
};

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

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

부분 적용 함수

부분 적용 함수란 n개의 인자를 받는 함수에 미리 m개의 인자만 넘겨 기억시켰다가, 나중에 (n-m)개의 인자를 넘기면 비로소 원래 함수의 실행 결과를 얻을수 있게 하는 함수이다. this를 바인딩해야 하는 점을 제외하면 앞서 살펴본 bind 메소드의 실행 결과가 바로 부분 적용 함수이다.

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

addPartial함수는 인자 5개를 미리 적용하고, 추후 추가적으로 인자들을 전달하면 모든 인자를 모아 원래 함수가 실행되는 부분 적용 함수이다. add함수는 this를 사용하지 않으므로 bind 메소드만으로도 문제 없이 구현이 되었다. 그러나 this의 값을 변경할 수 밖에 없기 때문에 메소드에서는 사용할 수 없을 것 같다. this에 관여하지 않는 별도의 부분 적용함수가 있다면 범용성 측면에서 더욱 좋을텐데..

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

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

let dog = {
  name: '댕댕이',
  greet: partial(function(prefix, suffix) {
    return prefix + this.name + suffix;
  }, '왈왈, ')
};
dog.greet('입니다!'); // 왈왈, 댕댕이입니다.
  • 첫 번째 인자에는 원본함수를, 두 번째 인자 이후부터는 미리 적용할 인자들을 전달한다.
  • 반환할 함수(부분 적용함수)에서는 다시 나머지 인자들을 받아 이들을 한데 모아(concat) 원본 함수를 호출(apply)한다.
  • 실행 시점의 this를 그대로 반영함으로써 this에는 아무런 영향을 주지 않게 되었다!

커링함수

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

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

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

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

let getMinWith10 = curry3(Math.min)(10);
console.log(getMinWith10(8)); // 8
console.log(getMinWith10(25)); // 10

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

let 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)
                  };
              };
          };
      };
  };
};
let getMax = curry5(Math.max);
console.log(getMax(1)(2)(3)(4)(5));

5개만 받아서 처리했음에도 이를 표현하기 위해 자그마치 13줄이나 소모했다..! 하지만 ES6에서는 화살표 함수를 써서 같은 내용을 단 한줄에 표기할 수 있다..!!

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

화살표 함수로 구현하면 커링함수를 이해하기 훨씬 수월해진다!
화살표 순서에 따라 함수에 값을 차례로 넘겨주면 마지막에 func가 호출될 거라는 흐름이 한눈에 파악된다. 각 단계에서 받은 인자들이 모두 마지막 단계에서 참조할 것이므로 GC되지 않고 메모리에 차곡차곡 쌓였다가, 마지막 호출로 실행 컨텍스트가 종료된 후에야 비로소 한꺼먼에 GC의 수거대상이 된다.

커링함수 왜 쓰나?
당장 필요한 정보만 받아서 전달하고, 또 필요한 정보가 들어오면 전달하는 식으로 하면 결국 마지막 인자가 넘어갈 때까지 함수 실행을 미루는 셈이 된다. 이를 함수형 프로그래밍에서는 지연실행이라고 칭한다.
원하는 시점까지 지연시켰다가 실행하는 것이 요긴한 상황이라면 커링을 쓰기에 적합할 것이다. 혹은 프로젝트 내에서 자주 쓰이는 함수의 매개변수가 항상 비슷하고 일부만 바뀌는 경우에도 적절한 후보가 될 것이다.

let getInformation = function(baseUrl) { // 서버에 요청할 주소의 기본 URL
  return function(path) { // path 값
    return function(id) { // id 값
      return fetch(baseUrl + path + '/' + id); // 실제 서버에 정보를 요청
    };
  };
};
// ES6
let getInformation = baseUrl => path => id => fetch(baseUrl + path + '/' + id);

보통 REST API를 이용할 경우 baseUrl은 몇 개로 고정되지만 나머지 path나 id값은 매우 많을 수 있다. 이런 상황에서 서버에 정보를 요청할 필요가 있을 때마다 매번 baseUrl부터 전부 기입해주기보다는 공통적인 요소는 먼저 기억시켜두고, 특정한 값(id)만으로 서버 요청을 수행하는 함수를 만들어 주는 편이 효율성이나 가독성 측면에서 좋다!

let imageUrl = 'http://imageAddress.com/';
let productUrl = 'http://productAddress.com/';

// 이미지 타입별 요청 함수 준비
let getImage = getInformation(imageUrl); // http://imageAddress.com/
let getEmoticon = getImage('emoticon'); // http://imageAddress.com/emoticon
let getIcon = getImage('icon'); // http://imageAdress.com/icon

// 제품 타입별 요청 함수 준비
let getProduct = getInformation(productUrl); // http://productAddress.com/
let getFruit = getProduct('fruit'); // http://productAddress.com/fruit
let getVegetable = getProduct('vegetable'); // http://productAddress.com/vegetable

// 실제 요청
let emoticon1 = getEmoticon(100); //http://imageAddress.com/emoticon/100
let emoticon2 = getEmoticon(102); //http://imageAddress.com/emoticon/102
let icon1 = getIcon(205); //http://imageAddress.com/icon/205
let icon2 = getIcon(234); //http://imageAddress.com/icon/234
let fruit1 = getFruit(300); //http://productAddress.com/fruit/300
let fruit2 = getFruit(400); //http://productAddress.com/fruit/400
let vegetable1 = getVegetable(456); //http://productAddress.com/vegetable/456
let vegetable2 = getVegetable(789); //http://productAddress.com/vegetable/789

위와 같은 방법으로 만든 Redux의 미들웨어를 예로 들어보자

//Redux Middleware 'Logger'
const logger = store => next => action => {
  console.log('dispatching', action);
  console.log('next state', store.getState());
  return next(action)
};
//Rdux Middleware 'thunk'
const thunk = store => next => action => {
  return typeof action === 'function'
  	? action(dispatch, store.getState)
  	: next(action);
};
  • 위 두 미들웨어는 공통적으로 store, next, action 순서로 인자를 받는다.
  • 이 중 store는 프로젝트 내에서 한번 생성된 후로는 바뀌지 않는 속성이고, dispatch의 의미를 가지는 next 역시 마찬가지다.
  • action은 매번 달라진다.
  • store와 next값이 결졍되면 Redux 내부에서 logger 또는 thunk에 store, next를 미리 넘겨서 반환된 함수를 저장시킨다.
  • 이후에는 action만 받아서 처리할 수 있게끔 한 것!

정리

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

0개의 댓글