프로그래밍-학습기록/Javascript

클로저는 글로 적기 글렀어.

leesche 2021. 2. 15. 18:47

클로저, 글로 적어 보자

클로저의 의미 및 원리 이해

예제로 시작해봅시다.

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

함수 outer은 inner 함수 자체를 반환합니다. 변수 outer2에는 outer의 반환 값이 담깁니다. 그리고 outer2를 두 번 호출해 출력합니다. 실행 결과는 2와 3입니다.

기존 상식으로 콘솔 출력 결과가 나오기까지 과정을 생각해보면 다음과 같습니다.

  1. (9번째 줄) outer2를 호출합니다(outer2에는 inner 함수 그 자체가 담겼습니다). inner 함수가 호출됩니다.
  2. 전역 컨텍스트 위에 outer 실행 컨텍스트가 콜스택에 쌓입니다. 이어서 그 위에 inner 함수의 실행 컨텍스트가 쌓입니다.
  3. inner 함수가 선언될 시점의 inner 함수의 outerEnvironmentReference 에는 outer 함수의 LexicalEnvironment를 참조합니다.
  4. 스코프체이닝으로 outer에서 선언한 변수 a에 접근해 1만큼 먼저 증가시키고 그 값을 반환합니다.
  5. inner 함수의 실행 컨텍스트가 종료됩니다.

여기서 생각해봐야 할 것이 있습니다.

  • inner가 outer2에 담길 시점에 outer 함수는 종료됐습니다.
  • 따라서 outer 함수 내부에 선언된 변수 a도 메모리에서 사라져야 합니다.
  • 하지만 이후(9~10번째 줄) inner 함수를 호출하면서 a를 참조하여 값을 더해 a를 출력했습니다.
  • 메모리에 없는 변수를 어떻게 참조할 수 있을까요? 이거 잘못된 거 아녀?!

실행 컨텍스트의 관점에서 보면, 이미 종료된 outer 함수의 LexicalEnvironment에 inner 함수가 접근한 '현상'인데요. 이런 현상이 일어난 이유는 자바스크립트의 가비지 컬렉터의 동작 방식 때문입니다. 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있다면 그 값은 수집 대상에 포함시키지 않습니다.

외부 함수인 outer의 실행 결과로 반환된 내부 함수 inner가 outer2에 담깁니다. inner 함수는 내부 함수이지만 언젠가 outer2가 실행되면 호출될 수도 있습니다. 즉, 언젠가 쓰일 가능성을 위해 outer 함수의 LexicalEnvironment가 가비지컬렉터의 수집 대상에서 제외됩니다. 즉, 변수 a = 1는 나중에 (outer2 때문에) 쓰일 수도 있기 때문에 메모리에서 사라지지 않았던 것입니다.

클로저는 이런 현상을 뜻합니다. 정확하게,

클로저는 어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상입니다.

이 현상이란, 외부 함수의 LexicalEnvironment가 가비지컬렉팅 되지 않는 현상을 말합니다.

클로저와 메모리 관리

클로저는 어떤 필요에 의해 의도적으로 함수의 지역 변수로 하여금 메모리를 소모하도록 함으로써 발생합니다. 그 필요성이 사라진다면 더는 메모리를 소모하지 않도록 해줘야 합니다. 그 방법은 간단합니다. 해당 변수(식별자)에 null 또는 undefined를 할당하면 됩니다.

클로저 활용 사례

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

  1. 콜백 함수를 내부 함수로 선언해 외부 변수를 직접 참조(클로저 사용)

     var fruits = ["apple", "banana", "peach"];
     var 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 배열의 요소 각각 이벤트리스너를 추가하는 작업을 합니다.

    addEventListener에 넘겨준 콜백 함수 (B)는 fruit이라는 외부 변수를 참조합니다. 외부 함수 (A)의 종료 여부와 무관하게, li 엘리먼트가 클릭될 때 실행되는 (B) 함수는 fruit 변수를 찾아야 합니다. (B) 함수의 outerEnvironmentReference 가 선언될 당시의 (A) 함수의 LexicalEnvironment를 참조해 fruit을 찾습니다. 따라서 클로저가 발생합니다. fruit은 가비지컬렉팅되지 않고 계속 참조가 가능하게 된 것입니다.

  2. bind를 활용해 값을 직접 넘겨주는 방법, 그러나 여러 제약사항 있음

     var fruits = ["apple", "banana", "peach"];
     var ul = document.createElement("ul");
    
     var alertFruit = function (fruit, event) {
       // 두 번째 인자로 이벤트 객체가 전달된다.
       alert("your choice is " + fruit);
       console.log(event);
     };
    
     fruits.forEach(function (fruit) {
       var li = document.createElement("li");
       li.innerText = fruit;
       li.addEventListener("click", alertFruit.bind(null, fruit));
       // bind() 메소드가 호출되면 새로운 함수를 생성합니다.
       // 받게되는 첫 인자의 value로는 this 키워드를 설정하고,
       // 이어지는 인자들은 바인드된 함수의 인수에 제공됩니다
       ul.appendChild(li);
     });
     document.body.appendChild(ul);

    콜백 함수로 alertFruit를 호출하면, 이 함수에 대한 제어권을 addEventListener가 갖기 때문에 alertFruit의 첫번째 인자로 이벤트 객체가 전달되게 됩니다. 이를 해결하기 위해 bind 메서드를 사용해야 합니다. 그렇게 하면 새로 전달된 함수의 첫번째 인자로 fruit이 들어가고, 그 다음 두 번째 인자로 이벤트 객체가 전달됩니다.

  3. 콜백 함수를 고차 함수로 바꿔 클로저를 적극적으로 활용한 방법

     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);

    forEach의 익명 함수에서 li 엘리먼트에 이벤트리스너를 붙일 때 alertFruitBuilder 함수가 호출됩니다. alertFruitBuilder는 인자로 전달된 fruit을 출력하는 익명함수를 반환합니다. 이렇게 반환된 함수가 이벤트리스너의 콜백함수가 됩니다.

    이후 li 엘리먼트에서 클릭 이벤트가 발생하면 콜백함수가 실행되고, 일전에 전달됐던 fruit가 사라지지 않고 있던 alertFruitBuilderLexicalEnvironment를 (클릭 이벤트 발생할 때의)콜백함수의 outerEnvironmentReference가 들여다보게 되고, 그 안에서 fruit을 참조하여 alert합니다. 즉, 이벤트리스너가 부착될 당시 alertFruitBuilder의 반환값인 함수에는 클로저가 존재하는 것입니다. 콜백함수는 언젠가 호출되어 선언될 당시의 변수가 사용될 가능성이 있기 때문입니다.

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

정보 은닉은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서 모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 개념입니다. 외부에 노출되지 않는다는 것은 외부로부터의 접근이 제한됐다는 말과 같습니다. 흔히 public은 외부에서 접근 가능한 것이고, private은 내부에서만 사용하며 외부에 노출되지 않는 것을 의미합니다.

자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 직접 부여하도록 설계되어 있지 않습니다. 그렇다면 접근 권한 제어가 불가능한 것일까요? 아닙니다. 클로저를 이용하면 함수 차원에서 public한 값과 private한 값을 구분하는 것이 가능합니다.

클로저를 활용하면 외부 스코프에서 함수 내부의 변수들 중 선택적으로 일부의 변수에 대한 접근 권한을 부여할 수 있습니다. 특히 return을 활용합니다. 함수는 외부(전역 스코프)로부터 격리된 공간이고, 외부에서는 오직 그 함수가 return한 정보에만 접근할 수 있기 때문입니다.

자동차 게임을 위한 간단한 자동차 객체를 만들어보면 다음과 같습니다.

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.fuel = 100000000;
car.power = 10000000;
car.moved = 10000000;

즉, car 객체의 프로퍼티는 public 합니다. 누구나 들어와서 수정할 수 있습니다. 이런 식이면 게임이 진행되지 않습니다. 접근에 제한을 줘야 합니다. 그 방법은 클로저를 활용하는 것입니다. 누구나 접근하여 값을 수정할 수 있는 객체 말고, car을 함수로 만들고 필요한 멤버만 return하도록 합니다.

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

createCar 변수에 함수를 선언합니다. 지역 변수로 fuel, power, moved가 선언됐습니다. 이 변수들은 비공개 멤버들로 외부에서 접근이 제한됩니다. 그리고 createCar 함수는 어떤 객체를 반환합니다. 이 객체는 moved 라는 getter 변수와 run이라는 함수가 담겨있습니다. getter 변수는 읽기 전용 속성입니다. run 함수는 fuel, power, moved에 접근하여 값을 변경하고 수정합니다.

이로써 외부에서는 오직 run 메서드를 실행하는 것과 현재의 moved 값을 확인(읽기)하는 것 두 가지 동작만 할 수 있게 됩니다.

하지만 run 메서드를 아예 다른 내용으로 덮어씌우는 부정 행위는 여전히 가능합니다. 이런 부정까지 막기 위해서는 객체를 반환하기 전에 미리 변경할 수 없게끔 조치를 취해야 합니다. 부정행위의 대상이 될 소지가 있는 객체를 변수로 감싸고 그 변수를 freeze 하면 됩니다.

부분 적용 함수

부분 적용 함수(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));
  };
};

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

var dog = {
  name: "흰둥이",
  greet: partial(function (prefix, suffix) {
    return prefix + this.name + suffix;
  }, "왈왈, "),
};
console.log(dog.greet("입니다!"));

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

하지만, 부분 적용 함수에 넘길 인자를 반드시 앞에서부터 차례로 전달할 수밖에 없다는 점은 아쉬운 점입니다. 따라서 인자들을 원하는 위치에 미리 넣어두고 나중에는 빈 자리에 인자를 채워넣어 실행할 수 있는 예제를 보겠습니다.

Object.defineProperty(globalThis, "_", {
  value: "EMPTY_SPACE",
  writable: false,
  configurable: false,
  enumerable: false,
});

var partial2 = 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);
    for (var i = 0; i < partialArgs.length; i++) {
      if (partialArgs[i] === _) {
        partialArgs[i] = restArgs.shift();
      }
    }
    return func.apply(this, partialArgs.concat(restArgs));
  };
};

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

var dog = {
  name: "흰둥이",
  greet: partial2(function (prefix, suffix) {
    return prefix + this.name + suffix;
  }, "왈왈, "),
};
console.log(dog.greet(" 살려줘요!")); // 왈왈, 흰둥이 살려줘요!

'비워놓음'을 표시하기 위해 미리 전역 객체에 _라는 프로퍼티를 준비하고 삭제, 변경 등의 접근에 방어하기 위해 여러 가지 프로퍼티 속성을 설정했습니다. 처음에 넘겨진 인자들 중 _로 들어온 인자들은 나중에 넘어온 인자들이 그 공간에 차례대로 끼워넣어지도록 구현한 코드입니다.

커링 함수

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

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

커링 함수(currying function)란, 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출 될 수 있게 체인 형태로 구성한 것을 말합니다. 부분 적용 함수와 기본 맥락은 일치하지만 다른 점은 커링은 한 번에 하나의 인자만 전달하는 것을 원칙으로 합니다. 또한 중간 과정에 있는 함수를 실행한 결과는 그 다음 인자를 받기 위해 대기만 할 뿐입니다. 즉, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않습니다. 각 단계에서 받은 인자들은 모두 마지막 단계에서 참조할 것이므로 가비지컬렉팅 되지 않고 마지막 호출로 실행 컨텍스트가 종료된 후에야 비로소 한꺼번에 가비지컬렉팅 대상이 됩니다.

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

필요한 인자 개수만큼 함수를 만들어 계속 리턴해야 하기 때문에, 인자가 많아질수록 가독성이 떨어질 수 있습니다. 하지만 ES6에서는 화살표 함수를 써서 같은 내용을 단 한 줄에 표기할 수 있습니다.

var curry5 = (func) => (a) => (b) => (c) => (d) => (e) => func(a, b, c, d, e);
var getMax = curry5(Math.max);

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

이 커링 함수가 유용한 경우는 함수를 '지연 실행'할 때입니다. 당장 필요한 정보만 받아 그때그때 전달하고, 마지막 인자가 넘어갈 때까지 함수 실행을 미룰 때 커링 함수가 적합합니다.

var getInformation = function (baseUrl) { // 서버에 요청할 주소의 기본 url
  return function (path) { // path 값
    return function (id) { // id 값
      return fetch(baseUrl + path + "/" + id); // 서버에 정보를 요청
  };
    };

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

여러 개의 변수를 기억해야 하지만, 마지막의 특정 변수가 무수히 많을 경우, 마지막의 변수 때문에 모든 변수를 매번 인자에 넣어 실행하기 보다, 마지막 이전 단계까지 함수에 전달해 공통적인 요소는 먼저 기억시켜두고 마지막에 함수를 실행하는 것이 효율적이고 가독성도 좋습니다.

정리

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

참고 및 출처

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