Study/JavaScript

[JavaScript] 클로저 - 1부

life-of-jibro 2023. 3. 27. 11:41

1. Closure 란?

자바스크립트를 학습하다보면 클로저(Closure)라는 개념에 대해 많이 들어보게 됩니다. 하지만, 클로저는 자바스크립트 고유의 개념이 아닙니다. 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어들에서 사용되는 중요한 특성입니다. [1]

MDN에서는 클로저에 대해 다음과 같이 정의합니다. [2]


A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).


클로저함수와 함수가 선언된 렉시컬 환경의 조합이다.


즉, 클로저는 “함수가 선언된 렉시컬 환경”이 키워드가 되는 것입니다. [1]

1.1. 함수가 선언된 렉시컬 환경

다음 예제[2]를 살펴보도록 하겠습니다.

function init() {
    var name = 'Mozilla';
    function displayName() {
        alert(name);
    }
    displayName();
}

init(); // 'Mozilla'

위 예제 코드에서 init()은 지역변수 namedisplayName()을 생성합니다. 따라서 displayName()init() 안에 정의된 내부 함수이며 init() 내부에서만 사용할 수 있습니다. 여기에서 displayName() 함수는 name을 alert해줍니다. 하지만 displayName() 함수 그 어디에도 name이 없습니다. 즉, displayName() 함수는 내부 지역 변수가 없는 것입니다. 이 경우, 실행 컨텍스트에 의해 상위 렉시컬 환경을 참조하게 됩니다. displayName() 함수의 상위 스코프인 init() 함수의 지역변수로 name이 있기 때문에 해당 변수에 할당된 메모리 주소값에 저장된 “Mozilla”를 가져와 출력합니다.

만약 위 예제를 다음과 같이 바꾼다면 어떻게 될까요?

function init() {
    var name = 'Mozilla';
    displayName();
}

function displayName() {
    alert(name);
}

init(); // ''

init() 함수가 동작하고, name에 ‘Mozilla’가 값으로 할당된 다음, displayName() 함수를 호출하게 됩니다. 이에 따라 displayName() 함수가 동작하는데, 이 때 alert() 함수는 동작하나 namedisplayName() 스코프 안에 없고, 그 상위 스코프인 전역 스코프에서는 참조할 수 있는 name이 없기 때문에 아무것도 적히지 않은 알림창만 브라우저에 나타나게 됩니다.

위 두 예제를 통해 우리는 자바스크립트가 렉시컬 스코프를 따르는 동작 방식으로 구성되어 있는 언어라는 것을 확인할 수 있습니다.

1.2. 렉시컬 스코프 [1]

실행 컨텍스트 관점에서 렉시컬 스코프를 살펴보면 다음과 같이 정의할 수 있다.

자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다. 이를 렉시컬 스코프(정적 스코프)라 한다.

실행 컨텍스트에 따라 스코프는 실행 컨텍스트의 렉시컬 환경이다. 그리고 이 렉시컬 환경은 자신의 “외부 렉시컬 환경에 대한 참조”를 말한다. 따라서 “함수의 상위 스코프를 결정한다.”는 것은 “렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값을 결정”하는 것과 같다. 그러므로, “함수를 어디에 정의했는지”는 곧, “정의한 함수의 외부 렉시컬 환경에 대한 참조값을 무엇으로 정의했는지”와 동일하다.

1.3. 함수 객체의 내부 슬롯 [[Environment]] [1]

함수가 정의된 환경(위치)와 호출되는 환경(위치)는 다를 수 있습니다. 따라서 렉시컬 스코프가 가능하려면 함수는 자신이 호출되는 환경과는 무관하게 자신이 정의된 환경, 즉 상위 스코프(함수 정의가 위치하는 스코프가 바로 상위 스코프입니다.)를 기억해야 합니다. 이를 위해 함수는 자신이 평가되어 함수 객체가 생성되는 시점에 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장합니다. 왜냐하면 함수 정의가 평가되어 함수 객체를 생성하는 시점은 함수가 정의된 환경, 즉 상위 함수(또는 전역 코드)가 평가 또는 실행되고 있는 시점이며, 이 때 현재 실행 중인 실행 컨텍스트는 상위 함수(또는 전역 코드)의 실행 컨텍스트이기 때문입니다.

따라서 함수 객체의 내부 슬롯 [[Environment]]에 저장된 현재 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조가 바로 상위 스코프입니다. 또한, 자신이 호출되었을 때 생성될 함수 렉시컬 환경의 “외부 렉시컬 환경에 대한 참조”에 저장된 참조값입니다. 함수 객체는 내부 슬롯 [[Environment]]에 저장한 렉시컬 환경의 참조, 즉 상위 스코프를 자신이 존재하는 한 기억합니다.

다음 예제에 대한 실행 컨텍스트를 도식화 하면 다음과 같습니다.

const x = 1;
// foo함수가 평가되어 foo함수 객체가 생성된 시점이
// foo함수의 상위 스코프가 된다.
function foo() {
    const x = 10;
    bar();
}
// bar함수가 평가되어 bar함수 객체가 생성된 시점이 
// bar함수의 상위 스코프가 된다.
function bar() {
    console.log(x);
}

foo(); // 1
bar(); // 1

함수 객체의 내부 슬롯 [[Environment]]에는 상위 스코프가 저장된다.

따라서 함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조는 바로 함수의 상위 스코프를 의미합니다.

1.4. 클로저와 렉시컬 환경 [1]

앞서 언급한 렉시컬 환경과 렉시컬 스코프의 내용을 기반으로 다음 코드를 살펴보겠습니다.

const x = 1;

// (1)
function outer() {
    const x = 10;
    const inner = function () {
        console.log(x); // (2)
    }
    return inner;
}

// outer함수를 호출하면 중첩함수 inner를 반환한다.
// 그리고 outer함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다.
const innerFunc = outer(); // (3)
innerFunc(); // 10

위의 코드를 살펴보면, outer함수가 inner함수를 반환하며 outer 함수는 실행 컨텍스트의 스택에서 제거되고 반환된 inner 함수는 innerFunc 변수에 저장된다. innerFunc을 실행한 결과, 10이 출력된다. 어떻게 된 일인 걸까?

innerFunc에 할당된 inner 함수는 상위 스코프로 outer 함수를 가진다. 그리고 10이 출력되었다는 것은 바로 outer 함수가 실행 컨텍스트 스택에서는 제거되었지만, 여전히 inner 함수의 [[Environment]] 내부 슬롯에 의해 outer 함수의 렉시컬 환경이 참조되고 있음을 의미한다.

위의 내용을 도식화하면 다음과 같다.

Step 1 - 전역 함수 객체의 상위 스코프 결정

Step 2 - 중첩 함수의 상위 스코프 결정

Step 3 - outer 함수의 실행 컨텍스트가 제거되어도 outer 함수의 렉시컬 환경은 유지된다.

Step 4 - 외부 함수가 소멸해도 반환된 중첩 합수는 외부 함수의 변수를 참조할 수 있다.

앞서 살펴본 바와 같이 중첩 함수 inner는 자신이 정의된 위치에 의해 결정된 상위 스코프를 기억하여 참조할 수 있으므로 상위 스코프의 식별자 x를 참조할 수 있고, 삭별자의 값을 변경할 수도 있다.

자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저이다. 하지만 일반적으로 모든 함수를 클로저라고 하지는 않는다. 그 이유에 대해 살펴보자.

1.5. 클로저가 아닌 경우 [1]

클로저라고 하기 위한 조건들에 대해 예제와 함께 살펴보도록 하겠습니다.

1.5.1. 상위 스코프의 참조값이 없는 경우

아래의 예제를 살펴보자.

// ex 1
function foo() {
    const x = 1;
    const y = 2;

    // 상위 스코프인 foo 함수 내의 어떠한 식별자도 참조하지 않고 있다.
    // 이러한 경우 bar 함수는 클로저라고 볼 수 없다.
    function bar() {
        const z = 3;
        // debugger;
        console.log(z);
    }

    return bar;
}

const bar = foo();
bar();

위 예제를 통해 알 수 있듯이, 상위 스코프의 어떤 식별자도 참조하지 않는 경우 최신 브라우저는 메모리의 낭비를 줄이기 위해 상위 스코프를 기억하고 있지 않는다. 따라서 “상위 스코프의 어떤 식별자도 참조하지 않는 경우의 중첩 함수는 클로저라고 할 수 없다.”

1.5.2. 생명주기가 외부 함수보다 짧은 경우

function foo() {
    const x = 1;

    // bar 함수는 클로저였지만, foo 함수보다 빠르게 소멸한다.
    // 이러한 경우에도 클로저라고 할 수 없다.
    function bar() {
        // debugger;
        console.log(x);
    }
    // bar 함수가 호출되며 상위 스코프인 foo 함수의 식별자 x를 참조한다.
    // 하지만 foo 함수 내에서 bar 함수가 실행되고
    // 실행 컨텍스트 스택에서 사라진다.
    bar();
}

foo();

위 예제를 통해 알 수 있듯이, 외부 함수 foo보다 중첩 함수 bar의 생명 주기가 짧기 때문에, 중첩 함수 bar가 상위 스코프의 식별자인 x를 참조하고 있음에도 불구하고, “생명 주기가 종료된 외부 함수의 식별자를 참조할 수 있다.”는 클로저의 본질에 부합하지 못하다. 따라서 생명 주기가 외부 함수보다 짧은 중첩 함수에 대해서도 클로저라고 하지 않는다.

1.6. 클로저의 진정한 의미를 찾아서! [1]

처음 클로저에 대한 MDN의 정의였던 “함수와 함수가 선언된 렉시컬 환경의 조합”은 잘 와닿지 못하다. 앞선 우리의 과정에서 진정한 클로저의 의미를 파악하기 위해 마지막으로 하나의 예제를 더 살펴보자.

function foo() {
    const x = 1;
    const y = 2;

    // 중첩 함수 bar는 외부 함수보다 더 오래 유지되며
    // 상위 스코프의 식별자 x를 참조한다.
    function bar() {
        // debugger;
        console.log(x); // 1
    }
    return bar;
}

const bar = foo();
bar();

위 예제의 중첩 함수 bar는 상위 스코프의 식별자를 참조하고 있으므로 클로저다. 그리고 외부 함수의 외부로 반환되어 외부 함수보다 더 오래 살아 남아있게 된다. 이러한 중첩 함수를 클로저라고 한다.

앞서 1.5에서 살펴본 “클로저가 아닌 경우”를 통해 우리는 클로저를 다음과 같이 다시 정의할 수 있다.

클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고, 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적이다.

하지만 위의 예제에서 중첩 함수 bar는 상위 스코프의 식별자인 x, yx만을 참조하고 있다. 이러한 경우, 참조하고 있는 식별자 x에 대해서만 기억하게 된다. 그리고 이를 “자유 변수(free variable)”라고 한다.

따라서 클로저(Closure)란, “함수가 자유 변수에 대해 닫혀 있다(closed).”라는 의미이다.

참고자료

[1] 모던 자바스크립트 딥다이브, 이웅모, 2022/08/24 - pp. 388 - 403

[2] MDN 클로저 - https://developer.mozilla.org/ko/docs/Web/JavaScript/Closures

[3] 모던 자바스크립트 딥다이브, 이웅모, 2022/08/24 - p. 366