JavaScript에는 클로저라는 개념이 있다.
외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있으며 이러한 중첩 함수를 클로저라 부른다.
아래 예시를 보자.
const x = 10;
function fn1() {
const x = 20;
const fn2 = function () {
console.log(x);
};
return fn2;
}
const closure = fn1();
closure(); // 20
fn1 함수가 호출되며 fn2를 반환하고 생명주기가 마감된다.
생명주기가 마감되면서 fn1 함수의 실행 컨텍스트가 제거되었고 변수 또한 생명주기가 마감된다.
변수에 접근할 수 없는 상태인데 실행 결과가 20인 이유는 무엇일까?
1. 렉시컬 스코프
우선 렉시컬 스코프라는 개념에 대해 알아야 한다.
JS 엔진은 함수의 호출 위치가 아니라 함수의 정의 위치에 따라 상위 스코프를 결정하며 이를 렉시컬 스코프라 한다.
const x = 10;
function fn1() {
const x = 20;
fn2();
}
function fn2() {
console.log(x);
}
fn1();
fn2();
앞서 본 개념에 따라 함수 두 개의 상위 스코프를 결정해보자.
fn1과 fn2는 모두 전역에서 정의된 전역 함수이다.
따라서, 두 함수의 상위 스코프는 전역이다.
fn2가 fn1 안에서 호출되지만 이는 상위 스코프를 결정하는에 아무런 상관이 없다.
결국 fn2도 전역에 정의되어 있으므로 호출 위치에 관계없이 상위 스코프는 전역이 된다.
따라서, 위 두 함수의 실행 결과는 둘 다 10이다.
1-1. [[Environment]]
함수의 상위 스코프에 대한 정보를 어떻게 얻을 수 있을까?
함수의 내부 슬롯 [[Environment]]에 상위 스코프에 대한 정보를 담고 있다.
const x = 10;
function fn1() {
const x = 20;
const fn2 = function () {
console.log(x);
};
return fn2;
}
const closure = fn1();
closure(); // 20
따라서, 처음에 봤던 이 코드에서 fn2은 fn1 안에 정의되어 있기 때문에 상위 스코프는 fn1일 것이다.
fn1이 호출되면서 fn1의 렉시컬 환경이 생성되고 fn1의 [[Environment]]에 전역 렉시컬 환경이 저장될 것이다.
이후, fn2가 평가되며 fn2는 자신의 [[Environment]] 내부 슬롯에 현재 실행 중인 실행 컨텍스트의 렉시컬 환경인 fn1 함수의 렉시컬 환경을 상위 스코프로서 저장한다.
fn1이 종료되면서 자신의 생명주기가 종료되고 실행 컨텍스트가 소멸하지만 fn1의 렉시컬 환경은 여전히 fn2에 의해 참조되고 있다.
가비지 컬렉터는 참조되고 있는 메모리 공간을 함부로 해제하지 않기 때문에 fn1의 렉시컬 환경은 GC 의 대상이 되지 않는다.
GC의 대상이 되지 않기 때문에 메모리에 대한 걱정이 있을 수 있지만 JS 엔진은 최적화가 잘 되어 있어 클로저가 참조하고 있지 않는 식별자는 기억하지 않는다.
클로저의 메모리 점유는 필요한 것을 기억하는 것이므로 걱정할 대상이 되지 않는다.
2. 클로저의 활용
클로저는 위와 같은 성질을 이용한다.
중첩 함수가 외부 함수의 변수를 참조할 수 있는 이유에 대해 알아봤다.
이러한 클로저를 활용할 수 있는 방법은 무엇일까?
클로저는 상태를 안전하게 변경하고 유지하기 위해 사용된다.
상태를 은닉하고 특정 함수를 통해서만 상태 변경을 허용하기 위해 사용한다.
function Counter() {
let count = 0;
const increase = function () {
console.log(++count);
};
const decrease = function () {
console.log(--count);
};
return { increase, decrease };
}
const counter = Counter(); // ...(1)
counter.increase(); // 1
counter.increase(); // 2
counter.increase(); // 3
counter.decrease(); // 2
const counter2 = Counter(); // ...(2)
counter2.increase(); // 1
counter2.increase(); // 2
위와 같은 코드가 있다고 해보자.
increase와 decrease 함수가 평가될 때 실행 컨텍스트는 Counter의 렉시컬 환경이다.
increse와 decrease의 상위 스코프는 현재 실행중인 실행 컨텍스트 (Counter의 렉시컬 환경)이 될 것이다.
따라서, increase와 decrease는 Counter의 변수에 접근할 수 있다.
또한, counter과 counter2의 변수가 서로 영향을 받지 않는다는 것이다.
이는 Counter가 실행되며 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖기 때문이다.
Counter가 실행되며 실행 컨텍스트가 생기고 Counter 함수는 increase와 decrease를 반환하고 소멸된다.
Counter를 호출할 때 서로 다른 실행 컨텍스트를 갖기 때문에 (1)에서 Counter가 반환한 함수들과 (2)에서 Counter가 반환한 함수들은 서로 다른 렉시컬 환경을 상위 스코프로서 기억하고 있다.
따라서, 각각은 서로에게 영향을 미칠 수 없다.
또 다른 예시로는 고차 함수를 이용한 함수형 프로그래밍에서 활용하는 예시이다.
function getCounter(fn) {
let count = 0;
return function () {
count = fn(count);
return count;
};
}
function increase(count) {
return ++count;
}
function decrease(count) {
return --count;
}
const increaser = getCounter(increase);
console.log(increaser()); // 1
console.log(increaser()); // 2
console.log(increaser()); // 3
const decreaser = getCounter(decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2
console.log(decreaser()); // -3
[참고자료]
위키북스, 모던 자바스크립트 Deep Dive, 2020
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] 커링(Currying) (0) | 2023.12.23 |
---|---|
[JavaScript] querySelectorAll VS getElementsByClassName (0) | 2023.09.10 |
[JavaScript] 이벤트 버블링과 캡처링 (0) | 2023.09.03 |
[JavaScript] addEventListener과 onclick의 차이 (0) | 2023.09.03 |
[NodeJS] 싱글 스레드와 이벤트 루프 (0) | 2023.07.29 |