본문 바로가기

Backend/Javascript

Javascript - Closure (클로저) 에 대해

- 클로저에 대한 이해

 

클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성입니다.

바로 코드 예시를 보도록 하겠습니다.

 

const outer = function () {
  let a = 1;
  const inner = function () {
    console.info(++a);
  };
  
  inner();
};

outer();

 

위 코드가 실행될 때 콜스택 및 실행 컨텍스트를 도식으로 나타내 보겠습니다.

 

 

뭐 딱히 특별한 점은 없습니다. (실행 컨텍스트 참고 링크)

그럼 위 코드를 조금 변경 해보겠습니다.

 

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

const out = outer();
console.info(out()); // 2
console.info(out()); // 3

 

inner 함수는 outer 함수 내부에서 선언됐으므로 스코프 체이닝에 따라 outer 에서 선언한 a 에 접근해 1을 증가시킨 값을 반환하고, inner 함수의 실행 컨텍스트가 종료되는 형태로 되어 있습니다.

 

여기서 조금 특이한 점은, inner 함수의 실행 시점에는 outer 함수가 이미 종료된 상태인데 inner 가 어떻게 outer 함수에 접근할 수 있는가? 입니다. 이는 GC (가비지 컬렉터) 의 동작 방식 때문인데, 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있다면 그 값을 수집 대상에 포함하지 않습니다.

 

outer 함수는 실행 종료 시점에 inner 함수를 반환합니다. outer 실행이 종료되어도 inner 함수가 out 을 언젠가 실행할 수 있게 됨으로써, inner 함수의 실행 컨텍스트가 활성화되면 outerEnvironmentReference 가 outer 함수의 LexicalEnvironment 를 참조해야 하기 때문에 수집 대상에서 제외됩니다. 이 때문에 inner 함수가 outer 의 변수에 접근할 수 있는 것입니다.

이 상황을 다시 도식화해보면 아래와 같습니다.

 

 

어떤 함수의 실행 컨텍스트가 종료된 후 LexicalEnvironment 가 가비지 컬렉터의 수집 대상에서 제외되는 경우는 위처럼 지역변수를 참조하는 내부함수가 외부로 전달된 경우가 유일합니다. 외부 함수의 LexicalEnvironment 가 가비지 컬렉팅되지 않는다 라고 표현할 수 있겠네요.

 

정리하자면, 클로저란 어떤 함수 P 에서 선언한 변수 x 를 참조하는 내부함수 Q 를 외부로 전달할 경우 P 의 실행 컨텍스트가 종료된 이후에도 변수 x 가 사라지지 않는 것 을 말합니다.

 

* 참고 : 스펙상으로는 선언 당시의 LexicalEnvironment 전부를 가비지 컬렉팅하지 않도록 되어 있으나 V8 엔진의 경우 내부함수에서 실제로 사용하는 변수만 남겨두고 나머지는 가비지 컬렉팅 하도록 최적화되어 있습니다.

 

위 코드처럼 굳이 return 이 아니어도, 콜백함수에서의 지역변수 참조나 DOM 에 등록하는 eventListener 에서도 지역변수를 참조한다면 이 또한 클로저입니다.

 

- 클로저, 메모리 관리

 

위에서 얘기한 특성 때문에 클로저는 메모리 누수를 유발하므로 사용을 조심하거나 일부에선 아예 쓰면 안된다 라고 하기도 합니다.

(근래엔 V8 엔진의 최적화 레벨이 상당히 올라와서 클로저에 의한 메모리 누수는 거의 없다고 하기도 합니다)

하지만 '메모리 누수' 라는 건 개발자의 의도와 달리 어떤 변수의 참조가 해제되지 않아 가비지 컬렉팅이 되지 않는 경우를 말하며, 클로저를 만들었다는 건 참조가 해제되지 않게끔 의도한 부분이므로 '누수' 라고 할 수 없습니다.

메모리 소모는 클로저의 본질적인 특성이며, 오히려 이를 잘 이해하고 적재적소에 활용하도록 해야 합니다.

 

클로저는 필요에 의해 의도적으로 함수의 지역변수가 메모리를 소모하도록 함으로써 발생하고, 필요성이 사라진 시점에는 더이상 메모리를 소모하지 않게 해주면 됩니다. 아주 심플한 방법이고 예전부터 많이 쓰였던 방법인데, null 이나 undefined 를 할당하는 것입니다.

 

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

console.info(outer());
console.info(outer());
outer = null;

 

근데 요즘은 GC 성능이 엔진의 향상과 함께 상당히 올라갔고, 또한 저도 실제로 업무에서 이렇게 코드를 만들어서 GC 를 태우도록 의도한적은 없습니다. 그냥 이렇게 할 수도 있구나.. 정도로 체크하고 넘어가면 되겠습니다.

 

- 클로저 활용 예시

 

제가 업무에서 클로저를 사용한 부분을 예시로 들어보겠습니다.

 

const somethingCost = function(cost) {
  let modifiedCost = cost;

  const modify = function(fn, opts) {
    modifiedCost = fn(modifiedCost, opts);
    return this;
  };

  const get = function() {
    return modifiedCost;
  };

  return { modify, get };
};

const applyExtraRate = (cost, extraRate) => {
  if (extraRate > 1) {
    return Math.floor(cost * extraRate * 1000) / 1000;
  } else {
    return cost;
  }
};

const abcCost = somethingCost(100).modify(applyExtraRate, 1.3).get();
const defCost = somethingCost(200).modify(applyExtraRate, 1.3).modify(applyExtraRate, 0.8).get();

 

somethingCost 의 내부 함수 modify, get 이 modifiedCost 를 참조하도록 클로저를 생성했고, modify 에선 this 를 리턴하게끔 만들어 체이닝 형태로 함수 호출도 가능하게끔 만들었습니다. defCost 에는 코드를 덧붙이기 귀찮아서 applyExtraRate 함수를 두 번 적용하고 있지만, modify 를 저런 형태로 만들어 두었기에 얼마든지 cost 에 다른 로직을 태우도록 별도의 함수를 만들어 쉽게 적용 및 대체가 가능할 것입니다.

 

혹은 앱에서 전역으로 관리하는 redis client 등을 클로저로 만들어서 앱이 실행될 때 하나의 공통 client 만 갖게끔 활용하고 있습니다. (물론 이런 부분은 class 문법을 써서 싱글톤 객체로 만드는게 더 나을지도 모릅니다..)

 

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

 

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

보통 Java 에선 public, protected, private 와 같은 접근 제어자를 지원하지만 자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 부여하도록 설계되어 있지 않습니다. (TS 를 쓰면 되긴 하지만, 설명 차원에서..)

 

하지만 클로저를 사용하면, 접근 권한을 제어해 public 과 private 로 구분시킬 수 있습니다.

 

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

const out = outer();
console.info(out());

 

그 방법은 위에서처럼 return 을 활용하는 것입니다. 외부에 제공하고자 하는 정보들은 return 함으로써 public member 가 되고, 내부에서만 사용할 정보들은 return 하지 않아 private member 로 만들 수 있습니다.

 

• 부분 적용 함수 (partially applied function)

 

부분 적용 함수란 n 개의 인자를 받는 함수에 미리 m 개의 인자만 넘겨 기억시켰다가 나중에 나머지 인자를 넘기면 원래 함수의 실행 결과를 얻을 수 있게끔 하는 함수입니다. 간단히 예시로만 보고 넘어가도록 하겠습니다.

 

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

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

 

• 커링 함수 (currying function)

 

커링 함수란 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성한 것을 말합니다. 부분 적용 함수와 비슷하지만 다른 점이 있습니다.

커링은 한 번에 하나의 인자만 전달하는 원칙이 있습니다. 또한 중간 과정상의 함수를 실행한 결과는 다음 인자를 받기 위해 대기할 뿐, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않습니다. (부분 적용 함수는 여러 인자를 전달할 수 있고, 과정에서 원본 함수도 실행됩니다)

 

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

const getMax = curry(Math.max)(10);
console.info(getMax(4)); // 10
console.info(getMax(23)); // 23

 

이 방법은 유용하지만 (특히 함수형 프로그래밍에서), 인자가 많아질수록 가독성이 상당히 떨어집니다.

ES6 에선 화살표 함수를 써서 그나마 간단하게 표기할 수는 있습니다.

 

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

 

그나마 낫긴 하지만, 개인적으론 그럼에도 가독성은 좋지 않은 것 같습니다. 개발간에 적절한 트레이드오프가 필요한 부분인 것 같습니다.

 

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

 

커링 함수는 정보를 인자로 받아 마지막 인자가 넘어갈 때까지 함수 실행을 미루게 되는데, 이를 함수형 프로그래밍에선 지연실행 이라고 칭하며 많이 사용하고 있습니다.

'Backend > Javascript' 카테고리의 다른 글

ES2022  (0) 2022.01.30
Javascript 내장 클래스 확장  (0) 2021.12.25
Javascript - 실행 컨텍스트란  (0) 2021.07.10
JavaScript - this 에 대해  (0) 2021.07.03
JavaScript - 프로토타입 (Prototype) 에 대해  (2) 2021.06.26