-본 포스트은 모던자바스크립트 Deep Dive 제 24장을 기반으로 정리했습니다.
-본 포스티는 다크모드에 최적화 되어 있습니다.
모던자바스크립트 Deep Dive는 클로저를 시작하며, "난해하기로 유명한 자바스크립트의 개념 중 하나"라고 소개한다. 그러나 클로저 자바스크립트 고유의 개념이 아니다. 함수를 일급객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.
MDN은 클로저에 대해서 이렇게 정의한다. "클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다." 키워드는 "함수가 선언된 렉시컬 환경"이다. 이것을 이해할 수 있는 좋은 두 개의 비교 코드가 있다.
const x = 1;
function outerFunc() {
const x = 10;
function innerFunc(){
console.log(x)
}
innerFunc()
}
outerFunc();
const x = 1;
function outerFunc() {
const x = 10;
innerFunc()
}
function innerFunc(){
console.log(x)
}
outerFunc();
먼저 첫번째 사례에서 innerFunc()의 console.log(x)는 10이 될 것이다. 반면 두번째 사례에서 innerFunc()의 console.log(x)는 1이 될 것이다. 이러한 차이가 발생되는 이유는 실행컨텍스트의 렉시컬 환경이 콜스택 위에서 실행되는 과정 속에서 진행되는 스코프체인의 결과이다.
렉시컬 스코프를 실행 컨텍스트의 관점에서 살펴보면, 자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라, 함수를 어디에 정의했는지에 따라서 상위 스코프를 결정하는데 이를 렉시컬(정적) 스코프라고 한다. 첫번째 사례에서의 innerFunc()는 outerFunc() 내부에 있었으며, 해당 함수가 실해되는 과정에서의 스코프 체인은 outerFunc() 에 선언된 변수x를 발견했기에 해당 내용을 출력하였다.
반면에 두번째 사례에서의 innerFunc()는 outerFunc()와 독립된 위치에 놓여있으며, 이때 outerFunc()의 변수 x는 함수스코프의 한계로 함수 내에서만 접근이 가능하게 되기에 해당 내용은 접근하지 못하고, 전역에로 나와서 전역에 있는 const x = 1를 발견하여 이를 출력한 것이다.
이 과정이 렉시컬 환경 Lexical Envivonment에서 실행되는 두번째 단계은 outerEnvivonmentRecord의 모습을 가장 근면하게 보여주는 사례이다.
함수선언문은 그 코드가 평가되는 시점에서 함수 객체를 생성하는데, 이때 자신이 정의되어 있는 상위 스코프의 렉시컬 환경의 참조를 저장한다. 이때 상위 스코프가 콜스택에서 제거되었다고 하더라도, 해당 렉시컬 환경의 참조가 소멸되는 것이 아니라, 간직한 체 자신의 실행 준비를 계속 하고 있는 상태, 이것이 클로저인 셈이다. 이를 또 다른 사례로 살펴보자.
const x =1;
funtionc outer() {
const x= 10;
const inner = functiond() {console.log(x)}
return inner;
}
const innerFunc = outer()
innerFunc();
console.log(x)의 결과에 따라서 콘솔에 10이 기록될 것이다. 1이 아닌 이유는 const inner = functiond()가 놓여있는 위치의 상위 렉시컬 환경이 funtionc outer()이기 때문이며, inner()에서 선언된 변수x를 찾고자 하는 스코프체인은 바로 위의 상위 스코프인 funtionc outer()의 스코프에서 해당 내용을 발견했기 때문이다.
그런데 위의 코드를 보면 코드에는 생명주기라는 것이 있다. outer()함수는 return을 통해서 내용을 반환함으로 그 실행이 종료되었으며, 스택에서 제거된다.(pop) 이때 outer()의 실행 컨텍스트는 제거되었기에, 추후에 선언된 const innerFunc = outer()의 코드나, innerFunc() 함수가 호출되었을 때 정상적인 작동이 되지 않을 것을 예상하게 된다. 그러나 코드를 실행해보면 결과를 받게 되는데, 이것이 클로저인 것이다. 설명을 덧붙이자면, 외부함수(outer())보다 중첩 합수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있는 것이 바로 클로저에 대한 설명이다.
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.
자바스크립트의 모든 함수는 자신의 상위 스코프를 기억한다고 했다. 모든 함수가 기억하는 상위 스코프는 함수를 어디서 호출하든 상관없이 유지된다. 따라서 함수를 어디서 호출하든 상관없이 함수는 언제나 자신이 기억하는 상위 스코프의 식별자를 참조할 수 있으며 식별자에 바인딩된 값을 변경할 수도 있다. 그리고 이 말은 이때 저장된 상위 스코프는 함수가 존재하는 한 유지된다는 것을 의미한다.
모던자바스크립트 Deep Dive에 따르면, 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 모든 함수는 클로저다. 하지만 일반적으로 모든 함수를 클로저라고 하지는 않는다.
클로저는 상태(state)를 안전하게 변경하고 유지하기 위해 사용된다. 다시 말해, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.
let num = 0;
const increase = function() {
return ++num;
};
console.log(increase())
console.log(increase())
console.log(increase())
함수가 호출될 때마다 호출된 횟수를 누적하여 출력하는 카운터를 만들어보자. 이 예제의 호출된 횟수(num변수)가 안전하게 변경하고 유지해야 할 상태이다. 그러나 위의 코드는 잘 동작하지만 오류를 발생시킬 가능성을 내포하고 있는 좋지 않은 코드라고 교재는 언급한다. 무엇이 잘못되었고, 클로저는 여기서 어떻게 활용될까?
만약 누군가에 의해 의도치 않게 카운트 상태, 즉 전역 변수 num의 값이 변경되면 이는 오류로 이어지기 때문에 조절이 필요하다. 이를 위해 전역 변수 num을 increase 함수의 지역 변수로 변경해보자.
const increse = function() {
let num =0;
return ++num;
};
console.log(increase());
console.log(increase());
console.log(increase());
위에서 기록했던 전역변수 let num =0;을 함수 안의 지역변수로 귀속시켰다. 그런데 문제가 있다. 바로 함수가 호출될 때마다 지역 변수 num은 다시 선언되고 0으로 초기화되기 때문에 출력 결과는 언제나 1이고, 상태가 변경되기 이전 상태를 유지하지 못한다. 여기서 클로저의 사용이 등장하게 된다.
const increase = (function() {
let num=0;
return function() {
return ++ num;
};
}());
console.log(increase())
console.log(increase())
console.log(increase())
return function() {return ++ num;} 가 바로 클로저이다. increase()는 환경의 변수 num을 return function()은 기억하고 있다. 즉 즉시 실행 함수가 반환한 클로저는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하고 있다. 왜냐하면 클로저기 때문이다. (403쪽)
모던 자바스크립트 DeepDive 제12장7절을 보면 설명이 나와 있는데, 즉시실행함수란 함수 정의와 동시에 즉시 호출되는 함수이며, 단 한 번만 호출되면 다시 호출할 수 없다. 일반적으로 즉시실행함수는 이름이 없는 익명함수를 사용하는 것이 일반적이다.
(function () {
var a=3;
var b=5;
return a*b;
}());
이름을 붙여도 되지만, 한 번만 실행되기 때문에, 다시 호출할 수 없다. 즉 이름을 붙여도 함수호출은 할 수 없는 것이다.
(function foo() {
var a=3;
var b=5;
return a*b;
}());
foo() // ReferenceError : foo is not defined
작성하는 일반적인 규칙이 있다. 반드시 즉시실행 함수는 그룹 연산자(...)로 감싸야 한다.
//정상적으로 작동
(function foo() {
//.....
}())
//연산자를 넣어주지 않으면 SyntaxError 가 발생된다.
function foo() {
//.....
}();
즉시실행함수도 일반 함수처럼 값을 반환(return)할 수 있고, 인수를 전달받아 매개변수로 활용할 수도 없다.
이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용된다. 앞선 코드에 감소하는 함수도 추가해보자.
const counter = (function () {
let num =0;
return {
increase() {
return ++num
},
decrease() {
return num > 0 ? --num : 0;
}
};
}());
console.log(counter.increase())
console.log(counter.increase())
정리하면, 변수 값은 누군가에 의해 언제든지 변경될 수 있어 오류의 근원이 된다. 외부 상태 변경이나 가변 데이터를 피하고 불변성을 지향하는 함수형 프로그래밍에서 클로저는 오류를 피하고 프로그램의 안정성을 위해서 적극 활용된다.