[JS] Lexical Environment로 알아보는 Closure
쓰게 된 계기
모던 자바스크립트를 요즘 정리하면서 얘기하는 스터디를 하고 있습니다. 그런데 이 부분 내용을 따로 공유하면 좋을 것 같아서 분리하게 되었습니다.
변수의 유효범위와 클로저
자바스크립트는 함수형 프로그래밍을 지원합니다. 함수가 일급 객체이기 때문에 고차함수는 익숙하게 볼 수 있습니다.
let과 const를 사용하면 block scope으로 사용하게 됩니다.
(자바스크립트에서도 이런 용어를 쓰는지 모르겠지만) 이렇게 눈으로 바로 블록에 쌓인 변수 범위를 lexical scope이라고 부릅니다.
외부 scope에서 내부 scope에 있는 변수를 사용하지 못하기 때문에 실수를 많이 줄여줍니다.
사실 이런 Lexical Scope과 이것을 활용해 굴러가는 Closure에 관해서 정확히 설명하려면 Execution Context를 알아야 합니다.
그래서 그런지 이 책에서 처음으로 Deep 하게 들어가네요.
책에 있는 설명보다는 제 방식으로 한번 설명해보겠습니다.
코어 자바스크립트나 다른 책을 읽으면서 개인적으로 공부한 자료가 있어서
스터디 때 했던 자료와 함께 제 나름대로 설명해 보겠습니다.
Execution Context란 어떤 걸까요?
Execution은 말 그대로 실행을 의미하고 책에 따라서 Context는 맥락, 환경 등으로 표현되고는 합니다. 운영체제와 같은 것을 공부해 보셨다면 다들 익숙하실 겁니다.
이 Context를 정확하게 파악할 수 있어야지 실행이 어떻게 될지 예상할 수 있습니다.
let, const가 추가되면서 for문, while문, if문 등에서 쓰이는 Block Scope에 대해서 독립적으로 동작할 수 있도록 된다는 것은 다들 익숙할 겁니다.
하지만 이때 별도의 실행 콘텍스트가 생성되는 것은 아닙니다.
실행 콘텍스트는 함수에 의해서 생깁니다.
실행 컨텍스트는 3가지 정보를 담고 있습니다.
이 중에서 LexicalEnvironment와 VariableEnvironment에는 현재 환경과 관련된 식별자 정보가 들어있습니다.
VariableEnvironment에는 VariableStatements으로 생성된 바인딩을 가지고 있습니다.
- 식별자 정보를 수집하는 용도로 쓰입니다.
- 변화를 반영하지 않습니다
LexicalEnvironment는 code에서 referece 하고 있는 식별자를 resolve를 하는데 씁니다.
- 각 식별자의 데이터를 추적하는데 쓰입니다
- 값의 변화가 실시간으로 반영됩니다
그래서 값을 계속 추적하는 다들 LexicalEnviroment로 설명을 많이 합니다.
LexicalEnvironment는 뭘까요?
LexicalEnviroment는 식별자 정보를 저장하는 어휘적 환경입니다.
LexicalEnviroment는 다음의 정보를 가지고 있습니다.
사실 최신 명세서(ECMA-262, 13th edition, June 2022)에서는 이것과 관련된 정보를 찾기가 어려워서 예전 명세서(Standard ECMA-262 5.1 Edition / June 2011)에서 찾아왔습니다.
- EnviromentRecord
- 현재 컨텍스 내부의 식별자 정보
- 실행 콘텍스트가 제일 처음으로 실행될 때 제일 먼저 하는 일이 이 정보를 수집하는 것입니다. == 호이스팅
- Outer Environment Referece (최신 문서에서는 [[OuterEnv]]?)
- 외부 환경을 참조 정보
- 외부의 환경 == 현재 환경과 관련 있는 LexicalEnvironment
- 이것은 함수가 선언될 때 결정됩니다. 실행될 때가 아닙니다!
- 이것을 통해 scope chain이 만들어집니다
Closure에 대해서 알아봅시다.
Closure는 함수형 언어면 흔하게 접할 수 있는 개념입니다.
자바스크립트에서 Closure는 내부함수 + LexicalEnviroment입니다.
용어 정리 :: 자유 변수 (Free Variable)
수학에서 F(x) = x + y라고 하면 x는 F(1)이라고 하는 순간 정해집니다. 따라서 식은 1 + y가 됩니다. 이때 파라미터로 주어지는 (묶여있는) x와 달리 y는 어떤 애일지 예상할 수 없습니다. 이런 변수를 우리는 자유 변수라고 합니다.
코드로 한번 보겠습니다. 우리는 흔하게 이런 함수를 사용합니다.
const sum = (function outerFunction() {
const y = 2;
return (function innerFunction() {
const x = 1;
return x + y;
}());
}());
console.log(sum)
이때 innerFunction 입장에서는 y는 free variable이라고 볼 수 있습니다.
왜냐하면 innerFunction 입장에서 정해져 있는 값은 x이고 outerScope에서 오는 y는 아니기 때문입니다.
function multiplyBy(n){
return function(x) {
return x * n;
}
}
이 multiplyBy는 몇으로 곱할지 인자로 넘겨주면 무조건 그 숫자로 곱해주는 함수를 반환합니다. 자바스크립트를 사용한다면 이런 식으로 사용하는 함수를 자주 볼 수 있을 겁니다.
// 2배수로 곱해주는 함수 생성
const multiplyByTwo = multiplyBy(2);
// 5배수로 곱해주는 함수 생성
const multiplyByFive = multiplyBy(5);
// 사용
console.log(multiplyByTwo(4)); // 8
console.log(multiplyByTwo(8)); // 16
console.log(multiplyByFive(1)); // 5
console.log(multiplyByFive(4)); // 20
그런데 생각해 보면 multiplyBy함수의 호출이 끝나는 순간(= context execution stack에서 pop 되는 순간) n은 지역변수가 때문에 전역 변수이기 때문에 더 이상 접근할 수 없어야 되는 것 아닐까요?
이때 리턴되는 함수의 입장에서는 n이 자유 변수(Free Variable)입니다.
이렇게 리턴되는 함수는 자기한테 필요한 환경이 있으면 Execution Context를 저장해 둡니다. 이것을 우리는 클로저라고 부는 겁니다.
확실히 주어지는 파라미터로만 연산을 하고 return 하는 함수랑은 다릅니다. 자유 변수(Free Variable)를 환경으로 가져가기 때문이죠.
정확하게 말하자면 클로저는 이처럼 자신이 생성될 때의 Lexical Environment을 기억합니다. 그리고는 Scope Chain을 통해 그때의 실제 변수에 접근할 수 있게 되는 겁니다.
위의 예제의 함수에 딸려 가는 환경은 다른 환경입니다.
우리가 2 배수 함수와 5 배수 함수 두 개를 만들었죠. n이 같은 메모리를 사용한다면 이것이 불가능할 겁니다. 항상 블록으로 새로운 클로저가 만들어질 때마다 다른 실행 환경이었기 때문에 그렇습니다.
혹시 리액트 useEffect 함수를 사용하면서 이전의 값이 계속 출력된다거나 한 적이 있나요?
그것도 Closure이기 때문에 당시의 free variable을 캡처해 갔기 때문입니다 ㅎㅎ.
이제 자유 변수를 떠올리시면 왜 함수가 아니라 Closure라고 부르는지도 이해가 갈 겁니다. Close..! + r 닫혀있다.. = 묶여있다..?! 뭔가 느낌이 오지 않나용.
보통 클로저를 설명할 때 다음과 같은 예제를 많이 들고 옵니다.
for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log('i = ' + i);
}, i * 1000);
}
보통 이것을 보면 1초 간격으로 i = 0, 1, 2, 3, 4가 출력될 것으로 예상합니다.
하지만 결과는 이렇습니다.
이유는 정확하게 설명하려면 이벤트 루프를 알아야 합니다.
지금 간단하게 말씀드리자면 i는 전역 변수입니다. console.log는 나중에 전역 변수인 i를 출력하게 됩니다. 그때 i는 5입니다. 따라서 5, 5, 5, 5, 5가 출력됩니다.
클로저로 해결해 봅시다.
일단 저는 먼저 드는 생각이 변수를 위한 별도의 실행 콘텍스트를 만들고 싶습니다. 그럼 함수를 만들어야겠네요?
네! 그래서 이런 문제는 예전에 IIFE로 해결하고는 합니다.
라이브러리 코드를 보시면 이런 방식을 이용한 것을 종종 볼 수 있습니다.
for (var i = 0; i < 5; i++) {
(function IIFE(tempI) {
setTimeout(function () {
console.log('i = ' + tempI);
}, i * 1000);
})(i);
}
IIFE를 이용해서 변수를 만들자마자 외부에서 접근할 수 없도록 없앴습니다. 오직 Closure에서만 Scope을 통해 그때의 Lexical Environment에 있는 변수에 접근할 수 있습니다.
사실 요즘은 간단하게 let을 사용하면 됩니다.
for (let i = 0; i < 5; i++) {
setTimeout(function () {
console.log('i = ' + i);
}, i * 1000);
}
주의할 점!
Closure는 이런 특성 때문에 메모리 누수의 주요 원인 중 하나입니다!
Clouse가 가지고 있는 환경이 memory allocation을 한다면 어떻게 될까요?
매번 Closure가 만들어질 때마다 Heap이 늘어나겠죠!
그래서 우리는 해당 클로저를 초기화시켜서 (reference 카운드가 없도록!) Garbage Collection 될 수 있게 만들어줘야 합니다.
추가 자료 추천
- https://poiemaweb.com/이나 이 블로그 주인님이 쓰신 모던 자바스크입트 Deep Dive에 이와같은 내용이 엄청 자세하게 적혀있다고 합니다.
- https://betterprogramming.pub/execution-context-lexical-environment-and-closures-in-javascript-b57c979341a5
추가적인 자료가 필요하신 분은 참고하세용!
댓글
이 글 공유하기
다른 글
-
모던 JavaScript 튜토리얼 파트 1 :: 8장 - "프로토타입과 프로토타입 상속", 9장 - "클래스" 정적 메서드까지 정리
모던 JavaScript 튜토리얼 파트 1 :: 8장 - "프로토타입과 프로토타입 상속", 9장 - "클래스" 정적 메서드까지 정리
2023.02.01 -
모던 JavaScript 튜토리얼 파트 1 :: 6장 "함수 심화학습" 정리
모던 JavaScript 튜토리얼 파트 1 :: 6장 "함수 심화학습" 정리
2023.01.16 -
모던 JavaScript 튜토리얼 파트 1 :: 5장 "자료구조와 자료형" 정리
모던 JavaScript 튜토리얼 파트 1 :: 5장 "자료구조와 자료형" 정리
2023.01.09 -
모던 JavaScript 튜토리얼 파트 1 :: 3장 "코드 품질", 4장 "객체:기본" 정리
모던 JavaScript 튜토리얼 파트 1 :: 3장 "코드 품질", 4장 "객체:기본" 정리
2023.01.02