본문 바로가기
javascript

[javascript] 클로저

by 박연호의 개발 블로그 2020. 5. 8.

 

 


클로저의 의미 및 원리 이해

클로저(closure)는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성입니다. 자바스크립트 고유의 개념이 아니라 ECMAScript 명세에서도 클로저의 정의를 다루지 않고 있고 그것때문이라고 할 수는 없지만 어쩃든 다양한 문헌에서 제각각 클로저를 다르게 정의 또는 설명하고 있습니다. 다양한 서적에서 클로저를 한 문장으로 요약해서 설명하는 부분들을 소개하면 다음과 같습니다.

 

  • 자신을 내포하는 함수의 컨테스트에 접근할 수 있는 함수 
  • 함수가 특정 스코프에 접근할 수 있도록 의도적으로 그 스코프에서 정의하는 것
  • 함수를 선언할 때 만들어지는 유효범위가 사라진 후에도 호출하 수 있는 함수
  • 이미 생명 주기상 끝난 회부 함수를 참조하는 함수
  • 자유변수가 있는 함수와 자유변수를 알 수 있는 환경의 결합
  • 로컬 변수를 참조하고 있는 함수 내의 함수
  • 자신이 생성될 때의 스포크에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하며 유지시키는 함수

MDN(Mozila Developer Network)에서는 클로저에 대해 "A Closure is the combination of a function and the lexical environment within which that function was decakred" 라고 소개합니다. 직역하면 "클로저는 함수와 그 함수가 선언될 당시의 lexical environment의 상호관계에 따른 현상" 입니다.

 

"선언될 당시의 lexical environment"는 '실행컨텍스' 에서의 outerEnvironmentReference입니다. LexicalEnvironment의 environmentRecord와 outerEnvironmentReference에 의해 변수의 유효범위인 스코프가 결정되고 스코프 체인이 가능해진다고 했습니다. 어떤 컨텍스트 A에서 선언한 내부함수 B의 실행 컨텍스트가 활성화된 시점에는 B의 outerEnvironmentReference가 참조하는 대상인 A의 LexicalEnvironment에도 접근이 가능하겠죠 A에서는 B에서 선언한 변수에 접근할 수 없지만 B에서는 A에서 선언한 변수에 접근 가능합니다.

 

여기서 "combination"의 의미를 파악할 수 있습니다. 내부함수 B가 A의 LexicalEnvironment를 언제나 사용하는 것은 아닙니다. 내부함수에서 외부 변수를 참조하지 않는 경우라면 combination이라고 할 수 없겠죠. 내부함수에서 외부 변수를 참조하는 경우에 한해서만 combination, 즉 '선언될 당시의 LexicalEnvironment와의 상호관계'가 의미가 있을 것입니다.

 

지금까지 파악한 내용에 따르면 클로저는 "어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상" 이라고 볼 수 있겠습니다. 확 와닿지는 않지만 실제 예제를 통해 좀 더 명확히 밝혀봅시다.

 

1 var outer = function(){
2      var a = 1;
3      var inner = function() {
4          console.log(++a);
5      };
6      inner();
7 };
8 outer();

outer 함수에서 변수 a를 선언했고, outer의 내부함수인 inner 함수에서 a의 값을 1만큼 증가시킨 다음 출력합니다. inner 함수 내부에서는 a를 선언하지 않았기 때문에 environmentRecord에서 값을 찾지 못했으므로 outerEnvironmentReference에 지정된 상위 컨텍스트인 outer의 LexicalEnvironmentdp 접근해서 다시 a를 찾죠. 4번째 줄에서는 2가 출력됩니다. outer 함수의 실행 컨텍스트가 종료되면 LexicalEnvironmentdp 저장된 식별자들(a, inner)에 대한 참조를 지웁니다. 그러면 각 주소에 저장돼 있던 값들은 자신을 참조하는 변수가 하나도 없게 되므로 가비지 컬렉터의 수집 대상이 될 것입니다.

 

위의 경우에는 outer 함수의 실행 컨텍스트가 종료되기 이전에 inner 함수의 실행 컨텍스트가 종료돼 있으며, 이후 별도로 inner 함수를 호출할 수 없습니다. 그렇다면 outer의 실행 컨텍스트가 종료된 이후에도 inner 함수를 호출할 수 있게 만들면 어떨까요 ?

1 var outer = function(){
2      var a = 1;
3      var inner = function() {
4          console.log(++a);
5      };
6      return inner;
7 };
8  var outer2 = outer();
9  console.log(outer2());        // 2
10 console.log(outer2());        // 3

이번에는 6번째 줄에서 inner 함수의 실행 결과가 아닌 inner 함수 자체를 반환했습니다. 그러면 outer 함수의 실행 컨텍스트가 종료될 때(8번째 줄) outer2 변수는 outer의 실행 결과인 inner 함수를 참조하게 될 것 입니다. 이후 9번째에서 outer2를 호출하면 앞서 반환된 함수인 inner가 실행되겠죠.

 

inner 함수의 실행 컨텍스트의 environmentRecord에는 수집할 정보가 없습니다. outer-EnvironmentReference에는 inner함수가 선언된 위치의 LexicalEnvironment가 참조복사됩니다. outer-EnvironmentReference에는 inner함수가 선언된 위치의 LexicalEnvironment가 참조 복사됩니다. inner 함수는 outer 함수 내부에서 선언됐으므로, outer 함수의 LexicalEnvironment가 담길 것입니다. 이제 스코프 체이닝에 따라 선언한 변수 a에 접근해서 1만큼 증가시킨 후 그 값인 2를 반환하고, inner 함수의 실행 컨텍스트가 종료됩니다 10번째 줄에서 다시 outer2를 호출하면 같은 방식으로 1을 증가시킨 후 3을 반환합니다.

 

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

 

클로저는 어떤 함수에서 선언한 변수를 참조하는 내부 함수에서만 발생하는 현상이라고 했습니다. 앞서 살펴본 예제에서 일반적인 함수의 경우와 마찬가지로 outer의 LexicalEnvironment에 속하는 변수가 모두 가비지 컬렉팅 대상에 포함된 반면, 마지막 예제의 경우 변수 a가 대상에서 제외되었습니다. 이렇게 함수의 실행 컨텍스트가 종료된 후에도 LexicalEnvironment가 가비지 컬렉터의 수집 대상에서 제외되는 경우에도 지역 변수를 참조하는 내부 함수가 외부로 전달된 경우가 유일합니다. 그러니깐 "어떤 함수에서 선언한 변수를 참조하는 내부함수에서만 발생하는 현상"이란 "외부 함수의 LexicalEnvironment가 가비지 컬렉팅되지 않는 현상"을 말하는 것입니다.

 

이를 바탕으로 클로저의 정의를 다시 생각해보면, 클로저란 "어떤 함수 A에서 선언한 변수a를 참조하는 내부 함수 B를 외부로 전달한 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상"을 말합니다.

 

여기서 한가지 주의할 점은 바로 '외부로 전달'이 곧 return을 의미하는 것은 아닙니다. 아래의 예를 보겠습니다.

1 (function (){
2    var a = 0;
3    var intervalId = null;
4    var inner = function() {
5      if(++a >= 10){
6           clearInterval(intervalId);
7       }
8       console.log(a);
9 };
10 intervalId = setInterval(inner,1000);
11 })();
1 (function (){
2   var count = 0;
3   var button = document.createElement('button');
4   button.innerText = 'click';
5   button.addEventListener('click', function() {)
6        console.log(++count,'times clicked');
7     });
8
9   document.body.appendChild(button);
10 })();

첫번째 예제는 별도의 외부객체인 window의 메서드(setTImeout 또는 setInterval)에 전달한 콜백 함수 내부에서 지역변수를 참조합니다. 두번째 예제는 별도의 외부객체인 DOM의 메서드(addEventListener)에 등록할 handler 함수 내부에서 지역변수를 참조합니다. 두 상황 모두 지역변수를 참조하는 내부 함수를 외부에 전달했기 때문에 클로저 입니다.


클로저와 메모리 관리

메모리 누수의 위험을 이유로 클로저 사용을 조심해야 한다거나 심지어 지양해야 한다고 주장하는 사람들도 있지만 메모리 소모는 클로저의 본질적인 특성일 뿐입니다. 오히려 이러한 특성을 정확히 이해하고 잘 활용하도록 노력해야 합니다. '메모리 누수'라는 표현은 개발자의 의도와 달리 어떤 값의 참조 카운트가 0이 되지 않아 가비지 컬렉터의 수거 대상이 되지 않는 경우에는 맞는 표현이지만 개발자가 의도적으로 참조 카운트를 0이 되지 않게 설계한 경우는 '누수'라고 할 수 없겠죠. 과거에는 의도치 않게 누수가 발생하는 여러가지 상황들이 있었지만 그중 대부분은 최근의 자바스크립트 엔진에서는 발생하지 않거나 거의 발견하지 힘들어 졌으므로 이제는 의도대로 설계한 '메모리 소모'에 대한 관리법만 잘 파악해서 적용하는 것으로 충분합니다.

 

관리방법은 간단합니다. 클로저는 어떤 필요에 의해 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생합니다. 그렇다면 그 필요성이 사라진 시점에는 더는 메모리를 소모하지 않게 해주면 됩니다. 참조 카운트를 0으로 만들면 언젠가 가비지 컬렉터가 수거해갈 것이고, 이대 소모됐던 메모리는 회수되겠죠. 이때 참조 카운트를 0으로 만드는 방법은 식별자에 참조형이 아닌 기본형 데이터(보통 null, undefined)를 할당해주면 됩니다.

1 var outer = (function (){
2   var a = 1;
3   var inner = function() {
4        return ++a;
5      };
6   return inner;
7 })();
8
9   console.log(outer());  
10  console.log(outer());
11  outer = null;           // outer 식별자의 inner 함수 참조를 끊음

클로저 활용 사례

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

1 var fruits = ['apple', 'banana',' 'peach'];
2 var $ul = document.createElement('ul');             
3 
4 fruit.forEach(function(fruit) {                  // A
5   var $li = document.createElement('li');
6   $li.innerText = fruit;
7   $li.addEventListener('click', fuction() {      // B
8          alert('your choice is '+fruit);
9       });
10    $ul.appendChild($li);
11 });
12 document.body.appendChild($ul);

fruit 변수를 순회하며 li를 생성하고 각 li를 클릭하면 해당 리스너에 기억된 콜백 함수를 실행하게 했습니다. 4번째 줄의 forEach 메서드에 넘겨준 익명의 콜백함수(A)는 그 내부에서 외부 변수를 사용하지 않고 있으므로 클로저가 없지만 7번째 줄의 addEventListener에 넘겨준 콜백 함수(B)에는 fruit 이라는 외부변수를 참조하고 있으므로 클로저가 있습니다. (A)는 fruits의 개수만큼 실행되며 그때마다 새로운 실행 컨텍스트가 활성화될 것입니다. A의 실행종료 여부와 무관하게 클릭이벤트에 의해 각 컨텍스트의 (B)가 실행될 때는 (B)의 outerEnvironmentReference가 (A)의 LexicalEnvironment를 참조하게 되겠죠. 따라서 최소한 (B) 함수가 참조할 예정인 변수 fruit에 대해서는 (A)가 종료된 후에더 CG대상에서 제외되어 계속 참조 가능할 것입니다. 

 

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

정보 은닉(information hiding)은 어떤 모듈으ㅢ 내부 로직에 대해 외부로의 노출을 최소화해서 모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 현대 프로그래밍 언어의 중요한 개념 중 하나입니다. 흔지 접근 권한에는 public, private, protected 세 종류가 있습니다. 자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 직접 부여하도 설계돼 있지 않습니다. 그렇다고 접근 권한 제어가 불가능 한 것은 아닙니다. 클로저를 이용하면 함수 차원에서 public한 값과 private한 값을 구분하는 것이 가능합니다.

1 var outer = function() {
2       var a = 1;
3       var inner = function() {
4              return ++a;
5      };
6        return inner;
7  };
8  var outer2 = outer();
9  console.log(outer2()); // 2
10 console.log(outer2()); // 3

클로저를 활용하면 외부 스코프에서 함수 내부의 변수들 중 선택적으로 일부의 변수에 대한 접근 권한을 부여할 수 있습니다. 바로 return을 활용해서죠. 

 

closure라는 단어는 사전적으로 '닫혀있음, 폐쇄성, 완결성' 정도의 의미를 가집니다. 이 폐쇄성을 주목해보면 위 예즈를 조금 다르게 받아들일 수 있습니다. outer 함수는 외부로부터 철저하게 격리된 닫힌 공간입ㄴ디ㅏ. 외부에서는 외부 공간에 노출돼 있는 outer라는 변수를 통해 outer 함수를 실행할 수는 있지만, outer 함수 내부에서는 어떠한 개입도 할 수 없습니다. 외부에서는 오직 outer 함수가 return한 정보에만 접근할 수 있습니다. return 한 값이 외부에서 정보를 제공하는 유일한 수단인 것이죠.

 

클로저를 활용해 접근권한을 제어하는 방법을 정리해보면 다음과 같습니다.

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

3. 커링 함수

커링 함수(currying function)란 여러 개의 인자를 받는 함수를 하나ㅅ의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성하는 것을 말합니다. 커링을 한번에 하나의 인자만 전달하는 것을 원칙으로 합니다. 또한 중간 과정상의 함수를 실행한 결과는 그 다음 인자를 받기 위해 대기할 뿐으로, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않습니다. 

1 var curry5 = function(func){
2     return function(a) {
3         return function(b) {
4             return function(c) {
5                  return function(d) {
6                       return function(e) {
7                           return func(a,b,c,d,e);
8                        };
9                  };
10           };
11      };
12 };
13
14 var getMax = curry(Math.max);
15 console.log(getMAx(1)(2)(3)(4)(5));

ES6에서는 화살표 함수를 사용하여 같은 내용을 단 한 줄로 표기할 수 있습니다.

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

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

 

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


참고 : 코어 자바스크립트(Core JavaScript), 정재남

'javascript' 카테고리의 다른 글

[javascript] 클래스  (1) 2020.05.11
[javascript] this  (0) 2020.05.04
[javascript] 실행 컨텍스트  (0) 2020.05.02
[javascript] undefined와 null  (0) 2020.05.02
[javascript] 데이터 타입  (0) 2020.05.01