JS Lexical Scope와 Closure

JS Lexical Scope와 Closure

앞으로 2개의 주제를 다룰 예정인데, 2개의 주제는 모두 JS의 this를 이해하기 위한 내용들이다. this를 이해하는 것에 어려움을 겪으면서 헤메다가 JS의 다른 주제들을 공부하면서 드디어 this를 조금이나마 이해하게 되었다. 그리고 역시 mdn 문서가 짱이다… mdn arrow function 문서를 읽고 나서 this를 이해하는 데 어려움을 겪었던 부분을 많이 해소하게 되었다. 우선 처음에는 lexical scope와 closure를 다루도록 하겠고, 그 다음 JS function과 arrow function을 다루면서 this에 대해 설명해보겠다.

문제의 시작

this와의 첫만남

심각하게 JS에 대해 잘 모르는(아직도 많이 모르는…) 상태에서 들었던 Code Spitz의 ES6+기초 강의에서 나는 Iterable 예제 코드가 너무 이해가 되지 않았다. 그래서 예제 코드 next 메서드의 return 값을 이리저리 바꿔보면서 this가 누구를 가르키는지 이해해 보려고 했다.

  • Iterator Interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const N2 = class {
constructor(max) {
this.max = max;
}
[Symbol.iterator]() {
// iterable object which return iterator object
let cursor = 0,
max = this.max;
return {
done: false,
next() {
// iterator object which return IteratorResultObject => (done, value)
if (cursor > max) {
this.done = true;
} else {
this.value = cursor * cursor;
cursor++;
}
return this; // this is both iterator object and iteratorResultObject
},
};
}
};
//출처: 코드 스피츠77 ES6+ 기초편 3회차 강의
//(https://www.youtube.com/watch?v=xTaCosid1-k&list=PLBNdLLaRx_rIF3jAbhliedtfixePs5g2q&index=4)

위의 예제는 간단하지 않은데 Iterable, Iterator 객체의 정의를 알고 있어야 이해가 되고, 또 Computed property name의 개념을 알아야 `[Symbol.iterator]` 함수가 낯설지 않게 느껴진다. 하지만 이번 주제에서는 앞의 내용들을 다루지 않기 때문에 이미 Iterable과 Iterator의 정의를 알고 있다는 것을 전제로 하고 설명을 하겠다.
예제 코드를 보면 Iterable 객체인 N2가 있고, N2를 Iterable로 만들어주는 `[Symbol.iterator]` 메소드는 next() 함수를 가진 객체를 반환한다. 위의 next 함수가 재밌는 점은 원래 Iterator 객체가 되기 위해서는 해당 객체가 next가 키인 메소드를 가지고 있어야 하고, next 메소드는 key가 각각 value와 done인 IteratorResultObject를 반환해야 한다. 그런데 위의 next 메소드는 this를 반환하는데, 이 this는 next 메소드 자신이 속한 객체를 가리킨다. next가 속한 객체를 보면 next 메서드와 value와 done모두를 가지고 있다. 따라서 next함수는 그냥 IteratorResultObject인 객체를 반환하는 게 아니라 IteratorResultObject이면서 동시에 Iterator인 객체를 반환하게 된다.(어렵다...)

그래서 this는 누구를 가르키는데?

지금은 this가 누구를 가르키는 지 코드를 보고 이해할 수 있지만 처음에는 this가 누구를 가르키는 지 이해하기 어려웠다. 앞의 예제 코드에서 N2는 class로 작성되었는데, Java를 사용했던 사람들이라면 누구나 위의 코드가 이해되지 않을 것이다. Java의 경우에는 모든 메소드가 클래스에 귀속되기 때문에 this는 항상 자신을 가지고 있는 인스턴스를 의미한다. 만약에 Java에서처럼 위의 코드를 이해한다면 this는 N2 class를 바탕으로 만들어진 인스턴스를 가르켜야 하고 그 인스턴스는 max 속성과 [Symbol.iterator] 메소드를 가지고 있을 것이다. 그런데 앞서 이야기 했듯이 next 메서드가 반환하는 this는 next 메서드와 value와 done을 모두 가진 객체이다. 이를 통해 알 수 있는 사실은 next메서드를 감싸고 있는 block은 단순히 next를 감싸고 있는 게 아니라 새롭게 생성된 객체라는 것이다. 정리하자면 JS의 this는 Java에서의 this처럼 동작하지 않는다.(추가적으로 Java의 클래스와 JS의 클래스에 대한 차이는 JS의 객체를 공부하고 JS가 프로토타입 체인을 통해 oop를 구현한다는 것을 공부하다보면 이해할 수 있다.). 또한 Java와는 다르게 JS의 객체는 {}을 만드는 것만으로도 생성된다.

this가 누구를 가르키는 지의 문제는 Javascript 면접의 단골 질문으로 알려져 있을 정도로 잘 알려진 질문이다. 그런데 이 질문에 대해 대답한 자료들은 그 대답에 따라 발생하는 새로운 질문에 대답해 주지는 않았다. 그래서 우선 대다수의 자료에서 설명하는 this가 누구를 가르키는 지에 대한 대답을 보고서 이어 발생하는 질문을 다루어 보겠다. 다음은 this가 누구를 가르키는 지에 대한 가장 간단한 답이다.

The object that is executing the current function.


즉, this는 자신을 실행한 객체를 가르키게 된다.
  • this example
1
2
3
4
5
6
7
8
9
10
11
function foo() {
console.log(this.a); // this points for what?
}

var bar = {
a: 10,
foo: foo,
};

foo(); //undefined
bar.foo(); //10

위의 예제를 보면, foo 함수가 그냥 실행되었을 때와 bar 객체의 함수로 실행되었을 때 결과 값이 다른 것을 보여준다. 그냥 foo함수를 실행한 경우에는 this값이 전역객체 window이다. 따라서 window 객체에 변수 a가 있는지 확인하고, window 객체에는 a가 없기 때문에 undefined를 출력한다. 반대로 foo 함수가 bar 객체의 함수로 실행된 경우 this 값이 bar 객체이기 때문에 bar 객체의 a 값인 10을 출력한다. 이제 문제가 간단하게 해결되었다. this가 함수 실행에 따라서 동적으로 결정된다는 것을 알게 되었다! 가 아니다. 여기서 질문이 발생한다. 변수가 동적으로 binding되는 언어는 dynamic scope를 가진다고 말한다. 그러면 js는 앞서 this가 동적으로 결정된 것을 봤기 때문에 dynamic scope를 가진 언어겠구나라는 생각을 하게 된다. 그런데 ecmascript spec(es6이후의 스펙들)을 보면 lexical binding에 대한 내용만 기술되어 있고 dynamic binding에 대한 내용은 없다. JS를 설명한 모든 자료들은 JS가 lexical scope라고 말한다. 그러면 두 개의 사실이 충돌한다. JS는 lexical scope이므로 함수 사용 시가 아니라 생성 시에 변수가 binding된다. 그런데 this는 함수 사용시에 binding된다. 그렇다면 JS는 기본적으로 lexical binding인데 this만 예외적으로 dynamic binding인가? 헷갈린다. 사실 이 문제에 대한 답은 꼭 scope를 이해하지 않아도 된다. 하지만 이후에 다룰 function, arrow function과 함께 this를 이해하기 위해서는 scope와 closure를 먼저 이해하는 게 도움이 된다.

lexical scope vs dynamic scope

scope를 간단히 이해하자면 변수의 생명기한과 같다. scope는 변수가 언제 생겨서 언제까지 살아있을 지를 결정한다. 그래서 우리는 프로그래밍을 할 때 변수마다 언제 만들어서 언제까지만 사용할지 고민하고 용도에 맞게 scope를 설정하게 된다. 반복문 안에서만 사용할 변수를 굳이 반복문 밖에서 생성해서 반복문이 끝난 이후에도 살아있게 만들 필요가 없다. 따라서 반복문이 동작하는 동안만 사용할 변수는 반복문 내에 생성한다. 그리고 반복문이 끝나면 사라진다. 그런데 어떤 변수는 반복문이 진행됨에 따라서 값을 기억하거나 축적해야 할 수도 있다. 그런 변수들은 반복문 밖에 생성해서 반복문이 끝나도 값을 기억하고 계속 사용할 수 있도록 한다. 여기서 생각해볼 점은 ‘어떻게 반복문 내에서 반복문 밖에 있는 변수를 사용하는가’이다. ‘반복문 내에서 변수가 선언된 경우, 반복문이 끝나면 변수가 사라진다’는 말은 반복문 내의 scope과 반복문 밖의 scope이 다르다는 걸 의미한다. 이 말은 반복문 안에서 반복문 밖에 있는 변수를 사용하기 위해서는 반복문 밖에 있는 변수를 가지고 와야한다는 뜻이다. 함수에서도 이런 상황이 동일하게 존재한다. 만약 함수 내에서 선언하지 않은 변수를 함수가 사용하고자 한다면 그 변수를 어디에서 찾아가지고 올 것인가?

함수 내에서 선언하지 않은 변수를 찾아오는 방식이 2가지가 있다. 하나가 lexical binding이고 다른 하나가 dynamic binding이다. lexical binding의 경우는 함수가 생성될 때 자신을 포함하고 있는 scope에서 변수를 찾는다. 그리고 JS는 자신을 포함하고 있는 lexical scope에 변수가 없는 경우 자신을 포함하고 있는 scope을 포함하는 scope을 또 추적한다. 이렇게 계속 추적하다가 최초의 scope(보통 전역 scope)에도 없는 경우에는 해당 변수가 없다는 오류를 출력한다. 이렇게 scope를 거꾸로 추적하는 것을 scope chain이라고 한다. 반대로 dynamic binding의 경우에는 함수 내에서 선언하지 않는 변수를 찾을 때에 call stack을 확인한다. call stack을 한층한층 내려가면서 이름이 같은 변수가 있는지 찾고 그 변수를 찾은 경우 해당 변수를 binding한다.

  • JavaScript with Dynamic Scope
1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log(x);
}

function bar() {
var x = 15;
foo();
}

var x = 10;
foo(); // 10
bar(); // 15
// 출처: https://bestalign.github.io/2015/07/12/Lexical-Scope-and-Dynamic-Scope/
  • JavaScript with Lexical Scope
1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log(x);
}

function bar() {
var x = 15;
foo();
}

var x = 10;
foo(); // 10
bar(); // 10
// 출처: https://bestalign.github.io/2015/07/12/Lexical-Scope-and-Dynamic-Scope/

위의 예제는 dynamic scope, lexical scope일 때 값이 어떻게 출력되는지 보여준다.(물론 JS는 lexical scope이기 때문에 2개 중 아래의 예제 대로만 동작한다.) dynamic scope인 경우를 보자. foo 실행시에 자신을 실행한 곳은 전역 환경이다. 따라서 x의 값으로 10을 binding하고 10을 출력한다. bar 실행시에는 bar에서 foo 함수를 실행하고, bar에서 실행된 foo는 x를 찾기 위해 call stack을 확인하고 bar를 찾아낸다. 그런 뒤 bar에 있는 x값 15를 binding하고 15를 출력한다. 그러나 lexical scope는 dynamic scope과 다르다. 자기에게 변수가 없는 경우 자신을 생성한 lexical scope를 확인한다. foo 함수는 bar함수에서 실행되었지만 상관없다. 생성시의 환경은 여전히 전역 환경이다. 따라서 bar 함수 실행시에도 foo 함수는 그대로 10을 binding하고 출력한다.

lexical scope와 closure

C, Java를 비롯한 대다수의 프로그래밍 언어들은 lexical scope를 채택하여 사용한다. dynamic scope을 사용할 경우 변수 binding이 실행에 따라서 결정되기 때문에 변수 관리와 디버깅이 어렵기 때문이다. 그런데 JS는 lexical scope이면서 함수 생성이 런타임 때 이루어 지기 때문에 closure라는 독특한 환경이 생긴다. C나 Java 같은 정적인 언어들은 컴파일 때 함수의 생성과 메모리 할당이 완료되는 반면 JS의 경우에는 함수의 생성과 메모리 할당이 런타임때 이루어진다. 앞서 이야기 했던 것과 같이 lexical scope은 함수 생성 때의 scope의 변수들이 binding되는데, 함수의 생성이 컴파일 때 완료되는 경우 함수가 볼 수 있는 환경이 전역 변수 밖에 없다.(C나 Java는 함수 내 함수 선언 자체가 불가능하기도 하다.) 그래서 JS는 똑같이 lexical scope이지만 함수 내에서 함수(nested function)를 만든 경우에는 다른 언어들과는 다른 환경이 생긴다. JS는 함수 내 함수(inner function)의 생성이 런타임 때 이루어 지기 때문에 자신을 생성한 함수의 변수들이 자신의 환경이 된다. 이렇게 함수 자신이 선언하지 않았지만 환경으로서 사용할 수 있는 변수들을 자유 변수라고 부른다. JS의 경우 이런 자유 변수들을 읽기만 할 수 있는 게 아니라 변경도 가능하다.(읽기만 가능한 언어들도 있다.)

  • function without exposing closure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const mainFunc = () => {
let i = 100;
const externalFunc = () => {
console.log("It's external the i is " + i);
return i + 1;
};
i = externalFunc();
console.log("It's main the i is " + i);
};

mainFunc(); // It's external the i is 100
// It's main the i is 101

// 출처: 분명히 출처가 있는데, 어떤 자료에서 보여준 예제인지 찾지 못했습니다. 혹시 출처를 아신다면 답글로 알려주세요....

위의 예제를 보면 externalFunc 내부에는 변수 i가 없기 때문에 자신을 포함한 scope인 mainFunc를 확인한다. mainFunc에는 i값이 있기 때문에 해당 i값이 binding된다. 변수 i가 externalFunc의 자유변수가 된 것이다. 이제 externalFunc는 자유변수 i까지 포함하는 환경을 가지게 되었다. 위의 예제의 경우에는 externalFunc는 mainFunc 내부에서 실행되고 종료된다. 그래서 i가 externalFunc에서 사용되었지만 mainFunc함수의 종료와 함께 변수 i도 할당이 해제될 것이다. 그러나 경우에 따라서는 내부에서 선언된 externalFunc의 사용에 따라서 i값 할당 해제가 불가능할 수도 있다.
  • function with exposing closure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const mainFunc = () => {
let i = 100;
const externalFunc = () => {
console.log("It's external the i is " + i);
return (i = i + 1);
};
return externalFunc;
};

let foo = mainFunc();

foo(); // It's external the i is 100
foo(); // It's external the i is 101
foo(); // It's external the i is 102

// 출처: 분명히 출처가 있는데, 어떤 자료에서 보여준 예제인지 찾지 못했습니다. 혹시 출처를 아신다면 답글로 알려주세요....

만약 위의 경우에서 처럼 mainFunc가 externalFunc를 return 하는 경우 외부에서 externalFunc를 사용할 수 있게 된다. 근데 문제는 mainFunc는 externalFunc를 반환하면서 실행이 종료되지만 externalFunc안에서 사용한 mainFunc의 변수 i는 할당 해제가 불가능하다. externalFunc가 변수 i값을 사용하고 있기 때문이다. 이처럼 내부의 함수가 자유변수를 사용하고 외부에 노출된 경우 자신의 변수가 아님에도 불구하고 함수 내부에 해당 자유변수들이 갇힌 공간이 만들어진다. lexical scope이면서도 런타임 때 함수가 생성되는 언어의 경우 함수 내부의 함수가 자유변수를 사용함으로써 자유변수를 가두는 공간이 만들어진다. 이렇게 만들어진 공간은 closure라고 하는데 자유변수들의 closure를 말하는 것이다.(자료에 따라서 closure에 대한 다른 정의를 하기도 한다.)

정리
  • JS에서 this는 현재 함수를 실행한 객체를 가리킨다.

  • 그런데 JS는 함수 실행시가 아닌 생성시에 변수가 binding되는 lexical scope이다.

  • JS는 함수 생성이 런타임 때 일어나기 때문에 함수 내의 함수(nested function)의 내부 함수(inner function)의 경우 자신을 생성한 함수의 변수들이 환경으로 제공된다.

  • 위의 경우처럼 자신이 선언하지 않았음에서 lexical scope에서 사용할 수 있는 변수들을 자유 변수라고 하고 자유 변수들을 포함하는 갇힌 환경을 closure라고 한다.


이제 다음 글에서는 지금까지 다룬 내용을 바탕으로 function과 arrow function의 실행을 살펴보고, JS가 lexical scope이면서도 불구하고 this가 어떤 이유로 실행시의 객체를 가리키는지를 밝히고 this에 대한 설명을 마무리하겠다.

참고자료

댓글