클로저는 함수와 그 함수가 선언된 렉시컬(정적)환경과의 조합이다.
Javascript를 공부하며 이런 추상적인 말을 들어본 적이 있다.
오늘은 추상적으로 알고있던 클로저에 대해 정리하겠다.
1. 렉시컬 스코프
자바스크립트 엔진은 함수를 어디에 정의했는지에 따라 상위 스코프가 결정 되며, 이를 렉시컬(정적) 스코프라 한다.
함수의 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정되고 변하지 않는다.
23장의 "실행 컨텍스트"에서 정리했듯이 스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다. 이 렉시컬 환경은 자신의 "외부 렉시컬 환경에 대한 참조"를 통해 상위 렉시컬 환경과 연결된다. 이러한 연결이 스코프 체인이다.
함수의 상위 스코프를 결정하는 것은 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값을 결정하는 것이다. 이 때 저장하는 참조값은 상위 렉시컬 환경이 되며, 이는 상위 스코프와 같다.
정리하자면, 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장할 상위 스코프에 대한 참조는 함수 선언이 평가되는 시점에 함수가 선언되는 환경에 의해 결정된다. 이것이 렉시컬 스코프이다.
2. 함수 객체의 내부 슬릇 [[Environment]]
함수의 선언 환경과 호출 환경이 다를 수 있어 렉시컬 스코프가 가능하려면 함수의 내부 슬릇에 상위 스코프를 저장해야 한다.
함수 내부 슬릇의 [[Environment]]에 상위 스코프가 참조되는 이유는 함수 정의가 평가되어 함수 객체를 생성하는 시점은 함수의 평가 혹은 실행되는 시점이며, 이때 현재 실행 중인 실행 컨텍스트는 상위 함수(또는 전역 코드)의 실행 컨텍스트이기 때문이다.
따라서 함수 객체 내부 슬릇 [[Environment]] 에 저장된 현재 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조가 상위 스코프이다. 또한 상위 스코프는 함수가 호출되었을때 생성될 함수 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장될 참조값이다. 함수 객체는 내부 슬릇 [[Environment]]에 저장한 렉시컬 환경(상위 스코프)를 자신이 존재하는 한 기억한다.
3. 클로저와 렉시컬 환경
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.
이때 렉시컬 환경은 상위 스코프를 의미하는 실행 컨텍스트의 렉시컬 환경을 의미한다.
const x = 1;
// ⓐ
function outer() {
const x = 10;
const inner = function () {console.log(x);}; // ⓑ
return inner;
}
// outer 함수를 호출하면 중첩 함수 inner를 반환한다.
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거된다.
const innerFunc = outer(); // ⓒ
innerFunc(); // ⓓ 10
outer 함수의 실행이 종료되면 inner 함수를 반환하며 outer 함수의 생명주기는 종료된다.
outer 함수의 실행 컨택스트가 스택에서 제거되면 outer 함수의 지역변수인 x에 대한 접근이 불가능할 것 같지만, outer 함수의 렉시컬 환경까지 소명되는 것이 아니기에 x에 대한 접근이 가능하다.
이처럼 외부 함수보다 더 오래 유지되며 외부 함수의 생명주기가 종료되어도 외부 함수의 변수를 참조할 수 있는 중첩 함수를 클로저라 부른다.
outer 함수의 렉시컬 환경은 inner 함수의 [[Environment]] 내부 슬릇에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않는다.
JS의 모든 함수는 상위 스코프를 기억하기에 이론적으로는 모두 클로저라 할 수 있지만, 일반적으로 모든 함수를 클로저라 하지는 않는다.
외부 함수보다 중첩 함수가 더 오래 유지되지만 상위 스코프의 어떤 식별자도 참조하지 않으면 클로저라 부를 수 없다.
클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 외부 함수보다 더 오래 유지되는 중첩함수를 클로저라 하는 것이 일반적이다.
4. 클로저의 활용
클로저는 상태를 안전하게 변경하고 유지하기 위해 사용한다. 상태를 안전하기 은닉 하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.
// 카운트 상태 변수
let num = 0
// 카운트 상태 변경 함수
const increase = function() {
// 카운트 상태를 1만큼 증가시킨다.
return ++num
}
console.log(increase()) // 1
console.log(increase()) // 2
console.log(increase()) // 3
num 변수의 값은 increase 함수로 변화를 주며 활용할 수 있지만, 다른 누군가가 num에 접근할 수 있기에 안전하지 않은 코드이다.
// 카운트 상태 변경 함수
const increase = function() {
// 카운트 상태 변수
let num = 0
// 카운트 상태를 1만큼 증가시킨다.
return ++num
}
console.log(increase()) // 1
console.log(increase()) // 1
console.log(increase()) // 1
그렇다고 위 코드처럼 작성하면 num을 increase 함수의 지역변수의 상태가 변할 수 없다.
이를 해결한 코드는 아래와 같다.
// 카운트 상태 변경 함수
const increase = (function() {
// 카운트 상태 변수
let num = 0
// 클로저
return function() {
// 카운트 상태를 1만큼 증가시킨다.
return ++num
}
})()
console.log(increase()) // 1
console.log(increase()) // 2
console.log(increase()) // 3
즉시 실행 함수는 호출된 이후 소멸하지만, 반환한 클로저는 increase 변수에 할당되어 호출된다. 이때 즉시 실행 함수가 반환한 클로저는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하고 있다.
즉시 실행 함수는 한번만 실행되며 increase가 계속 호출되어도 num이 계속 초기화될 일을 없으며, num 변수 또한 직접 접근할 수 없게 된다.
이번엔 decrease 기능을 함수로 구현해 보자.
// 카운트 상태 변경 함수
const counter = (function() {
// 카운트 상태 변수
let num = 0
// 클로저
// 객체 리터럴은 스코프를 만들지 않는다.
// 따라서 아래 메서드들의 상위 스코프는 즉시 실행 함수의 렉시컬 환경이다.
return {
increase() {
// 카운트 상태를 1만큼 증가시킨다.
return ++num
},
decrease() {
// 카운트 상태를 1만큼 감소시킨다.
return --num
},
}
})()
console.log(counter.increase()) // 1
console.log(counter.increase()) // 2
console.log(counter.decrease()) // 1
console.log(counter.decrease()) // 0
위의 기능을 생성자 함수로 만들어 보자.
const Counter = (function() {
// 카운트 상태 변수
let num = 0
function Counter() {
// this.num = 0; // 프로퍼티는 public하므로 은닉하지 않는다.
}
Counter.prototype.increase = function() {
return ++num
}
Counter.prototype.decrease = function() {
return --num
}
return Counter
})()
const counter = new Counter()
console.log(counter.increase()) // 1
console.log(counter.increase()) // 2
console.log(counter.decrease()) // 1
console.log(counter.decrease()) // 0
변수 값은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적인 원인이 될 수 있다. 외부 상태 변경이나 데이터를 피하고 불변성을 지향하는 함수형 프로그래밍에서 부수 효과를 최대한 억제하기 위해 클로저 사용이 권장된다.
// 함수를 인수로 전달받고 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.
function makeCounter(predicate) {
// 카운트 상태를 유지하기 위한 자유 변수
let counter = 0
// 클로저를 반환
return function() {
// 인수로 전달받은 보조 함수에 상태 변경을 위임한다.
counter = predicate(counter)
return counter
}
}
// 보조 함수
function increase(n) {
return ++n
}
function decrease(n) {
return --n
}
// 함수로 함수를 생성한다.
// makeCounter 함수는 보조 함수를 인수로 전달 받아 함수를 반환한다.
const increaser = makeCounter(increase)
console.log(increaser()) // 1
console.log(increaser()) // 2
console.log(increaser) // [Function (anonymous)]
const decreaser = makeCounter(decrease)
console.log(decreaser()) // -1
console.log(decreaser()) // -2
console.log(decreaser) // [Function (anonymous)]
주의할 점은 makeCounter 함수를 호출해 함수를 반환하는 경우 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖는다는 것이다. 함수를 호출할 때마다 새로운 makeCounter 함수 실행 컨텍트의 렉시컬 환경이 생성된다.
위의 예제에서 increaser와 decreaser에 할당된 함수는 각각 독립된 렉시컬 환경을 갖기에 서로의 counter를 공유하지 않는다. 이를 위해서는 makeCounter 함수를 두 번 호출 하지 않으면 된다.
// 함수를 인수로 전달받고 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.
const counter = (function() {
// 카운트 상태를 유지하기 위한 자유 변수
let counter = 0
// 클로저를 반환
return function(predicate) {
// 인수로 전달받은 보조 함수에 상태 변경을 위임한다.
counter = predicate(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(decrease)) // 1
console.log(counter(decrease)) // 0
5. 캡슐화와 정보 은닉
캡슐화는 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶은 것을 말한다. 캡슐화는 객체의 특정 프로퍼티나 메서드를 감출 목적으로 사용하기도하는데 이를 정보 은닉이라 한다.
Javascript는 public, private, protected 와 같은 접근 제한자를 제공하지 않기에 JS 객체의 모든 프로퍼티와 메서드는 기본적으로 공개적이다.
function Person(name, age) {
this.name = name // public
let _age = age // private
// 인스턴스 메서드
this.sayHi = function() {
console.log(`Hi! My name is ${this.name}. I am ${_age}.`)
}
}
const me = new Person('Son', 20)
me.sayHi() // Hi! My name is Son. I am 20.
console.log(me.name) // name
console.log(me._age) // undefined
위의 예처럼 _언더바 기호로 시작하는 변수를 만들면 private 변수를 만들 수 있다.
단, sayHi가 프로토타입 메서드가 아니기 때문에 중복생성되는 것을 방지해보자.
const Person = (function() {
let _age = 0 // private
// 생성자 함수
function Person(name, age) {
this.name = name // public
_age = age // private
}
// 프로토타입 메서드
Person.prototype.sayHi = function() {
console.log(`Hi! My name is ${this.name}. I am ${_age}.`)
}
// 생성자 함수를 반환
return Person
})()
const me = new Person('Son', 20)
me.sayHi() // Hi! My name is Son. I am 20.
console.log(me.name) // name
console.log(me._age) // undefined
위와 같은 패턴을 사용하면 JS에서도 정보 은닉이 가능한 것처럼 보인다.
하지만, Person 생성자 함수가 여러개의 인스턴스를 생성하면 _age 변수의 상태가 유지되지 않는다.
const me = new Person('Son', 20)
me.sayHi() // Hi! My name is Son. I am 20.
const you = new Person('Sunny', 30)
you.sayHi() // Hi! My name is Sunny. I am 30.
// _age 값이 변경되었다.
me.sayHi() // Hi! My name is Son. I am 30.
Person.prototype.sayHi 메서드가 단 한 번 생성되는 클로저이기 때문에 발생하는 현상이다. Person.prototype.sayHi 메서드는 즉시 실행 함수가 호출되는 경우에 생성된다. 즉 즉시 실행 함수의 실행 컨텍스트를 [[Environment]]에 저장한다.
JS는 완전한 정보 은닉을 지원하지 않으며 private를 흉내 내는 것은 가능하지만, 프로토타입 메서드를 사용하면 이마저도 불가능해진다.
하지만 다행히 TC39 프로세스의 stage 3 에는 class에 private 필드를 정의할 수 있으며 이에 대해서는 다음 글에서 자세히 다룰 예정이다.
클로저를 사용할 때 자주 발생하는 실수
var funcs = []
for (var i = 0; i < 3; i++) {
funcs[i] = function() {
return i
}
}
for (var j = 0; j < funcs.length; j++) {
console.log(funcs[j]())
}
// 3 3 3
위 예제는 for 문의 변수 선언문에서 var 키워드로 선언한 i 변수는 블럭 레벨 스코프가 아닌 함수 레벨 스코프를 갖기에 전역 변수이다. 전역 변수 i 에는 0, 1, 2가 순차적으로 할당되기에 funcs 배열의 요소로 추가한 함수들을 호출해 보면 전역 변수 i 를 참조해 3이 출력된다.
위의 예제를 올바르게 동작하기 위해서 아래와 고쳐보자.
var funcs = []
for (var i = 0; i < 3; i++) {
funcs[i] = (function(id) {
return function() {
return id
}
})(i)
}
for (var j = 0; j < funcs.length; j++) {
console.log(funcs[j]())
}
// 0 1 2
위 문제의 경우 JS의 함수 레벨 스코프 특성으로 인해 발생하는 문제를 let 키워드로 간단하고 깔끔하게 없앨 수 있다.
var funcs = []
for (let i = 0; i < 3; i++) {
funcs[i] = function() {
return i
}
}
for (var j = 0; j < funcs.length; j++) {
console.log(funcs[j]())
}
'JavaScript > 모던 자바스크립트 Deep Dive' 카테고리의 다른 글
25. 클래스 - 2 (0) | 2024.07.16 |
---|---|
25. 클래스 - 1 (0) | 2024.07.16 |
23. 실행 컨텍스트 (0) | 2024.05.09 |
22. this (0) | 2024.04.23 |
21. 빌트인 객체 (1) | 2024.04.11 |