JS 탐구생활 - 클로저 연대기 1. 클로저의 개념과 응용
- javascript
이 글은 작성 중입니다.
클로저 탐구 시리즈
시리즈 소개
클로저는 렉시컬 환경에 대한 참조와 함께 묶인 함수의 조합이다.
MDN Web Docs, Closures
JavaScript를 공부하다 보면 클로저라는 말을 한번쯤 듣게 된다. 굉장히 중요하다는 말이 따라올 때도 많다. 그런데 시간이 지나고 클로저에 대해 들은 횟수가 늘어가면서 두 가지 질문이 생겼다.
- 클로저는 무슨 의미이고 무엇을 할 수 있는 걸까?
- 클로저는 대체 어디서 나왔고 어떻게 JavaScript까지 들어가서 이렇게 유명해졌을까?
이 두 가지 질문에 대해 할 수 있는 한 많은 것을 찾아보고 정리하여 두 개의 글을 쓴다. 클로저가 무엇이고 뭘 할 수 있는지에 관해 하나, 클로저의 역사에 관해 하나다. 실용적인 내용은 첫번째 글에 더 많겠지만 개인적으로는 두번째 글에 훨씬 더 많은 시간과 관심을 쏟았다.
- 글에서 사용되는 코드는 특별한 언급이 없는 한 모두 JavaScript로 작성되었다. 단 개념의 설명을 위해 실제 JavaScript 문법과는 다르게 의도된 코드가 있을 수 있는데 이 경우 별도의 설명으로 표시하였다.
시작
클로저에 대한 수많은 설명과 정의가 있다. 서문에도 MDN 문서의 정의를 써 놓았으며 많은 JavaScript 책에서도 각자의 정의를 내놓는다. 하지만 나는 내가 배우고 이해한 바를 토대로 JavaScript의 클로저를 이렇게 설명하고자 한다. MDN의 정의를 조금 일반화한 것이다. 또한 역사적으로 클로저라는 용어를 처음 쓴 피터 랜딘 또한 비슷한 정의를 사용했다1.
"클로저는 일급 객체 함수와 렉시컬 스코프를 사용하는 언어에서 표현식의 평가 결과로서 표현식, 그리고 표현식이 평가된 렉시컬 환경에 대한 참조의 묶음이다"
이때 JavaScript가 바로 일급 객체 함수와 렉시컬 스코프를 사용하는 대표적인 언어 중 하나이다. 따라서 JavaScript에서도 표현식의 평가 결과가 외부 렉시컬 환경에 대한 참조와 함께 묶이며, 이건 ECMA-262 명세 서술에 의해서도 뒷받침된다. 그리고 이렇게 외부 렉시컬 환경에 대한 참조를 유지하며 거기 접근할 수 있는 객체 혹은 함수가 있다는 것이 클로저의 다양한 활용을 만들었다.
이 글에서는 앞서 설명한 내용들을 다룬다. 정리해 보면 다음과 같다.
- 클로저의 개념
- 앞서 설명한 클로저의 정의를 더 자세히 다루고 필요성을 정당화한다.
- JavaScript 명세상의 클로저
- ECMA-262 명세의 개념들을 토대로 클로저를 설명한다.
- 클로저의 활용
- JavaScript 코드에서 클로저를 어떻게 활용할 수 있는지를 알아본다.
클로저의 개념
앞서 나온 정의를 토대로 여러 질문을 해볼 수 있다. 다음과 같은 것들이다.
- 어째서 표현식의 평가 결과가 표현식과 렉시컬 환경 참조의 묶음, 즉 클로저가 되어야 하는 것인가?
- 외부 렉시컬 환경에 대한 정보를 어딘가에 따로 남겨 놓는 것이 아니라 왜 표현식의 평가 결과와 함께 묶을까? 혹은 그냥 표현식의 값을 확정해 버릴 수는 없을까?
- 그럼 클로저의 정보는 어디에 저장될까?
이 문단에서는 이러한 질문들에 답하고자 한다.
표현식의 평가
앞서 클로저를 이렇게 설명했다.
"클로저는 일급 객체 함수와 렉시컬 스코프를 사용하는 언어에서 표현식의 평가 결과로서 표현식, 그리고 표현식이 평가된 렉시컬 환경에 대한 참조의 묶음이다"
클로저는 어떤 표현식의 평가 결과로 만들어지는 대상이라는 것이다. 이 문장의 설명을 위해서는 먼저 표현식의 평가에 대해 생각해 봐야 한다. 잠시 클로저의 정의를 잊고, 표현식을 어떻게 평가할지 생각해 보자.
표현식은 값으로 평가될 수 있는 코드 조각이다. 표현식의 평가란 이 표현식이 만들어내는 값을 계산하는 것이다. 예를 들어 이런 표현식은 15
로 평가된다.
그런데 정말 이걸로 충분할까? 일반적으로 프로그래밍을 하면서 사용하는 표현식들은 이보다 훨씬 복잡하다. 많은 함수와 다른 표현식들을 사용한다. 앞서 본 10 + 2 + 3
처럼 그 자체로 값이 나오는 표현식은 오히려 드물다.
예를 들어 이 표현식을 보자.
이는 b
에 a + 1
의 값을 할당하고 그 자체로 b
의 새로운 값으로 평가된다. 그런데 이는 앞의 15
처럼 고정된 값이 아니다. 기존에 a
가 어떻게 정의되었는지에 따라 달라진다. 만약 a
가 1
이었다면 표현식의 평가 결과는 2
가 될 것이고 a
가 5
였다면 표현식의 평가 결과는 6
이 될 것이다.
따라서 위의 표현식이 평가된 결과는 단순히 b = a + 1
이 그 자체로 평가된 것이 아니다. 해당 표현식의 외부 환경에 대한 정보와 묶여서 함께 평가된 것이다. 더 일반적으로 생각해도 마찬가지다. 표현식의 평가에 있어 표현식 그 자체만으로는 부족하며 외부 환경에 대한 정보도 고려해야 한다는 건 당연하다.
일급 객체 함수, 렉시컬 스코프 그리고 클로저
클로저의 필요성에 대한 의문
그런데 클로저가 이렇게 외부 환경과 함께 묶여서 평가된 표현식의 결과일 뿐이라면, 굳이 클로저를 사용할 필요가 있을까? 앞서 설명한 내용은 사실 클로저 같은 게 없는 C언어에서도 마찬가지인 내용이다.
그런데 시작하며 내렸던 클로저의 정의에서 아직 설명하지 않은 부분이 있다. 바로 "일급 객체 함수와 렉시컬 스코프를 사용하는 언어"라는 말이다. 이게 바로 클로저가 존재하는 이유이다. 즉 JavaScript가 렉시컬 스코프를 사용하며 C와 달리 일급 객체 함수를 지원하기 때문에 클로저가 필요한 것이다.
일급 객체 함수와 렉시컬 스코프가 만드는 문제
먼저 일급 객체 함수와 렉시컬 스코프란 무엇인가? 물론 프로그래밍을 해본 사람이라면 한번쯤 귀를 스쳐지나간 말이겠지만 개인적으로는 이것부터 확실히 하는 게 클로저를 이해하는 데에 도움이 되었다.
먼저 일급 객체 함수를 지원한다는 건 함수를 다른 데이터 타입 값들처럼 자유롭게 다룰 수 있는 것을 의미한다. 일급 객체 함수를 지원하는 언어에서는 함수를 변수에 할당하거나 자료구조에 저장하고 다른 함수의 인수로 전달하거나 다른 함수의 반환값으로 사용할 수 있다.
렉시컬 스코프는 코드(특히 함수)가 정의된 위치에 따라 스코프가 정적으로 정해지는 것을 뜻한다. 즉 앞서 언급한 "표현식을 평가할 때 고려되는 외부 환경"으로 코드가 정의된 위치를 사용한다는 것이다. 다음 코드를 보자. f
는 어디에서 호출되거나 상관없이 자신이 정의된 위치, 전역 스코프의 a
값을 사용한다.
그럼 이렇게 일급 객체 함수와 렉시컬 스코프를 사용하는 언어에서는 일반적인 표현식의 평가를 어떻게 해야 할까? 위에서 했던 것처럼 외부 환경을 쓰면 되겠다고 적당히 생각해 볼 수 있겠다. 하지만 2가지 문제가 있다.
(스택 기반으로 메모리를 관리하는 언어에서) 실제 구현에서는 외부 환경에 접근하기 위해서 해당 환경을 갖는 함수의 스택 프레임에 접근해야 하기 때문이다. 이때 스택 프레임이란 매개변수, 지역 변수 등 함수의 실행에 필요한 여러 정보들이 저장되는 영역이며 함수의 호출이 완료되면 메모리에서 사라진다.
렉시컬 스코프에 대해 설명했던 코드를 보자.
여기서 g
를 호출하면 g
가 호출되고 g
내부에서 f
가 호출된다. 그리고 f
는 자신이 정의된 전역 스코프의 a
식별자 값을 가져와서 콘솔에 출력하게 된다. 그런데 함수 호출에 따라 스택 프레임이 쌓이는 걸 생각해보자. 콜스택에는 전역 프레임이 쌓이고 그 위에 g
함수의 스택 프레임, 그 위에 f
의 스택 프레임이 쌓였을 텐데 f
는 어떻게 자신이 정의된 위치인 전역 스코프 환경에 접근한 걸까?
이 문제는 콜스택에서 상위 스택 프레임의 포인터를 가지고 있어서 접근이 가능하다고 하면 해결된다.
하지만 함수가 다른 함수의 인수로 전달될 때는? 혹은 재귀 함수일 때는? 만약 인수로 전달된 함수를 사용하는 함수와 인수로 전달된 함수가 갖는 스택 프레임간의 거리가 너무 길다면 이런 식의 접근은 비효율적이다. 더 나쁜 경우로는 함수가 다른 함수의 반환값으로 사용될 때는 아예 외부 함수의 환경을 갖고 있는 스택 프레임이 사라져 버린다.
하지만 JavaScript는 일급 객체 함수와 렉시컬 스코프를 지원하므로 위와 같은 코드는 당연히 허용되어야 하고 표현식 평가 시 식별자의 값을 찾기 위한 외부 환경에 대한 접근도 늘 가능해야 한다. 즉 앞서 언급한 2가지 문제란 바로 다음과 같은 경우이다.
이 문제를 해결하고 일급 객체 함수와 렉시컬 스코프를 사용하는 언어에서도 표현식의 평가를 정상적으로 가능하게 해주는 게 바로 클로저이다.
클로저의 등장
클로저를 사용하면 표현식의 평가 결과로 표현식과 렉시컬 환경에 대한 참조의 묶음을 저장한다. 가령 위에서 보았던 함수를 다른 함수 반환값으로 사용하는 코드를 보자.
inner
함수 선언 또한 표현식이다. 클로저를 사용하면 이 inner
선언을 평가할 때 inner
와 inner
가 선언된 환경인 outer
의 환경에 대한 참조를 함께 저장한다. 이건 힙에 저장되기 때문에 inner
함수가 반환되고 outer
함수 호출이 종료되어도 inner
를 호출할 시 inner
의 내부에서는 outer
의 환경에 접근할 수 있다.
여기서 만약 inner
함수가 단독으로 선언되었다면 어땠을까? 이런 코드를 말한다.
식별자 a
가 정의되어 있지 않으므로 호출시 에러가 발생할 것이다. 외부에 의존하는 값이 있어서 inner
호출의 평가 결과는 단독으로 결정될 수 없기 때문이다. 클로저는 이렇게 외부에 의존하는 값을 갖는 표현식의 평가 결과를 외부 환경과 함께 묶어서 힙에 저장함으로써 해당 표현식이 사용될 때 외부 환경에 대한 정보를 얻을 수 있게 한다.
이게 바로 클로저가 하는 일이다. 표현식의 평가 결과로 표현식과 표현식이 평가된 렉시컬 환경에 대한 참조의 묶음을 저장하고 스택 프레임이 없어져도 외부 스코프를 사라지지 않게 하며, 해당 참조를 힙에 저장하여 표현식에 묶인 외부 환경의 관리를 스택 프레임이 아니라 가비지 컬렉터에 맡기는 것이다.
참고로 이렇게 외부에 의존하는 값을 "free"하다고 하며 이렇게 free한 값이 있는 표현식을 "open", free한 값이 없는 표현식을 "closed"라고 한다. 이때 표현식의 평가 결과가 open 상태라면 이를 closed 상태로 만들어 준다는 뜻으로 클로저(closure, 폐쇄)라는 용어를 사용하는 것이다.
클로저와 메모리 관리
앞서 클로저는 힙에 저장된다고 했다. 그런데 메모리 상에서는 클로저를 어떻게 관리해야 할까?
클로저로 인한 환경들 간의 연결은 트리와 같은 구조를 하고 있을 것이다. 같은 외부 환경을 공유하는 표현식이 여러 개 있을 수 있기 때문이다. 표현식 간에 같은 외부 환경을 공유하는 가장 단순한 예시로 내부에 중첩 함수가 2개 선언된 함수를 생각해 볼 수 있을 것이다. 물론 반대로 여러 외부 환경에 대한 참조를 갖고 있는 표현식도 있을 수 있다.
아무튼 환경들 간에 연결 예시로 다음과 같은 트리 구조를 생각해 볼 수 있다. 트리의 노드는 각 환경이며, A 환경의 표현식이 다른 외부의 B 환경의 식별자를 사용한다면 A 환경은 B 환경으로 가는 간선(참조)을 갖는다2.
이때 이 트리를 이루는 각 환경들의 정보를 언제 가비지 컬렉팅할지는 간단하다. 해당 환경의 식별자를 참조하는 표현식이 없다면 해당 환경은 가비지 컬렉팅 대상이 된다. 구현상의 편의를 위해 참조하는 외부 환경으로 향하는 "위쪽 방향" 포인터뿐 아니라 외부 환경이 자신의 식별자를 사용하는 환경으로 향하는 "아래쪽 방향" 포인터도 가질 수 있다.
또한 JavaScript는 참조 카운팅 방식이 아닌 도달 가능성을 기반으로 가비지 컬렉팅을 수행하지만 결국 해당 환경의 식별자를 사용하는 표현식이 있다면 해당 환경은 가비지 컬렉팅 대상이 아니게 된다. 클로저는 표현식의 평가 결과로 외부 환경에 대한 참조를 가지며 표현식이 남아 있는 한 해당 외부 환경도 가비지 컬렉터 입장에서 "도달 가능"하기 때문이다.
단 이는 의도치 않게 메모리 누수를 일으키는 원인이 될 수 있다. 더 이상 사용하지 않는 표현식, 특히 외부 환경의 식별자를 사용하는 표현식을 메모리에 남겨 두게 되면 해당 환경이 사용하는 외부 환경 또한 메모리에 남게 되기 때문이다. 다만 이렇게 메모리를 사용하는 것 자체는 클로저의 특성이며, 클로저를 사용하는 개발자가 이를 인지하고 적절히 관리해야 한다.
앞선 질문들
즉 앞서서 했던 질문들에는 다음과 같이 답을 할 수 있을 것이다.
- 어째서 표현식의 평가 결과가 표현식과 렉시컬 환경 참조의 묶음, 즉 클로저가 되어야 하는 것인가?
표현식의 평가를 위해서는 외부 렉시컬 환경에 대한 정보가 필요하기 때문이다.
- 그럼 외부 렉시컬 환경에 대한 정보를 어딘가에 따로 남겨 놓는 것이 아니라 왜 표현식의 평가 결과와 함께 묶을까? 혹은 그냥 표현식의 값을 확정해 버릴 수는 없을까?
렉시컬 스코프를 이용하는 언어에서 표현식의 평가는 식별자가 "정의된 위치"(사용된 위치가 아니다)에 따라 결정된다. 또한 언어에서 함수를 일급 객체로 사용할 경우 외부 함수의 렉시컬 환경이 콜스택에서 사라져 버릴 수 있다. 따라서 표현식이 정의된 외부 렉시컬 환경에 대한 정보를 따로 저장해 놓아야 한다.
- 그렇다면 클로저의 정보는 어디에 저장될까?
힙에 저장되며 해당 클로저를 외부 렉시컬 환경으로 하는 표현식이 존재하지 않으면 가비지 컬렉팅 대상이 된다.
JavaScript 명세상의 클로저
이런 개념은 JavaScript 명세인 ECMA-262에도 어느 정도 나타나 있다. 스코프 체인을 이용해서 어떻게 외부 환경에 대한 접근을 하는지 그리고 그 결과로 무엇을 반환하는지를 보면 된다.
물론 이제부터 설명할 실행 컨텍스트나 환경 레코드 등의 객체들은 실제 코드에서 접근할 수는 없기에 실제 클로저의 활용과 연관은 없다. 하지만 모든 JavaScript 구현체가 지켜야 하는 명세상의 정보에도 앞서 본 것과 같은 클로저가 존재한다는 것을 알 수 있다.
이번에도 꼬리무는 질문을 던져본다. 이번에는 어차피 글에 쓸 개념들이 답에 포함되어 있기 때문에 짧은 답과 함께다.
- 왜 JavaScript에서 표현식의 평가 결과는 클로저, 그러니까 표현식과 렉시컬 환경에 대한 참조의 묶음일까?
ECMA-262 명세에서는 표현식 내의 식별자 값을 평가할 때 ResolveBinding
을 사용한다. 그 리턴값은 해당 식별자의 이름과 식별자가 속한 환경 레코드의 참조를 가지는데 이때 환경 레코드는 렉시컬 환경이기 때문이다.
- 그럼 식별자의 평가 결과는 어떻게 만들어지는가?
환경 레코드와 식별자를 인수로 받는 GetIdentifierReference
를 이용해 만들어진다. 이는 주어진 환경 레코드에서 스코프 체인을 따라 올라가면서 식별자를 찾고, 식별자를 찾으면 해당 환경과 식별자를 묶어 리턴한다.
- 그때 쓰이는 스코프 체인의 외부 렉시컬 환경 참조는 어디에 저장되는가?
함수 선언 등 새로운 스코프를 만드는 표현식이 평가될 때는 환경 레코드가 새로 만들어진다. 여기의 [[OuterEnv]]
필드에 해당 스코프의 외부 렉시컬 환경 참조가 저장된다.
- 그럼 이것은 어떻게 사용되는가?
식별자 평가 시, 실행 중인 실행 컨텍스트의 LexicalEnvironment에서 시작해서 [[OuterEnv]]
를 따라 올라가며 식별자를 탐색한다.
정리하자면, ECMA-262 명세 상에서도 표현식의 평가 결과는 식별자의 평가 결과에 포함된 렉시컬 환경 참조를 포함한다. 외부 렉시컬 환경에 대한 참조도 [[OuterEnv]]
로 유지된다. 그리고 이후 함수 호출 등으로 해당 표현식이 다시 실행 중인 실행 컨텍스트에 들어갔을 때 표현식의 평가에 사용된다. 용어가 조금 추가되었을 뿐 앞서 설명한 클로저의 개념과 비슷하다. 즉 클로저는 ECMA-262 명세 상에서도 표현식의 평가 결과라고 할 수 있다.
그럼 다시 처음으로 돌아가서 실행 컨텍스트에 관한 내용부터 다시 되짚어 올라와보자.
글의 주제를 너무 벗어나지 않기 위해 명세에 서술된 모든 부분을 설명하지는 않았다. 생략된 부분에 대해서는 주석의 명세 링크들을 참고할 수 있다.
실행 컨텍스트
실행 컨텍스트는 JavaScript에서 중요하게 다뤄지는 것 중 하나인 만큼 이에 대해서만 써도 많은 내용이 있다. 하지만 여기서는 호이스팅, this
등을 생략하고 오로지 글의 주제인 클로저에 관련된 부분만 다룬다.
실행 컨텍스트는 Javascript 엔진이 코드의 런타임 평가를 추적하기 위해 쓰이는 객체이다. 실행할 코드에 제공할 환경 정보를 가지고 있다. 이때 에이전트(브라우저 탭과 같은 걸 생각할 수 있다)당 코드를 실행하고 있는 실행 컨텍스트는 하나만 존재할 수 있다. 이렇게 실행되고 있는 실행 컨텍스트를 "실행 중인 실행 컨텍스트(running execution context)"라고 한다.
새로운 실행 컨텍스트가 생성되는 경우로는 전역 공간 생성, 함수 호출, eval
, 블록 구문이 있다. 다른 부분의 코드로 제어가 넘어갈 때 생성된다고 생각할 수 있다. 이렇게 새로운 실행 컨텍스트가 생성되면 스택의 최상위에 올라가서 실행 중인 실행 컨텍스트가 된다.
명세상 실행 컨텍스트가 스택 프레임과 완전히 똑같은 건 아니지만3 글의 주제에 관한 부분에 대해서는 함수 호출시 함수에 필요한 정보를 가지고 스택에 쌓이는 스택 프레임과 비슷하다고 볼 수 있다. 즉 실행 중인 실행 컨텍스트란 사실상 콜스택의 최상위에 있는 실행 컨텍스트이다.
그리고 실행 컨텍스트가 가지고 있는 정보들 중 클로저에 관련된 것은 다음과 같다4.
실행 컨텍스트가 갖는 다른 정보들은 Execution Context의 명세에서 볼 수 있다.
이중 우리가 중요하게 봐야 할 것은 LexicalEnvironment이다. 클로저는 결국 외부 렉시컬 환경에 대한 참조를 표현식과 묶어서 평가한다는 것일 뿐이고, 이때 이 외부 렉시컬 환경에 대한 참조를 담당하는 것이 LexicalEnvironment이기 때문이다.
- LexicalEnvironment: 실행 컨텍스트 내에서 실행되는 코드의 식별자 참조를 분석할 때 사용하는 환경 레코드
실행 컨텍스트는 코드의 런타임 평가를 추적하기 위한 객체이며 환경 레코드라는 걸 가지고 있다. 그럼 환경 레코드가 뭔지 알아볼 때다.
환경 레코드
환경 레코드(Environment Record)는 ECMAScript 코드의 렉시컬 스코프 구조에 따라 식별자와 특정 변수 및 함수를 연결하는 데 사용되는 명세 타입이다. 일반적으로 환경 레코드는 ECMAScript 코드의 특정 구문 구조, 예를 들어 함수 선언, 블록 구문, try 문의 catch 절과 연관된다. 이와 같은 코드가 평가될 때마다 해당 코드에 의해 생성된 식별자 바인딩을 기록하기 위해 새로운 환경 레코드가 생성된다.
ECMA-262, 9.1 Environment Records
그리고 모든 환경 레코드는 [[OuterEnv]]
필드를 가지고 있고 이는 null
이거나 외부 환경 레코드에 대한 참조이다. [[OuterEnv]]
가 null
인 환경 레코드는 전역 환경 레코드이다.
위의 두 정보를 조합해 보자. 먼저 환경 레코드는 JavaScript에서 렉시컬 스코프 구조에 따라 식별자와 값을 연결하는 데 사용되며 스코프를 생성하는 구문을 평가할 때 생성된다. 그리고 코드가 사용되는 곳이 아니라 코드가 정의된 환경을 따라간다. 당연히 환경 레코드의 [[OuterEnv]]
도 함수가 정의된 렉시컬 환경을 따라간다.
지금까지 다룬 내용을 함수에 대해서만 보면 실행 컨텍스트는 함수가 호출될 때 생성되어 콜스택의 최상위에 쌓인다. 함수가 호출되면서 생성된 실행 컨텍스트가 가지고 있는 환경 레코드 정보는 해당 함수 선언이 처음 평가될 때의 렉시컬 환경에 기반한다.
환경 레코드와 함수
그런데 의문이 든다. 환경 레코드는 분명 함수를 호출할 때 생성된다고 했다. 하지만 이때 생성된 환경 레코드는 [[OuterEnv]]
로 함수가 정의된 렉시컬 환경을 가지고 있어야 한다. 어떻게 이게 가능할까?
이는 함수 객체가 자신이 정의된 환경의 레코드에 대한 참조를 내부 슬롯 [[Environment]]
에 가지고 있기 때문이다. 함수 호출 시 생성되는 환경 레코드가 [[OuterEnv]]
를 갖는 과정은 다음과 같다.
JavaScript 엔진은 함수 선언문을 평가하여 함수 객체를 생성할 때 현재 실행 중인 실행 컨텍스트의 렉시컬 환경을 내부 슬롯 [[Environment]]
에 저장한다5.
이후 함수가 호출될 때는 해당 함수 객체(F
라고 하자)를 이용해 새로운 실행 컨텍스트와 환경 레코드를 생성한다. 이때 생성된 환경 레코드의 [[OuterEnv]]
는 F
의 [[Environment]]
를 참조한다6.
즉 함수 객체는 생성될 때 자신이 생성된 환경을 기억하며, 함수 호출 시 이를 이용해 새로운 환경 레코드를 생성한다. 이렇게 함수 객체는 자신이 생성될 때의 렉시컬 환경을 참조할 수 있게 된다. 또한 해당 렉시컬 환경을 생성한 실행 컨텍스트가 스택에서 사라져도 함수 객체가 여전히 그 렉시컬 환경에 대한 참조를 가지고 있기 때문에 렉시컬 환경은 가비지 컬렉팅 대상이 되지 않는다.
참고로 클로저의 개념을 설명할 때 같은 외부 환경을 공유하는 표현식이 여러 개 있을 수 있다고 했는데 이는 JavaScript 명세에서도 마찬가지다. 하나의 실행 컨텍스트 맥락에서 여러 함수가 정의되어 있다면 이들은 같은 외부 환경을 공유한다.
표현식의 평가 결과
어떻게 환경 레코드 간의 연결을 하는지 알아보았다. 그럼 명세상에서 표현식의 결과를 어떤 형식으로 설명하며 어떻게 표현식을 평가해서 해당 결과를 얻는지 알아봐야 한다. 이게 클로저의 핵심이기 때문이다. 먼저 표현식의 평가 결과가 어떻게 표현되는지 알아보자.
먼저 표현식의 평가 결과는 Completion Record라는 객체로 나타낸다7. 이 객체는 표현식의 평가에 쓰였을 경우 평가의 완료 상태(정상, 예외, 중단 등)인 [[Type]]
필드와 평가된 표현식의 결과를 나타내는 [[Value]]
필드를 가진다8.
이중 식별자의 평가 결과는 Reference Record를 [[Value]]
필드로 포함하는 Completion Record로 나타난다9. Reference Record는 분석된 식별자나 프로퍼티 바인딩을 나타내는 데 사용되며 다음과 같은 필드를 가진다10.
[[Base]]
: 식별자 바인딩을 가지고 있는 값 혹은 환경 레코드[[ReferencedName]]
: 분석된 식별자 이름
즉 ECMA-262 명세에서도 식별자의 평가 결과는 단순히 값으로 나오는 게 아니라 식별자 이름 그리고 식별자에 대한 바인딩을 가지고 있는 환경의 묶음으로 나타난다.
구체적인 값을 찾기 위해서는 Reference Record의 GetValue
를 사용한다. 이는 [[Base]]
환경 레코드에서 해당 식별자 바인딩을 찾는 방식으로 실제 값을 찾아낸다11.
표현식의 평가
그럼 이런 대상들을 이용하여, 표현식 그 중에서도 식별자의 평가는 구체적으로 어떻게 이루어질까?
ECMA-262 명세에서 식별자는 ResolveBinding
을 사용해 평가된다. 이때 인수로는 식별자의 문자열 값(변수명, 함수명 등)이 들어간다12. 명세의 문장은 다음과 같다.
ResolveBinding(StringValue of Identifier)
이렇게 식별자의 문자열 값이 전달된 ResolveBinding
은 다음과 같은 과정을 거쳐 식별자의 바인딩을 찾아낸다1314. 값이 정상적으로 평가되었을 경우 Reference Record를 포함하는 Completion Record를 반환한다.
- 현재 실행 중인 실행 컨텍스트의 LexicalEnvironment를
env
로 한다. env
와 식별자의 이름인name
을 인수로GetIdentifierReference
를 호출한다.- 만약
env
환경 레코드에name
의 바인딩이 있을 경우 다음과 같은 형태의 Reference Record를 반환한다.
env
에name
바인딩이 없을 경우env.[[OuterEnv]]
를env
로 하고 2단계로 돌아가서 다시 시도한다.
표현식을 평가하면서 사용하는 스코프 체인은 실행 중인 실행 컨텍스트의 LexicalEnvironment에서 시작한다. 그리고 스코프 체인은 해당 함수 혹은 스코프가 생성된 위치의 렉시컬 환경을 따라 거슬러 올라간다. 식별자 바인딩을 찾아내면 찾아낸 렉시컬 환경과 해당 식별자의 이름이 식별자의 평가 결과가 된다. 이는 표현식의 평가 결과의 일부이고 클로저를 이룬다.
JavaScript 코드
그럼 이걸 기반으로 앞서 보았던 코드를 다시 보자. 이 코드는 어떤 과정을 거쳐 평가될까?
먼저 전역 실행 컨텍스트가 만들어지고 실행된다. 이때 식별자 outer
가 등록되고 outer
함수 객체가 생성된다. 이 함수 객체는 자신이 생성된 렉시컬 환경을 [[Environment]]
에 저장한다.
outer
함수가 호출되면 새로운 실행 컨텍스트가 생성된다. 여기서 식별자 a
와 inner
가 등록되고 a
의 바인딩이 1
로 설정된다. 그리고 inner
함수 객체가 생성되고 이 함수 객체 또한 자신이 생성된 렉시컬 환경(outer
내부의 환경)을 [[Environment]]
에 저장한다. 그리고 inner
함수 객체가 반환된다.
outer
가 종료되면서 outer
의 실행 컨텍스트는 스택에서 사라진다. 하지만 outer
에서 리턴한 함수 객체가 여전히 해당 렉시컬 환경을 참조하고 있기 때문에 outer
의 렉시컬 환경은 가비지 컬렉팅 대상이 되지 않는다.
그다음 outer
에서 리턴한 inner
함수 객체가 inner
에 할당되고 inner
함수가 호출된다. 그럼 inner
함수의 실행 컨텍스트가 생성되고 inner
의 함수 객체가 기억하고 있던 렉시컬 환경을 이용해 a
의 바인딩을 찾아내어 1
을 반환한다.
클로저의 활용
앞서 클로저는 표현식의 평가 결과이며 표현식과 렉시컬 환경에 대한 참조의 묶음이라고 했다. 그리고 이것이 일급 객체 함수와 렉시컬 스코프를 사용하는 언어에서 필요한 개념이며 JavaScript의 ECMA-262 명세에서도 엿볼 수 있는 개념이라는 것을 보았다.
이 점은 클로저의 활용에도 영향을 미친다. 어떤 표현식의 결과로 나온 객체를 사용할 때 해당 객체가 선언된 렉시컬 환경에 대한 참조를 함께 사용할 수 있다는 뜻이기 때문이다. 즉 클로저를 사용한다는 것은 어떤 표현식을 사용할 때 해당 표현식이 평가된 렉시컬 환경의 정보를 사용한다는 뜻이다.
이를 이용하는 방향은 크게 두 가지로 바라볼 수 있다. 하나는 정보를 은닉하는 것이다. 표현식이 평가된 외부 렉시컬 환경을 만든 함수가 이미 종료되었을 경우, 해당 환경에 접근할 수 있는 건 그 표현식뿐이다. 이를 이용해서 외부로 노출되지 않아야 하는 정보를 숨기는 데 사용할 수 있다
또 하나는 내부적인 정보를 추적하는 것이다. 정보 은닉에서 한 발 더 나아가서, 내부적으로 추적해야 하는 정보를 클로저에 담아 두는 방식이다. 둘 다 사실 같은 뿌리지만 보는 관점이 약간 다르다.
이 두 가지 방식을 예시를 통해 알아보자.
정보 은닉
ES6 이전의 JavaScript에는 클래스도 없었고, 비공개 프로퍼티를 선언할 수 있는 방법이 없었다. 이를 클로저를 이용해 흉내낼 수 있었다. 예를 들어 다음의 makeCounter
는 카운터를 만드는 함수이다15.
이 함수에서 반환한 객체의 increment
, decrement
, value
가 갖는 환경 레코드는 [[OuterEnv]]
필드를 통해 makeCounter
가 실행될 당시 만들어진 LexicalEnvironment를 참조한다.
makeCounter
는 이미 종료되었으므로 해당 함수가 반환한 객체를 통해서만 privateCounter
에 접근할 수 있다. 또한 makeCounter
함수가 실행될 때마다 실행 컨텍스트가 새로 만들어지고 따라서 LexicalEnvironment도 새로 만들어지기 때문에 각각의 카운터 객체는 서로 다른 privateCounter
를 갖게 된다.
이렇게 클로저를 이용하면 외부에 노출되지 않아야 하는 정보를 숨길 수 있다. 이는 정보 은닉의 한 예시이며 모듈 디자인 패턴을 따른다고도 한다.
정보 추적
추적해야 할 정보를 클로저에 저장하는 것의 대표적인 예시로는 함수를 만드는 함수를 들 수 있다. 이는 비슷한 동작을 하는 다양한 이벤트를 만들어야 하는 웹 프로그래밍에서 활용될 수 있다. 예를 들어서 색상을 받아서 화면 배경을 해당 색상으로 바꾸는 이벤트를 만드는 함수를 만들어야 한다고 하자.
makeColorChanger
가 실행될 때 생성되는 LexicalEnvironment에 인수가 추가되고, 반환하는 함수에서는 해당 데이터에 접근할 수 있는 권한을 갖는다. 즉 이렇게 만든 함수는 클로저에 저장된 color
에 접근할 수 있다.
비슷한 예시로는 고차 함수를 들 수 있다.
클로저의 주의사항
참고
D. A. Turner, "Some History of Functional Programming Languages", 2012
https://www.cs.kent.ac.uk/people/staff/dat/tfp12/tfp12.pdf
Joel Moses, "The Function of FUNCTION in LISP, or Why the FUNARG Problem Should be Called the Environment Problem", 1970
https://dspace.mit.edu/handle/1721.1/5854
정재남, "코어 자바스크립트", 위키북스
https://product.kyobobook.co.kr/detail/S000001766397
이웅모, "모던 자바스크립트 Deep Dive", 위키북스, 23장 - 24장
MDN Web Docs, 클로저
https://developer.mozilla.org/ko/docs/Web/JavaScript/Closures
MDN Web Docs, 표현식과 연산자
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Expressions_and_operators
Wikipedia, First-class function
https://en.wikipedia.org/wiki/First-class_function
Wikipedia, Scope (computer science)
https://en.wikipedia.org/wiki/Scope_(computer_science)
Stack Frame과 Execution Context는 같은 개념일까?
https://onlydev.tistory.com/158
함수는 어디까지 접근 가능한가? - Closure와 this 이해하기
https://www.oooooroblog.com/posts/90-js-this-closure
ECMA 262 9.1 Environment Records 명세
https://tc39.es/ecma262/#sec-environment-records
ECMA 262 9.4 Execution Contexts 명세
https://tc39.es/ecma262/#sec-execution-contexts
Lexical Environment로 알아보는 Closure
https://coding-groot.tistory.com/189
TOAST UI, 자바스크립트의 스코프와 클로저
https://ui.toast.com/weekly-pick/ko_20160311
Footnotes
-
P. J. Landin, "The mechanical evaluation of expression", 1964 ↩
-
Joel Moses, "The Function of FUNCTION in LISP, or Why the FUNARG Problem Should be Called the Environment Problem", 1970, 11p Figure 4 참고 ↩
-
함수 호출에 따라서 쌓이는 스택 프레임과 달리 실행 컨텍스트는 ES6부터 블록에 의해서도 생성된다. 그리고 스택 프레임은 콜스택 관리를 위한 정보, 예를 들어 함수 호출이 끝난 뒤 돌아갈 리턴 주소값 등이 저장되지만 실행 컨텍스트는 오로지 코드의 실행을 위한 정보만 담기는 등의 차이가 있다. Stack Frame과 Execution Context는 같은 개념일까? 등의 자료를 참고할 수 있다. ↩
-
https://tc39.es/ecma262/#table-additional-state-components-for-ecmascript-code-execution-contexts ↩
-
ECMA-262 명세의 "10.2.11 FunctionDeclarationInstantiation", "8.6.1 Runtime Semantics: InstantiateFunctionObject", "10.2.3 OrdinaryFunctionCreate" 등 참고 ↩
-
ECMA-262 명세의 "10.2.1.1 PrepareForOrdinaryCall", "9.1.2.4 NewFunctionEnvironment" 참고 ↩
-
ECMA-262 8.1 Runtime Semantics: Evaluation https://tc39.es/ecma262/multipage/syntax-directed-operations.html#sec-evaluation ↩
-
ECMA-262 6.2.4 The Completion Record Specification Type https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-completion-record-specification-type ↩
-
ECMA-262 9.4.2 ResolveBinding(name[, env]) https://tc39.es/ecma262/#sec-resolvebinding ↩
-
ECMA-262 6.2.5 The Reference Record Specification Type https://tc39.es/ecma262/#sec-reference-record-specification-type ↩
-
ECMA-262 6.2.5.5 GetValue(V) https://tc39.es/ecma262/#sec-getvalue ↩
-
ECMA-262 13.1 Identifiers - 13.1.3 Runtime Semantics: Evaluation https://tc39.es/ecma262/#sec-identifiers-runtime-semantics-evaluation ↩
-
ECMA-262 9.4.2 ResolveBinding(name[, env]) https://tc39.es/ecma262/#sec-resolvebinding ↩
-
ECMA-262 9.1.2.1 GetIdentifierReference(env, name, strict) https://tc39.es/ecma262/#sec-getidentifierreference ↩
-
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures#emulating_private_methods_with_closures 원래 출처는 더글라스 크록포드, "자바스크립트 핵심 가이드", 2008 ↩