클로저에 대하여
면접 질문 중 단골이고, 자바스크립트에 관심이 있다면 한번쯤 들어봤을 개념인 클로저.
사실 많이 난해한 개념이기도 하고, 필자 역시 전에 한번 공부를 해보았지만 아직 확실히 와닿지는 않는 개념이다.
그래서 이번 기회에 제대로 정리하고 넘어가고자 한다. 함께 이 개념이 대체 뭔지 살펴보자.
클로저의 정의
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.
'이게 뭔소리지?' 싶은가? 나도 그러하다. 위의 정의에서 이해하여야 할 핵심 키워드는 함수가 선언된 렉시컬 환경이라는 것이이다.
const x = 1;
function outerFunc( ) {
const x = 10;
function innerFunc( ) {
console.log(x); // 10
}
innerFunc( );
}
outerFunc( );
outerFunc 내부에서 중첩 함수 innerFunc가 정의되고 호출되었다.
따라서 중첩 함수 innerFunc 내부에서 자신을 포함하고 있는 outerFunc의 x 변수에 접근할 수 있다. 만약 innerFunc 함수가 outerFunc의 내부에서 정의되지 않았다면, 즉 외부에서 별도로 정의 되었다면 innerFunc를 outerFunc 내부에서 호출해도 outerFunc 함수의 변수에 접근이 불가능하다.
const x = 1;
function outerFunc( ) {
const x = 10;
// 안에서 호출되었지만 접근 불가능
innerFunc( );
}
function innerFunc( ) {
// 상위 스코프인 전역에서 선언된 1
console.log(x); // 1
}
outerFunc( );
위와 같은 현상은 자바스크립트가 렉시컬 스코프를 따르기에 발생한다.
렉시컬 스코프
자바스크립트 엔진은 함수를 어디서 호출했느냐가 아니라 함수를 어디서 정의했는지에 따라 상위 스코프를 결정 한다.
이를 렉시컬 스코프라고 한다.
위의 예제코드를 다시한번 본다면, outerFunc와 innerFunc는 모두 전역에서 정의 되었고, 함수의 상위 스코프는 함수를 어디서 정의했는지에 따라 결정되므로 두 함수의 상위 스코프는 모두 전역이다.
함수의 상위 스코프는 결국, 함수의 정의된 위치에 따라 정적으로 결정되고, 함수의 호출된 위치는 어떠한 영향도 주지 못한다.
렉시컬환경: 변수를 저장하고 외부 스코프와 연결을 유지하는 객체라고 생각하면된다. 자바스크립트에서는 함수가 생성될 때마다 렉시컬 환경이 만들어지며, 함수 내부의 변수뿐 아니라 함수가 선언된 위치에 있는 외부 변수도 기억하게된다.
코드가 위치한 곳에 따라 **변수와 함수를 어디서 찾아볼지 알려주는 일종의 "지도"**라고 생각할 수도 있다
렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결이된다.
따라서 함수의 상위 스코프를 결정한다는 것은, 현재 함수의 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값을 결정한다는 것을 의미한다.
렉시컬 스코프를 다시한번 정의해보자면, 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장한 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정되는 것이라고 할 수 있다.
함수는 자신의 내부슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다. 이곳을 참조해서 자신이 호출되었을 때 생성될 함수 렉시컬환경의 "외부 렉시컬 환경에 대한 참조"에 저장될 참조값을 보고, 자신이 존재하는 한, 이 [[Environment]] 슬롯에 저장한 렉시컬 환경의 참조, 즉 상위 스코프를 기억한다.
클로저와 렉시컬환경
그렇다면 다음의 코드를 살펴보자
const x = 1 ;
function outer() {
const x = 10;
const inner = function ( ) {console.log(x)}
//inner함수 반환
return inner;
}
// outer함수를 호출하면 중첩 함수 inner를 반환한다.
// 그리고 outer 함수의 실행 컨텍스트는 제거된다.
const innerFunc = outer( );
innerFunc( )// 10
outer함수를 호출하면 outer함수는 중첩함수 inner를 반환하고 생명주기를 마감한다.
즉 outer함수의 실행이 종료되었으므로, 실행컨텍스트가 제거된다. (실행컨텍스트 스택에서 pop된다.)
이때 outer 함수의 지역변수x 역시 생명주기를 마감했으므로, 실행 컨텍스트가 제거되어 유효하지 않아 보인다.
그러나 위의 실행 결과는 outer 지역 변수x 의 값인 10을 반환한다. 이미 생명 주기가 종료되어 outer 함수의 지역변수 x가 실행 컨텍스트 스택에서 제거되었는데도 다시 부활이라도 한 것 마냥 말이다.
이처럼 외부 함수보다 중첩함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명주기가 종료한 외부함수의 변수를 참조할 수 있다.
이러한 중첩 함수를 클로저라고 부른다.
다시 정의로 돌아가보자.
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.
위 정의를 예제에 대입해보자면 함수는 반환된 중첩함수(inner)를 의미하고 그 함수가 선언될 때의 렉시컬 환경(Lexical environment)란 그 중첩 함수(inner)가 정의됐을 때의 스코프를 의미하는 것이다.
즉, 클로저는 반환된 중첩 함수가 자신이 선언됐을 때의 렉시컬 환경 즉, 스코프를 기억하여 자신이 선언됐을 때의 렉시컬 환경 밖에서 호출되어도 그 렉시컬 환경(스코프)에 접근할 수 있는 함수를 말한다.
조금 더 간단히 말하면 클로저는 자신이 생성될 때의 상위 스코프(렉시컬 환경)을 기억하는 함수이다
inner 함수의 [[Environment]] 슬롯이 outer 함수의 렉시컬 환경을 참조하고, inner가 전역 변수 innerFunc에 저장되어 계속 사용되고 있으므로, 이와 연결된 모든 요소는 가비지 컬렉터에 의해 제거되지 않습니다.
가비지 컬렉터(Garbage Collector)는 프로그램이 더 이상 사용하지 않는 메모리를 자동으로 해제해 주는 자바스크립트 엔진의 기능이다.
가비지 컬렉터는 특정 메모리 공간이 더 이상 참조되지 않을 때 그 공간을 "가비지"로 판단하여 메모리를 해제한다. 반대로, 누군가가 참조하고 있는 메모리 공간은 함부로 해제하지 않는다.
클로저는 자바스크립트의 강력한 기능으로, 필요하다면 적극 활용해야 한다. 클로저가 유용하게 사용되는 상황을 살펴보자.
클로저의 활용
상태를 안전하게 변경하고 유지할 때
클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고, 특정 함수에게만 상태 변경을 허용하여, 상태를 안전하게 변경하고 유지할 수 있도록 사용한다.
const counter = (function ( ) {
// 은닉된 상태 (외부에서 접근 불가)
let num = 0;
// 클로저인 메서드를 갖는 객체를 반환한다.
// 객체 리터럴은 스코프를 만들지 않는다.
// 따라서 아래 메서드들의 상위 스코프는 즉시 실행 함수의 렉시컬 환경이다.
return {
increase() {
return ++num;
}
decrease() {
return num>0? --num: 0;
}
}());
console.log(counter.increase()); // 1
console.log(counter.decrease()); // 2
console.log(counter.decrease()); // 1
console.log(counter.decrease()); // 0
즉시 실행 함수는 호출된 이후 소멸되지만, 즉시 실행 함수가 반환한 클로저(increase, decrease)는 자신의 상위 스코프인 즉시 실행함수의 렉시컬 환경을 기억하고 있다.
이 코드에서
- 카운터 상태(num 변수의 값)은 increase, decrease 함수가 호출되기 전까지 변경되지않고 유지되며 외부에서 접근할 수 없다.
- 카운터 상태는 오직 increase, decrease로 정의된 함수로만 변경이 가능하다.
이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용된다.
전역변수의 사용을 억제할 때
클로저는 전역 변수 사용을 억제하고 대신 함수 내부의 변수로 상태를 관리할 수 있도록 도와준다. 이렇게 하면 전역 변수를 사용하지 않고도 데이터가 안전하게 유지되며, 다른 코드와 충돌하지 않는 이점을 얻을 수 있다.
let counter = 0; // 전역 변수
function incrementCounter() {
counter += 1;
return counter;
}
console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2
console.log(counter); // 전역 변수에 접근 가능 (위험)
위 코드에서는 counter가 전역에 선언되어 있기 때문에 어디서든 접근 가능하여, 이러면 다른 코드에서 counter를 실수로 변경할 위험이 있다.
function createCounter() {
let counter = 0; // 함수 내부 변수로 관리
return function() {
counter += 1;
return counter;
};
}
const incrementCounter = createCounter();
console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2
console.log(typeof counter); // 'undefined' - 전역에서 접근 불가
이 코드에서는 counter 변수가 createCounter 함수 내부에만 존재하므로 외부에서 직접 접근하거나 수정할 수 없다.
대신, incrementCounter 함수는 클로저를 통해 counter를 기억하고 있으므로 호출할 때마다 counter를 안전하게 증가시킬 수 있다.
이처럼, 클로저를 사용하면 함수 내 지역 변수를 통해 상태를 관리하게 되어 전역 변수를 사용하지 않아도 안전하게 데이터 상태를 유지할 수 있다.
React의 useState
이런 이점을 활용하여 react의 useState에도 클로저가 활용된다.
useState를 사용하면 컴포넌트 내에서 상태를 관리하게 되는데, 이 상태가 컴포넌트가 렌더링될 때마다 유지되도록, 또한 setState 함수로만 상태가 변경되도록 하는 데에 클로저가 활용되는 것이다.
이와 관련하여서는 다음 포스팅에서 자세하게 다룰 예정이다.
결론
지금까지 자바스크립트의 어렵지만 주요한 개념인 클로저에 대해 살펴보았다.
결국, 클로저는 **함수와 그 함수가 선언된 렉시컬 환경의 조합으로, 자바스크립트에서 함수는 자신이 정의된 위치에 따라 상위 스코프를 결정하는 렉시컬 스코프를 따르기에에, 중첩 함수가 외부 함수의 스코프를 참조하여 외부함수가 생명주기가 끝났음에도 그 함수의 변수를 참조할 수 있는 함수를 의미한다.