(자알쓰) 클로저

자알쓰란?

바스크립트 자. (잘 쓰자는 의미도 담겨있다.)
자바스크립트라는 언어 자체는 내 기준에서는 설계 상 미스가 참 많다.
함수 단위의 스코프, 호이스팅, 동적 타입 등등
자바와 같은 깐깐(?)한 언어를 배우고 바라본 자스는 허점 투성이처럼 보였다.
애초에 자바스크립트는 어떠한 프로그램을 만들기 위해서 탄생했다기 보다는
웹 페이지에 입력값에 대한 유효성 검사(데이터가 공란인지 아닌지 등등)와 같은
페이지의 동적 제어가 주된 목적 + 짧은 개발 기간(넷 스케이프 사의 새로운 브라우저에 탑재 예정) 때문에
설계 상에 미스가 있을 수 밖에 없다고 나는 생각된다.
일종의 안전 장치가 없어서 개발자가 일일이 구현해주고, 신경써야 하는 느낌이었다.
그렇다고 해서 자바스크립트를 극혐하거나 그런 것은 아니고 매우 사랑한다.
또한 그 허점을 아는 사람은 허점을 보완해서 요리조리 피해서 잘 쓰겠지만…
잘 모르는 부분들은 잘못 써도 동작이 잘 되기 마련이다.
이는 지금 당장에는 큰 문제가 안 될지 모르겠지만, 추후에 대규모 웹 어플리케이션을 만들거나
직면할 문제로부터 미리 해방시키기 위해 처음부터 좋은 습관을 들여가는 것이 좋다고 생각한다.
그 열 세 번째 시리즈는 클로저를 주제로 진행하겠다.

들어가기 전에

프로그래밍 언어에는 지역 변수란 게 존재한다.
이 지역변수는 변수의 스코프에 의존적이다.
여타 프로그맹 언어에서 변수의 스코프는 {} 블록 단위지만,
자바스크립트의 변수의 스코프는 함수 단위이다. (물론 ES6의 const와 let의 스코프는 블록 단위)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 글로벌 스코프 시작
var global = 2;
var func = function() {
// 로컬 스코프 func 시작
var local = 1;
// 로컬 스코프에서는 로컬 스코프와 글로벌 스코프에 접근 가능
console.log(global);
console.log(local);
// 로컬 스코프 끝
}
// 글로벌 스코프에서는 글로벌 스코프만 접근 가능
console.log(global);
console.log(local); // Uncaught ReferenceError: local is not defined
// 글로벌 스코프 끝

스코프의 접근 가능한 스코프는 자기 자신을 포함한 상위 스코프이다.
글로벌 스코프의 접근 가능한 범위는 글로벌 스코프가 최상위 스코프이므로 글로벌 스코프 자기 자신 밖에 없고,
로컬 스코프 func의 접근 가능한 스코프는 자기 자신인 func와 자신의 상위 스코프인 global 스코프가 된다.

왜 이런 현상이 발생할까?

이는 전역 변수와 지역 변수가 메모리(RAM)의 어느 영역(Code, Data, Stack, Heap)에 적재되는지와 관련이 있다.
전역 변수는 메모리의 Data라는 영역에 적재돼서 프로그램의 종료 시까지 계속 적재돼있다.
따라서 어디서나 사용이 가능하다.
지역 변수는 메모리의 Stack이라는 영역에 적재되고, 지역 변수의 생명주기(Lifecycle)는 함수 호출 시 생성되고 함수가 종료되면 시스템에 의해 반환된다.

클로저는 왜 쓸까?

위와 같이 지역변수는 함수 호출 시 메모리에 할당되고, 함수가 종료되면 메모리에서 해제된다.
따라서 지역변수는 호출할 때마다 항상 같은 값으로 초기화된다.
하지만 가끔 함수를 호출할 때 이전에 존재했던 값을 유지하고 싶어질 때가 있다.
그러려면 함수가 종료됐을 때 해당 지역 변수가 메모리에서 해제되면 안 된다.

클로저는 어떻게 구현해야할까?

함수가 종료돼도 지역 변수가 메모리에서 해제되지 않으려면 어떻게 해야할까?
이를 위해서는 자바스크립트의 GC(Garbage Collector, 메모리 상에 쓸 데 없는 녀석 수거해가는 녀석)가 어떻게 동작하는지 간단하게나마 알아봐야한다.

1
2
3
var obj = {name: '양간장'};
var obj2 = {name: '간장냥'};
obj = obj2;

3번 라인에서 obj는 {name: '간장냥'}을 참조하게 만들었다.
그럼 {name: '양간장'}이라는 데이터는 참조가 불가능 해졌으므로 사용할 방법이 없는 쓸 데 없는 녀석이 된다.
이때 저 {name: '양간장'}은 GC(Garbage Collecting) 대상이 되며 GC(Garbage Collector)가 적절한 시점(개발자 도구 열닫 혹은 일정 시간 이후?)에 수거해간다.
따라서 메모리 상에서 데이터가 해제되지 않게 하려면 누군가는 해당 녀석을 참조하게 만들어서 GC(Garbage Collecting) 대상에서 제외시켜야한다.
이를 위해서는 다음과 같은 조건 제약이 따른다.

  • 조건 1. 참조하는 대상이 참조하는 녀석과 접근 가능한 스코프에 있어야 한다.
    가령 예를 들어 참조하는 대상({name: '양간장'})과 참조하는 녀석(obj)이 접근 가능한 스코프에 있어야한다는 소리이다.
    접근 가능한 스코프 상에 존재해야 오류 없이 해당 데이터를 참조할 수 있기 때문이다.

    1
    2
    3
    4
    5
    6
    7
    var a = function() {
    var b = 1;
    };
    var c = function() {
    console.log(b); // Uncaught ReferenceError: b is not defined
    };
    c();

    로컬 스코프 c의 접근 가능한 스코프는 자신을 포함한 상위 스코프이다.

  • 로컬 스코프 c

  • 전역 스코프
    따라서 로컬 스코프 c에서 로컬 스코프 b로 접근이 불가능하다.

    1
    2
    3
    4
    5
    6
    7
    8
    var a = function() {
    var b = 1;
    var c = function() {
    console.log(b++); // 1
    };
    c();
    };
    a(); // 1

    위 예제에서 로컬 스코프 c가 접근 가능한 스코프는 자신을 포함한 상위 스코프이다.

  • 로컬 스코프 c

  • 로컬 스코프 a

  • 전역 스코프
    위 코드는 참조하는 대상(b)이 로컬 스코프 a에 있고, 참조하는 녀석(console.log(b))이 로컬 스코프 c에 있다.
    참조하는 대상(로컬 스코프 a)이 참조하는 녀석(로컬 스포크 c)과 접근 가능한 스코프에 있어야 한다는 조건을 만족하고 있다.

  • 조건 2. 참조하는 대상이 존재하는 함수를 호출하는 게 아니라 참조하는 녀석이 존재하는 함수를 직접 호출해야한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var a = function() {
    var b = 1;
    var c = function() {
    console.log(b++); // 1
    };
    c();
    };
    a(); // 1
    a(); // 1
    a(); // 1

    기껏 위와 같이 로컬 스코프와 그 내부 스코프로 나누어서 b의 값을 유지하고자 했는데 함수 a를 호출할 때마다 계속해서 1이 출력된다.
    우리는 b의 값이 유지돼서 b++을 하고 있으므로 호출할 때마다 1이 상승된 값을 원했는데 그게 아니다.
    그러기 위해서는 조건 2를 만족시켜주면 된다.
    즉 참조하는 대상(b)이 존재하는 함수 a를 호출할 게 아니라 참조하는 녀석(console.log(b))이 존재하는 함수 c를 호출해야한다.
    함수 a를 백날 호출해봤자 지역변수 b를 초기화 시켜주는 구문이 매번 실행되기 때문에 참조를 아무리 한들 b의 값이 유지되는 게 아니다.
    하지만 스코프의 접근 가능한 스코프는 자신을 포함한 상위 스코프인데 전역 스코프에서 어떻게 로컬 스코프 a에 존재하는 c를 호출할 수 있을까?
    이는 자바스크립트의 특성인 함수는 first-class-citizen을 활용하면 된다.
    전역 스코프에서 c를 호출할 수 있게 하려면 전역 변수와 함수 c 사이에 어떠한 매개체를 뚫어줘야한다.
    이는 위에서 말한 일급 객체의 특성을 살려 함수 a를 호출했을 때 함수 c(를 호출하는 게 아님)를 리턴함으로써 전역 스코프와 함수 c 사이에 다리를 놔준다고 보면 된다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var a = function() {
    var b = 1;
    var c = function() {
    console.log(b++);
    };
    return c;
    };
    a = a();
    a(); // 1
    a(); // 2
    a(); // 3
    a(); // 4

    함수 a를 호출한 결과(내부 함수 c)를 다시 a에 담는 과정이 불필요하다고 생각되니 IIFE(즉시 실행 함수, Immediately Invoked Function Expressions)를 이용하면 아래와 같이 줄일 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var a = (function() {
    var b = 1;
    var c = function() {
    console.log(b++);
    };
    return c;
    })();
    a(); // 1
    a(); // 2
    a(); // 3
    a(); // 4

    함수 c를 리턴할 수도 있지만, 아래와 같이 익명함수로 리턴하는 게 대부분이다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var a = (function() {
    var b = 1;
    return function() {
    console.log(b++);
    };
    })();
    a(); // 1
    a(); // 2
    a(); // 3
    a(); // 4

함수 c와 같이 기명 함수를 리턴하는 경우는 함수 내에서 자기 자신을 호출하는 재귀 함수를 구현할 때 뿐인 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
var a = (function() {
var b = 1;
var c = function() {
console.log(b++);
if(b < 10) c();
};
return c;
})();
a(); // 1 2 3 4 5 6 7 8 9
a(); // 10
a(); // 11
a(); // 12
  • 조건 3. 참조하는 대상이 전역 스코프가 아니어야한다.
    전역 스코프는 어차피 프로그램 종료 시까지 메모리에 계속 할당돼있으므로 상관이 없는 이야기이다.

그래서 클로저가 뭔데?

먼저 클로저를 설명하기 보다는 적절한 사용 사례를 설명하고 클로저가 뭔지를 풀어 놓는 게 독자의 흥미를 유발할 거 같아서 일부러 뒤로 배치하였다.
클로저는 다음과 같이 정의하고 있다.
인사이드 자바스크립트 책에서는 이미 생명 주기가 끝난 외부 함수의 변수를 참조하는 함수,
한 블로그에서는 생성 당시의 스코프에 대한 연결을 갖는 블록이라고 정의하고 있다.
즉, 위에서 c가 클로저이다.
변수 b는 지역 변수이므로 a 함수 종료와 동시에 죽어야하는 변수이다.
하지만 그 생명 주기가 끝난 변수(b)를 참조하는 c라는 함수를 사용해서 클로저를 구현하였다.

클로저의 장점

  1. 위에서 본 바와 같이 함수를 호출할 때마다 기존에 생성했던 값을 유지할 수 있다.
  2. 외부에 해당 변수(참조하고 있는 변수)를 노출시키지 않는다.
    이게 무슨 장점이냐고 생각한다면 코드의 안정성을 보장해준다는 뜻이다.
    이는 캡슐화(encapsulation)와도 관련이 있는데 알약을 먹는 사람이 알약만 먹어서 병이 치료만 되면 되지, 그 내부의 내용물은 중요하지 않다는 개념이다.
    근데 만약 환자가 캡슐을 까서 내용물을 바꾸고 다시 캡슐을 씌우고 그 알약을 먹는다면?? 과연 환자의 상태를 보장할 수 있을까?
    이렇듯 프로그래밍에서 말하는 캡슐화는 환자가 내용물을 바꾸지 못하게(클로저에서 참조하는 변수를 변경하지 못하게 해서),
    즉 내가 개발한 소스를 사용하는 입장(내가 될 수도 있고 내가 만든 라이브러리를 사용하는 제 3자가 될 수도 있고)에서 코드의 안정성을 보장받게 되는 것이다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 개발자(약사) 입장에서 짠 코드
    var a = function() {
    var b = 1; // 개발자가 변수에 값을 넣었다. (약사가 약의 내용물을 넣었다.)
    var c = function() { // 클로저를 이용하여 내용을 캡슐화 하였다. (약사가 약들을 캡슐로 감쌌다.)
    console.log(b++);
    };
    return c; // 사용자(환자)가 해당 변수를 참조만 가능하고(약의 복용만 가능하고), 변경은 불가능하게(캡슐을 까서 그 안의 약물들을 못 갈아치우게) 했다.
    };

    // 실제 사용자(환자)는 해당 변수(약물)을 변경할 수 있는 방법이 없어서 코드의 안정성(환자의 상태)를 보장받을 수 있다.
    var d = a();
    d();

클로저의 단점

클로저는 위와 같이 좋은 점도 존재하는데 역시 신은 공평하듯 모든 걸 주시지 않았는데 바로 아래와 같은 단점이 존재하기 때문이다.

  • 클로저로 참조하는 변수는 프로그램 종료 시까지 계속 메모리에 할당돼있다.

그게 뭐 어때서? 라고 생각하는 경우가 있을 수도 있고 위 경우가 크게 문제가 되지 않을 수도 있다.
하지만 사용 가능한 메모리(브라우저마다 다르겠지만…)를 초과하는 사태가 발생할 수도 있고, 성능 상 좋다고 말할 수 있는 사항은 아니다.
위와 같은 현상이 발생하는 이유는 계속해서 참조를 하고 있으므로 GC(Garbage Collecting) 대상이 되지 않기 때문이다.
따라서 클로저는 이러한 성능 이슈를 가지고 있기 때문에 항상 주의를 기울여 사용을 최소화해야하며 오남용해서는 안 된다.

마치며

클로저를 알아야 자바스크립트 고수니 중급으로 넘어가느니 등등의 얘기가 많이 들리는 것 같은데 실상 제대로 파고들어가 보면 별 거 아니란 사실을 알 수 있다.
하지만 이와 같은 사항들을 알기 위해서는 아래와 같은 사항은 반드시 짚고 넘어가야 할 것이다.

  1. 변수의 스코프
  2. 변수의 생명주기(Lifecycle)
  3. GC(Garbage Collector)가 GC(Garbage Collecting)하는 대상
  4. First Class Citizen