외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 한다. - 모던 자바스크립트 Deep Dive p.393
클로저는 자바스크립트만의 것이 아니라, 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 주된 특성입니다. 위에서 정의한대로라면 클로저는 두 가지 조건에 부합해야 합니다.
몇 가지 예시를 확인하며 클로저가 아닌 경우와 클로저인 경우를 살펴보겠습니다.
function outer() {
const x = 1;
function inner() {
const y = 1;
console.log(y);
}
return inner; // 중첩 함수를 반환!
}
const innerFunc = outer();
innerFunc(); // 1
외부 함수인 outer 함수는 inner 함수를 중첩 함수로 하고 있고 또한 inner 함수를 반환하고 있습니다. (이하 outer를 외부 함수, inner를 중첩 함수라 부르겠습니다.) 전역에서 외부함수가 호출되었고, 중첩 함수를 반환했습니다. 외부 함수는 실행을 마치고 생명 주기가 종료되었지만, 중첩 함수는 외부의 식별자에 의해 참조되어 결과적으로 외부 함수보다 오래 유지되고 있습니다. 이 때 중첩 함수를 클로저라고 할 수 있을까요?
중첩 함수가 상위 스코프의 식별자를 참조하고 있지 않습니다. 클로저의 조건 중 중첩 함수가 외부 함수의 식별자를 참조하고 있어야 한다는 조건이 있습니다. 따라서, 이 중첩 함수는 클로저라고 할 수 없습니다.
그럼 다음 경우는 어떨까요?
function outer() {
const x = 1;
function inner() {
console.log(x); // 상위 스코프의 식별자 참조!
}
inner();
}
outer();
이번엔 제대로 중첩 함수가 상위 스코프의 식별자를 참조하고 있습니다. 그럼 클로저라고 할 수 있을까요?
외부 함수를 호출했고, 외부 함수는 그 안에서 중첩 함수를 호출하고 있습니다. 중첩 함수가 상위 스코프의 식별자를 참조하고 있으므로 클로저라고 할 수 있겠습니다. 하지만, 중첩 함수가 종료되어야만 외부 함수가 종료됩니다. 이러한 경우, 외부 함수의 생명 주기가 중첩 함수보다 더 기므로 클로저의 정의에 부합하지 않습니다. 그러므로, 이러한 경우의 중첩 함수도 클로저라 할 수 없습니다.
function outer() {
const x = 1;
function inner() {
console.log(x);
}
return inner;
}
const innerFunc = outer();
innerFunc(); // 1
이쯤 되면 클로저란 무엇인지 감이 옵니다. 이번엔 중첩 함수가 제대로 상위 스코프의 식별자를 참조하고 있고, 외부 함수가 중첩 함수를 반환하고 있습니다. 외부 식별자에 의해 중첩 함수가 참조되고 있어 중첩 함수가 외부 함수보다 오랜 생명 주기를 가지고 있습니다. 중첩 함수를 참조하고 있는 innerFunc을 통해 중첩 함수를 실행하게 되면, 외부 스코프에 존재하는 식별자를 기억하여 이를 출력하는 것까지 확인할 수 있습니다.
다시 말해 클로저란, 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우를 뜻합니다.
그럼 중첩 함수는 어떻게 상위 스코프의 식별자를 참조할 수 있는 걸까요? 그리고, 상위 스코프의 정보는 어디에 저장되는 걸까요?
클로저의 동작 방식을 이해하려면 실행 컨텍스트에 대한 이해가 선행되어야 합니다.
실행 컨텍스트는 소스코드를 실행하는 데 필요한 환경을 제공하고 코드의 실행 결과를 실제로 관리하는 영역이다. - 모던 자바스크립트 Deep Dive p.364
자바스크립트는 소스 코드를 실행하는 실행 과정(런타임)에 앞서 평가 과정을 거칩니다. 이 평가 과정에서 실행 컨텍스트가 생성되고, 변수나 함수 등의 선언문을 먼저 실행하여 식별자를 키로 하여 실행 컨텍스트가 관리하는 스코프에 등록하게 됩니다.
실행 컨텍스트가 관리하는 스코프는 전역 스코프, 지역 스코프, eval 스코프, 모듈 스코프로 총 4가지입니다. 전역 스코프와 지역 스코프만 살펴보자면, 전역 스코프는 var로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수를 가집니다. 지역 스코프는 함수 내부에서 지역 변수, 매개변수, arguments 객체를 관리합니다.
예시와 함께 살펴보면서 실행 컨텍스트가 언제 어떻게 생성되는지 알아보겠습니다.
// 전역 변수 선언
const x = 1;
const y = 1;
// 전역 함수 선언
function foo() { ... }
전역 코드를 실행하기 앞서, 평가 과정을 거치며 전역 코드를 실행할 준비를 해야합니다. const x, const y 그리고 function foo() {}는 선언문으로 평가 과정에서 먼저 실행됩니다. 이렇게 생성된 전역 변수와 전역 함수는 실행 컨텍스트가 관리하는 전역 스코프에 등록됩니다. 이 때, var 키워드로 선언된 전역 변수나 함수 선언문으로 정의된 전역 함수는 전역 객체의 프로퍼티와 메서드가 됩니다. 선언문을 모두 실행하고 나면 전역 평가 과정이 끝나고 런타임으로 진입해 소스 코드의 첫 줄부터 순서대로 실행됩니다. x = 1, x = 2의 실행문을 읽으면, 실행 컨텍스트로부터 x와 y를 검색하고 해당 전역 변수를 갱신합니다.
function foo(a) {
// 지역 변수 선언
const x = 10;
const y = 20;
console.log(a + x + y);
}
foo(100);
foo(100) 실행문에서 함수가 호출되면 전역 코드의 실행을 멈추고 함수 내부로 진입하여 실행을 계속하게 됩니다. 호출에 의해 함수 내부로 진입하면 마찬가지로 실행 과정 전에 평가 과정을 거칩니다. 이 땐, 매개변수와 지역 변수가 먼저 선언되고 실행 컨텍스트가 관리하는 지역 스코프에 등록됩니다. 또한, arguments 객체가 생성되어 지역 스코프에 등록되고 this 바인딩이 결정됩니다.
평가 과정이 끝나면 실행 과정인 런타임이 시작됩니다. 이 때, 등록된 매개변수와 지역 변수에 값이 할당됩니다. console.log 메서드가 호출될 땐 어떻게 동작할까요? console이라는 식별자를 스코프 체인에서 검색하게 됩니다. console은 스코프 체인에는 등록되어 있지 않지만, 전역 객체의 프로퍼티로 존재합니다. 전역 객체의 프로퍼티는 마치 전역 변수처럼 전역 스코프를 통해 검색 가능하므로 console을 찾을 수 있습니다. log 메서드는 console 객체의 프로토타입 체인을 통해 검색합니다. 찾아낸 log 메서드에 a + x + y 인수를 전달하면 이를 평가하고 함수가 실행됩니다. 함수의 실행이 종료되면 함수 호출 이전으로 되돌아가 전역 코드 실행을 계속합니다.
스코프 체인이란 스코프가 함수의 중첩에 의해 계층적 구조를 갖게 되어, 상위 스코프 방향으로 마치 체인처럼 연결된 구조를 의미합니다. 자바스크립트 엔진은 스코프 체인을 통해 상위 스코프로 이동하면서 식별자를 검색합니다.
전역 객체란 런타임 이전 어떠한 객체보다도 먼저 생성되는 특수한 객체이며 어떤 객체에도 속하지 않는 최상위 객체를 말합니다. 브라우저 환경에서는
window가, Node.js 환경에서는global이 전역 객체를 참조합니다. 전역 객체의 프로퍼티는window혹은global을 생략할 수 있고,Object.prototype을 상속받습니다.
프로토타입 체인이란 객체의 프로퍼티나 메서드에 접근하려고 할 때, 해당 객체에 접근하려는 프로퍼티나 메서드가 존재하지 않는다면,
[[Prototype]]내부 슬롯의 참조에 따라 부모 역할을 하는 포로토타입의 프로퍼티를 검색하는 과정을 뜻합니다. 객체 지향 프로그래밍에서 상속이 구현된 모습이라고 생각할 수 있습니다.
위와 같이 코드가 실행되기 위해선 스코프, 식별자, 코드 실행 순서, 전역 객체 등과 같의 관리가 필요합니다.이 모든 것을 생성하고 관리하는 것이 실행 컨텍스트의 역할이라고 볼 수 있습니다.
멀리 왔습니다. 여기서 클로저의 두 가지 조건에 대해서 다시 알아보겠습니다.
중첩 함수가 상위 스코프의 식별자를 참조한다는 건 위에서 스리슬쩍 언급한 스코프 체인으로부터 찾아낸다는 건 알겠습니다. 그래도 외부 함수보다 중첩 함수의 생명 주기가 더 길다는데 그렇다면 외부 함수의 생명 주기가 다하면 중첩 함수도 스코프 체인으로부터 식별자를 참조하지 못해야하는 거 아닐까요? 이 의문을 해소하기 위해선 실행 컨텍스트 스택과 렉시컬 환경에 대해서 알아야 합니다.
생성된 실행 컨텍스트는 스택 자료구조로 관리됩니다. 다음 코드에서 실행 스택은 어떻게 관리될까요?
const x = 1;
function foo() {
const y = 2;
function bar() {
const z = 3;
console.log(x + y + z);
}
bar();
}
foo();
프로그램 실행 => []
전역 코드 평가 => [전역 실행 컨텍스트]
foo 함수 평가 => [전역 실행 컨텍스트, foo 함수 실행 컨텍스트]
bar 함수 평가 => [전역 실행 컨텍스트, foo 함수 실행 컨텍스트, bar 함수 실행 컨텍스트]
foo 함수 복귀 => [전역 실행 컨텍스트, foo 함수 실행 컨텍스트]
전역 코드 복귀 => [전역 실행 컨텍스트]
프로그램 종료 => []
스택 구조에서 실행 컨텍스트가 push 혹은 pop 되며 실행 순서를 관리하고 있습니다. 스택의 최상단에 존재하는 실행 컨텍스트는 현재 실행 중인 코드의 실행 컨텍스트가 됩니다.
렉시컬 환경은 실행 컨텍스트를 구성하는 컴포넌트 중 하나로, 식별자와 그 식별자에 바인딩된 값 그리고 상위 스코프에 대한 참조를 기록하는 자료구조입니다. 한마디로 렉시컬 환경은 실행 컨텍스트에서 스코프와 식별자를 관리합니다.
다시 렉시컬 환경은 환경 레코드와 외부 렉시컬 환경에 대한 참조 컴포넌트로 구성됩니다. 환경 레코드 컴포넌트는 객체 형태의 스코프에 식별자를 키로 등록하고 등록된 식별자에 바인딩된 값을 관리하는 저장소 역할을 합니다. 외부 렉시컬 환경에 대한 참조 컴포넌트는 상위 스코프를 참조합니다. 상위 스코프란 외부의 렉시컬 환경을 뜻합니다. 이러한 외부 렉시컬 환경에 대한 참조로 인해 단방향 링크드 리스트인 스코프 체인이 생성됩니다.
렉시컬 스코프는 함수를 어디서 호출했는지가 아니라 어디에서 정의했는지에 따라 상위 스코프가 결정되는 방식을 의미합니다.
실행 컨텍스트가 생성되는 건 실제로 코드가 실행되기 이전, 즉 함수가 호출됐을 때라고 할 수 있습니다. 이 때, 외부 렉시컬 환경에 대한 참조 컴포넌트가 참조하고 있는 상위 스코프가 결정되어야 할텐데 상위 스코프가 무엇인지 어떻게 알 수 있을까요?
렉시컬 스코프 방식에 따라 함수가 정의될 때 상위 스코프에 대한 정보를 기억합니다. 함수가 정의될 때 현재 실행 컨텍스트의 렉시컬 환경이 곧 상위 스코프가 되므로 이러한 정보를 [[Enviornment]]라는 내부 슬롯에 저장됩니다.
함수가 호출되고 실행 컨텍스트가 생성되어 새로운 함수 스코프가 생성될 때, [[Enviornment]] 내부 슬롯에 저장된 상위 스코프 정보를 "외부 렉시컬 환경에 대한 참조"에 저장하게 됩니다.
이제 위에서 봤던 클로저 함수에 대해 보면서 실행 컨텍스트와 스코프가 어떻게 되는지 정리해봅시다.
function outer() {
const x = 1;
function inner() {
console.log(x);
}
return inner;
}
const innerFunc = outer();
innerFunc(); // 1
전역 코드 평가 과정에서 전역 실행 컨텍스트 생성 (outer 함수와 innerFunc 전역 변수 등록)
outer 함수의 상위 스코프는 전역 스코프이므로 [[Enviornment]]는 전역 렉시컬 환경을 참조전역 코드 실행 과정에서 outer 함수가 호출되어 전역 코드 실행을 멈추고 outer 함수 내부로 이동
outer 함수의 실행 컨텍스트가 생성되며 [[Enviornment]]를 참조하여 전역 렉시컬 환경을 outer 함수의 "외부 렉시컬 환경에 대한 참조"에 할당
outer 함수 실행 컨텍스트 pushouter 함수의 평가 과정에서 지역 변수 x와 중첩 함수 inner를 등록.
inner 함수 객체는 [[Enviorment]] 내부 슬롯에 outer 함수 스코프의 렉시컬 환경을 상위 스코프로 저장outer 함수의 실행 과정에서 inner 반환하고 전역 실행 컨텍스트를 통해 호출된 곳으로 돌아가 전역 코드 실행 재개
outer 함수 실행 컨텍스트 popouter 함수의 실행 결과인 inner 함수를 innerFunc 변수가 참조하기 위해 전역 컨텍스트에서 innerFunc 검색 후 갱신
innerFunc()을 통해 inner 함수를 호출되었으므로 전역 코드 실행을 멈추고 inner 함수 내부로 이동
inner 함수의 실행 컨텍스트가 생성되며 inner의 [[Enviornment]]를 참조하여 outer 함수의 렉시컬 환경을 "외부 렉시컬 환경에 대한 참조"에 할당
inner 함수 실행 컨텍스트 pushinner 함수의 실행 과정에서 console.log(x);를 만나고 console 객체는 전역 객체에서 log 메서드는 console 객체에서 검색 진행
x를 현재 실행 컨텍스트에서 검색, 없으면 "외부 렉시컬 환경에 대한 참조"로 연결된 스코프 체인을 통해 상위 스코프에서 검색
outer 함수의 렉시컬 환경에서 x를 찾아내고 이를 console.log 메서드의 인자로 전달하고 함수 실행 종료
inner 함수 실행 컨텍스트 pop전역 코드 실행을 종료
여기서 가장 중요하게 봐야할 점은, outer 함수는 실행 컨텍스트 스택에서 제거되어 소멸되지만 innerFunc 변수가 inner 함수를 참조하고 있기 때문에 outer의 렉시컬 환경까지는 소멸되지 않는다는 점입니다. 실행 컨텍스트의 정보는 더 이상 아무도 참조하고 있지 않으면 자바스크립트 엔진의 최적화 과정에 의해 자동으로 소멸됩니다. 하지만, inner 함수가 상위 스코프의 렉시컬 환경을 기억하고 있으므로 소멸되지 않습니다.
outer 함수는 호출과 반환으로 소멸되었지만, inner 함수에 의해 outer 함수의 렉시컬 환경이 남아 있어서 그 상위 스코프의 식별자를 참조하고 값을 변경할 수도 있습니다.
이렇게 클로저와 실행 컨텍스트를 알아보았으며 그 원리를 파악하면서 렉시컬 환경과 어떻게 연관되는지 알아보았습니다. 단순히 클로저에 대해 설명하기 위해 생략된 부분도 있으므로 부족한 부분이 있을 수 있습니다. 궁금한 부분이나 글에서 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.