본문 바로가기

JavaScript

closure

클로저란.

 

A closure is the combination of a function and the lexical environment within which that function was declared. - MDN

 

클로저는 렉시컬 스코프에 의존하여 코드를 작성한 결과로 그냥 발생하는 것이다. 모든 코드에서 클로저는 발생하고 사용되고 있다. - You don't know JS

 

자신을 내포하는 함수의 컨텍스트에 접근할 수 있는 함수이다. - 자바스크립트 핵심 가이드

 

함수가 특정 스코프에 접근할 수 있도록 의도적으로 그 스코프에서 정의하는 것 - 러닝 자바스크립트

 

이미 생명 주기가 끝난 외부 함수의 변수를 참조하는 함수 - 인사이드 자바스크립트

 

자유 변수가 있는 함수와 자유 변수를 알 수 있는 환경의 결합 - Head First JavaScript Programming

 

자신이 생성될 때의 스코프에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하여 유지시키는 함수 - 함수형 자바스크립트 프로그래밍

 

함수를 선언할 때 만들어지는 유효범위가 사라진 후에도 호출할 수 있는 함수 - 자바스크립트 닌자 비급

 

클로저의 정의중 제가 가장 좋아하는 정의는 MDN의 정의입니다.  클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical Environment)과의 조합이다.

 

클로저는 자바스크립트의 고유 개념이 아닙니다. 단지 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성일 뿐이죠. 위와 같이 클로저의 정의가 다양한 이유또한 자바스크립트의 고유 개념이 아니기 때문에 ECMAScript 명세에도 클로저의 정의를 다루지 않기 때문입니다. 

 

closure의 개념을 이해해라면 스코프의 개념을 알아야합니다. 스코프는 함수가 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정됩니다. 이를 렉시컬 스코핑(Lexical scoping)이라고 합니다.

 

예제를 하나 보겠습니다.

const outer = function() {
	let a = 1; 
    const inner = function() {
    	console.log(++a);
    }
    inner()
}
outer()

 

내부 함수가 호출되면 자신의 실행 컨텍스트가 실행 컨텍스트 스택에 쌓이고 변수 객체 (Variable Object)와 스코프 체인(Scope chain) 그리고 this에 바인딩할 객체가 결정된다. 이때 스코프 체인은 전역 스코프를 가리키는 전역 객체와 함수 outer의 스코프를 가리키는 함수 outer의 활성 객체(Activation object)그리고 함수 자신의 스코프를 가리키는 활성 객체를 순차적으로 바인딩한다. 스코프 체이닝이 바인딩한 객체가 바로 렉시컬 스코프의 실체이다. 

 

inner에서 외부 함수의 변수 a에 접근할 수 있다는 것은 상위 스코프에 접근할 수 있다는 것을 말하고, 이 뜻은 렉시컬 스코프의 레퍼런스를 차례대로 저장하고 있는 실행 컨텍스트의 스코프 체인을 자바스크립트 엔진이 검색했기에 가능하다.

 

1. inner 함수 스코프(함수 자신의 스코프를 가리키는 활성 객체) 내에서 변수 a를 검색 -> 검색이 실패

2. inner 함수를 포함하는 외부 함수 outer의 스코프(outer의 스코프를 가리키는 함수. outer의 활성 객체)에서 변수 a를 검색한다. 검색이 성공하였다.

 

inner 함수에서 outer의 LexicalEnvironment를 사용합니다. 내부함수에서 a를 선언하지 않았기 때문에 environmentRecord에서 값을 찾지 못하므로 outerEnvironmentReference에 지정된 상위 컨텍스트인 outer의 LexicalEnvirnment에 접근해서 a를 찾습니다. 이때 outer 함수의 실행 컨텍스트가 종료되면 LexicalEnvironment에 저장된 식별자들 (a, inner)에 대한 참조를 지웁니다. 그러면 각 주소에 저장돼 있던 값들은 자신을 참조하는 변수가 하나도 없게 되므로 가비지 컬렉터의 수집 대상이됩니다. 

 

const outer = function () {
  let a = 1;
  const inner = function () {
    return ++a;
  };
  return inner;
};

const outer2 = outer();
console.log(outer2()); // 2
console.log(outer2()); // 3

 

위와 같은 경우는 어떨까요? 이번에는 함수 자체를 반환합니다. outer2는 outer의 실행결과인 inner함수를 참조하게 됩니다. inner함수의 실행 컨텍스트의 environmentRecord에는 수집할 정보가 없습니다. outerEnvironmentReference에는 inner함수가 선언된 위치의 LexicalEnvironment가 참조 복사됩니다. 

 

inner함수는 outer 함수 내부에 선언됐으므로, outer함수의 LexicalEnvironment가 담깁니다. 이제 스코프 체이닝에 따라서 outer에 선언한 변수 a에 접근해서 1만큼 증가시킨 후 그 값인 2를 반환하고 inner함수의 실행 컨텍스트가 종료됩니다. 그후 outer2를 한번도 호출하면 같은 방식으로 a의 값을 2에서3으로 1증가시킨후 3을 반환합니다. inner함수의 실행 시점에는 outer 함수는 이미 실행 종료된 상태인데 outer함수의 LexicalEnvironment에 어떻게 접근할 수 있는 걸까요? 

 

이는 가비지 컬렉터의 동작 방식 때문입니다. 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 존재하면 그 값은 수집 대상에 포함시키지 않습니다.

 

이게 클로저입니다. 또한, 클로저에 의해 참조되는 외부함수의 변수 즉 outer 함수의 변수 x를 자유변수(Free variable)이라고 부릅니다. 클로저라는 이름은 자유변수에 함수가 닫혀있다(closed)라는 의미로 의역하면 자유변수에 엮여있는 함수라는 뜻입니다.

 

실행 컨텍스트 관점에서 이야기한다면 내부함수가 유효한 상태에서 외부함수가 종료하여 외부함수의 실행컨텍스트가 반환되어도, 외부함수 실행 컨텍스트 내에 활성 객체(Activation Object)(변수, 함수, 선언등의 정보를 가지고 있다)는 내부 함수에 의해 참조되는 한 유효하여 내부함수가 스코프 체인을 통해 참조할 수 있는 것을 의미합니다.

 

외부함수인 outer의 실행이 종료되더라도 내부함수인 inner 함수는 언젠가 outer2함수를 실행함으로써 호출될 가능성이 열립니다. 언젠가 inner함수의 실행 컨텍스트가 활성화되면 outerEnvironmentReference가 outer함수의 LexicalEnvironment를 필요로 할 것이므로 수집 대상에서 제외합니다. 그 덕분에 inner 함수가 이 변수에 접근할 수 있습니다.

 

함수의 실행 컨텍스트가 종료된 후에도 LexicalEnvironment가 가비지 컬렉터의 수집 대상에서 제외되는 경우는 지역변수를 참조하는 내부함수가 외부로 전달된 경우가 유일합니다. 

 

결국, MDN의 클로저 정의를 좀더 쉽게 풀어쓴다면, 클로저란 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상을 말합니다. 

closure의 활용방안

자바스크립트는 클로저를 활용해서 전역 변수의 사용을 줄일 수 있습니다. 전역 변수는 언제든지 접근가능하고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야합니다. 

 

변수의 값은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적 원인이 될 수 있습니다. 상태 변경이나 가변(mutable) 데이터를 피하고 불변성(Immutability)을 지향하는 함수형 프로그래밍에서 부수효과(side effect)를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저는 적극적으로 사용됩니다. 

 

클로저는 커링함수에 쓰입니다. 커링함수란 여러개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출할 수 있게 체인 형태로 구성하는 것을 말합니다. 커링은 한번에 하나의 인자를 전달하는 것을 원칙으로한다. 또한 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않습니다.

 

const curry = function(func) {
	return function(a) {
            return function(b) {
                return func(a,b)
            }
    }
}

const getMaxWith10 = curry(Math.max)(10)
console.log(getMaxWith10(8)) // 10
console.log(getMaxWith10(25)) // 25

const curry2 = (func) => (a) => (b) => (c) => (d) => (e) => func(a,b,c,d,e)

 

각 단계에서 받은 인자들을 모두 마지막 단계에서 참조할 것이므로 GC되지 않고 메모리에 차곡차곡 쌓았다가, 마지막 호출로 실행 컨텍스트가 종료된 후에야 비로서 한꺼번에 GC의 대상이 됩니다.

 

커링 함수는 당장 필요한 정보만 받아서 전달하고 또 필요한 정보가 들어오면 전달하는 식으로 결국 마지막 인자가 넘어갈 때까지 함수의 실행을 미룹니다. 이를 함수형 프로그래밍에서는 지연 실행(lazy execution)이라고 합니다. 원하는 시점까지 지연시켰다가 실행하는 것이 요긴한 상황이라면 커링을 쓰는 것이 적합합니다.

 

 

Reference

MDN

Core JavaScript

Modern JavaScript Deep Dive