View

[JS] 클로저

Yuu's 2023. 5. 1. 09:00
반응형

클로저의 의미와 원리 이해

클로저(Closure)는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성으로 자바스크립트 고유의 개념은 아닙니다. 몇몇 언어에서는 구현이 불가능하거나 특수한 방식으로 구현해야 합니다. 하지만 자바스크립트에서는 생성자 함수를 제외한 대부분의 함수는 자연스럽게 클로저가 됩니다. 

 

우선 다양한 문헌에서 클로저를 제각각 다르게 다루고 있는데요. 한 번 살펴볼까요?

"자신을 내포하는 함수의 컨텍스트에 접근할 수 있는 함수" - 더글라스 크록포드, <자바스크립트 핵심 가이드>

"함수가 특정 스코프에 접근할 수 있도록 의도적으로 그 스코프에 정의하는 것" - 에단 브라운, <러닝 자바스크립트>

"함수를 선언할 때 만들어지는 유효범위가 사라진 후에도 호출할 수 있는 함수 - 존 레식, <자바스크립트 닌자 비급>

"이미 생명 주기상 끝난 외부 함수의 변수를 참조하는 함수" - 송형주 고현준, <인사이드 자바스크립트>

"자유 변수가 있는 함수와 자유변수를 알 수 있는 환경의 결합" - 에릭 프리먼, <Head First Javascript Programming>

"로컬 변수를 참조하고 있는 함수 내의 함수 - 야마다 요시히로, <자바스크립트 마스터북>
"자신이 생성될 때의 스코프에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하여 유지시키는 함수- 유인동, <함수형 자바스크립트 프로그래밍>

- 코어 자바스크립트 中 -

 

코어 자바스크립트 책에서는 "클로저란 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상"이라고 말하고 있습니다.

 

한 번 코드로 살펴볼까요?

var outer = function() {
    var a = 1;
    var inner = function () {
    	return ++a;
    };
    
    return inner; // 함수 자체를 반환합니다.
};

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

 

"outer" 함수는 "inner" 함수를 반환합니다. 자바스크립트에서는 스코프를 결정 지을 때 호출되는 시점이 아닌 선언되는 시점에 스코프를 결정 짓게 됩니다. "inner" 함수는 선언된 시점에서 "++a"를 반환하지만, 변수 "a"를 가지고 있지 않습니다. 자바스크립트에서는 이 경우 실행 컨텍스트의 렉시컬 환경을 참조하여 현재 해당 변수가 없을 경우 상위 스코프에서 식별자를 검색합니다. 이러한 행위를 "스코프 체인"이라고 합니다. 용어들이 낯설고 아직 이해가 되지 않으시다면 자바스크립트의 실행 컨텍스트, 렉시컬 환경, 스코프에 대해 먼저 학습하시고 클로저에 대해 학습하시면 쉽게 이해하실 수 있을 겁니다.

 

위의 코드처럼 함수가 함수를 반환하는 형태를 클로저라고 합니다. 하지만 꼭 함수를 반환하지 않아도 클로저가 발생하는 경우도 있습니다.

 

한 번 함수를 반환하지 않아도 클로저가 발생하는 경우도 살펴보겠습니다.

// (1) setInterval/sestTimeout
(function(){
    var a = 0;
    var intervalId = null;
    var inner = function () {
    	if(++a >= 10) {
        	clearInterval(intervalId);
        }
    }
    console.log(a);
   };
   intervalId = setInterval(inner, 1000);
})();
// (2) eventListener
(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);
})();

(1)의 경우 window 객체의 메서드(setTimeout 또는 setInterval)에 전달할 콜백 함수 내부에서 지역변수를 참조하며 (2)는 DOM 객체의 메서드(addEventListener)에 등록할 handler 함수 내부에서 지역 변수를 참조합니다. 두 상황 모두 지역변수를 참조하는 내부함수를 외부에 전달했기 때문에 클로저가 됩니다.

 

자, 이제 어느 정도 클로저에 대해 이해가 되시나요? 클로저 개념은 크게 어렵지 않습니다. 아직까지 이해가 안되셨다면 단순히 클로저는 은닉하고 싶은 변수가 있을 때 사용하는 것으로 이해하시면 될 것 같습니다. 즉, 외부에서 특정 변수에 대한 접근을 막고 싶을 때 클로저를 사용하면 됩니다. 자바스크립트에서는 Java, C#, C++ 같은 언어와 달리 접근자 private 개념이 없어 이러한 형태로 private 변수를 관리합니다. 하지만 클로저는 남용하면 메모리 누수의 문제가 발생할 수 있습니다.

 

메모리 누수 문제에 대해 이야기 해보기 전에 우선 처음에 말했듯이 생성자 함수를 제외하고 대부분의 함수는 클로저가 된다고 하였는데 왜 생성자 함수는 클로저가 될 수 없는지에 대해 간단하게 다루고 넘어가겠습니다.

 

생성자 함수는 클로저가 될 수 없는 이유

자바스크립트의 함수는 숨김 프로퍼티인 [[Environment]]를 이용해 자신이 어디서 만들어졌는지를 기억합니다. 함수 본문에선 [[Environment]]를 사용해 외부 변수에 접근합니다. 반면 생성자 함수의 경우 "new" 키워드를 이용해 생성하는데, 이 때 [[Environment]] 프로퍼티가 현재 렉시컬 환경이 아닌 전역 렉시컬 환경을 참조하게 됩니다. 따라서 "new" 키워드를 이용해 생성한 생성자 함수는 외부 변수에 접근할 수 없고 오직 전역 변수에만 접근할 수 있습니다.

 

 

클로저와 메모리 누수

클로저는 객체지향과 함수형 모두를 아우르는 매우 중요한 개념입니다. 메모리 누수의 위험을 이유로 클로저 사용을 조심해야 한다거나 지양해야 한다고 주장하는 사람들도 있지만 메모리 소모는 클로저의 본질적인 특성일 뿐입니다. 따라서 이러한 클로저의 특성을 잘 이해하고 활용하도록 노력해야 합니다. 

 

"메모리 누수"라는 표현은 개발자의 의도와 달리 가비지 컬렉터가 수거하지 않는 경우에는 맞는 표현이지만 개발자가 의도적으로 가비지 컬렉터가 수거할 수 있도록 설계하는 경우는 "누수"라고 표현할 수 없겠죠.

 

따라서 클로저 또한 개발자가 의도적으로 가비지 컬렉터가 수거할 수 있도록 설계할 수 있으므로 메모리 누수가 발생한다고 표현할 수 없습니다.

 

관리 방법은 정말 간단합니다. 우선 왜 사람들이 메모리 누수가 발생한다고 표현하는지부터 알아볼까요?

 

자바스크립트의 가비지 컬렉터는 내부적으로 더 디테일한 내용이 있지만 간단하게 말하면 변수가 특정 값을 참조하고 있지 않을 경우 가비지 컬렉터의 수거 대상이 됩니다. 하지만 클로저의 경우 외부 함수가 호출되었지만 내부 함수가 외부 함수의 변수를 참조하고 있죠. 그래서 가비지 컬렉터의 수거 대상이 되지 않아 메모리에 지속적으로 남게되어 메모리 누수가 발생하죠.

 

그렇다면 참조를 안하면 되지 않을까요~? 맞습니다. 변수에 참조형 데이터가 아닌 기본형 데이터를 할당해줍니다. 즉, null, undefined를 할당해주면 가비지 컬렉터가 수거해 메모리에서 제거되는 것이죠.

 

앞서 보여준 예제를 메모리 해제할 수 있게 코드를 수정해보겠습니다.

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

console.log(outer()); // 2
console.log(outer()); // 3
outer = null // outer 식별자의 inner 함수 참조를 끊음
(function(){
    var a = 0;
    var intervalId = null;
    var inner = function () {
    	if(++a >= 10) {
          clearInterval(intervalId);
          inner = null; // inner 식별자의 함수 참조를 끊음
        }
        console.log(a);
    };
    intervalId = setInterval(inner, 1000);
})();
(function() {
   var count = 0;
   var button = document.createElement('button');
   button.innerText = 'click';
   
   var clickHandler = function () {
     console.log(++count, 'times clicked');
     if(count >= 10) {
       button.removeEventListener('click', clickHandler);
       clickHandler = null;
     }
   };
   button.addEventListener('click', clickHandler);
   document.body.appendChild(button);
})();

 

클로저 활용 사례

 클로저는 정말 다양한 곳에서 광범위하게 활용됩니다. 클로저가 실제로 어떤 상황에서 유용하게 사용되는지 알아보겠습니다.

 

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

익명 콜백 함수 (A)는 내부에서 외부 변수를 사용하지 않으므로 클로저가 아닙니다. (B)에는 (A)의 매개변수인 "fruit"를 참조하고 있으므로 클로저입니다. (A)의 실행 종료와 무관하게 클릭 이벤트가 발생하면 각 컨텍스트의 (B)가 실행되어 (B)의 outerEnvironmentReferencer가 (A)의 렉시컬 환경을 참조하게 되는 거죠.

 

따라서 (A)가 가비지 컬렉터에 의해 수거가 되어도 (B) 함수는 "fruit"를 계속 참조할 수 있습니다.

 

 

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

앞서 말했듯이 클로저는 은닉하고 싶은 데이터가 있을 때 주로 사용한다고 하였습니다. 자바스크립트는 객체의 프로퍼티 접근에 대해 자유도가 매우 높습니다. 객체가 가지고 있는 프로퍼티의 이름을 통해 언제든지 해당 객체에 접근할 수 있으며 언제든지 데이터를 수정할 수 있죠. 그래서 개발자들은 프로퍼티의 이름 앞에 언더바(_)를 붙이면서 해당 식별자가 private한 속성을 가진다는 것을 명시해줬습니다. 즉, "해당 프로퍼티의 값은 건드리지 말아주세요. 그냥 읽기 전용 데이터로 사용해주세요."라는 일종의 약속인 것이죠.

 

하지만 아무리 개발자들 간의 약속이라고 하여도 해당 값은 변경이 가능합니다. 만약 보안에 민감한 정보를 담고 있는 프로퍼티라면 해당 프로퍼티가 수정되면 정말 심각한 문제를 초래하겠죠. 이러한 문제를 방지하기 위해서 클로저를 이용해 데이터를 은닉합니다.

 

ES2019부터는 class에서 "#" prefix를 통해 private 필드를 추가해줄 수 있게 되었습니다. 하지만 함수형 프로그래밍에서는 class를 자주 사용하지 않으니 클로저를 알아두면 나중에 민감한 데이터를 숨기고 싶을 때 정말 유용하게 쓰이겠죠? 아니면 민감한 정보가 있는 데이터는 class를 이용해 구현하고 의존성을 주입해주면 되겠네요.

 

자 그럼 이전에 호출할 때마다 1씩 증가하는 "outer" 함수를 다시 살펴보겠습니다.

var outer = function() {
    var a = 1;
    var inner = function () {
    	return ++a;
    };
    
    return inner; // 함수 자체를 반환합니다.
};

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

outer 함수를 종료할 때 inner 함수를 반환함으로써 outer  함수의 지역변수인 a의 값을 외부에서도 읽을 수 있게 됐습니다. outer 함수의 반환값인 inner 함수를 가지고 있는 "increase" 함수를 호출해야만 outer함수 a의 값을 읽어올 수 있으며 해당 변수를 직접 변경할 수 없는 구조가 된 것이죠.

 

이처럼 클로저는 외부에 제공하고자 하는 데이터들을 inner 함수처럼 내부 함수에 작성해 반환하고, 외부에 노출되면 안되는 데이터의 경우 반환하지 않는 것으로 접근 권한 제어를 하는 것이죠. 즉, return을 한 변수들은 공개 멤버가 되고, 그렇지 않은 변수들은 비공개 멤버가 되는 것입니다.

 

3. 부분 적용 함수

부분 적용 함수란 n개의 인자를 받는 함수에 미리 m개의 인자만 넘겨 기억시켰다가 나중에 (n-m)개의 인자를 넘기면 비로소 원래 함수의 실행 결과를 얻을 수 있게끔 하는 함수입니다.

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

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

 

실무에서 부분 함수를 사용하기에 적합한 예로 디바운스입니다. 디바운스는 짧은 시간 동안 동일한 이벤트가 많이 발생할 경우 이를 전부 처리하지 않고 처음 또는 마지막에 발생한 이벤트에 대해 한 번만 처리하는 것으로, 프론트엔드 성능 최적화에 큰 도움을 주는 기능 중 하나입니다. 보통 웹에서 DOM 이벤트 중 scroll, wheel, mousemove, resize 등에 적용하기 좋습니다. 보통은 직접 구현하기보다는 Lodash 같은 라이브러리를 많이 사용합니다. 가끔 최소한의 기능만 필요해 라이브러리를 사용하지 않고 직접 구현을 해야하는 경우도 있습니다. 디바운스의 경우 최소한의 기능에 대한 구현은 간단합니다.

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

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

 

4. 커링 함수

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

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

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

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

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

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

var getMax = curry5(Math.max);
console.log(getMax(1)(2)(3)(4)(5));

 

ES6에서 화살표 함수를 사용하면 가독성이 떨어진다는 단점을 보완할 수 있습니다.

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

 

화살표 함수로 구현하면 커링 함수를 이해하기에 훨씬 쉬워집니다. 화살표 순서에 따라 함수에 값을 차례로 넘겨주면 마지막에 func가 호출될 거라는 흐름이 한 눈에 파악되기 때문이죠.

 

이 커링 함수가 유용한 경우가 또 있습니다. 필요한 정보만 받아서 전달하고 필요한 정보가 들어오면 또 전달하는 식으로 결국 마지막 인자가 넘어갈 때까지 함수 실행을 미루게 되는데 이를 함수형 프로그래밍에서는 지연실행(lazy execution)이라고 합니다. 즉, 만약에 원하는 시점까지 지연시켰다가 실행해야 하는 상황이 발생하면 커링 함수를 사용하는게 적합할 것입니다.

 

혹은 프로젝트 내에서 자주 쓰이는 함수의 매개변수가 항상 비슷하고 일부만 바뀌는 경우에도 유용하게 사용됩니다.

var getInformation = function (baseUrl) {
    return function (path) {
    	return function (id) {
        	return fetch(baseUrl + path + '/' + id); // 실제 서버에 요청
    	};    
    };
};

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

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

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

// 이미지 관련 실제 요청
var emoticon1 = getEmoticon(100);  // http://imageAddress.com/emoticon/100
var emoticon2 = getEmoticon(102);  // http://imageAddress.com/emoticon/102
var icon1 = getIcon(205);		   // http://imageAddress.com/icon/205
var icon1 = getIcon(234);		   // http://imageAddress.com/icon/234

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

var fruit1 = getFruit(300);					// http://productAddress.com/fruit/300
var fruit2 = getFruit(400);					// http://productAddress.com/fruit/400
var vegetable1 = getVegetable(456);			// http://productAddress.com/vegetable/456
var vegetable2 = getVegetable(789);			// http://productAddress.com/vegetable/789

 

커링 함수는 최근 여러 프레임워크나 라이브러리 등에서 정말 많이 쓰이고 있습니다. 가장 대표적인 예로 Flux 아키텍처가 있죠. Flux 패턴은 데이터가 단방향으로 흐르는데 이를 ES6의 화살표 함수로 구현하면 정말 데이터가 흐르는 것처럼 보일 수 있게 할 수 있죠.

 

Flux 아키텍처의 구현체 중 하나인 Redux의 미들웨어를 예로 들면 다음과 같습니다.

// Redux Middleware 'Logger'
const logger = store => next => action => {
    console.log('dispatching', action);
    console.log('next state', store.getState());
    return next(action);
};

// Redux 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만 받아서 처리할 수 있게끔 한 것이죠.

 

정리

이번 포스팅은 클로저에 대해 알아봤습니다. 클로저는 어떤 함수의 지역 변수를 참조하는 내부 함수가 외부로 전달되는 즉, 함수가 함수를 리턴하는 구조를 가지는 것으로 함수의 실행 컨텍스트가 종료된 후에도 참조하고 있는 해당 변수가 사라지지 않는 현상을 말합니다. 

 

꼭 함수가 함수를 리턴하는 구조 말고도 콜백으로 전달하는 경우도 있으며 클로저의 특징이 메모리를 계속 차지하고 있으므로 더 이상 사용하지 않는 클로저에 대해서 메모리를 차지하지 않도록 메모리 회수를 해야 한다는 점을 기억해주시면 될 것 같습니다.

 

아울러 프런트엔드 개발자라면 "클로저"의 개념은 매우 중요합니다. 대부분의 채용 인터뷰에서 "클로저가 무엇입니까?"라는 질문이 자주 나오는 편이죠. 해당 질문이 나올 경우 클로저의 정의를 말하고 자바스크립트에서 왜 대부분의 함수가 클로저인지에 관해 설명하면 될 것 같습니다. 이 때 [[Environment]] 프로퍼티와 렉시컬 환경이 어떤 방식으로 동작하는지에 대한 설명을 덧붙이면 더욱 좋습니다.

 

해당 포스팅의 대부분 내용은 정재남님의 "코어 자바스크립트" 책을 참고하였으며 개인적으로 이해가 되지 않는 부분만 조금 살을 덧붙여서 작성하였습니다. 또한 포스팅에서 나온 모든 예제는 해당 책에 나온 예제와 동일하며 개인적으로 핵심적인 내용이라고 생각된 부분만 일부 발췌해서 가져왔을 뿐 책에서 클로저에 대해 더 자세한 설명과 예제 코드가 더 있으므로 자세한 내용은 해당 도서를 읽어보시기를 권장합니다.

 

[참고 자료]

"코어 자바스크립트" - 정재남

https://ko.javascript.info/closure

https://ko.javascript.info/new-function

 

반응형

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

[JS] 실행 컨텍스트  (0) 2023.04.24
Share Link
reply
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30