클로저는 난해하기로 유명한 JS의 개념 중하나이다. 클로저는 JS의 고유 개념은 아니며 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.
클로저는 JS 고유의 개념이 아니므로 클로저의 정의가 ECMAScript 사양에 등장하지 않는다.
MDN 문서에서는 클로저를 아래와 같이 정의하고 있다.
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다
const x = 1;
// ①
function outer() {
const x = 10;
const inner = function () { console.log(x); }; // ②
return inner;
}
// outer 함수를 호출하면 중첩 함수 inner를 반환한다.
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거된다.
const innerFunc = outer(); // ③
innerFunc(); // ④ 10
outerFunc 함수 내부에서 중첩함수 innerFunc가 정의되고 호출되었다. 따라서 innerFunc내부에서 자신을 포함하는 외부함수 outerFunc 의 x변수에 접근할 수 있었다.
만약 innerFunc 함수가 outerFunc 함수의 내부에서 정의된 중첩 함수가 아니라면 innerFunc 함수를 outerFunc 함수의 내부에서 호출한다 하더라도 outerFunc 함수의 변수에 접근할 수 없다.
렉시컬 스코프
JS엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다. 이를 렉시컬 스코프(정적 스코프)라고 한다.
https://supersfel.tistory.com/547
렉시컬 스코프에 대한 내용은 예전에 한번 다뤘었다.
클로저와 렉시컬 환경
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 함수를 호출하면 otuer 함수는 중첩함수 inner를 반환하고 생명주기를 마감한다.
생각해보면 outer함수의 실행컨텍스트가 제거되기때문에 outer함수의 지역변수 x는 더는 유효하지 않을 것 같아 보인다. 하지만 실제로는 x에 접근이 가능하다.
이처럼 외부함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 한다.
JS의 모든 함수는 자신의 상위 스코프를 기억한다. 따라서 함수를 어디서 호출하든 함수는 언제나 자신이 기억하느 ㄴ상위 스코프의 식별자를 참조할 수 있다.
위 예제에서 inner 함수는 자신이 평가 되는 경우 상위 스코프를 [[Environment]] 내부 슬롯에 저장한다. 즉 outer 함수의 렉시컬 환경을 상위 스코프로 저장해두었기 때문에 outer 함수의 실행컨텍스트는 스택에서 제거되어도 outer함수의 렉시컬 환경까지 소멸하지는 않게 된다.
inner함수의 [[Environment]] 내부슬롯에 의해 outer 함수가 참조되고 있기 때문에 가비지 컬렉션의 대상이 되지 않기 때문이다.
JS의 모든 함수는 상위 스코프를 기억하기 때문에 이론적으로 모든 함수는 클로저이다. 다만 일반적으로 모든 함수를 클로저라고 하지는 않는다.
중첩함수가 외부함수보다 오래 유지되지만 상위 스코프의 어떤 식별자도 참조하지 않는다면 대부분의 브라우저는 상위 스코프를 기억하지 않고, 그렇기에 이러한 함수들은 클로저라고 할 수 없다.
클로저는 중첩함수가 상위 스코프의 식별자를 참조하고 있으며 중첩함수가 외부 함수보다 도 오래 유지되는 경우를 의미하는 것이 일반적이다.
클로저의 활용
클로저는 상태를 안전하게 변경하고 유지하기 위해 사용된다. 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용할 수 있다.
// 카운트 상태 변수
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 변수에 할당되어 호출될 수 있다. 이때 즉시 실행 함수가 반환한 클로저는 자신이 정의된 위치에 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하고 있다.
즉시 실행함수는 한번만 실행되므로 incrase가 호출될 때마다 num 변수가 재차 초기화될 일은 없으며 num변수또한 직접 접근할수 없게 된다.
위 예제의 카운트를 감소하는 기능도 만들어 보자.
// 카운트 상태 변경 함수
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
캡슐화와 정보 은닉
캡슐화는 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶는 것을 말한다. 캡슐화는 객체의 특정 프로퍼티나 메서드를 감출 목적으로 사용하기도 하는데 이를 정보 은닉이라고 한다.
JS는 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 메서드가 prototype 메서드가 아니기 때문에 중복생성되는데 이 부분을 바꿔보자.
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프로세스의 stage3에는 클래스에 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를 참조하여 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]())
}
'FrontEnd > Deep Dive' 카테고리의 다른 글
[JS] DeepDive(26) ES6함수의 추가기능 (0) | 2023.08.27 |
---|---|
[JS] DeepDive(25) 클래스 - 1 (0) | 2023.08.24 |
[JS] DeepDive(23) 실행 컨텍스트 (0) | 2023.08.21 |
[JS] DeepDive(22) this (0) | 2023.08.19 |
[JS] DeepDive(20) 빌트인 객체 (0) | 2023.08.17 |