Closure와 변수의 유효범위
렉시컬 환경 (Lexical Environment)
렉시컬 환경은 JS가 어떻게 동작하는지 설명하기 위한 이론상의 객체이다. 이를 이해하는 것이 클로저에 대한 명확한 이해를 돕기 때문에, 먼저 이에 대해 익혀보자.
이는 두 부분으로 구성된다.
- 환경 레코드(Environment Record) : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체.
this
값과 같은 기타 정보도 여기에 저장된다. - 외부 렉시컬 환경(Outer Lexical Environment)에 대한 참조(Reference) : 외부 코드와 연관
즉, 변수는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐이다. 때문에 변수를 가져오거나 변경하는 것은 곧 환경 레코드의 프로퍼티를 가져오거나 변경하는 것을 의미한다.
// phrase: <uninitialized>
let phrase = 'Hello';
// phrase: undefined
phrase = 'Hello';
// phrase: 'Hello'
phrase = 'Bye';
// phrase: 'Bye'
함수는 변수와 마찬가지로 값인데, 다만 함수 선언문(Function Declaration)으로 선언한 함수는 일반 변수와는 달리 바로 초기화된다는 점에서 차이가 있다. 즉, 변수는 선언 전까지 사용할 수 없지만, 함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있다. 물론, 함수 표현식의 경우는 변수를 함수에 할당한 것이므로 변수와 동일하게 취급된다.
let phrase = 'Hello';
function say(name) {
alert(`<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal">h</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">a</span><span class="mord mathnormal">se</span></span><span class="mpunct">,</span></span></span></span>{name}`);
}
함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다. 이 렉시컬 환경엔 함수 호출 시에 넘겨받은 매개변수와 함수의 지역 변수가 저장된다. 즉, 위에서 say('John')
을 호출하면 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경에서 가리키는 외부(여기서는 전역) 렉시컬 환경으로 두개를 갖게 된다.
내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖는다.
코드 내에서 변수에 접근하는 경우, 먼저 내부 렉시컬 환경을 검색하며, 여기서 원하는 변수를 찾지 못하는 경우 검색 범위를 확장하여 외부 렉시컬 환경을 참조한다. 이는 검색 범위가 전역 렉시컬 환경에 도달할 때까지 반복된다.
반환함수
자, 이제 아래의 예시를 보자.
function makeCounter() {
let count = 0;
return function () {
return count++;
};
}
let counter = makeCounter();
makeCounter()
를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어진다. 그리고 이 렉시컬 환경 객체에는 makeCounter
를 실행하는데 필요한 변수들이 저장된다.
단, 위의 경우는 makeCounter
가 실행되는 도중에 별도의 중첩 함수가 생성되었다. 현재는 생성까지만 하고, 실행은 되지 않았다.
이제 중요한 사실이 하나 더 있다. 모든 함수는 본인이 생성된 곳의 렉시컬 환경을 기억한다는 점이다. 함수는 [[Environment]]
라는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 위치의 렉시컬 환경에 대한 참조가 저장된다.
따라서, 위의 변수 counter
에 저장된 함수의 [[Environment]]
에는 {count: 0}
이 있는 렉시컬 환경에 대한 참조가 저장된다. 이를 통해 어디에서 호출되던 상관없이 자신이 어디에서 생성되었는지를 알 수 있으며, [[Environment]]
는 함수가 생성될 때 처음 딱 한번 그 값이 생성되고, 이는 영원히 변하지 않는다.
counter()
를 호출하면 각 호출마다 새로운 렉시컬 환경이 만들어진다. 그리고 각 렉시컬 환경은 [[Environment]]
에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조하게 되는데, 이는 모두 똑같이 {count}
이 있는 렉시컬 환경이다.
따라서, counter()
를 여러번 호출하게 되면, count
변수가 1, 2, 3...
으로 점차 증가하게 된다.
클로저
클로저(Closure)는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다.
설명하기 용이하게는, 선언될 당시의 환경을 기억했다가 나중에 호출되었을 때에 기억했던 환경에 따라 실행되는 함수가 되겠다.
JS에서는 모든 함수가 자연스럽게 클로저가 되는데, JS에서의 함수는 숨김 프로퍼티인 [[Environment]]
를 통해서 본인이 어디서 생성되었는지를 기억하고 있으며, 함수 내부의 코드는 이 [[Environment]]
를 향해 확장하며 외부 변수에 접근하기 때문이다.
가비지 컬렉션
함수의 호출이 끝나면, 렉시컬 환경은 메모리에서 제거되며, 함수와 관련된 변수들은 이 시점에서 모두 사라진다.
다시 가비지 컬렉션을 떠올려보자. JS에서의 모든 객체는 도달 가능한 상태라면 메모리에 유지된다.
위와 같은 형태의 중첩 함수를 구현하게 되면, [[Environment]]
프로퍼티에 생성 당시의 외부 함수 렉시컬 환경에 대한 정보가 저장되고, 따라서 도달 가능한 상태가 되는데, 이 때문에 함수 호출 자체가 끝났어도 렉시컬 환경이 메모리에 여전히 유지될 수 있는 것이다.
JS엔진에 의한 최적화
앞서 말했듯, 이론 상으로는 중첩함수를 통해 함수가 살아있는 경우에 모든 외부 변수들 역시 메모리에 유지된다.
그러나 실제로는 JS 엔진(특히 V8)이 이를 지속해서 최적화하게 되는데, 엔진이 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거하기 때문이다.