자바스크립트 실행 컨텍스트와 클로저


⚠️ 이 포스트에서 “자바스크립트”라는 말은 사실 엄밀히 말해 “ECMAScript”를 말하는 것입니다. 하지만 편의를 위해 ECMAScript를 자바스크립트라고 하겠습니다.

Environment Record(환경 레코드)

우선 자바스크립트의 실행 컨텍스트에 대해 살펴보기 전에, Environment Record(환경 레코드)에 대해 살펴봅시다.

환경 레코드는 자바스크립트 코드의 렉시컬 중첩 구조(lexical nesting structure)에 기반하여 식별자들을 특정 변수·함수값으로 바인딩하는 데 사용됩니다. 즉, 환경 레코드는 어떤 렉시컬 환경(쉽게 말해, 렉시컬 스코프라고 생각해도 될 것 같습니다)에서 생성된 식별자들의 바인딩 정보를 “기록(record)“하는 저장소라고 할 수 있습니다. 환경 레코드는 자바스크립트 스펙 상으로만 존재하는 추상 개념이라 자바스크립트 프로그램에서 환경 레코드에 접근하는 것은 불가능합니다.

일반적으로 환경 레코드는 함수 선언·블록 문·Catch 절과 같은 자바스크립트 코드의 특정 문법 구조와 관련됩니다. 이와 같은 코드들이 평가(혹은 실행, evaluate)될 때마다 해당 코드에 의해 생성된 식별자들의 바인딩 정보를 기록하기 위해 새로운 환경 레코드가 생성됩니다. 이때 식별자를 바인딩한다는 말은 식별자(이름)를 값에 대응(associate)시키는 것이라고 볼 수 있습니다.

모든 환경 레코드는 해당 환경 레코드를 논리적으로 감싸는(surround) 바깥 환경 레코드, 즉 부모 환경 레코드를 가리키는(참조하는) [[OuterEnv]]라는 필드를 가집니다. 만약 글로벌 영역과 같이 외부 환경 레코드가 없다면 [[OuterEnv]]의 값은 null입니다. 또한, 어떤 함수 내부에 여러 개의 중첩 함수들이 정의될 수 있는 것처럼, 하나의 환경 레코드가 여러 개의 내부 환경 레코드에 대한 외부 환경 레코드의 역할을 할 수도 있습니다. 이 같은 경우 각 내부 함수의 환경 레코드는 해당 내부 함수들을 감싸고 있는 외부 환경 레코드를 [[OuterEnv]]의 값으로 가집니다.

환경 레코드 타입 구조

객체 지향 모델로 비유하자면 환경 레코드는 3개의 구상 클래스(concrete class)를 가지는 추상 클래스(abstract class)로 볼 수 있습니다. 이러한 구조를 그림으로 나타내면 아래와 같습니다:

환경 레코드 계층
환경 레코드 계층.

선언 환경 레코드

선언 환경 레코드(declarative Environment Record)는 변수 선언·함수 선언·함수 인자 등의 바인딩 정보를 기록합니다. 예를 들면,

// "a", "b", "c" 식별자 바인딩 모두 선언 환경 레코드에 저장됩니다
function foo(a) {
  let b = 10;
  function c() {}
}

// catch 절의 예외 인자 "e"의 바인딩 또한 선언 환경 레코드에 저장됩니다
try {
  // ...
} catch(e) {
  // ...
}

일반적으로 선언 환경 레코드의 바인딩들은 가상 머신의 레지스터와 같이 “로우 레벨”에 저장되는 것으로 여겨집니다. 즉, 스펙에선 이를 단순한 객체와 같은 형태로 구현할 것을 요구하고 있지 않습니다(오히려 이러한 형태로 구현하지 말 것을 간접적으로 권장하고 있습니다). 왜냐면 비효율적일 수 있기 때문이죠.

선언 환경 레코드는 lexical addressing기법을 사용하여 변수 접근을 최적화할 수도 있습니다. 즉 스코프의 중첩 깊이에 관계없이, 스코프 체인을 찾아보지 않고 원하는 변수에 바로 접근하는 것이죠. 물론 스펙에서 이를 직접 언급하고 있지는 않습니다.

💡 Activation Object
선언 환경 레코드에 대한 개념은 ES5에 나온 개념이고, 그 이전의 ES3 까진 activation object 라는 개념이 사용됐었습니다. 실제로 activation object에선 객체를 이용하여 이와 같은 정보들을 저장했는데, 자바스크립트 창시자인 Brendan Eich에 의하면 이는 실수라고 합니다. 1995년 자바스크립트 개발 당시, 개발을 서두르기 위해 이렇게 했다고 하네요. 따라서 이러한 역사를 살펴봤을 때, 선언 환경 레코드가(activation object를 대체하기 위해) 등장한 이유는 구현의 효율성을 증대하기 위함이라고 볼 수 있을 것 같습니다.

선언 환경 레코드는 또다시 두 개의 자식 클래스로 나뉩니다.

  • 함수 환경 레코드(function Environment Record): 어떤 함수 내의 최상위 스코프(top-level)에 존재하는 식별자들의 바인딩을 저장합니다. 만약 이 함수가 화살표 함수가 아니라면 this 바인딩도 같이 저장합니다. 또, 화살표 함수가 아니면서 super 메서드를 참조하고 있다면 super 메서드 호출에 필요한 상태 또한 저장합니다.
  • 모듈 환경 레코드(module Environment Record): 어떤 모듈의 최상위 스코프에 존재하는 식별자들의 바인딩을 저장합니다. 또한, 이 모듈이 명시적으로 import 한 바인딩에 대한 정보도 저장합니다. 모듈 환경 레코드의 [[OuterEnv]] 는 글로벌 환경 레코드를 가리킵니다.

객체 환경 레코드

객체 환경 레코드(object Environment Record)는 객체의 “문자열” 식별자들의 바인딩을 저장합니다. 또한, 객체 환경 레코드는 바인딩 객체(binding object) 라고 하는 객체와 연관되는데, 이 객체는 var 선언, with 문과 같은 레거시 기능을 위해 존재합니다. 그 이유는 앞서 잠깐 언급한 것처럼, 기존엔 바인딩들이 “객체”에 저장되었기 때문입니다.

문자열이 아닌 식별자들은 저장되지 않습니다. 또한 자바스크립트에서 객체의 속성은 동적으로 추가되거나 제거될 수 있으므로, 객체 환경 레코드에 저장된 식별자 바인딩들은 속성을 추가하거나 제거하는 연산에 의해 변경될 수도 있습니다.

글로벌 환경 레코드

글로벌 환경 레코드(global Environment Record)는 글로벌 컨텍스트(스코프)에 존재하는 식별자들의 바인딩을 저장합니다. 즉, built-in 객체들, 전역 객체의 속성들과 더불어 글로벌 스코프 내의 모든 식별자들의 바인딩을 저장합니다. 이론상으로 글로벌 환경 레코드는 하나의 레코드이지만, 실질적으로 선언 환경 레코드 및 객체 환경 레코드로 구성되어 있습니다. 글로벌 환경 레코드의 [[OuterEnv]]null 입니다.

또한, 여기에서 살펴본 환경 레코드들 외에도 PrivateEnvironment Record라는 것이 존재합니다. 이 레코드에는 클래스의 private 속성·메서드·접근자(accessor)에 관한 정보가 저장됩니다.

실행 컨텍스트 (Execution Context)

💡 여기서부턴 실행 컨텍스트를 “EC”라고 줄여서 표시하겠습니다.

자바스크립트의 코드에는 크게 4가지 종류가 있습니다:

  • 글로벌 코드
  • Eval 코드
  • 함수 코드
  • 모듈 코드

이러한 코드들은 각자의 EC에서 실행되는데, 이때 EC에는 컨텍스트 내부 코드들의 진행 상황(progress)을 기록하기 위한 상태들이 저장됩니다. 쉽게 말해, EC는 자바스크립트 코드를 실행하는데에 필요한 정보들이 저장되는 객체라고도 볼 수 있습니다.

각 EC들은 최소한 아래의 상태를 가집니다:

구성 요소 목적
code evaluation state 이 EC와 연관된 코드를 실행·중단(suspend)·실행 재개(resume)하는데 필요한 상태들입니다.
Function 만약 이 EC가 함수 객체의 코드를 실행(evaluate) 중이라면 이 구성 요소의 값은 해당 함수 객체가 됩니다. 스크립트 혹은 모듈과 같이 함수 객체가 아닌 이외의 코드를 실행 중이라면 이 구성 요소의 값은 null이 됩니다. 이때, 현재 실행 중인 EC(running EC)의 Function 구성 요소 값을 active function object 라고도 합니다.
Realm 이 EC와 연관된 코드가 자바스크립트 리소스에 접근하는 Realm Record입니다. 이때, 현재 실행 중인 EC의 Realm 구성 요소 값을 current Realm Record 라고도 합니다. Realm에 대한 내용은 여기를 참고해 주세요.
ScriptOrModule 이 EC와 연관된 코드가 유래(originate)된 Module Record 혹은 Script Record를 가리킵니다. 만약 유래된 스크립트나 모듈이 없다면 이 구성 요소의 값은 null이 됩니다.

실행 컨텍스트 스택 (Execution Context Stack)

EC들은 EC 스택에 의해 관리되는데, EC 스택은 EC들의 제어(control) 흐름과 실행 순서 등을 관리하기 위해 사용되는 LIFO 구조입니다. 현재 실행 중인(running) EC는 스택의 맨 꼭대기(top)에 위치합니다.

함수를 호출하는 경우와 같이, “제어권”이 현재 실행 중인 EC의 코드에서 현재 실행 중인 EC와 관련 없는 EC의 코드로 넘어갈 때 새로운 EC가 생성됩니다. 이렇게 새로 생성된 EC는 EC 스택의 맨 꼭대기에 push 됨과 동시에 현재 실행 중인 EC가 됩니다. 이때 다른 EC를 호출하는 EC를 caller, 다른 EC에 의해 호출되는 EC를 callee라고 합니다.

EC 스택의 예시를 한번 살펴봅시다:

let a = 1;

function foo() {
	let b = 2;
	
	function bar() {
		let c = 3;
		console.log(a + b + c) // 6;
	}
	bar();
}
foo();

위 예제 코드 실행 시 EC들이 생성되고 사라지는 과정을 그림으로 나타내면 다음과 같습니다:

EC 예시
EC 예시.
  1. 최초에 프로그램이 실행되어 제어권이 전역 코드에 진입하면 전역 EC가 생성되어 EC 스택에 쌓입니다. 이때, 전역 EC는 프로그램이 종료될 때까지 유지됩니다.
  2. 함수를 호출하면 해당 함수의 함수 EC가 생성되어 EC 스택에 쌓입니다.
  3. 이렇게 호출되어 실행 중인 함수(즉, 현재 실행 중인 EC)가 종료되면 해당 함수의 함수 EC가 스택으로부터 제거되고 직전 EC로 제어권이 넘어갑니다. 이때 함수는 정상적으로 return 문을 통해 EC를 종료할 수도 있고, 혹은 에러와 함께 비정상적으로 종료될 수도 있습니다. 만약 에러와 함께 비정상적으로 종료되는 경우 다른 여러 개의 EC도 같이 종료될 수 있습니다.

일반적으로 EC에서 실행되는 코드는 run to completion의 특성을 가집니다. 즉, 해당 코드가 완전히 실행되어 종료될 때까진 중간에 다른 코드(다른 EC)가 끼어들지 못합니다.

하지만 제너레이터의 yieldasync 함수의 await과 같이, 특정 순간에 현재 실행 중인 EC가 잠시 중단(suspend)되는 경우도 있습니다. 이 경우 해당 EC에서 실행되는 코드 실행이 잠시 중단되어 이 코드의 모든 부분이 다 실행되기 전에 EC 스택에서 제거될 수 있습니다. 일반적으로 EC가 스택에서 제거되면 사라지지만 이 경우 아직 모든 실행을 끝마친 것이 아니므로 따로 저장되어 추후 실행을 재개(resume)할 때 다시 스택에 쌓이게 됩니다.

환경

환경(environment)이란 어떤 스코프 내에 생성된 변수·함수·클래스 등의 정보를 저장하는 저장소라고 할 수 있습니다.

EC는 앞서 살펴봤던 기본 4가지 구성 요소 이외에 아래의 구성 요소를 추가로 갖습니다:

구성 요소 목적
LexicalEnvironment EC 내의 코드에 존재하는 식별자들의 값을 매핑하는데 사용된 환경 레코드를 가리킵니다.
VariableEnvironment VariableStatements에 의해 생성된, EC 내의 코드에 존재하는 식별자들의 값을 매핑하는데 사용된 환경 레코드를 가리킵니다. ES6를 기준으로 LexicalEnvironment와 다른 점은 LexicalEnvironment에는 함수 선언·let·const 변수들의 바인딩에 관한 정보들이 저장되는 반면, VariableEnvironment에는 오직 var 변수들의 바인딩에 관한 정보들이 저장됩니다.

지금까지 살펴본 내용을 바탕으로, 아래 코드 예시의 환경 구조를 그림으로 나타내면 다음과 같습니다:

let x = 10;
let y = 20;

function foo(z) {
  let x = 100;
  return x + y + z;
}

foo(30); // 150
EC 구조 예시
EC 구조 예시.

💡 추가:

스택 오버플로우에 따르면(해당 답변의 댓글 부분 참고), 실제로 V8 엔진에는 variableEnvironment 가 구현되어 있지 않다고 합니다. 즉, 굳이 var 변수 바인딩을 let, const 변수 바인딩과 따로 분리하여 저장하지 않고 그냥 모두 lexicalEnvironment 에 저장한다는 것이죠!

클로저

일급 함수

자바스크립트에서 함수는 일급(first class)입니다. 일급이라는 말은 값으로 다룰 수 있다는 뜻으로, 일급이 되기 위해선 아래의 조건을 만족해야 합니다:

  • 변수에 저장할 수 있다.
  • 함수의 인자로 사용할 수 있다.
  • 함수의 결과로 리턴할 수 있다.

이에 대한 예제를 살펴봅시다:

// (1) 함수를 값으로 다루고 있습니다. typeof 연산자를 이용하여 타입이 'function'인지
// 확인하고 변수 a에 함수 f1을 저장합니다.
function f1() {}
const a = typeof f1 === 'function' ? f1 : function() {};

// (2) 함수에서 함수를 리턴하고 있습니다.
function f2() {
  return function() {};
}

// (3) a, b를 더하는 익명 함수를 선언하고, 인자 1, 2를 전달하여 즉시 실행하고 있습니다.
(function(a, b) { return a + b; })(1, 2);

// (4) 인자로 넘겨받은 두 함수를 실행하여 더하는 callAndAdd를 정의하고,
// callAndAdd를 실행하면서 익명 함수들을 선언하여 곧바로 인자로 넘기고 있습니다.
function callAndAdd(fnA, fnB) {
  return fnA() + fnB();
}

callAndAdd(() => 10, () => 5);

Funarg와 고차 함수

Funarg(functional argument)는 다른 함수에 인자로서 전달되는 함수를 의미합니다. 또한, 이렇게 함수를 인자로 받는 함수를 고차 함수(higher-order function, HOF)라고 합니다. 또, 함수를 리턴하는 함수를 function-valued 함수 혹은 function with a functional value라고 합니다.

좀 더 쉽게 말하자면, 고차 함수는 함수를 다루는 함수라고 할 수 있습니다. 즉, 넓은 범위에서 함수를 인자로 받는 함수 및 함수를 리턴하는 함수 모두 고차 함수라고 하기도 합니다.

const foo = () => {
  console.log('I am foo');
}

function bar(funArg) {
  funArg(); // 'I am foo'
  return funArg;
}
const baz = bar(foo);
baz(); // 'I am foo'

이 예제에서 foobar 고차 함수에 인자로 전달되고 있으므로 “funarg” 입니다. 또한, bar는 “functional-value”인 foo 함수를 리턴하고 있습니다.

자유 변수

일급 함수와 관련된 또 다른 중요한 개념은 바로 자유 변수(free variable) 입니다:

자유 변수란, 어떤 함수에서 사용하는 변수 중에 이 함수의 지역 변수도 아니고 인자도 아닌 변수를 말합니다.

다시 말해, 자유 변수는 이 변수를 사용하는 함수 자신의 환경에 존재하지 않는 변수(이 함수의 외부 환경에 존재하는 변수)를 의미합니다. 바인딩 되지 않은 자유 변수(즉, 외부 환경에서 찾지 못한 자유 변수)를 사용하려고 하는 경우 ReferenceError 가 발생하겠죠?

예를 살펴봅시다:

// 글로벌 환경 (GE)
const x = 10;

function foo(y) {
	// "foo 함수의 환경" (E1)
	const z = 30;

	function bar(q) {
		// "bar" 함수의 환경 (E2)
		return x + y + z + q;
	}
	return bar;
}

const baz = foo(20);
baz(40); // 100

이 예시에선 세 개의 환경 GE, E1, E2 가 존재합니다. 이들은 각각 순서대로 글로벌 객체, foo 함수, bar 함수에 해당합니다.

bar 함수에서, x, y, z 는 모두 자유 변수입니다. 이들은 모두 bar 함수의 인자도 아니고 지역 변수도 아니죠.

이때, foo 함수는 자유 변수를 사용하고 있지 않습니다. 하지만 foo 함수의 실행 도중에 생성된 bar 함수에서 x 변수를 사용하고 있기 때문에 foo 함수는 어쩔 수 없이 부모 환경(GE)의 바인딩을 저장하고 있어야 합니다. 그래야 이후에 bar 함수가 x 변수의 정보를 foo 함수의 환경(E1)에서 찾을 수 있을 테니까요.

baz(40) 의 실행 결과가 우리의 예상 대로 100이 나왔다는 소리는, foo 함수의 컨텍스트가 종료되었음에도 baz 함수가 “어찌어찌” 해서 foo 함수의 환경을 계속해서 기억하고 있다는 뜻입니다.

funarg 문제

앞서 살펴본 funarg, 즉 다른 함수에 인자로서 전달되는 함수엔 문제가 있습니다. 이를 Funarg 문제라고 하는데, 이 문제는 두 개의 작은 문제로 나뉩니다:

Upward funarg 문제는 내부 함수가 바깥(≒ upward)으로 리턴될 때 생기는 문제로, “만약 밖으로 리턴되는 내부 함수가 자유 변수를 사용하고 있다면, 어떻게 이러한 함수를 리턴하는 동작을 구현할 수 있을까?” 에 관한 문제입니다.

function outer(x) {
  return function inner(y) {
    // 어떻게 outer 함수가 종료된 이후에도 outer 함수의
    // "x" 변수를 참조할 수 있을까요?
    return x + y;
  }
}

outer(10)(20); // 30

실행 컨텍스트 섹션에서 알아본 것처럼, 일반적으로 함수는 호출되면 해당 함수의 EC가 새로 생성되어 EC 스택에 쌓이게 되고, 리턴(종료)하면 EC 스택에서 제거됩니다. 이때, 위 예시처럼 내부 함수가 (내부 함수를 리턴하는)외부 함수의 환경에 있는 자유 변수를 참조하는 경우 upward funarg 문제가 발생합니다. 즉 외부 함수는 이미 실행이 종료되어 EC 스택에서 제거가 된 상태인데, 이 경우 밖으로 리턴된 내부 함수는 어떻게 계속해서 자유 변수를 참조할 수 있을까요?

이에 대한 해결책 중 하나는 EC를 스택이 아니라 힙에 저장하는 것입니다. 그리고 더 이상 자유 변수가 필요 없어지면 가비지 컬렉팅을 통해 메모리에서 해제하는 것이죠. 하지만 이 경우 스택 기반의 동작보다 비효율적이고 구현하기 까다롭다는 단점이 존재한다고 합니다. 이를 보완하기 위해 몇몇 컴파일러들은 프로그램을 분석하여 funarg 문제를 발생시키는 함수들은 힙에, 이외의 함수들은 스택에 저장하는 방식을 취한다고 합니다.

Downward funarg 문제는 자유 변수를 사용하는 함수를 인자로 넘길 때 발생하는 모호함에 관련된 것입니다. 즉, 인자로 넘어가는 함수에서 사용되는 자유 변수의 값을 찾을 때, 어떤 스코프를 기준으로 해야 하는지에 관한 문제입니다.

let x = 10;
 
function foo() {
  console.log(x);
}
 
function bar(funArg) {
  let x = 20;
  funArg(); // 20이 아니라 10입니다!
}
 
// "foo" 함수를 "bar" 함수의 인자로 넘깁니다.
bar(foo);

foo 함수에서 x는 자유 변수입니다. 이때 위 코드와 같이, bar 함수에서 funArg 를 통해 받은 foo 함수를 호출할 때 x 의 값을 뭐라고 해석해야 할까요? foo 함수가 생성된 스코프? 아니면 foo 함수를 호출하고 있는 bar 함수의 스코프?

이것이 바로 downward funarg 문제에서 발생하는 변수의 해석과 관련된 “모호함” 입니다. 자바스크립트는 정적 스코프 시스템을 사용하므로 foo 함수를 생성할 당시의 스코프(환경)에서 x의 값을 찾는 것이 올바른 방법입니다.

여기서 정적 스코프(렉시컬 스코프)란, 쉽게 말해 코드를 실제로 실행해보기 전에 변수가 어떤 값으로 바인딩될 지 예상할 수 있는 스코프 시스템을 의미합니다. 스코프가 “변수를 어디서 어떻게 찾을지를 정한 규칙”임을 빗대어 볼 때, 우리가 앞서 살펴본 렉시컬 환경을 토대로 변수의 값을 찾는다고도 할 수 있습니다.

클로저

클로저는 자신이 생성된 환경(스코프)의 실행 컨텍스트 안에서 만들어졌거나 알 수 있었던 식별자 중 언젠가 자신이 실행될 때 사용할 식별자들만 기억하는 함수입니다. 이때 클로저가 기억하는 식별자의 값은 언제든지 변경될 수 있습니다.

즉, 좀 더 쉽게 말해 클로저는 자신이 생성될 당시의 환경에서 알 수 있었던 식별자들 중에서 추후에 자신이 사용할 식별자들만 기억하여 메모리에 유지하는 함수라고 할 수 있습니다. 혹은 정말 간단히 표현하자면 함수의 코드(함수 body) + 생성될 당시의 외부 환경 이라고도 할 수 있겠네요!

이때 주의해야 할 것이, 클로저가 기억하는 것은 변수의 “값”이 아니라 “변수 그 자체”라는 점 입니다. 즉 클로저가 생성될 당시의 해당 변수의 값(스냅샷)이 아니라, “(메모리에) 살아있는” 변수 그 자체를 기억한다는 것입니다. 따라서 클로저가 기억하는 변수의 값은 계속해서 변할 수 있습니다:

function counter(incrementStep) {
  let count = 0;
  return function() {
    return count += incrementStep;
  }
}

const incrementBy3 = counter(3);
console.log(incrementBy3()); // 3
console.log(incrementBy3()); // 6
console.log(incrementBy3()); // 9

const incrementBy5 = counter(5);
console.log(incrementBy5()); // 5
console.log(incrementBy5()); // 10
console.log(incrementBy5()); // 15

이때, incrementBy3 함수와 incrementBy5 함수는 같은 정의(counter 함수의 body)를 통해 생성되었지만 다른 환경, 즉 incrementStep의 값이 3인 환경과 5인 환경을 각각 따로 저장하므로 서로 독립적으로 동작합니다.

그렇다면 자바스크립트의 모든 함수는 어느 곳에서 어떠한 방법으로 생성하든 간에 자신이 생성될 당시의 환경을 기억하는데, 그럼 자바스크립트의 모든 함수가 클로저일까요?

관점에 따라 그렇다고 할 수도 있고 아니라고 할 수도 있는데, 함수가 실질적으로 클로저가 되기 위한 중요한 조건은 다음과 같습니다:

클로저로 만들 함수를 fn이라고 한다면, fn이 클로저가 되기 위해선 fn 내에서 선언하지 않은 변수, 즉 자유 변수를 fn이 사용하고 있어야 합니다. 그 변수를 a라고 한다면 a라는 이름의 변수가 “fn을 생성한 환경(스코프)“에서 선언되었거나 알 수 있어야 합니다.

function parent() {
  const a = 5;
  function fn() {
    console.log(a);
  }
  // ...
}

function parent2() {
  const a = 5;
  function parent1() {
    function fn() {
      console.log(a);
    }
    // ...
  }
  // ...
}

parentparent2fn에선 a라는 자유 변수를 사용하고 있습니다. 이때 parent의 변수 afn을 생성하는 스코프에서 정의되었고, parent2의 변수 afn을 생성하는 스코프의 상위 스코프에서 정의되었다.

만약 위와 같은 조건을 만족하지 않는다면 제아무리 함수가 다른 함수의 내부에서 선언되었다고 해도 일반 함수와 다를 바가 없습니다. 또한 자신의 상위 스코프를 통해 알 수 있는 자유 변수를 사용하고 있지 않다면 그 환경을 기억해야 할 필요가 없습니다(자바스크립트 엔진에 따라 기억하는 경우가 있긴 하지만 사용하지 않기에 별 의미가 없다고 할 수 있습니다).

글로벌 스코프를 제외한 외부 스코프에 있었던 변수 중 클로저 혹은 다른 누군가가 참조하지 않는 변수는 실행 컨텍스트가 종료된 이후 가비지 컬렉션의 대상이 되어 사라집니다. 어떤 함수가 외부 스코프의 변수를 사용하지 않았고, 그로 인해 외부 스코프의 환경이 가비지 컬렉션의 대상이 된다면, 사라지게 내버려 두는 함수를 클로저라고 보기는 어렵습니다.

클로저에 대한 또 다른 관점으로, 자바스크립트 엔진이 클로저와 관련해서 어떻게 동작하는가를 살펴볼 수 있습니다. 2016년을 기준으로, 자바스크립트를 사용하는 대부분의 환경에서 특정 조건을 만족하는 함수만 클로저가 되는데, 클로저 생성에 대해선 V8 > SpiderMonkey(파이어 폭스) > WebKit(사파리) 순으로 최적화가 잘 되어 있습니다.

V8과 SpiderMonkey의 경우, 내부 함수가 사용하는 변수 중 외부 스코프의 변수가 하나도 없는 경우엔 클로저가 되지 않습니다. 또한 클로저가 된 경우에도 자신이 사용한 외부 변수만 기억하며 외부 스코프의 나머지 변수는 전혀 기억하지 않습니다 (여기서 외부 변수에는 외부의 함수도 포함됩니다. 쉽게 말해 “식별자”라고 생각하면 될 것 같습니다). 이외의 환경에서도 클로저를 외부로 리턴하여 해당 변수를 지속적으로 참조해야만 메모리에 남으며, 그렇지 않으면 모두 가비지 컬렉션의 대상이 됩니다.

예제들을 좀 더 살펴봅시다:

const a = 10;
const b = 20;
function f1() {
  return a + b;
}

f1(); // 30

이 예제에서 f1은 비록 앞서 강조했던 것 처럼 상위 스코프의 변수 ab를 사용하여 결과를 만들고 있으나 글로벌 스코프에서 선언된 모든 식별자는 해당 식별자를 사용하는 함수의 존재 유무와 관계없이 유지되기 때문에 f1은 엄밀히 따져 클로저가 아닙니다. 변수 abf1에 의해 (사라져야 하는데) 사라지지 못하는 상황이 아니기 때문이죠.

그렇다고 해서 클로저가 함수 안에서 함수가 생성되는 경우에만 생기는 것이라고 할 수 있는 것은 아닙니다. 웹 브라우저에선 함수 내부가 아니라면 모두 글로벌 스코프였지만, 요즘 자바스크립트에선 함수 내부가 아니면서 글로벌 스코프도 아닌 경우가 있습니다. ES6 모듈과 Node.js가 이러한 경우인데, 이 경우 js 파일 하나의 스코프는 모듈 스코프이지 글로벌 스코프가 아닙니다. 따라서 이러한 환경에서 위 예제를 실행하면 f1은 클로저라고 할 수 있습니다.

function f2() {
  const a = 10;
  const b = 20;
  function f3(c, d) {
    return c + d;
  }
  return f3;
}

const f4 = f2();
f4(1, 2); // 3

위 코드의 f3도 언뜻 보기엔 외부 함수 안에서 리턴되고 있으므로 클로저인 것처럼 보일 수 있으나, 사실 클로저가 아닙니다. 그 이유는, f3f2 안에서 생성되었고 f3 바로 위엔 f2 의 지역변수 a, b 가 존재하지만, f3 에서 사용하는 변수는 c, d 이고 이 두 변수는 모두 f3의 인자입니다. 즉, f3 함수는 자신이 생성될 당시의 스코프로부터 알 수 있었던 변수 a, b 를 사용하지 않았으므로 f3이 기억해야 할 외부 변수는 없습니다. 따라서 abf2가 종료되고 나면 사라지므로 f3은 클로저가 아닙니다.

function f5() {
  const a = 10;
  const b = 20;
  function f6() {
    return a + b;
  }
  return f6();
}
f5(); // 30

이 코드는 어떨까요? 정확히 말하자면, 이 코드에선 클로저가 “있었다”가 되겠습니다. 결과적으론 클로저가 없다고 할 수 있겠네요.

위 코드에선 f5가 실행되고 a, b가 할당된 후 f6가 정의됩니다. 그리고 f6 에는 a, b가 사용되고 있으므로 f6는 자신이 생성된 환경을 기억하는 클로저가 됩니다. 그런데 f5의 마지막 줄을 보면 f6가 아니라 f6(); 즉, f6 함수를 “실행한 결과”를 리턴하고 있음을 알 수 있습니다. 결국 f6를 참조하는 곳이 어디에도 없기 때문에 f6는 사라지게 되고, f6가 사라지면 a, b도 사라질 수 있기에 클로저는 f5가 실행되는 동안에만 잠깐 생겼다가 사라집니다.

function f7() {
  const a = 10;
  function f8(b) {
    return a + b;
  }
  return f8;
}

const f9 = f7();
f9(20); // 30
f9(10); // 20

드디어 클로저를 만났습니다! f8은 진짜 클로저 입니다! f8a를 사용하기 때문에 a를 기억해야 하고, f8f9에 담겼기 때문에 클로저가 되었습니다. 원래라면 f7의 지역 변수는 모두 사라져야 하지만 f7의 실행이 끝났어도 f8a를 기억하는 클로저가 되었기 때문에 a는 사라지지 않으며, f9를 실행할 때마다 새로운 변수인 b와 함께 사용되어 결과를 만듭니다(여기서도 만약 f7의 실행 결과인 f8f9에 담지 않았다면 f8은 클로저가 되지 않습니다).

또한 중요한 점이, 위 상황에서 메모리 누수는 존재하지 않는다는 점입니다. 메모리가 해제되지 않는 것과 메모리 누수는 다릅니다. 물론 메모리 누수는 메모리가 해제되지 않을 때 발생하는 것이지만 위 상황은 개발자가 의도한 것이기에 메모리 누수라고 하기 어렵습니다. a는 한 번 생겨날 뿐, 계속해서 생기거나 하지 않습니다. 개발자가 모르는 사이에 계속해서 새어 나가야 누수라고 할 수 있습니다. 메모리 누수가 지속적으로 반복될 때는 치명적인 문제를 초래할 수 있습니다.

어쨌든 위 코드에선 f9가 아무리 많이 실행되어도 이미 할당된 a가 그대로 유지되므로 메모리 누수는 일어나지 않습니다. 이와 같은 패턴도 필요한 상황에 잘 선택하여 얼마든지 활용할 수 있습니다.

function f10() {
  const a = 10;
  const f11 = function(c) {
    return a + b + c;
  }

  /* 혹은 */
  // const f11 = c => a + b+ c;
  
  // function f11(c) {
  //   return a + b + c;
  // }
  const b = 20;
  return f11;
}

const f12 = f10();
f12(30); // 60

위 코드에서 f12(30)의 실행 결과는 정상적으로 10 + 20 + 30 = 60이 됩니다. 앞서 클로저는 자신이 생성될 “당시”의 환경에서 알 수 있었던 변수를 기억하는 함수라고 했었는데, 여기서 “당시”는 생각보다 깁니다.

f11에는 익명 함수를 담았는데, f11이 생성되기 이전 시점에선 아직 b20으로 초기화되지 않았습니다. 클로저는 자신이 생성되는 스코프의 모든 라인, 즉 자신이 생성되는 스코프 내의 어느 곳에서 선언된 변수든 참조하고 기억할 수 있습니다. 클로저는 변수의 스냅샷이 아니라 변수 그 자체를 기억하므로 클로저가 생성된 이후에 클로저가 참조하는 변수의 값이 변해도 계속해서 클로저는 변한 값을 참조할 수 있습니다.

“당시”가 길다고 한 이유는, 여기서 말하는 “당시”가 함수가 생성되는 라인이나 그 이전을 의미하는 것이 아니라 해당 스코프가 실행되고 있는 컨텍스트 전체를 말하기 때문입니다. 만약 이 스코프 안에서 비동기가 일어나면 “당시”는 더욱 길어지게 될 수도 있습니다. 간혹 클로저를 설명할 때 캡처라는 말을 사용하기도 하는데 이는 오해를 불러일으킬 만한 단어입니다.

이때 스코프는 함수 스코프일 수도 있는데, 만일 함수라면 함수가 실행될 때마다 그 스코프의 환경은 새로 만들어집니다 (앞에서 살펴봤죠? 😁). 클로저가 생성될 “당시의 스코프(환경)에서 알 수 있었던”의 의미는 “클로저가 생성되고 있는 이번 실행 컨텍스트에서 알 수 있었던” 이라는 의미입니다.

“이번 실행 컨텍스트”라고 표현한 것은 이것이 계속해서 다시 발생하는 실행 컨텍스트이기 때문이고, 자기 부모 스코프의 실행 컨텍스트도 특정 시점 안에 있는 것이기 때문에 “있었던”이라는 시점을 담은 표현으로 설명했습니다.

클로저는 어디에 사용되는가?

앞서 살펴본 것처럼 클로저는 처음에 funarg 문제를 해결하기 위해 도입된 기술입니다. 이외에도 클로저는 변수나 함수 등을 은닉(즉, 노출을 최소화)하거나, 이전 상황을 나중의 상황과 이어가는 경우, 혹은 함수로 함수를 만들거나 부분 적용 할 때 주로 사용됩니다.

이 중에서 “이전 상황을 나중에 일어날 상황과 이어 나갈 때”에 대해 예를 들자면, 이벤트 리스너에게 이벤트 핸들러 함수를 넘기기 이전에 알 수 있었던 상황들을 변수에 담아 클로저로 만들어 기억해두는 경우가 있을 수 있습니다. 이렇게 하면 나중에 이벤트가 발생하여 클로저가 실행되었을 때, 기억해두었던 이전 변수들로 이전 상황을 (나중에 일어난 상황과) 이어나갈 수 있습니다.

또한 마지막으로, 흔한 클로저 실수와 같은 상황에 유의해서 클로저를 사용하도록 해야 합니다!

레퍼런스