[JS] 클로저
클로저란?
함수의 렉시컬 스코프를 기억해서 함수가 렉시컬 스코프를 벗어난 외부 스코프에서 실행될 떄도 자신의 렉시컬 스코프에 접근할 수 있도록 해주는 현상입니다. 이전에 배웠던 스코프 체이닝을 사용해서 외부 함수의 값을 내부함수에서 사용하는데, 이를 통해 외부함수가 종료된 다음에도 내부 함수를 다른 곳에서 참조한다면 가비지 콜렉팅을 피할 수 있습니다.
1. 클로저가 아닌 코드
var outer = () => {
var a = 1;
var inner = () => {
console.log(a + 1);
};
inner();
};
outer();
첫 번 째 예시는 클로저가 아닌 경우입니다. 현재 inner 함수가 outer의 a를 참조하고 있지만 outer 안에서 실해되고 있기 때문에 outer가 종료되면 inner도 종료되고 a는 가비지 컬렉팅을 당합니다.
2. 클로저인 코드
var outer = () => {
var a = 1;
var inner = () => {
return ++a;
};
return inner;
};
var closure = outer();
console.log(closure());
이번엔 inner함수를 외부에서 호출하도록 하겠습니다. 방법은 간단합니다. inner함수를 return해주면 됩니다. outer 함수의 반환값은 inner함수이기 때문에 closure를 호출하면 inner함수를 호출하는 것과 같습니다. 그래서 콘솔창엔 ++a가 된 2가 찍히게 됩니다. 이 때 outer 함수의 실행 컨텍스가 종료됐는데도 스코프 체이닝이 가능했던 이유는 outer의 환경레코드가 가비지 컬렉팅의 대상이 아니었기 때문입니다. 자바스크립트에서 가비지 컬렉터는 해당 값을 참조하는 변수가 하나라도 있다면 해당 변수를 제거 대상에 포함시키지 않습니다.
3. 클로저 같이 보이지만 클로저가 아닌 코드
그렇다면 아래 코드는 어떨까요? inner 함수를 return하고 값도 2로 같은데 closure로 볼 수 있을까요?
var outer = () => {
var a = 1;
var inner = () => {
return ++a;
};
return inner();
};
var closure = outer();
console.log(closure);
답은 아니오입니다. 현재 outer 함수가 inner함수의 반환값을 closure에 담아주기때문에 console 창엔 ++a의 값이 2가 찍힐 뿐 a는 closure에서 참조되지 않기에 가비지 컬렉팅 대상입니다. 아래 예시를 보면 클로저와 차이를 명확히 알 수 있습니다.
var outer = () => {
var a = 1;
var inner = () => {
return ++a;
};
return inner;
};
var closure = outer();
console.log(closure()); //2
console.log(closure()); //3
먼저 클로저 함수의 경우 a를 사용하고 있기 때문에 함수를 여러번 실행할 경우 ++a가 되서 2,3,4,… 값이 증가합니다.
var outer = () => {
var a = 1;
var inner = () => {
return ++a;
};
return inner();
};
var closure = outer();
console.log(closure);
console.log(closure);
반면 일반 함수는 a가 아니라 inner을 실행결과를 사용하기 때문에 해당 함수를 여러번 실행해도 2가 찍힙니다. 즉, 클로저는 함수를 선언할 때 만들어진 유효범위가 사라진 뒤에도 호출할 수 있는 함수를 의미합니다. 다만 여기서 외부에 호출을 하기 위해 내부 함수를 반드시 return해줄 필요는 없습니다.
return 없이도 클로저가 발생
setInterval,setTimeout, addEventListener의 경우 return문으로 반환되지 않아도 자신의 외부 함수의 렉시컬 환경을 기억했다가 외부 함수가 종료된 후 렉시컬 환경을 벗어난 시점에도 해당 값을 참조해 사용할 수 있습니다.
(function outer() {
let timer;
let a = 1;
const inner = () => {
console.log(a + 1);
clearTimeout(timer);
};
timer = setTimeout(inner, 5000);
})();
예로 위 코드의 경우 outer 함수가 즉시 실해되서 생명주기가 끝난 후에도 5초 후 setTimeout 함수의 콜백 inner가 outer의 변수 a를 참조하고 있습니다.
클로저와 메모리 관리
클로저는 가비지 컬렉팅을 회피하는 방법이라 당연히 메모리 사용량이 증가할 수 밖에 없습니다. 하지만 이를 메모리 누수라고 부르면서 지양해야 한다는 주장은 옳지 않습니다. 과거 순환참조, 인터넷 익스플로러의 이벤트 핸들러 같은 상황에 의도치 않은 메모리 누수가 있었던 건 사실이지만 현재 그런 부분은 개선됐습니다. 그리고 클로저의 경우 추 후 사용할 값을 개발자가 의도적으로 가비지 컬렉팅에서 제외시키는 행동이기 때문에 메모리 남용 수준이 아니라면 잘 활용해서 효율적인 코딩을 할 수 있습니다.
그렇다면, 메모리 관리는 어떻게?
return이 있는 경우
만약 내부 함수를 반환하는 경우라면 외부함수를 null로 만들어 주면 됩니다.
var outer = (function () {
var a = 1;
var inner = function () {
return ++a;
};
return inner;
})();
console.log(outer());
console.log(outer());
outer = null;
outer를 즉시 실행하는 경우는 위처럼 outer를 null로 만들어 주면 되고, 다른 변수에 outer를 할당한 경우라면 해당 변수를 null로 만들어주면 됩니다.
var outer = function () {
var a = 1;
var inner = function () {
return ++a;
};
return inner;
};
var closure = outer();
console.log(closure());
closure = null;
return이 없는 경우
만약 return을 하지 않는 클로저 함수라면 내부함수 자체를 null로 만들어서 외부함수와의 연결고리를 끊어주면 됩니다. 예로 아래 setInterval 함수의 경우 0.3초 마다 ++a 값이 찍히는 데 외부함수가 종료된 뒤에도 계속 외부함수의 a를 참조하는 클로저입니다. 만약 a가 10 미만 일 때만 a가 필요하다면 아래처럼 ++a가 10 이상일 때 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, 300);
})();
먄약 inner =null을 넣지 않고 clearInterval만 한다면 아래 setInterval(inner,300)이란 코드에서 여전히 inner를 참조할 가능성이 있기 때문에 가비지 컬렉팅 대상에서 제외되고 그 땐 불필요하게 메모리를 차지하게 됩니다. 따라서 clearInterval를 했다고 해도 inner를 null로 만들어 줘야 합니다.
(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);
});
addEventListener의 경우도 마찬가지입니다. count가 10이상일 때 이벤트를 제거한다면 removeEventListener한 뒤에 clickHandler라는 이벤트 핸들러 함수도 null로 만들어서 메모리를 차지하지 않도록 해줘야 합니다.
클로저 활용 사례
클로저가 외부함수의 렉시컬 환경을 기억하고 있다가 다른 곳에서 실행될 때 사용하는 건 알겠는데, 이런 특성을 어떻게 활용하면 좋을까요?
콜백함수 내부에서 외부 데이터를 활용
먼저 위에서 다룬 반환하지 않는 클로저 함수형태로 예를 들어 보겠습니다. addEventListener의 콜백함수는 외부 함수의 데이터를 기억하고 있다가 해당 이벤트가 발생하면 이미 실행 종료된 외부 함수의 변수를 참조합니다. 아래 코드는 fruits 배열의 값을 list 태그에 각각 text로 넣고 해당 list 클릭시 할당된 fruit값이 alert창에 출력되도록 합니다.
var fruits = ["apple", "banana", "peach"];
var $ul = document.createElement("ul");
fruits.forEach(function (fruit) {
var $li = document.createElement("li");
$li.innerText = fruit;
$li.addEventListener("click", function () {
alert("you like" + fruit);
});
$ul.appendChild($li);
});
document.body.appendChild($ul);
그런데 만약 alert("you like" + fruit);
이 부분을 콜백에서만 쓰는 게 아니라면 재사용성을 생각해서 함수를 따로 만들 필요가 있습니다.
var alertFruit = (fruit) => {
alert("you like" + fruit);
};
alertFruit(fruits[0]);
그런데 alertFruit을 만들고 직접 호출을 할 땐 fruit의 특정 인덱스 값을 prop으로 넣어주면 되지만 addEventListener에서 사용할 땐 alertFruit(fruit)
이런 식으로 사용할 수가 없습니다. 왜냐하면 그렇게 사용하면 click 이벤트가 발생하기 전에 즉시 호출을 하기 때문입니다.
$li.addEventListener("click", alertFruit(fruit)); //즉시 호출
$li.addEventListener("click", alertFruit); //prop으로 event 객체를 받아옴
$li.addEventListener("click", alertFruit.bind(null, fruit)); //
그렇다고 해서 alertFruit을 그냥 호출하면 prop으로 이벤트 객체가 들어가서 원하는 값이 찍히지 않을테고 bind를 써서 인자를 조작한다면 값은 잘 찍히지만 this를 null로 변경해야 되기 때문에 좋은 방법이 아닙니다. 이럴 때 클로저를 사용할 수 있습니다.
var alertFruit = (fruit) => {
return function () {
alert("you like" + fruit);
};
};
이러면 alertFruit(fruit)
로 해당 함수가 즉시 호출이 되더라도 함수가 반환되서 click 이벤트 시 해당 함수가 호출됩니다. 또는 기존 alertFruit 함수를 익명함수 안에서 호출하는 방법도 있습니다.
$li.addEventListener("click", () => {
alertFruit(fruit);
});
정보 은닉
두 번째로는 정보를 은닉해야 할 때입니다. 모듈의 결함을 찾아 고칠 때, 또는 값의 오염을 막을 때를 위해, 모듈간 결합도는 낮고 유연해야합니다. 그러기 위해서 내부의 로직을 최대한 은닉해야 하는데 그럴 때 접근 권한을 클로저로 제어할 수 있습니다. 아래 예를 보면 a는 outer 안에만 존재하는 변수로 inner함수를 통해 접근할 수 있습니다. inner 함수의 ++a로 값이 변하기는 하지만 외부에서 값을 직접 조작할 수 없어서 변수 오염의 위험이 없습니다.
var outer = () => {
var a =1
var inner = () {
return ++a
}
return inner
}
var outer2 = outer()
console.log(outer2())
console.log(outer2())
즉, 클로저를 통해 외부에 제공할 정보만 모아서 return을 해주고 나머지는 내부에서만 사용하도록 접근을 제어해 안정적인 모듈을 구축할 수 있습니다. 또 다른 예로 자동차 게임을 할 수 있는 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 < wasteFuel) {
console.log("기름 고갈");
return;
}
this.fuel -= wasteFuel;
this.moved += km;
console.log(km + "km 이동 ( 총" + this.moved + "km");
},
};
car.run();
car 객체를 여러개 만들어서 선택 후 코드를 실행하면 현재 fuel, power가 무작위로 설정되기 때문에 운에 맡긴 레이싱 게임을 할 수 있습니다. 그런데 현재 코드는 승부조작이 가능하다는 문제가 있습니다. car 객체의 값을 직접 조작할 수 있기에 car.fuel = 엄청 큰 값
이런식으로 큰 값을 넣으면 돈은 이 사람이 다 딸 수밖에 없습니다. 이 문제를 막을 때도 클로저를 사용하면 됩니다. 위에서 설명한대로 클로저를 통해 접근을 제어하면 됩니다.
var createCar = () => {
var fuel = Math.ceil(Math.random() * 10 + 10);
var power = Math.ceil(Math.random() * 3 + 2);
var moved = 0;
function run() {
var km = Math.ceil(Math.random() * 6);
var wasteFuel = km / power;
if (fuel < wasteFuel) {
console.log("기름 고갈");
return;
}
fuel -= wasteFuel;
moved += km;
console.log(km + "km 이동 (총" + moved + "km)");
}
return run;
};
var car = createCar();
console.log(car());
이런식으로 run을 클로저로 만들면 외부에서 값을 조작할 수 없습니다. 다만 run을 메소드형식으로 호출할 수 없는데 메소드 형식으로 호출하고 싶거나 다른 함수와 함께 반환하고 싶다면 아래와 같이 run 함수가 담긴 객체를 반환해주면 됩니다. 단, 이 때 객체를 조작하지 못 하도록 Object.freeze를 사용합니다.
var createCar = () => {
var fuel = Math.ceil(Math.random() * 10 + 10);
var power = Math.ceil(Math.random() * 3 + 2);
var moved = 0;
var methods = {
get moved() {
return moved;
},
run: function () {
var km = Math.ceil(Math.random() * 6);
var wasteFuel = km / power;
if (fuel < wasteFuel) {
console.log("기름 고갈");
return;
}
fuel -= wasteFuel;
moved += km;
console.log(km + "km 이동 (총" + moved + "km)");
},
};
return Object.freeze(methods);
};
var car = createCar();
console.log(car.run());
부분 적용 함수
세 번째는 n개의 인자를 받는 함수에 n개보다 적은 인자를 미리 부분적으로 적용할 때 활용할 수 있습니다. 아래 예시를 보면 add라는 함수는 인자를 전부 더한 값을 반환합니다. 그리고 addPartial는 bind를 사용해서 add의 인자를 부분적용합니다.
var add = () => {
var result = 0;
for (var i of arguments) {
result += i;
}
return result;
};
var addPartial = add.bind(null, 1, 2, 3, 4, 5);
console.log(addPartial(6, 7, 8, 9, 10)); //55
이를 클로저 함수로 만든다면 다음과 같습니다. arguments의 0번째엔 add 함수가 들어 있기 때문에 해당 값에 나머 arguments를 인자로 넣어서 return 하면 부분적으로 들어온 인자들만 다 더해서 값을 반환하는 함수를 받습니다.
var partial = () => {
var originalPartialArgs = arguments;
var func = originalPartialArgs[0];
if (typeof func !== "function") {
throw new Error("첫 번째 인자가 함수가 아닙니다.");
}
return function () {
var partialArgs = Array.prototype.slice.call(originalPartialArgs, 1);
var restArgs = Array.prototype.slice.call(arguments);
return func.apply(this, partialArgs.concat(restArgs));
};
};
var add = () => {
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)); //55
이를 디바운스에 활용할 수 있는데요. 디바운스는 동일한 이벤트가 계속해서 발생한다면 마지막 이벤트에만 함수호출을 하도록 하는 기법입니다. 만약 아까 만든 과일 list에 click 이벤트로 해당 과일이 찍히는 fruitHandler함수를 만들고 debounce를 건다면 delay 인자 값만큼의 시간이 지난 뒤 fruitHandler가 실행됩니다.
var debounce = (fn, delay) => {
let timer;
return function () {
const self = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(fn.bind(self, args), delay);
};
};
const fruitHandler = (fruit) => {
console.log(fruit);
};
$li.addEventListener("click", () => {
debounce(fruitHandler(fruit), 1000);
});
delay시간이 지나기 전에 또다시 이벤트가 발생하면 clearTimeout(timer)으로 기존 setTimeout이 초기화되기 때문에 fruitHandler는 마지막 인자인 이벤트가 들어올 때까지 호출되지 않습니다.
커링 함수
마지막으로 클로저는 커링함수에 활용할 수 있습니다. 커링함수란 여러개 인자를 하나의 인자만 받는 함수로 나눠 순차적으로 호출될 수 있도록한 체인 형태의 함수를 의미합니다. 인자를 나눠서 받고, 막지막 인자를 받은 후 최종 값을 반환한 다는 점에서 부분함수와 같아보이지만 한 번에 하나의 인자만 전달하고 중간에 실행한 결과는 그 다음 인자를 받기 위해 대기만 할 뿐이란 점에서 차이가 있습니다.
예로 다음 curry함수의 경우 마지막 b인자를 받을 때까지 return된 중간 함수는 대기상태고 마지막 b를 받은뒤 fn(a, b);
를 실행합니다.
const curry = function (fn) {
return function (a) {
return function (b) {
return fn(a, b);
};
};
};
const getMax = curry(Math.max)(10);
console.log(getMax(20)); //20
console.log(getMax(100)); //100
인자가 많아지면 가독성이 떨어질 수 있는데, 이는 ES6의 화살표함수를 사용하면 깔끔하게 정리할 수 있습니다.
const curry = (fn) => (a) => (b) => (c) => (d) => fn(a, b, c, d);
a,b,c,d 인자들 모두 마지막 함수에서 참조할 예정이기 때문에 가비지 컬렉팅 대상에서 제외됩니다. 또 마지막 인자가 들어올 때까지 지연실행을 하기 때문에 원하는 시점까지 지연을 시켰다 실행할 필요가 있다면 유용하게 사용할 수 있습니다. 예로 redux의 미들웨어 logger와 redux-thunk를 보면 바뀌지 않는 인자(store,next)를 먼저 받아서 해당 부분까지 미리 값을 넣어 저장하고 추후 새롭게 바뀌는 인자(action)를 마지막에 받아 효율적인 처리가 가능하도록 합니다.
const logger = (store) => (next) => (action) => {
console.log("dispatch", action);
console.log("nextState", store.getState());
return next(action);
};
const thunk = (store) => (next) => (action) => {
return typeof action === "function"
? action(dispatch, store.getState)
: next(action);
};
퀴즈
1. 클로저는 반환값이 외부환경을 참조하는 문법이기에 꼭 반환값이 함수형태일 필요는 없다. (o,x)
2. console에 출력된 결과는?
const curry = (fn) => (a) => (b) => (c) => fn(a, b, c);
const getMax = curry(Math.max)(1);
console.log(getMax(2));
console.log(getMax(3));
console.log(getMax(4));
댓글남기기