[javascript] 클로저 - 클로저와 메모리관리, 활용사례

8 minute read

코어 자바스크립트 책에 있는 내용과 예제를 요약한 포스트입니다.

2. 클로저와 메모리 관리

클로저는 참조값이 가비지 컬렉터의 수거대상이 되지 않으므로 메모리 소모가 존재한다. 따라서 이에 대한 관리법을 잘 파악하면 된다.

필요성이 사라진 시점에서 참조 카운트를 0으로 만들면 되는데, 식별자에 기본형 데이터(null or undefined)를 할당하면 된다.

(1) return에 의한 클로저의 메모리 해제

var outer = (function(){
    var a = 1;
    var inner = function(){
        return ++a;
    };
    return inner;
})();
console.log(outer());
console.log(outer());
outer = null;   // inner의 참조를 끊음

(2) setInterval에 의한 클로저의 메모리 해제

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

(3) eventListener에 의한 클로저의 메모리 해제

(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;    // clickHandler 식별자의 함수 참조를 끊음.
        }
    };

    button.addEventListener('click', clickHandler);
    document.body.appendChild(button);
})();

3. 클로저 활용 사례

3.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('you choice is ' + fruit);    
    });
    $ul.appendChild($li);
});
document.body.appendChild($ul);

위 예제는 fruits 변수를 순회하며 li를 생성하고, 각 li를 클릭하면 해당 리스너에 기억된 콜백 함수를 실행하게 하는 코드이다.

(A)의 경우 외부 변수를 사용하지 않고 있으므로 클로저가 없으나 (B)는 fruit라는 외부 변수를 참조하고 있으므로 클로저가 있는 상태이다.

(B)가 콜백에 국한되지 않는 경우 반복을 줄이기위해 fruit를 인자로 받아 출력하는 형태로 외부 분리한다.

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);  // [object MouseEvent]
    $li.addEventListener('click', alertFruit.bind(null, fruit)); 
    $ul.appendChild($li);
});
document.body.appendChild($ul);
alertFruit(fruits[1]);

예제 7번 라인대로 실행하면, li를 클릭 했을 때 과일명이 아닌 [object MouseEvent]가 출력된다. addEvnetListner는 콜백 함수를 호출할 때 첫 번째 인자에 이벤트 객체를 주입하기 때문인데 8번 라인처럼 bind를 활용하면 해결 된다.

단, bind를 사용하면 이벤트 객체가 인자로 넘어오는 순서가 바뀌는 점, 함수 내부에서 this가 달라지는 점을 감안 해야하므로 고차함수(함수를 인자로 받거나 함수를 리턴하는 함수)를 활용한다.

  • 고차함수 활용
...
var alertFruitBuilder = function(fruit){
    return function(){  // alertFruit
        alert('your choice is ' + fruit);   // outerEnvironmentReference를 통해 fruit 참조
    }
};
fruits.forEach(function(fruit){
    var $li = document.createElement('li');
    $li.innerText = fruit;
    $li.addEventListener('click', alertFruitBuilder(fruit));
    $ul.appendChild($li);
});

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

자바스크립트는 기본적으로 변수에 접근 권한을 직접 부여하도록 설계돼있지 않아서 클로저를 이용하여 접근권한을 제어할 수 있다.

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

위 예제에 따르면 outer 함수를 종료할 때 inner를 반환함으로서 outer의 지역변수인 a를 외부에서도 읽을 수 있다. 이 처럼 return을 활용하면 외부 스코프에서 함수 내부의 변수들 중 일부 변수에 대한 접근권한을 부여할 수 있다.

다음은 예제를 참고 해보자.

  • 턴마다 주사위를 굴려 나온숫자 km 만큼 이동.
  • 연료량(fuel)과 연비(power)는 무작위 생성
  • 남은 연료가 이동할 거리에 필요한 연료보다 부족하면 이동하지 못함.
  • 모든 유저가 이동할 수 없을 때 종료
  • 게임 종료 시점에 가장 멀리 이동한 사람의 승리

위 조건에 따라 car 객체를 생성하면

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 < wasterFuel ){
            console.log('이동불가');
            return;
        }
        this.fuel -= wasterFuel;
        this.moved += km;
        console.log(km + 'km 이동 (총 ' + this.moved + 'km)');
    }
};

아래와 같으나, 이 경우 car객체의 필드에 직접 값을 할당하여 마음대로 바꿀 수 있는 문제가 있다. 이 때 클로저를 활용하여 객체가 아닌 함수로 만들고 필요한 멤버만을 return 시키면 문제가 해결된다.

함수를 통한 접근권한 설정

var createCar = function(){
    var fuel = Math.ceil(Math.random() * 10 + 10);  // 비공개 멤버
    var power = Math.ceil(Math.random() * 3 + 2);   // 비공개 멤버
    var moved = 0;                                  // getter를 부여
    return {
        get moved(){
            return moved;
        },
        return: function(){
            var km = Math.ceil(Math.random() * 6);
            var wasteFuel = km / power;
            if(fuel < wasteFuel){
                console.log('이동불가');
                return;
            }
            fuel -= wastFuel;
            moved += km;
            console.log(km + 'km 이동 (총 ' + moved + 'km)');
        }
    };
}; 
var car = createCar();  // createCar를 실행함으로써 객체를 생성.

car.run();              // ...
console.log(car.moved); // moved
console.log(car.fuel);  // undefined
console.log(car.power); // undefined

위 예제와 같은 경우, 외부에서는 run메서드를 실행하는 것과 현재 moved를 확인하는 동작만 가능하다. 그러나 run 메서드를 덮어씌우는 어뷰징은 가능한 상태이므로 return 전에 변경할 수 없게끔 해야한다.

var createCar = function(){
    ...
    var publicMembers = {
        ...
    };
    Object.freeze(publicMembers);
    return publicMembers;
}

요약

클로저를 활용해 접근권한을 제어하는 방법은 다음과 같다.

  1. 함수에서 지역변수 및 내부함수를 생성
  2. 외부에서 접근권한을 주고자하는 대상들로 구성된 참조형 데이터를 return (대상이 여럿일때는 객체,배열/하나일 때는 함수)

3.3. 부분 적용 함수

부분 적용 함수(partially applied function)란 n개의 인자를 받는 함수에 미리 m개의 인자만 넘겨 기억시켰다가, 나중에 나머지 인자(n-m)을 넘겨야 원래 함수의 실행결과를 얻을 수 있게끔 하는 함수이다.

  • bind 메서드를 활용한 부분 적용 함수
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

addPartial 함수는 인자 5개를 미리 적용하고, 추후 인자를 전달하여 실행되는 부분 적용 함수이다. add는 this를 사용하지 않으므로 bind만으로 구현되었다. 그러나 bind를 사용하면 this의 값을 변경할 수 밖에 없으므로 메서드에서는 사용할 수 없는 문제가 있다.

var partial = function(){
    var originalPartialArg = arguments;
    var func = originalPartialArg[0];
    if(typeof func !== 'function'){
        throw new Error('첫 번째 인자가 함수가 아닙니다.');
    }
    return function(){
        var partialArgs = Array.prototype.slice.call(originalPartialArg, 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));

var dog = {
    name: '초코',
    greet: partial(function(prefix, suffix){
        return prefix + this.name + sufffix;
    }, '왈왈,')
};
dog.greet('입니다!') // 왈왈, 초코입니다!

apply를 사용하여 this에 영향을 주지 않으면서 인자들을 모아 원본함수를 호출하는 코드다. 이 코드는 인자들을 앞에서부터 차례로 전달하는 예제로, 원하는 위치에 미리 넣어놓고 나중에는 빈자리를 채워 넣어 실행하려면 다음과 같이 구현한다.

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

var partial2 = function(){
    var originalPartialArg = arguments;
    var func = originalPartialArgs[0];
    if(typeof func !== 'function'){
        throw new Error('첫 번째 인자가 함수가 아닙니다.');
    }
    return function(){
        var partialArgs = Array.prototype.slice.call(originalPartialArg, 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, _, 4, 5, _, _, 8, 9);
console.log(addPartial(3, 6, 7, 10));   // 55

var dog = {
    name: '초코',
    greet: partial2(function(prefix, suffix){
        return prefix + this.name + suffix;
    }, '왈왈, ')
};
dog.greet(' 배고파요!'); // 왈왈, 초코 배고파요!
  • [참고] Symbol.for 메서드를 사용하여 상수만들기

ES5에서는 _를 ‘비워놓음’으로 사용하기 위해 전역공간을 침범했으나 ES6에서는 Sybol.for를 활용해 전역 심볼공간에 인자로 넘어온 문자열이 이미 있으면 해당 값을 참조하고, 선언돼 있지 않으면 새로 만드는 방식으로 어디서든 접근가능하며 유일무이한 상수를 만들때 사용한다.

(function(){
    var EmptySpace = Sybol.for('EMPTY_SPACE');
    //  기존 심볼공간에 'EMPTY_SPACE' 문자열을 가진 심볼이 없으므로 새로 생성
    console.log(EmptySpace);
})();
(function(){
    console.log(Sybol.for('EMPTY_SPACE'));  // 기존 전역 공간에 존재하는 심볼 참조
})();

부분적용함수 - 디바운스

디바운스(debounce)는 짧은 시간 동안 동일한 이벤트가 많이 발생할 경우, 처음 또는 마지막에 발생한 이벤트에 대해 한 번만 처리하는 것 - 프런트엔드 성능 최적화에 도움

scroll, wheel, mosemove, resize 등에 적용하기 좋다.

var debounce = function(eventName, func, wait){
    var timeoutId = null;
    return function(event){     //클로저로 EventListener에 호출될 함수 반환.
        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', wheelHandler, 500));

6번 라인에서 이전에 저장한 대기열을 초기화하고, 7번에서 새로운 대기열을 등록하기 때문에 wait(ms) 이내에 발생하는 한 마지막에 발생한 이벤트만이 실행될 것이다.

3.4. 커링 함수

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

부분 적용함수와 차이점

  • 한번에 하나의 인자만 전달함.
  • 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않음.
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

커링함수는 필요한 인자 수 만큼 함수를 만들어 리턴해 주다가 마지막에 조합하여 리턴하면 된다. 단, 인자가 많아지면 가독성이 떨어지는 단점이 있다.

ES6에서는 화살표 함수를 통해 다음 내용을 한줄로 정리할 수 있다.

// 인자가 5개인 경우 가독성이 떨어지는 상황
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);
                    };
                };
            };
        };
    };
};
// ES6 화살표 함수로 정리
const 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));
  • 지연실행의 예

마지막 인자가 넘어갈때까지 함수의 실행을 미루는 것을 지연실행(lazy execution)이라 하는데, 지연실행이 효율적인 경우 커링을 쓰기 적합하다. 또는 프로젝트 내에서 자주 쓰이는 함수의 매개변수가 항상 비슷하고 일부만 바뀌는 경우도 적절하다.

var getInformation = function(baseUrl){
    return function(path){
        return function(id){
            return fetch(baseUrl + path + '/' + id);
        };
    };
};
//ES6
const getInformation = basUrl => path => id => fetch(`${baseUrl}${path}/${id}`);

var imageUrl = 'http://imageAddress.com/';
var getImage = getInfromation(imageUrl);
var getEmoticon = getImage('emoticon');
var getIcon = getImage('icon');

// 실제 요청
var emoticon1 = getEmotion(100);    // http://imageAddress.com/emoticon/100
var emoticon2 = getEmotion(102);    // http://imageAddress.com/emoticon/102
var icon1 = getIcon(205);           // http://imageAddress.com/icon/205

HTML fetch 함수는 url을 받아 HTTP에 요청하는데, 이때 path와 id 값은 여러 값으로 상이하기 때문에 공통적인 요소를 미리 기억시켜두고 커링함수를 사용하는 것이 효율성이나 가독성 측면에서 좋다.

이런 이유로 최근 여러 framework나 library 등에서 커링을 광범위 하게 사용하고 있다.

4. 정리

  • 클로저는 어떤 함수에서 선언한 변수를 참조하는 내부 함수를 외부로 전달할 경우, 함수의 실행 컨텍스트가 종료된 후에도 변수가 사라지지 않는(가비지컬렉터가 수집하지 않는) 현상을 말한다.
  • 내부 함수를 외부로 전달하는 방법은 return 뿐만아니라, 콜백으로 전달하는 경우도 포함한다.(setInterval, addEventListener)
  • 클로저는 메모리를 계속 차지하는 개념으로, 더 이상 사용하지 않는 클로저에 대해서는 메모리를 차지하지 않도록 관리해줄 필요가 있다.

Comments