클로저

Ordinary·2023년 5월 22일
0

클로저의 의미 및 원리 이해

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

A closure is the combination of a function and the lexical environment within which that function declared
클로저는 함수와 그 함수가 선언될 당시의 lexical environment의 상호 관계에 따른 현상
[MDN]

여기서 lexical envrionment란 outerEnvironmentReference를 의미합니다.

2장 실행 컨텍스트의 내용을 다시 상기해보자면,
함수가 호출될 때 생성되는 실행 컨텍스트는 variableEnvironment, lexical Environment,
this Binding을 구성 요소로 갖습니다.

그 중 lexicalEnvironment는 코드 실행 전 모든 식별자에 대한 정보를 environmentRecord에 담고, 
함수가 선언될 때의 환경 즉, 외부 스코프에 대한 lexicalEnvironment 정보를
outerEnvironmentReference에 담습니다.

MDN의 정의에서 주목해야 할 단어는 바로 combination입니다.

어떤 함수 A안에서 B라는 함수를 선언하고 실행했을 때, B의 outerEnvironmentReference는 A의 lexicalEnvironment를 참조하고 있습니다. 따라서, B에서는 A에서 선언한 변수에 대해서 접근이 가능합니다. 이처럼 내부 함수에서 외부 변수를 참조하는 경우에 한해서 combination 즉, 선언될 당시의 lexicalEnvironment의 상호 관계가 생긴다고 말할 수 있습니다.

아래 간단한 예시에서는 inner변수에 저장된 함수에서 outerEnvironmentReference를 통해 식별자 a를 검색하고, 1을 증가해 출력하고 있는 것을 볼 수 있습니다.

//외부함수에서 선언한 변수에 접근하는 간단한 예시1

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

	inner();
}

outer();

위 예시를 다음과 같이 바꿔볼 수 있습니다.

//외부함수에서 선언한 변수에 접근하는 간단한 예시2

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

	return inner();
}

console.log(outer());

예시 1과 예시 2는 출력 결과가 동일합니다.

또한, outer함수의 실행이 종료되면(실행 컨텍스트가 삭제되면), 식별자 a, inner의 값에 대한 참조도 사라지게 되므로 가비지 컬렉터에 의해 소멸된다는 공통점이 있습니다. 즉, outer함수를 다시 호출하지 않는 이상, 함수가 종료되면 내부에서 선언된 inner함수는 다시 호출할 수 없습니다.

만약, outer함수가 종료되어도 inner함수를 별도로 호출할 수 있게 만들려면 어떻게 해야 할까요?

//외부함수에서 선언한 변수에 접근하는 간단한 예시3

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

	return inner;
}

var outer2 = outer();
console.log(outer2()); //2
console.log(outer2()); //3

예시 3에서는 inner함수 자체를 반환하고 있습니다.

outer2outer함수의 반환값인 inner함수를 참조하고 있습니다. outer2를 실행하게 되면 실제로는 inner함수가 실행되는 것이죠.

  1. inner함수가 호출될 때, 실행 컨텍스트의 outerEnvironmentReference는 선언될 당시의 환경인 outer함수의 lexicalEnvironment가 참조복사됩니다.
  2. inner함수 실행 시, 식별자 a에 대해서 검색하기 위해 environmentRecord를 찾아보지만 a에 대해서 찾을 수 없습니다.
  3. inner함수의 스코프 체인에 따라 outerEnvironmentReference를 통해서 outer함수의 lexicalEnvironment에 있는 변수 a에 접근해서 1만큼 증가시킵니다.

이렇게 동작할 수 있는 경우는 var outer2라는 변수가 inner함수를 참조하고 있고, inner함수의 outerEnvironmentReference가 outer의 lexicalEnvironment를 참조하고 있기 때문입니다.

앞선 예시 1과 예시 2에서는 outer함수의 실행 종료로 인해 실행 컨텍스트가 삭제되면 식별자 a, inner도 같이 사라지게 되므로 저장되어 있던 값에 대한 참조 카운트가 0이 됩니다. 참조 카운트가 0이되면 가비지 컬렉터의 수집 대상이 되어서 소멸하게 됩니다.

하지만, 예시 3과 같은 경우는 outer함수의 lexicalEnvironment가 outer함수가 실행 종료된 이후에도 변수 outer2로 인해 inner함수를 실행시킬 수 있습니다. 즉, outer함수의 lexicalEnvironment의 참조 카운트가 0이 아니기 때문에 가비지 컬렉터의 수집 대상에서 제외됩니다. 이로 인해 inner함수가 계속해서 outer함수의 변수 a에 접근할 수 있게 됩니다.

앞선 클로저의 정의를 이렇게 다시 고쳐보겠습니다.

A closure is the combination of a function and the lexical environment within which that function declared
클로저는 함수와 그 함수가 선언될 당시의 lexical environment의 상호 관계에 따른 현상
[MDN]
1. 외부 함수의 environment가 가비지 컬렉팅되지 않는 현상
2. 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우, A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상

return이외의 클로저 발생하는 경우 및 메모리 관리

클로저가 발생하는 경우는 return 이외에도 다양합니다.

(function() {
	var a = 0;
	var intervalId = null;
	var inner = function() {
		if (++a >= 10) {
			clearInterval(intervalId);
		}
		console.log(a);
	};
	intervalId = setInterval(inner, 1000)
})();

setInterval의 콜백함수로 넘겨주고 있는 inner함수의 경우 선언될 당시의 환경을 기억해 내부에서 intervalId를 계속 참조하고 있습니다.

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

addEventListener의 콜백 함수 내부에서 선언될 당시의 환경을 기억하고, count변수를 내부에서 참조하고 있습니다.

클로저와 메모리 누수 위험성

클로저는 실행 컨텍스트가 종료되어도 참조 카운트가 0이 되지 않기 때문에, 일각에서는 메모리 누수의 위험 때문에 오히려 클로저의 사용을 지양해야 한다고 합니다.

메모리 누수란, 프로그램이 작동하며 할당됐던 메모리가 더 이상 사용되지 않는 시점에서도 반환되지 않는 현상을 말합니다. 이렇게 반환 되지 않은 메모리가 계속 누적되면 프로그램에 할당할 수 있는 메모리 공간이 부족해지면서 비정상적으로 작동하거나 크래시가 발생할 수 있습니다.

하지만, 메모리 누수는 클로저의 본질적인 특성일 뿐, 개발자가 의도를 가지고 이런 특성을 고려해서 클로저를 사용한다면, 오히려 클로저를 통해 많은 이점을 얻을 수 있습니다.

메모리 소모를 잘 관리하면서 클로저를 사용하는 방법은 참조의 필요성이 사라진 시점에 해당 값의 참조카운트를 0으로 만들어주는 것입니다. 식별자에 기본형 데이터 null 혹은 undefined를 할당하면 됩니다.


//반환된 함수를 참조하고 있는 outer2의 값을 null로 초기화
var outer = function() {
	var a = 1;
	var inner = function() {
		return ++a;
	}

	return inner;
}

var outer2 = outer();
outer2 = null;

//setInterval의 콜백이 필요 없어진 시점에 inner를 null로 초기화
(function() {
	var a = 0;
	var intervalId = null;
	var inner = function() {
		if (++a >= 10) {
			clearInterval(intervalId);
			inner = null;
		}
		console.log(a);
	};
	intervalId = setInterval(inner, 1000)
})();

//이벤트 핸들러가 필요 없어지는 시점에 null로 초기화
(function() {
	var count = 0;
	var button = document.createElement("button");
	button.innerText = "click";

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

클로저 활용 사례

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

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

현재 B함수에서 외부 매개변수 fruit를 참조하고 있습니다.

B의 outerEnvironmentReference가 A함수의 lexicalEnvironment이기 때문에 A함수가 종료되어도 이벤트 핸들러인 B함수는 계속 fruit를 참조할 수 있습니다.

위 예시에서 익명으로 정의함 콜백 함수를 일반 함수로 정의하고 인자로 넘겨주고 싶으면 2가지 방법을 생각해볼 수 있습니다. 하나는 bind메서드를 이용하여 일반 함수로 정의하는 것이고, 또 하나는 고차함수를 이용하는 것입니다.

함수를 따로 정의하여 콜백 함수로 넘겨주고 싶을 때

콜백함수를 공통 함수로 쓰고자 외부로 꺼내서 바로 넘겨주게 되면 addEventListener에게 콜백 함수 인자에 대한 제어권이 넘어 가게 됩니다. 이 때 첫번째 인자로 이벤트 객체를 주입하기 때문에 fruit에 대한 인자를 명시적으로 넘기기 위해서 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);
alertFruit(fruits[1]);

하지만, 고려 해야 할 점이 있습니다.

  • bind는 첫번째 인자인 this를 생략할 수 없기 때문에 위와 같이 null이 되면 함수 내부에서의 this가 달라질 수 있는 점
  • 이벤트 객체가 첫번째가 아닌 2번째 인자로 넘어온다는 점
    var alertFruit = function(fruit, event){
      alert('your choice is '+ fruit);
    }
    
    //banana, [PointerEvent]

고차함수를 이용해 콜백함수를 넘겨줄 때

고차함수란, 함수를 인자로 받거나, 함수를 결과값으로 반환하는 함수를 말합니다.

alertFruitBuiler함수는 기존 alertFruit함수를 반환하는 고차함수입니다. 이벤트 핸들러를 넘겨줄 때, alertFruitBuilder함수를 fruit인자와 함께 실행함으로써 결과적으로 기존 alertFruit함수를 콜백 함수로 넘겨주게 되었습니다.

이후, 클릭 이벤트가 발생하여 이벤트 핸들러가 실행될 때, 콜백 함수는 자신의 outerEnvironmentReference인 alertFruitBuilder의 lexicalEnvironment를 참조하게 되어 매개변수 fruit값을 사용하게 됩니다.

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);
alertFruit(fruits[1]);

정보 은닉

정보 은닉은 어떤 모듈의 내부 로직에 대해서 외부로의 노출을 최소화하는 것을 말합니다. 이는 모듈 간의 결합도를 낮추고 유연성을 높이는 데 목적이 있습니다.

흔히, 정보의 접근 권한에는 public, private, protected가 있습니다. 그 중에 public은 외부에서 접근 가능하다는 의미이고, private은 접근이 불가능한, 내부에서만 사용이 가능함을 의미합니다. 자바스크립트에서는 public, private 키워드의 개념이 없지만, 클로저를 이용하면 내부 정보에 대해 접근 권한을 부여한 것처럼 만들 수 있습니다.

즉, 외부에 제공하고자 하는 정보들만 모아서 return하고, 내부에서만 사용할 정보들은 return하지 않는 것으로 권한을 부여합니다.

자동차 경주 게임에 사용할 간단한 객체를 만들어보겠습니다. 자동차 경주 게임의 규칙은 다음과 같습니다.

  • 자동차에는 연료량과 연비가 무작위로 주어집니다.
  • run메서드를 통해 자동차가 랜덤한 거리를 이동하게 되면 현재 연료량 내에서 갈 수 있는 만큼 움직이게 됩니다. 연료가 부족하면 이동하지 못합니다.
  • 모든 유저가 이동하면 게임이 종료되고 가장 많이 이동해 있는 사람이 승리합니다.
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, power, moved 모두에 접근할 수 있게 됩니다. 만약, 악의적인 의도를 가진 플레이어가 경기를 승리하려고 객체에 접근한다면, 값을 마음대로 바꿀 수 있다는 위험성이 있습니다.

값을 마음대로 바꾸지 못하도록 방어하기 위해서 클로저를 활용할 수 있습니다.
즉, 객체가 아닌 함수로 만들고 객체로 필요한 부분만 반환하게 하는 것입니다.

var car = 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 / this.power;
		  if (this.fuel < wasteFuel) {
		    console.log("이동불가");
		    return;
		  }
		  this.fuel -= wasteFuel;
		  this.moved = km;
		  console.log(km + "km 이동 (총" + this.moved + "km)");
		},
	}
};
  • moved변수는 getter를 통해 읽기 전용 속성을 부여합니다. 접근자 - JavaScript | MDN
  • 객체를 생성해 반환하는 함수로 변경했기 때문에 외부에서는 오직 run메서드와 moved변수의 조회만 가능합니다.

부분 적용 함수

부분 적용 함수란 전체 n개의 인자를 넘겨 받는 함수가 있다고 했을 때, 미리 m개의 인자만 넘겨 놓고 나중에 나머지 인자를 전달하여 비로소 원래 함수의 실행 결과를 얻을 수 있게 하는 함수입니다. bind메서드를 통해서 구현할 수 있습니다.

부분 적용 함수의 적합한 예시로 디바운스를 살펴보겠습니다.

디바운스란, 짧은 시간 동안 동일한 이벤트가 많이 발생할 경우, 이를 전부 처리하지 않고 처음 혹은 마지막 발생한 이벤트에 대해서만 처리하는 것으로 프런트엔드 성능 최적화에 큰 도움을 주는 기능 중 하나입니다.

즉, 미리 인자를 넣어 함수를 기억하고, 추후 필요한 시점에 기억한 인자들과 함께 실행한다는 점에서 디바운스는 부분 적용 함수이면서 클로져의 개념과 부합합니다.

var debounce = (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 = (e) => {
	console.log('move event 처리');
};

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

document.body.addEventListener('mousemove',debounce('move', moveHandler, 500));

document.body.addEventListener('mousewheel',debounce('wheel', moveHandler, 700));

현재 debounce가 반환하는 함수의 경우, eventName, timeoutId, wait에 대해서 외부 스코프의 식별자를 참조하고 있어 클로저를 내부에서 사용하고 있습니다. 이렇게 필요한 인자들을 기억해 두었다가 필요한 시점에 최종적으로 func함수가 실행된다는 점에서 부분 적용 함수라고 말할 수 있습니다.

커링 함수

커링 함수란, 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눈 것으로 순차적으로 호출될 수 있게 체인 형태로 구성한 것을 말합니다.

커링 함수는 한 번에 하나의 인수만 전달하는 것을 원칙으로 하며, 중간 과정상의 함수는 인자를 받기 위한 기능만 수행할 뿐, 실제 수행되는 원본 함수의 실행은 마지막 인자까지 전달된 후입니다.

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

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

커링 함수는 인자가 많아질 수록 깊이가 깊어져 가독성이 떨어집니다. ES6의 화살표 함수를 이용하면 훨씬 이해하기 쉽게 커링 함수를 구현할 수 있습니다.

var curry2 = function (func) {
  return function (a) {
    return function (b) {
			return function (c) {
				return function (d) {
					return func(a, b, c, d);
		    };
	    };
    };
  };
};

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

커링 함수가 유용한 경우는 지연실행이 필요한 경우입니다.

원하는 시점까지 함수를 지연 시켰다가 실행하고 싶을 때, 즉, 마지막 인자가 넘어올 때까지 당장 필요한 정보만 받아서 전달하고 이후에 또 필요한 정보를 받아서 전달하는 식으로 함수의 실행을 미룰 수 있습니다.

혹은 함수의 매개변수가 항상 비슷하고 일부만 바뀔 때 커링함수를 사용할 수 있습니다.

var getInformation = baseUrl => path => id => fetch(baseUrl + path + "/" + id);

//baseUrl
var imageUrl = "http://imageAddress.com/"

//타임별 요청 함수 준비
var getImage = getInformation(imageUrl); //http://imageAddress.com/

var getEmoticon = getImage("emoticon"); //http://imageAddress.com/emoticon

//실제 요청
var emoticon1 = getEmoticon(100); //http://imageAddress.com/emoticon/100

위 예시에서는, HTTP요청 함수를 정의해서 리소스를 요청하려고 합니다. baseUrl같은 경우, 고정되어 있지만, path나 id값은 경우가 매우 많고 빈번하게 바뀔 수 있습니다.

따라서, 공통되는 요소인 baseUrl을 기억시켜 놓고, 필요한 시점에 path와 id를 인자로 넘겨주어 서버 요청을 수행하도록 구현할 수 있습니다. 이렇게 커링함수를 이용하면 마지막 인자가 넘어올 때까지 함수 실행을 지연시킬 수 있고, 가독성과 분리로 인한 개발 효율성이 높아지는 것을 알 수 있습니다.

0개의 댓글