클로저 (closure)

컴퓨터 과학 분야에서 클로저라는 개념은 란딘이 발표한 논문 The mechanical evaluation of expressions에서 처음 등장하지만 자바스크립트를 사용하는데 있어 이 정의는 크게 도움이 되지 않는다.

그는 클로저를 다음과 같이 정의하였다

클로저는 환경 파트와 컨트롤 파트로 나뉘어진 람다 표현식이고 이는 표현식을 평가하는데 쓰인다.
(a closure has an environment part and a control part which consists of a list whose sole item is an AE.)

(www.cs.cmu.edu/afs/cs/user/crary/www/819-f09/Landin64.pdf)
자바스크립트의 명세인 ECMAScript 스펙에도 클로저는 정의되어 있지 않다. 따라서 자바스크립트 내부에서 정확히 클로저가 무엇을 의미하는지는 알 수 없다.

다만 자바스크립트의 가상 머신인 v8에 정의된 클로저 스코프라는 용어를 근거로 정의할 수 있다

자바스크립트에서 클로저란 클로저 스코프를 포함한 함수를 뜻한다

만일 함수가 클로저 스코프를 포함하지 않아도 클로저가 될 수 있다면 클로저 스코프의 존재 의의가 없어진다.

그런 의미에서 이러한 정의는 어느정도의 설득력을 가질 수 있다.

위의 정의에 의하면 함수가 가지고 있는 스코프 체인 내부에 클로저 스코프라는 유형의 스코프가 존재할 때 해당 함수는 클로저이다.

클로저 스코프란 스코프의 일종인데 V8 엔진 내부에는 총 9종류의 스코프가 존재한다

enum ScopeType {

    ScopeTypeGlobal = 0,
    ScopeTypeLocal,
    ScopeTypeWith,
    ScopeTypeClosure,
    ScopeTypeCatch,
    ScopeTypeBlock,
    ScopeTypeScript,
    ScopeTypeEval,
    ScopeTypeModule
};

< 참고 : v8/src/debug/debug-scopes.h >

이 정보는 enum 형식으로 ScopeIterator 클래스에 정의되어 있다.

클로저 스코프는 아래와 같은 상황에서 생성된다.

function outer() {

    var free = 1;

    function inner() {
        free++;
    };

    inner();
};

위의 코드는 함수가 중첩되어 있는 상황에서 안쪽에 정의된 함수가 바깥쪽에 정의된 변수를 참조하는 상황이다.

inner 함수가 호출될 때 생성되는 스코프 체인은 아래와 같다

aaa

위의 스코프에서 Closure (outer) 라고 적혀있는 스코프가 클로저 스코프이다. 변수 free는 클로저 스코프에 의하여 식별된다.

클로저 스코프가 생성되기 위한 조건을 정리하면 아래와 같다.

1. 두개의 함수가 있다. 이를 각각 inner 함수와 outer 함수라 하자.

2. inner 함수는 outer 함수 내부에 정의되어 있다.

3. inner 함수는 outer함수에 정의된 지역변수를 참조한다.
이 지역변수를 참조하는 과정에서 스코프 체이닝을 수행한다.
스코프 체이닝을 수행하는 과정에서 일련의 스코프를 순차적으로 탐색한다.

4. 스코프 탐색 도중 outer함수에 정의된 지역변수를 발견한다.
이 스코프의 이름을 클로저 스코프라고 한다

위의 내용을 더 간단하게 정리하면 아래와 같다.

함수가 중첩되어 있는 상황에서 안쪽의 함수가 바깥쪽 함수에 정의된 변수를 참조할 때 클로저 스코프라는 이름의 스코프에서 참조한다

위의 내용만 듣고보면 클로저의 유용함을 납득하기 어렵다. 위의 정의를 재해석해보면 아래와 같다

외부에 상태를 보존하고 있는 함수를 클로저라고 한다

outer 함수에 정의된 변수를 상태의 관점에서 접근하면 클로저의 유용함을 이해하기 편하다.

클로저는 함수가 호출되는 시점의 상태에 근거하여 리턴값을 반환할 수 있다. 상태라는 개념은 함수 내부에 선언된 변수가 아닌 함수 외부에 선언된 변수에 접근하여 구현할 수 있다.

이처럼 함수 외부에 선언되었고 함수 내부에서 접근할 수 있는 변수를 자유변수라고 한다. 함수를 호출할 때 중간 상태값에 근거하여 리턴값을 반환할 수 있다는 데서 자유변수의 의의가 있다.

이처럼 자유변수를 특정 함수에서만 접근할 수 있는 패턴은 아래와 같이 작성한다

function outer() {

    var free = 1;

    return function inner() {
        free++;
    };
};

const inner = outer();
inner();

위의 코드는 outer 함수를 호출하고 리턴값으로 inner 함수의 참조값을 반환받는다. 그리고 반환받은 inner 함수를 호출한다.

호출된 inner 함수는 변수 free를 참조하여 값을 1 증가시킨다. 만일 클로저라는 개념이 없다면 위의 코드는 에러를 발생시킨다. 왜냐하면 inner 함수를 호출하는 시점에는 메모리상에 변수 free가 해제되어 존재하지 않기 때문이다. 클로저가 없다고 가정하고 이 상황을 정리하면 아래와 같다

  1. 먼저 outer 함수가 호출된다. outer 함수가 호출되는 시점에 outer 함수의 실행 컨텍스트가 생성된다. 실행 컨텍스트 내부에는 outer함수 내부에서 선언된 지역 변수인 free가 저장되어 있다. 이 실행 컨텍스트는 메모리 heap 영역에 저장된다.

  2. outer 함수의 루틴이 실행되고 먼저 변수 free에 값 1을 대입한다.

  3. 함수 inner를 리턴한다. 이 때 inner 함수 자체를 리턴하지 않으며 함수가 메모리상에서 정의된 메모리 주소를 리턴한다.
    이를 참조값(reference) 이라고 한다. 따라서 함수의 참조값을 리턴하기 전 메모리상에 함수에 대한 정보를 저장해 놓는다.
    이 함수에 대한 정보에는 식별자 해결(identifier resolution) 메커니즘인 스코프 체이 포함되어 있다.
    스코프 체인은 함수 내부에서 사용되는 변수가 어떤 실행 컨텍스트에 정의되어 있는지 판별하는 식별자 해결 메커니즘이다.
    inner 함수에서 참조하는 변수 free는 함수 외부에 정의되어 있다. 따라서 이 free 변수를 참조할 수 있는 스코프를 스코프 체인 내부에 저장해 놓는다. 이 스코프의 이름은 outer이다

  4. outer 함수가 리턴되고 outer 함수의 변수를 저장해 놓은 실행 컨텍스트가 메모리에서 해제된다. 이제 outer함수의 실행 컨텍스트에 저장된 변수 free에 접근할 수 없다

  5. inner 함수가 호출된다. inner 함수가 호출되는 시점에 inner 함수의 실행 컨텍스트가 생성된다. 실행 컨텍스트 생성 과정에서 어떠한 지역 변수가 선언되었는지 확인한다. 확인결과 inner 함수의 내부에는 어떠한 지역 변수도 선언되지 않았다. 따라서 inner 함수의 실행 컨텍스트에는 어떠한 지역 변수도 정의되지 않는다.

  6. inner 함수 내부에서 free++ ; 연산을 수행한다. 이 때 free 변수가 inner함수 내부에 저장되어 있는지 확인하기 위하여 실행 컨텍스트를 참고한다. 확인결과 inner 함수는 정의되지 않았다. 따라서 식별자 해결을 수행하는데 이 때 스코프 체인을 참고한다. 이 스코프체인은 스탭 3에서 생성된 스코프 체인이고 이 스코프 체인에 따르면 free 변수는 outer 함수의 실행 컨텍스트에 저장되어 있다. 따라서 outer 함수의 컨텍스트로 이동하여 해당 변수 값을 참고하려 할 것이다.

  7. 그러나 outer 함수의 실행 컨텍스트는 outer 함수가 종료된 시점에 메모리 상에서 사라졌다. 따라서 outer 함수의 실행 컨텍스트를 참조할 수 없고 결과적으로 free 변수도 참조할 수 없다

  8. 변수를 참조할 수 없으므로 자바스크립트 엔진은 ‘Uncaught ReferenceError: free is not defined’ 에러를 발생시키고 프로그램을 종료한다 그러나 위의 시나리오와는 다르게 free 변수는 정상적으로 참조할 수 있다. 그 이유는 자바스크립트 엔진이 outer 함수의 실행 컨텍스트를 해제하지 않았기 떄문이다. 본래대로라면 함수가 리턴되는 순간 실행 컨텍스트가 해제되지만 몇가지 조건이 갖추어지면 함수가 리턴되는 상황에서도 실행 컨텍스트가 해제되지 않는다. 그 조건은 아래와 같다

1. 함수가 리턴할 때 자기자신이 아닌 또다른 함수를 리턴한다.
리턴되는 함수를 inner라 하자.

2. inner 함수 내부에서 현재 실행중인 함수의 지역변수를 참조한다

위와 같은 조건에서는 함수가 종료되어도 실행 컨텍스트가 해제되지 않는다. 이렇게 되면 outer 변수의 free에 접근할 수 있는 수단은 inner함수를 호출하는 방법 외에는 없다.

이로서 inner 함수가 자유변수를 사용할 수 있게 되었고, 오직 inner 함수를 통해서만 자유변수에 접근할 수 있게 되었다. 객체지향 프로그래밍을 공부해 본 사람이라면 이러한 개념이 객체가 제공하는 private 변수와 유사하다는 것을 느꼇을 것이다. 실제로 객체의 private 변수와 클로저의 자유변수는 개념상 거의 유사하고 추구하는 목적은 같다. 이러한 유사성에 대하여 윌리엄스 칼리지의 Daniel Barowy 교수는 다음과 같이 말했다

Objects are kind of closure

(www.cs.williams.edu/~dbarowy/cs334s18/assets/lecture_2018-04-10.pdf)

그리고 Norman Adams라는 사람은 다음과 같이 말했다

Objects are a poor man’s closures
(객체란 실력이 부족한 사람들이 사용하는 클로저이다)

(Ken Dickey, "Scheming with Objects")

반면 소르본 대학의 명예교수인 크리스티안(Christian Queinnec)은 다음과 같이 말했다

많은 사람들이 객체를 두고 실력이 부족한 사람들이 사용하는 클로저라고 말하는데,
사실 클로저는 실력이 부족한 사람들이 사용하는 객체이다.
(although many people consider objects to be ‘poor man’s closures, closures are in fact poor man’s objects)

(people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg03277.html)

위의 주장들은 누구의 주장이 옮다는 차원을 넘어서 클로저와 객체가 같은 지향점을 향한다는 점을 시사한다.

이 둘은 모두 캡슐화를 지향한다. 그 목표를 달성하는 수단이 다를 뿐이다
이런 점을 감안하면 굳이 클로저에 정의에 얽매일 필요가 없다. 자유변수를 하나의 함수에서만 접근할 수 있으면 그 함수는 클로저가 아니더라도 클로저처럼 사용할 수 있다

{
    let free = 0;

    function func() {
        return ++free;
    }
}

console.log(func());
console.log(func());
console.log(func());

위의 코드에서 함수 func는 정의상으로는 클로저가 아닐지 몰라도 의미상으로는 클로저와 같다. 왜 그런지 알아보자.

함수 func는 자유변수 free를 참조한다. 그리고 변수 free는 func를 통해서만 접근할 수 있다. 왜냐하면 블록 스코프 { } 내부에 정의된 let은 블록 스코프에서 벗어나는 순간 접근이 불가하기 때문이다.

본래대로라면 블록스코프 { } 를 벗어난 순간 변수 free는 메모리에서 해제되어 접근할 수 있는 방법이 없어지게 된다. 하지만 블록 스코프를 벗어나는 시점에 지역변수 free를 참조하는 함수가 존재하는지 검사하는데 위의 경우 함수 func가 변수 free를 참조하기 때문에 블록 스코프는 메모리에서 해제되지 않는다. 메모리에서 해제되지는 않지만 블록스코프를 벗어난 지점에서 변수 free에 직접 접근할 수는 없다. 변수 free는 func 함수를 호출하여 참조할 수 있고 그 외에 접근할 수 있는 수단은 없다. 자유변수를 참조할 수 있는 방법을 강제로 제한해 버린다는 측면에서 이 패턴은 클로저로 분류될 수 있다.
그렇다면 함수 func는 변수 free를 참조하는 스코프를 클로저스코프로 분류하는가? 아래 그림은 변수 free가 어느 스코프에서 참조되는지를 보여준다

aaa

변수 free는 block 스코프에서 참조된다. 그렇다면 변수 free가 클로저스코프에서 참조되지 않으니 함수 func는 클로저가 아니라고 말할 수 있는가? 정의상으로 클로저가 맞던 틀리던 간에 결과적으로 위의 자유변수 free는 객체지향 언어의 private 변수와 같은 개념으로 func함수가 접근할 수 있다. 따라서 클로저스코프를 생성하지는 않으나 함수를 사용하는 입장에서 func함수를 클로저로 보아도 큰 지장은 없다. 클로저의 정의에 얾매일 필요가 없다는 말은 이런 이유 때문이다.

주제를 바꿔서 이야기해보자면, 상태를 보존한다는 개념이 구체적으로 어떤 유용함이 있는가? 게임의 예를 들어보자.

1:1 격투게임을 구현할 때 각 플레이어의 HP를 감소시키려는 함수를 구현한다. 클로저를 사용하지 않은 상태에서 순수함수의 형태로 구현하면 아래와 같다.

function modifyHP(currentHP, damage) {

    return currentHP - damage;
}

그리고 위의 함수는 아래와 같은 형태로 사용된다

const damage = getDamage();

const currentHP = getCurrentHP();

const modifiedHP = modifyHP(currentHP, damage);

saveHP(modifiedHP);

만일 상태가 없는 함수를 사용한다면 상태를 불러와 함수를 호출하고 그 리턴값을 다른 함수를 통하여 저장해야 한다

반면 HP감소 함수를 클로저로 구현하면 아래와 같다.

function hp수정(maxHP) {

    let currentHP = maxHP;

    return function (damage) {
        return currentHP - damage;
    }
};

const modifyHP = outer(100);

그리고 위의 클로저 함수는 아래와 같이 사용된다.

const damage = getDamage()

const modifiedHP = modifyHP(damage)

내부에서 상태를 저장하므로 외부에서 상태를 불러올 필요가 없고 외부 함수를 통하여 상태를 저장할 필요가 없다.
한마디로 클로저는 개발의 편의성을 제공한다.

그러나 함수 외부에서 상태를 참조하는 것이 목적이라면 전역 변수를 참조하면 되는데 굳이 외부함수를 만들어 가면서까지 상태를 저장할 필요가 있는지 그 실용성에 의구심을 가질 수가 있다. 위에서 구현한 HP 감소 함수를 전역 변수를 사용하여 구현하면 다음과 같다.

let currentHP = maxHP;

function modifyHP(damage) {
    return currentHP - damage;
}

위의 코드는 클로저에 비하여 직관적이고 코드량이 적다. 모든 면에서 클로저보다 이상적으로 보이기도 한다.

소규모 프로젝트를 진행중이라면 이런 식의 코드 작성이 더 나은 선택일 수도 있다. 하지만 20명 이상이 참여하는 대규모 프론트엔드 프로젝트라면 이야기가 달라질 수도 있다.

위에서 정의한 currentHP 변수는 전역변수이기 때문에 나 뿐만이 아닌 다른 모든 팀원이 접근할 수 있다. 따라서 다른 팀원이 currentHP 변수에 접근하지 않아야 한다는 보장이 필요하다. 만일 다른 팀원이 currentHP 변수에 접근하여 내가 의도한 것과 다른 방식으로 변수값을 조정하는 경우 플레이어의 hp가 의도치 않은 상황에 증가하거나 감소하는 상황이 발생하여 게임 진행 자체가 불가능하게 될 것이다. 이와 같은 상황을 방지하려면 전역변수를 생성할 때 마다 다른 모든 팀원을 대상으로 공지사항을 알려줘야 한다.

currentHP는 제가 만든 변수이니 이 변수를 사용하지 말라.
currentMP도 접근하지 말라
currentPowerGauge 접근하면 안되고 …

코드를 작성할 수록 전역 변수는 늘어난다. 프로그램 실행중의 에러를 방지하려면 이 모든것을 문서화 해야 할 것이다. 이 자체만으로도 어려운 일이지만 만일 나 뿐만이 아닌 다른 모든 팀원들이 전역 변수를 사용한다면? 접근해서는 안되는 수백개의 전역변수를 기억한 채로 코드를 작성해야 한다. 이것은 고역이고 가능하지도 않다.

더 큰 문제는 누군가가 currentHP에 임의로 접근하여 의도치 않은 상황에 hp가 증가하거나 감소하는 경우, 디버깅 대상이 되는 코드는 프로젝트 내의 전체 코드가 된다. 프로그램 내에 어떠한 문맥에서도 전역변수에 접근할 수 있기 때문이다. 이 경우 어떻게 디버깅을 진행할 것인가 ? 팀원 한명한명을 불러서 네가 currentHP에 접근했는지 심문할 것인가? 사실 개발자는 자기가 작성한 코드를 일일이 기억할 수 없다. 따라서 ‘나는 currentHP를 건드리지 않았는데요’ 라고 어떤 개발자가 말했다고 해서 그 사람이 currentHP에 접근하지 않았다는 보장이 없다. 만일 전체 소스코드가 충분히 복잡하고 해당 버그를 재현하기도 어려운 상황이라면 최악의 상황에는 버그를 수정할 수 없는 상황까지 발생할 수 있다.

이처럼 전역변수를 사용하는 데서 오는 코스트는 감당하기 어렵다. 개발자들은 디버깅 코스트를 최소화하기 위하여 오랜기간 고민하였고 그 결과 나온 개념이 함수형 프로그래밍 진형에서는 클로저이고 객체지향 프로그래밍 진형에서는 프라이빗(private) 멤버변수라는 형태로 고안되었다. 그 결과 변수가 오용될 수 있는 범위를 극적으로 줄일 수 있게 되었다. 더불어 상태값이 잘못 할당되어 프로그램이 오작동 될 때 디버깅 해야하는 코드 범위를 줄여 막대한 생산성 향상을 이끌어 내었다.

— 끝

홈으로