[번역] ECMA-262-3 in detail. Chapter 6. Closures.

Posted by in Research

원문 출처 : ECMA-262-3 in detail. Chapter 6. Closures. by Dmitry Soshnikov

 

 

소개(Introduction)


 

이번에는 JavaScript의 아주 중요한 개념인 클로저(Closures)에 대해서 알아보겠다. 그 동안 여러 번 이야기했던 내용으로 새로운 것은 아니지만, 이론적인 관점에서 조금 더 깊이 있게 들여다 보고 ECMAScript가 어떻게 클로저를 다루는지 설명할 생각이다. 이번 챕터를 이해하려면 스코프 체인(Scope chain)과 변수 객체(Variable Object)의 개념을 알아야하기 때문에,  이전에 공부했던 내용을 다시 한 번 정리하고 넘어가자.

 

 

일반 이론(General theory)


 

바로 ECMAScript의 클로저를 논의하기 전에, 함수형 프로그래밍의 일반 이론에서 정의하고 있는 내용을 구체화 할 필요가 있다. 다들 알고 있듯이, 함수형 언어에서(ECMAscript는 함수형 언어의 패러다임과 문법을 일부 지원함) 함수는 데이터다. 즉, 다시 말해서 함수를 변수에 할당할 수 있고, 다른 함수에 인자로 전달할 수 있으며, 함수의 결과로 반환할 수 있다. 이러한 함수는 특별한 이름과 구조를 갖는다.

 

정의(Definitions)

 

함수 전달인자(“Funarg)는 값이 함수인 전달인자다.

 

예를 들어,

이 경우 exampleFunc 함수에 전달한 익명 함수가 바로 “funarg”다.

 

다음으로, 전달인자로 다른 함수를 받는 함수를 고차함수(HOF, High Order Function)라고 한다.

 

고차함수(HOF, High Order Function)는 기능적, 또는 좀 더 수학적으로 연산자로 볼 수 있다. 위 예제의 exampleFunc 함수는 고차 함수다. 이미 봤듯이, 함수를 전달인자로 넘길 수 있을 뿐만 아니라, 다른 함수의 결과 값으로 반환할 수도 있다.

 

다른 함수를 반환하는 함수를 값이 함수인 함수(또는 함수 반환 함수)라고 한다.

 

 

함수를 일반적인 데이터로 취급할 수 있는 경우, 다시 말해서 함수를 전달인자로 넘길 수 있고, 전달인자로 받을 수 있으며, 함수 값을 반환할 수 있을 때 이러한 함수는 일급 객체다.

 

ECMAScript의 모든 함수는 일급 객체다. 자기 자신을 전달인자로 받는 함수를 자기 응용 함수라고 한다.

자기 자신을 반환하는 함수를 자기 복제 함수라고 한다. 때로는 특정 문헌에서 자가 증식이라는 명칭을 사용하기도 한다.

 

 자기 복제 함수를 호출할 때는 인자로 콜렉션 전체가 아닌 각각의 원소를 하나씩 전달한다.

이렇게 함수를 호출할 수 있지만, 콜렉션 전체를 전달하는 방식이 더 효율적이고 직관적일 수 있다.

 

물론 함수 실행 시점에 전달인자로 넘기는 함수의 지역 변수에 접근할 수 있다. 콘텍스트에 진입할 때마다 콘텍스트 내부의 데이터 보관용 변수 객체를 만들기 때문이다.

하지만 Chapter 4. Scope Chain.에서 봤듯이, ECMAScript의 함수는 부모 함수에 속해 부모 콘텍스트의 변수를 사용할 수 있다. 이러한 특징으로 말미암아 소위 말하는 “함수 전달인자 문제(funarg problem)”가 발생한다.

 

함수 전달인자 문제(Funarg problem)

 

스택 지향 프로그래밍 언어는 함수를 호출할 때마다 함수의 지역 변수와 전달인자를 스택에 넣는다. 그리고 함수를 종료할 때 스택에서 변수를 제거한다. 이 모델은 함수를 값(예를 들어, 부모 함수가 반환하는 값으로서의 함수)으로 사용하기 어렵다. – 스택에서 제거되면 사라지기 때문 -. 대게 함수가 자유 변수를 사용할 때 이런 문제가 발생한다.

 

자유 변수는 함수가 사용하는 변수 중, 파라미터와 함수의 지역 변수를 제외한 변수를 말한다.

 

이 예제의 localVar는 innerFn 함수가 사용하는 자유 변수다. 스택 지향 모델이라고 가정해보자. testFn 함수를 종료하면서 모든 지역 변수를 스택에서 제거할 것이고, 이 때문에 외부에서 innerFn 함수를 실행하려고 할 때 에러가 발생할 것이다.

게다가 위의 예처럼 innerFn 함수를 반환하는 것은 아예 불가능하다. innerFn 함수가 testFn의 지역에 있기 때문에 testFn 함수가 종료되면서 innerFn 함수도 사라진다. 동적 스코프를 이용하는 시스템에서 함수를 전달인자로 넘길 때 함수 객체가 갖고 있는 또 다른 문제가 발생한다 .

 

예제(의사 코드)를 보자.

동적 스코프인 경우에는 동적(활동적) 스택을 이용해 변수를 처리한다. 결국 함수를 생성할 때 함수에 저장한 정적(어휘적) 스코프 체인이 아닌, 현재 실행중인 함수의 동적 스코프 체인에서 자유 변수를 찾는다. 이는 모호한 상황을 만든다. 예를 들어 지역 변수를 스택에서 제거하는 이전 예제와는 달리  z가 계속해서 살아있는 경우, 콘텍스트의 z를 사용해야할지 아니면 스코프의 z를 사용해야할지 알 수 없다.

지금까지 함수가 함수를 값으로 반환하거나(upward funarg), 함수를 다른 함수에 전달인자로 전달할 때(downward funarg) 생기는 2가지 유형의 함수 전달인자 문제(funarg problem)를 알아봤다. 클로저는 이러한 문제(및 서브타입)를 해결하기 위해 나온 개념이다.

 

클로저(Closure)

 

클로저는 코드 블럭과 이 코드 블럭을 생성한 콘텍스트가 갖고 있는 데이터의 조합이다.

 

의사 코드를 살펴보자.

위의 예제의 fooClosure는 물론 의사 코드다. ECMAScript 코드라면 foo 함수는 자신을 생성한 콘텍스트의 스코프 체인을 내부 속성으로 가질 것이다.

종종 어휘적이라는 단어를 생략하기도 하지만, 위 예제의 경우 클로저가 자기 부모의 변수를 소스 코드 내의 어휘적 위치에서 저장한다는 사실에 관심을 집중하자.  다음에 함수를 실행하면 저장한 콘텍스트 내에서 자유 변수를 검색한다. 위의 예제를 통해 ECMAScript에서는 변수 z가 항상 10인 것을 알 수 있다.

위에서 클로저를 정의할 때  “코드 블록”이라는 일반화 한 개념을 사용했지만, 보통 “함수”라는 용어를 사용한다. 하지만 오로지 함수만 클로저와 관련있는 것은 아니다. 루비의 경우 프로시저 객체, 람다 표현식이나 코드 블록 역시 클로저와 관련이 있다.

구현에 대해서 이야기를 해보자면, 콘텍스트가 종료된 후에도 지역 변수를 보존하고 싶다면 스택 기반의 아키텍처는 더이상 적합하지 않다. 따라서 이 경우에는 부모 콘텍스트의 데이터를 가비지 콜렉터(GC)와 참조 카운팅을 이용하는 동적 메모리 할당 방식으로 저장해야 한다(힙 기반 구현). 이 방식은 스택 기반 보다 느리다. 하지만 함수 안에서 자유 변수를 사용할지 판단하고 이 결정에 따라 스택이나 힙에 데이터를 배치하는 과정을 스크립트 엔진이 해석 시점에 최적화 할 수 있다.

 

 

ECMAScript의 클로저 구현(ECMAScript closures implementation)


 

앞에서 이론적인 이야기를 하면서 마지막에 ECMAScript의 클로저를 언급했다.  ECMAScript는 오직 정적(어휘적) 스코프만 사용한다는 사실을 명심하자(Perl 같은 일부 언어는 변수를 정적, 동적 스코프 두 가지 방식으로 선언할 수 있음).

기술적으로, 부모 콘텍스트의 변수는 함수 내부의 [[Scope]] 프로퍼티에 저장된다. Chapter 4에서 이야기했던 [[Scope]]와 스코프 체인을 완벽하게 이해했다면 ECMAScript의 클로저를 쉽게 이해할 수 있다.

함수 생성 알고리즘에 나와있듯이 ECMAScript의 함수는 부모 콘텍스트의 스코프 체인을 가지고 있기 때문에 모든 함수는 클로저다. 함수의 이후  실행 여부와는 상관없이 함수 생성 시점에 부모의 스코프를 함수의 내부 속성에 저장한다.

앞에서 언급했듯이, 함수가 자유 변수를 사용하지 않는 경우에는 성능 최적화를 위해 JavaScript 엔진이 부모 스코프 체인을 함수 내부에 저장하지 않을 수도 있다. 그러나 ECMA-262-3 스펙은 이에 대해서 언급하고 있지 않다. 따라서 공식적으로(그리고 기술적 알고리즘에 따라) 모든 함수는 생성 시점에 [[Scope]] 프로퍼티에 스코프 체인을 저장한다.

일부 엔진은 사용자가 클로저 스코프에 직접 접근하는 것을 허용한다. 예를 들어 Rhino의 경우 함수의 [[Scope]] 프로퍼티에 부합하는 비표준 프로퍼티인 __parent__를 갖고 있다. 이에 대해서는  변수 객체를 주제로 한 Chapter 2. Variable Object에서 이야기 한 적이 있다.

 

[[Scope]] 공유(One [[Scope]] value for “them all”)

 

ECMAScript에서 같은 부모 콘텍스트에서 만들어진 여러 중첩 함수는 같은 클로저 [[Scope]] 객체를 사용한다. 이는 어떤 클로저가 클로저 변수를 수정하면, 변경한 내용을 다른 클로저가 읽을 수 있다는 의미다.

 

즉, 모든 중첩 함수는 같은 부모의 스코프를 공유한다.

 

이와 관련해서 많은 사람들이 자주하는 실수가 있다.  모든 함수가 고유의 루프 카운터 값을 갖게 만들기 위해 루프 안에서 함수를 생성할 때, 의도하지 않은 결과를 얻는 경우가 종종 있다.

이전 예제에서 이 동작을 설명했다. 세 함수 모두 같은 콘텍스트의 스코프를 갖는다. 이 세 함수는 모두 [[Scope]] 프로퍼티를 통해 변수를 참조하여 부모 스코프에 존재하는 변수 k를 쉽게 변경할 수 있다.

개략적으로 다음과 같다.

따라서 세 함수는 실행 시점에 변수 k에 마지막으로 할당한 값인 3을 사용한다.

 

이는 코드 실행 이전, 즉 콘텍스트에 진입할 때 모든 변수를 생성하는 것과 관련 있다. 이러한 동작을 “호이스팅(hosting)”이라고 한다.

 

추가적으로 콘텍스트를 익명함수로 감싸면 이 문제를 해결할 수 있다.

이 경우에 무슨 일이 일어나는지 살펴보자.

  1. _helper 함수를 만들고 즉시 실행하면서 k를 인자로 넘긴다.
  2. _helper 함수를 실행하면서, 매개변수 x(함수 호출 시 전달한 k와 같음)를 포함하는 새로운 활성화 객체를 만든다.
  3.  _helper 함수의 활성화 객체를 [[Scope]]속성에 저장하고 있는 익명 함수를 결과값으로 반환한다.

 

결과로 반환한 함수의  [[Scope]]는 다음과 같다.

이제 함수의 [[Scope]] 프로퍼티는 추가적으로 만든 스코프가 갖고 있는 변수 x를 통해 필요한 값을 참조할 수 있다.

물론 결과로 반환한 모든 함수가 변수 k를 여전히 참조하고 있고 이 값은 3이다.

위에 나와있는 것처럼 필요한 값을 보존하기 위해 추가적인 함수를 만드는 패턴을 JavaScript 클로저로 한정 지어서 이야기하는 경우가 자주 있다. 실제적 관점에서 이렇게 생각하는 사람이 많다. 하지만 이론적 관점에서 보면 ECMAScript의 모든 함수를 클로저라고 할 수 있다.

위에 설명한 패턴이 클로저의 유일한 경우는 아니다. 다음과 같은 방법으로 변수 k의 값을 가져올 수도 있다.

 

함수 전달인자와 return(Funarg and return)

 

또 다른 특징은 함수가 클로저를 반환할 때 나타난다. ECMAScript의 클로저 반환문은 제어의 흐름을 호출 콘텍스트(caller)에 돌려준다. 루비와 같은 언어는 반환문을 처리하는 방법이 다양하여 여러 형태의 클로저를 가지고 있다. 루비에서는 호출자에게 제어를 반환하거나 실행 중인 콘텍스트를 모두 종료하는 것이 가능하다.

ECMAScript의 return은 다음과 같이 동작한다.

이 경우에 어떤 특별한 “break” 예외를 던지고 받을 수 있다.

 

이론 버전(Theory versions)

 

이전에 언급했듯이, 클로저를 부모 콘텍스트가 반환하는 중첩 함수로 한정 지어서 생각하는 경우가 자주 있다. 심지어 오직 익명 함수만 클로저로 보기도 한다.

 

다시 한 번 정리하자. 스코프 체인 메커니즘을 갖는 익명, 기명, 함수 표현식, 함수 선언식 등 모든 타입의 함수는 클로저다.

 

Function 생성자를 이용해서 생성한 함수는 [[Scope]]가 오직 전역 객체만 가지고 있기 때문에 이 규칙에 예외다.

그리고 이 문제를 분명하게 하기 위해, ECMAScript의 클로저를 두 가지 관점에서 정리하자.

  • 이론적 관점 : 모든 함수는 생성 이후에 부모 콘텍스트의 변수를 저장한다. 심지어 전역 변수를 참조하는 간단한 전역 함수도 자유 변수를 참조하며, 그 결과 일반적인 스코프 체인 방식을 이용한다.
  • 실재적 관점 : 부모 콘텍스트가 종료 된 후에도 이러한 함수는 계속해서 존재(예를 들어, 부모 함수가 중첩 함수를 반환하는 경우)하며, 자유 변수를 이용한다는 점에서 흥미롭다.

 

 

클로저의 실제적 사용(Practical usage of closures)


 

실제로 클로저를 이용하면 다양한 계산을 사용자가 함수 전달인자로 정의할 수 있게 하는 우아한 설계를 할 수 있다. 예를 들어 정렬 조건를 함수 전달인자로 받는 배열 정렬 메소드가 있다.

그리고 인자로 전달받은 함수를 배열의 각 원소에 적용한 결과를 갖는 새로운 배열을 만들어 돌려주는 map 메소드와 같은, 맵핑 고차함수(mapping functionals)가 있다.

검색 함수를 만들 때 함수를 전달인자로 받아 거의 무제한적인 검색 조건을 정의할 수 있게 구현해 놓으면 편리하다.

또한, 배열을 순회하면서 각각의 원소에 함수를 적용하는 forEach 메소드와 같은 함수 적용 고차함수(applying functional)도 있다.

함수 객체의 apply, call 메소드는 함수형 프로그래밍의 범함수에서 유래했다.
이미 이 메소드에 대해서는 this를 주제로 한 Chapter에서 이야기 했으므로, 이번에는 함수를 매개변수에 전달하는 방식을 살펴본다(apply는 전달인자 목록을 받고, call은 전달인자를 차례로 나열한다).

 

[역주]

프로그래밍에서 “apply”는 데이터에 임의의 함수를 적용한다는 의미를 가진다. 만약 함수의 이름과 기능이 확정되어 있고, 그것을 알 수 있다면 함수의 이름과 파라미터 형식에 맞춰서 함수를 호출할 수 있다. 그런데 함수형 언어의 함수는 일급 객체로서 데이터로 취급할 수 있기 때문에 함수를 변수에 저장할 수 있고, 다른 함수의 인자로 전달할 수  있으며, 결과 값으로 반환할 수 있다.

간단히 말해서 함수를 다른 곳으로 넘길 수 있다는 이야기다. 이렇게 함수를 다른 곳으로 넘겼을 때 함수를 받은 쪽에서 받은 함수를  호출할 수 있는 장치가 필요한데, 이 장치를 사용하는 것을 apply라고 한다. JavaScript 역시 함수를 일급 객체로 취급하기 때문에 함수를 apply 하기 위한 call, apply 메소드를 가지고 있다.

그리고 함수들의 집합을 정의역으로 갖는 함수를 수학 용어로는 범함수(functional)라고 한다.

 

지연 호출은 클로저의 또 다른 중요한 응용 사례다.

그리고 콜백 함수로 사용한다.

또는 보조 객체를 감추기 위해 캡슐화 한 스코프를 만들 수 있다.

 

 

결론(Conclusion)


 

이번 글에서 ECMA-262-3 스펙의 좀 더 일반적인 이론을 알아봤다. 이러한 일반 이론을 알고 있으면 ECMAScript 함수를 더 잘 이해할 수 있다고 생각한다.

 

 

추가 문헌(Additional literature)