JS 클로저

불꽃남자·2020년 9월 17일
0

실행 컨텍스트를 배웠다면 클로저가 무엇인지에 대해 깨우칠 수 있다.

Closure

Closure는 무엇인가

MDN의 클로저에 관한 웹 문서에서는 클로저에 대해 이렇게 이야기하고 있다.

클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다. 클로저를 이해하려면 자바스크립트가 어떻게 변수의 유효범위를 지정하는지(Lexical scoping)를 먼저 이해해야 한다.

렉시컬 스코핑은 무엇인가? 그에 대해선 이전에 알아본 바가 있다. 함수가 선언된 곳을 기준으로 해당 함수의 스코프를 정하는 것을 렉시컬 스코핑이라고 한다.

여기서 이야기하는 어휘적 환경은 Lexical Environment을 뜻한다. 실행 컨텍스트에서 만났던 렉시컬 환경이 어휘적 환경이다. 이는 번역의 차이이다.

용어는 모두 알고있지만 역시 설명만으로는 알기 어렵다. 백문이 불여일견이라 하였고 백견이 불여일타라고 하였다. 코드를 작성하며 알아보자.

function outerFunc() {
     var a = 5;
     function innerFunc() {
     	console.log(a);
     }
     return innerFunc;
}

var inner = outerFunc();
inner(); //5;


보통 함수 내부에 존재하는 변수는 함수가 실행되는 동안에만 존재하기 마련이다. 하지만 JS의 클로저는 다르다.

outerFunc 함수가 실행되면 내부에 선언된 innerFunc 함수를 반환한다. 반환된 innerFunc 함수는 inner 변수에 담긴다.
inner 담긴 innerFunc 함수를 실행되면 무슨 일이 일어나는지 알아보자.

innerFunc 함수가 호출되면 innerFunc 함수의 실행 컨텍스트가 생성되고 실행 스택에 쌓이게 된다. 실행 컨텍스트는 렉시컬 환경 컴포넌트를 가지고 있고, 렉시컬 환경 컴포넌트는 환경 레코드, 외부 환경 참조, This Binding을 가진다. 그리고 innerFunc 함수를 평가한다.
평가 도중 a라는 식별자를 발견하게 된다! 그럼 JS엔진은 innerFunc의 환경 레코드에서 a라는 이름의 변수가 있는지 탐색한다. innerFunc에서 a라는 식별자로 선언된 할당문은 없기 때문에 innerFunc의 환경 레코드에 a 변수는 없다. a 변수를 찾아내지 못 한 JS엔진은 innerFunc의 외부 환경 참조를 탐색한다. innerFunc의 외부 환경 참조는 outerFunc의 렉시컬 환경 컴포넌트이다.
innerFunc의 외부 환경 참조로써 접근된 outerFunc의 환경 레코드에서 a라는 식별자를 탐색한다. 아! 여기있었군. 이제 JS엔진은 이 변수 a를 innerFunc의 [[Scopes]]라는 유사 배열 프로퍼티의 0번째 인덱스에 저장한다. 원래 있던 0번째 인덱스는 한 칸 밀려나게 된다.
이로써 innerFunc의 평가가 종료된다.

자, 이제 innerFunc 함수의 코드가 실행된다. console.log(a)가 실행된다. 자신의 Scope를 탐색하지만 a는 없다. 이제 [[Scopes]]를 순서대로 탐색한다. Scopes[0]을 탐색한다... 아! 여기있다. a라는 key에 담긴 value 5를 대입한다.

마침내 console의 log에 5라는 숫자가 나타난다.


실제로 console.dir(inner)를 해보면 자신의 [[Scopes]]에 Closure 라는 이름으로 자신이 접근해야하는 외부 함수의 변수에 대한 참조를 저장해놓았다.

또한 내부 함수는 자신이 접근하지 않아도 되는 외부 함수의 변수에 대해서는 참조하지 않는다.

코드를 보면 outerFunc에 변수 b가 있지만 innerFunc의 outerFunc에 대한 Closure에 변수 b는 존재하지 않는다. 자신이 참조해야하는 변수 a만이 존재한다.
왜냐하면 innerFunc 함수의 평가 단계에서 그렇게 정해졌기 때문이다.

여기서 나는 궁금해졌다. '그렇다면 3중 중첩 함수의 클로저는 어떻게 구성되는가?'

3중 중첩 함수의 Closure

function outerFunc() {
     var a = 5;
     function innerFunc() {
     	var b = 6;
        function innerinnerFunc() { console.log(b + a); };
        return innerinnerFunc;
     }
     return innerFunc;
}

var inner = outerFunc();
var innerinner = inner();

innerinner();
console.dir(innerinner);

outerFunc는 innerFunc를 반환한다. innerFunc는 innerinnerFunc를 반환한다. innerinnerFunc는 3중 중첩 함수이며, outerFunc의 변수 a와 innerFunc의 변수 b에 접근하고 있다.

하하, console log에 11이 나타나는 걸 보니 잘 동작한다. console.dir(innerinner) 를 해보자.

[[Scopes]]에 outerFunc 클로저와 innerFunc 클로저가 순서대로 쌓여있는 것을 볼 수 있다.
이로써 알 수 있는 것은 무엇인가? 다시 머릿속으로 JS엔진을 돌려보자. 나는 JS엔진이다...

앞부분을 생략하고, innerinnerFunc 함수를 호출하는 부분이다.

innerinnerFunc 함수의 실행 컨텍스트가 생성되고 함수의 코드가 평가된다. 식별자 b를 만난다. 현재 실행 컨텍스트의 환경 레코드엔 b가 없다. 외부 환경 참조로 innerFunc의 환경 레코드를 탐색한다. 여기 있다. 함수 실행 컨텍스트에서 찾았으니 Closure고 이 실행 컨텍스트의 주인인 함수 이름은 innerFunc다. 이 Closure안에 b를 넣는다.
이 실행 컨텍스트는 전역 실행 컨텍스트로부터 2칸 떨어져있다. 2칸보다 더 멀리 떨어진 Closure가 innerinnerFunc의 [[Scopes]]에 존재하는가? 없다.
원래 있던 Scopes들은 한 칸씩 밀어내고 innerinnerFunc의 Scopes[0]을 새로 만든 뒤 이 곳에 innerFunc의 Closure를 넣는다.

다음으로 식별자 a를 만난다. 현재 실행 컨텍스트의 환경 레코드에서 a를 탐색하지만 없다. 외부 환경 참조로 innerFunc의 환경 레코드를 탐색한다. 여기도 없다. innerFunc의 외부 환경 참조로 outerFunc의 환경 레코드를 탐색한다. 여기 있다. 함수 렉시컬 환경 컴포넌트에서 찾았으니 Closure고 이 실행 컨텍스트의 주인인 함수의 이름은 outerFunc다. 이 Closure 안에 a를 넣는다.
이 실행 컨텍스트는 전역 실행 컨텍스트로부터 1칸 떨어져있다. 1칸보다 더 멀리 떨어진 Closure가 innerinnerFunc의 [[Scopes]]에 존재하는가? 있다! innerFunc의 Closure가 나보다 전역 실행 컨텍스트로부터 더 멀리 떨어져있고 Scopes[0]에 존재한다.

나는 Scopes[0]와 index값이 작거나 같은 Scopes를 제외한 Scopes들을 앞으로 한 칸씩 밀어내고 Scopes[0]의 index + 1인 Scopes[1]을 새로 만든다. 그리고 Scopes[1]안에 outerFunc의 Closure를 넣는다.

이제 innerinnerFunc의 함수 평가가 끝나고 함수의 코드가 실행된다. 변수 b와 a를 Scope Chain에서 찾아내고 코드는 Error를 반환하지 않고 무사히 종료된다.

아마 이런 느낌으로 코드가 동작하고 있다고 생각한다. 실제로는 if문과 Array.splice 메서드를 이용해서 Scopes를 만들어 낼 것이다.

하나의 Closure는 하나의 렉시컬 환경을 지닌다.

아래의 코드를 보자.

function makeAdder() {
  var y = 1;
  return function(z) {
    return y + z;
  };
}

var add1 = makeAdder();
var add2 = makeAdder();

console.log(add1(3)); //4
console.log(add2(5)); //6


add1과 add2는 같은 함수에서 생성된 함수이고 클로저로써 makeAdder 함수의 변수 y를 참조하고 있다. 하지만 참조 중인 y의 값은 공유하고 있지 않다.
이것이 의미하는 바는 함수 평가단계에서 makeAdder 환경 레코드의 변수 y를 클로저에 담아 가져올 때에, 변수 y의 메모리 주소를 복사한 게 아니라 변수 y와 그 값을 통째로 복사해서 다른 메모리에 할당했다는 의미이다.
그러니까, 얕은 복사를 한 게 아니라 깊은 복사를 했다는 의미이다.

그리고 이것이 의미하는 바는 클로저가 생성될 때 마다 메모리 어딘가에 클로저가 존재하게 된다는 것이고, 클로저를 마구 만들어대면 메모리 누수가 발생하게 된다는 뜻이다.

이것에 대한 자세한 원리는 가비지 컬렉터를 배우게 되면 알 수 있다.

마치며

이전에 클로저와 스코프에 대해 포스팅했었는데, 이 포스트는 그것의 advanced판이라고 할 수 있겠다. 이전 포스팅은 '이것은 이렇게 동작합니다. 원리에 대해서는 나중에 알게 됩니다.'라는 느낌이고 이번 포스팅이 '이것은 이렇게 동작합니다. 원리는 이러이러합니다.' 라는 느낌이다.

클로저에 대해 알아보고 있으니 자연스럽게 가비지 컬렉터 개념으로 이어지는 것이 신기하다.

참고 사이트

MDN의 클로저에 관한 웹 문서

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글