내 기준 ‘자바스크립트를 조금 할 줄 안다’의 기준은 클로저와 커링에 대한 이해도라고 생각한다(그런 의미에서 나는 걍 코드싸개임이 분명하다).
하지만 자바스크립트를 사용하는 프레임워크 대부분이 함수형 프로그래밍으로 되어 있고, 프론트엔드라면 함수 안에 함수가 들어있는 형태를 익숙해질 필요가 있다.
암튼 정리 시작
클로저
클로저란?
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다
여기서 중요하게 보아야 할 키워드는 ‘함수가 선언된 렉시컬 환경’이다.
const x = 1
function outerFunc(){
const x = 10
function innerFunc(){
console.log(x); //10
}
innerFunc();
}
outerFunc();outerFunc 함수 내부에서 중첩 함수 InnerFunc가 정의되고 호출되었다.
이때 중첩함수 InnerFunc의 상위 스코프는 외부 함수 outerFunc 의 스코프로, innerFunc 내부에서 자신을 포함하고 있는 외부 함수 outerFunc의 x 변수에 접근할 수 있다.
하지만 중첩된 함수가 아니라면 innerFunc는 outerFunc함수의 변수에 접근할 수 없다.
이 이유는 자바스크립트가 렉시컬 스코프를 따르는 언어이기 때문이다.
렉시컬 스코프(정적 스코프)란? 외부 렉시컬 환경에 대한 참조에 저장할 참조값 함수를 어디에 정의했는지에 따라 상위 스코프를 정할 수 있는 자바스크립트의 스코프
이처럼 선언된 함수(호출 위치x)의 위치에 따라서 상위 스코프가 정해지는 것이다.
따라서 위 예제의 outerFunc()를 보면 해당 함수는 가장 바깥에서 실행되었기 때문에 해당 함수의 스코프는 전역으로 볼 수 있다.
environment
위에서 말한 것처럼 함수가 정의된 환경(위치)와 호출되는 환경(위치)는 다를 수 있다. 따라서 렉시컬 스코프가 가능하기 위해서는 함수가 자신이 정의된 환경, 즉 상위 스코프를 기억해야 한다. 그렇기 때문에 함수는 자신의 내부 슬롯 environment에 자신이 저장된 환경(상위 스코프의 참조)을 저장해 놓는다.
가장 바깥의 함수의 경우 전역 렉시컬 환경을 참조하도록 맨 처음 함수 정의 평가가 실행될 때 저장되지만, 함수 안의 내부 함수는 바깥쪽 함수가 실행될 때 해당 외부 함수 렉시컬 환경의 참조가 저장된다. 이 부분이 이해가 가지 않는다면 함수가 호출될 때 실행되는 내부 로직을 보면 된다.
함수는 호출될 때 함수 내부로 코드의 제어권이 이동하며, 함수 코드를 평가한다.
- 함수 실행 컨텍스트 생성
- 함수 렉시컬 환경 생성 → 요기서 상위 스코프를 결정한다
- 함수 환경 레코드 생성
- this 바인딩
- 외부 렉시컬 환경에 대한 참조 결정
그래서 클로저가 뭔데..
const x = 1;
function outer(){
const x = 10;
const inner = () => { console.log(x); }
return inner
}
const innerFunc = outer();
innerFunc() //10예제를 통해 보자.
해당 환경에선 중첩 함수로 inner() 가 들어가 있다. 그리고 outer()는 실행되면서 해당 함수를 반환하고 생명주기를 마감하지만, 막상 inner를 실행했을 때는 10이 나온다.
이 이유는 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 중첩 함수는 이미 생명주기가 종료한 외부 함수의 변수를 참조할 수 있기 때문이다. 이러한 중첩 함수를 클로저라고 부른다.
이러한 클로저의 경우는 계속해서 외부 함수의 렉시컬 환경을 기억하고, 참조하고 있으며, 이는 계속해서 해당 중첩함수가 존재하는 한 유지된다. 해당 렉시컬 환경을 내부 함수가 참조하고 있기 때문에 가비지 컬렉션의 대상이 되지 않기 때문이다.
그렇다면 어찌됐든 가장 상위 스코프도 전역 렉시컬 환경을 참조하고 있기 때문에 클로저가 아니냐! 라고 하면 아니다. 이론적으로는 맞지만 일반적으로 쓰이는 경우는 해당 함수가 상위 렉시컬 환경의 변수를 참조하고 있느냐? 가 클로저임을 구분하는 중요한 기준이 된다.
추가적으로 이러한 클로저에 의해 참조되는 상위 스코프의 변수를 **자유변수(free variable)**이라고 부른다.
따라서 클로저를 다른 말로 하자면 ‘함수가 자유 변수에 의해 닫혀있다(closed)‘라는 의미이다. 여기서 닫히다라는 표현은 대상과 같은 범주 안에 속한다라는 의미로, 또 닫혀있다라는 말을 풀어보면 ‘자유변수에 묶여있는 함수라고 볼 수 있다.
어따 써먹을까?
클로저는 자바스크립트의 야무진 기능으로, 함수형 프로그래밍에 많이 활용된다.
정보의 은닉(information hiding)
클로저는 상태(state)를 안전하게 변경하고 유지하기 위해 사용한다. 이 말인 즉슨, 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용한다는 것이다.
let x = 1;
function increase(){
return ++x
}만약에 전역 변수 x의 상태를 바꾸는 함수를 이렇게 만들었다고 치면, 얘는 누구나 변경할 수 있다. 이러한 경우에 같은 함수를 계속 호출하다가도 중간에 누가 바꿔서 NaN 값이 떠버리는 때에는 바로 에러뜬다는 의미이다.
그렇기 때문에 카운트 상태 변경 함수 안의 렉시컬 환경에 변수를 선언한 다음 사용하는 것이 정보의 은닉화에 유리하다.
const increase = function () {
let x = 0;
return ++num
}
increase()//1
increase()//1
increase()//1근데 요런식으로 만들면 함수가 호출될 때마다 초기화가 이루어지기 때문에 이전 정보를 유지하지 못한다. 그래서 클로저를 활용한다.
const increase = (function () {
let x = 0;
return function(){
return ++num;
}
}())이런 식으로 클로저를 활용하면, 해당 함수는 상위 렉시컬 환경인 increase 함수의 x 변수값을 계속해서 참조하면서 카운트 상태를 업데이트 할 수 있다.
이런 점을 봤을 때, 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고, 특정 함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지시킬 수 있다.
생성자 함수를 통해 만들어보자
const Counter = (function () {
let num = 0;
function Counter(){
this.num = 0; //<-- 얘처럼 프로퍼티로 놓으면 은닉 안됨
}
Counter.prototype.increase = function () {
return ++num;
}
Counter.prototype.decrease = function () {
reuturn > 0 ? --num : 0;
}
return Counter;
}())
...
const counter = new Counter();
console.log(counter.increase()) //1
console.log(counter.increase()) //2
console.log(counter.increase()) //3
console.log(counter.increase()) //4이런 식으로 만들면 num은 계속해서 증가하지만, 우리가 접근할 수는 없고, increase()나 decrease() 메서드로만 접근할 수 있다.
추가적으로 프로토타입을 통해 increase, decrease 메서드를 상속받은 인스턴스를 만들어 해당 메서드를 실행시키는 방법은 이 각 메서드가 모두 자신의 함수 정의가 평가되어 함수 객체가 될 때 실행 중인 실행컨텍스트인 즉시 실행 함수 실행 컨텍스트의 렉시컬 환경을 기억하는 클로저기 때문에 즉시 실행 함수의 자유 변수 num을 사용할 수 있다.
하지만 this바인딩을 통해 함수의 프로퍼티로 놓게 된다면 은닉되지 않으므로 접근할 수 있는 값이 되어 은닉성이 사라지기 때문에 주의해야 한다.
아무튼 이러한 부분은 함수형 프로그래밍이 지향하는 바와 같은데, 외부 상태 변경이나 가변 데이터를 피하고 불변성을 지향하는 함수형 프로그래밍에서 부수효과를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높일 수 있는 킹갓제너럴 클로저를 많이 활용한다.
바람직한 클로저의 사용
//카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저 반환
function makeCounter(aux) {
let counter = 0;
return function () {
counter = aux(counter);
return counter;
};
}
// 보조함수들
function increase(n) {
return ++n;
}
function decrease(n) {
return --n;
}
const increaser = makeCounter(increase);
console.log(increaser()); // 1
console.log(increaser()); // 2
const decreaser = makeCounter(decrease);
console.log(decreaser()); //-1
console.log(decreaser()); //-2
해당 코드는 함수형 프로그래밍을 잘 활용한 코드로, makeCounter 함수를 고차함수로 만들어 보조 함수를 인자로 전달받고 함수를 반환하도록 하였다.
makeCounter 함수가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 makeCounter 함수의 스코프에 속한 counter 변수를 기억하는 클로저이다. 이를 실행하면 makeCounter 함수의 실행 컨텍스트가 생성되고 함수 객체를 생성하여 반환한 후 소멸되는 과정을 반복한다.
하지만 계속해서 보조 함수를 넣은 고차함수가 계속해서 반환하는 함수를 실행할 때마다 makeCounter가 만든 렉시컬 환경의 스코프 환경은 참조되고 있는 상태이기 때문에 소멸되지 않고 렉시컬 환경의 스코프에 속하는 counter 변수가 기억하는 변수를 계속해서 갱신할 수 있는 것이다.
여기에서는 increase, decrease를 인자로 받은 각각의 독립된 렉시컬 환경을 선언했지만, 만약 둘 다 가능한 카운터를 만들기 위해서는 고차함수를 두번 사용하지 않고 한번만 사용함으로써 두 렉시컬 환경을 합쳐주어야 한다.
//카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저 반환
const counter = (function () {
let counter = 0;
return function (aux) {
counter = aux(counter);
return counter;
};
})();
// 보조함수들
function increase(n) {
return ++n;
}
function decrease(n) {
return --n;
}
console.log(counter(increase)); //1
console.log(counter(increase)); //2
console.log(counter(increase)); //3
console.log(counter(increase)); //4
console.log(counter(decrease)); //3
console.log(counter(decrease)); //2
console.log(counter(decrease)); //1
console.log(counter(decrease)); //0
var의 사용 주의
클로저에서 주의해야 할 점 중 하나는 전역변수 var의 사용이다.
es6 문법이 나오기 전까지는 var가 모든 변수 선언을 대체하고 있었는데 이러한 var 키워드의 문제는 함수 레벨 스코프를 갖는다는 점이다.
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs[i] = () => {
return i;
};
}
funcs.forEach((j) => {
console.log(j()); // 3 3 3
});
위 코드처럼 i를 var로 선언한 다음 배열에 각각 i를 리턴하도록 하는 함수를 넣어주게 되면, 전역 변수 i를 계속해서 참조하고 있는 상태이기 때문에 배열이 다 돌고 난 후의 i값인 3만 계속해서 출력한다. 해당 함수가 아닌 그 상위의 i값을 보고 출력하는 함수의 형태로 되어있기 때문이다.
이를 위해서는 크게 두 가지의 방법이 있다.
1. i의 값을 인자로 받아 복사하기
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs[i] = ((id) => {
return function () {
return id;
};
})(i);
}
funcs.forEach((j) => {
console.log(j()); // 3 3 3
});
id를 인자로 받는 함수를 만들고, 거기에 리턴값으로 함수를 새로 선언하여 넣어준 뒤 해당 렉시컬 환경의 id를 가져오는 방식으로 바꿔준다. 또한 해당 함수를 실행시키면서 i의 값을 넣어주면 i의 값은 id의 인자로 받아들이며 복사되기 때문에 각각의 렉시컬 환경을 가지는 객체들을 배열마다 만들 수 있다.
2. es6 문법을 사용(let)
아까의 문제는 var 키워드로 선언한 i가 함수 레벨 스코프이기 때문에 발생한 문제지만, let은 블록 레벨 스코프이기 때문에 훨씬 편하다.
for문의 변수 선언문에서 let 키워드로 선언한 변수를 활용하면 for문의 코드 블록이 반복 실행될 때마다 for문 코드 블록만의 렉시컬 환경이 생성되기 때문에 해당 블록 내에서 선언한 함수의 경우 해당 함수의 상위 스코프는 새롭게 생성되던 렉시컬 환경을 참조하기 때문이다.
var funcs = [];
for (let i = 0; i < 3; i++) {
funcs[i] = () => {
return i;
};
}
funcs.forEach((j) => {
console.log(j()); // 0 1 2
});
이렇게 각각의 배열에 할당된 함수가 독립적인 렉시컬 환경을 가지고 있기 때문에 for문이 돌면서 갱신된 i의 값을 각각 다르게 기억하고 있음을 알 수 있다.
3. 고차함수의 활용
var funcs = Array.from({ length: 3 }, (_, i) => () => i);
funcs.forEach((j) => {
console.log(j()); // 3 3 3
});
고차함수를 사용하면 여러모로 좋은 점이 많다. 5줄이 넘어갈 수도 있는 코드를 한 줄로 줄인다는 점이 참 매력적이다.
Array.from은 두 번째 인자로 mapfn을 받는데, 이 콜백함수는 각각의 배열 요소들을 돌면서 콜백 함수를 실행시킨다.
이 과정에서 콜백함수 내에서 i라는 인덱스 값을 받아 i를 반환하는 함수를 선언하기 때문에 각 배열에 할당된 각 i는 독립적인 렉시컬 환경을 가지게 된다.
결론
클로저는 캡슐화와 보안에 뛰어난 효과를 지녔으며, 자바스크립트 함수형 프로그래밍의 정수와 같은 느낌이라고 생각한다.
그렇기 때문에 클로저를 통해서 렉시컬 환경을 이해하고, 이를 활용하는 수준이 곧 함수형 프로그래밍 코드의 퀄리티를 높인다고 생각한다. 앞으로 코드를 작성함에 있어서 조금 더 심화적으로 활용을 이어나가면서 익숙해져야겠다.