자바스크립트와 클로저

클로저의 기본 개념

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

사실, 클로저는 자바스크립트만의 고유 개념이 아니다. 따라서 ECMAScript에 클로저에 대한 내용과 개념이 명세되어 있지도 않다. 외려 이는 함수를 일급 객체로 취급하는 “함수형 프로그래밍 언어”에서 중요한 역할을 하는 개념이다.

위에서 간략하게 정의한 바와 같이, 클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합이다. 이전 포스팅(스코프, 실행컨텍스트)에서 함수가 어디서 “정의”됐는지에 따라 스코프가 결정되는 방식을 따르는 것이 렉시컬 스코프의 특징이라고 했었다. 그리고 이 스코프는 실행컨텍스트의 “렉시컬 환경”에 포섭되는 개념이기에, 렉시컬 환경이 결정되는 데에는 함수가 정의된 위치가 굉장히 중요하다는 것을 다시 한 번 상기해야 한다.

위의 코드 예시에서 볼 수 있듯, 함수의 렉시컬 환경과 상위 렉시컬 환경에 대한 참조는 함수가 선언된 위치에 따라 결정된다. 그럼 클로저에 대한 본론으로 들어가기 전에 스코프 및 렉시컬 환경에 대해 다시 한 번 짚고 넘어가는 시간을 가져보도록 하자.


[[Environment]] 내부슬롯

이전 실행 컨텍스트 포스팅에서 간략하게 다뤘듯, 함수는 함수의 평가 과정에서 함수 객체를 즉시 할당한다.
그리고 그 함수객체 내에 존재하는 [[Environment]] 내부 슬롯에 현재 실행중인 실행컨텍스트의 렉시컬 환경을 저장한다.

우선, 아래의 코드 예시를 통해 [[Environment]] 내부슬롯의 역할과 개념을 다시 한 번 정리해보도록 하자.

1
2
3
4
5
6
7
8
const x = 1;

function foo() {
const x = 10;
return x;
}

foo();

그러면 우선 전역 객체가 생성되고 → 전역 실행컨텍스트가 생성되며 → 전역 렉시컬 환경이 구축된다.

const로 선언한 변수 x는 선언적 환경 레코드에, 함수 선언문으로 정의된 foo는 객체 환경 레코드에 등록된다. 여기서 주의할 점은, xconst에 의해 선언됐기 때문에 초기화도 되지 않은채 등록만 된 상태지만, 함수 foo는 평가 즉시 함수 객체가 전역객체에 할당되고, 그 함수 객체 안에 존재하는 [[Environment]] 내부슬롯에 “현재 실행중인 실행 컨텍스트의 렉시컬 환경”을 참조하도록 한다는 것이다.

그러면 결국, 위의 사진과 같이 foo 함수객체의 [[Environment]] 내부 슬롯에 참조 될 렉시컬 환경은 전역 렉시컬 환경이 된다.

여기서 다시 한 번, 함수가 정의된 위치가 왜 스코프의 관점에서 중요한지 알 수 있게 된다. foo 함수가 정의된 위치는 “전역”이기에 함수 식별자의 평가는 전역에서 이루어진다. 이에 따라 현재 실행 중인 실행 컨텍스트의 렉시컬 환경인 “전역 렉시컬 환경”이 foo 함수의 [[Environment]] 내부 슬롯에게 참조되는 것이다.


외부 렉시컬 환경에 대한 참조는?

근데 이쯤에서… 위 내용에 대한 의문점이 생긴다.
”분명 상위 렉시컬 환경과의 연결은 외부 렉시컬 환경에 대한 참조라는 컴포넌트에 의해 동작하지 않았나..?”

이전 실행 컨텍스트 포스팅에서 각 렉시컬 환경 간의 연결(스코프 체인)은 “외부 렉시컬 환경에 대한 참조”라는 컴포넌트에 의해 이루어진다고 설명했었다. 도대체 “[[Environment]] 내부슬롯”과 “외부 렉시컬 환경에 대한 참조”의 차이점은 뭘까?

다시 한 번 위의 코드 예시를 이어서 살펴보며 그 차이점을 이해해보도록 하자.

우선, 함수가 호출되며 foo 함수의 실행컨텍스트 및 렉시컬 환경이 생성되고 → 함수 코드에 대한 평가가 시작된다.

그러면 식별자 및 this값이 환경 레코드에 등록되고 곧이어 외부 렉시컬 환경에 대한 참조가 결정된다. 이 때, 외부 렉시컬 환경에 대한 참조에 할당되는 것이 [[Environment]] 에 저장된 렉시컬 환경에 대한 참조다. 즉, 전역 렉시컬 환경이 foo 함수의 “외부 렉시컬 환경에 대한 참조” 컴포넌트에 할당되는 것이다.

이를 그림으로 표현하면 아래와 같다.

결국 둘이 가리키는 값은 같다는 것만 이해하고 넘어가도 충분하다.
그리고 저 [[Environment]] 내부슬롯으로 인해 함수가 정의된 위치에 따라 렉시컬 스코프가 결정된다는 것만 다시 한 번 기억하도록 하자.


클로저와 렉시컬 환경

서론이 조금 길어졌다…
이제 위에서 설명한 렉시컬 환경과 클로저의 상관관계, 그리고 클로저가 정확히 무엇을 뜻하는지 설명해보도록 하겠다.

아래와 같은 코드를 통해 우리는 클로저의 핵심 개념을 파악할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
const x = 1;

function outer() {
const x = 100;
const inner = function () {
console.log(x);
};
return inner;
}

const getInner = outer();
getInner(); // 100

위 코드에서 먼저, getInner에 대한 평가 → 할당 프로세스에 의해 outer()함수가 우선적으로 호출된다.

그러면 위의 사진과 같이 inner 함수 자체가 getInner에 할당된다. 그리고 곧 getInner()가 호출된다.

근데 여기서, outer함수의 호출과 실행이 끝났다면 outer 함수의 실행 컨텍스트가 소멸되며 지역변수 x 또한 소멸되어야 하는게 자연스럽게 느껴진다. 하지만, 아래의 getInner() 함수의 호출 결과는 100이 그대로 반환된다. 어떻게 된 일일까?

이는 inner라는 중첩 함수가 “클로저”이기 때문이다.

위에서 우리는 클로저를 함수와 그 함수가 정의된 렉시컬 환경의 조합이라고 했다. 그리고 위의 inner함수는 outer에서 정의됐기 때문에 outer 함수의 렉시컬 환경을 참조한다.

위 사진과 같이 inner라는 중첩 함수는 자신이 정의된 환경(outer)의 “렉시컬 환경”을 참조한다.

이전 포스팅에서 실행컨텍스트가 모든 코드의 실행을 끝내고 소멸하더라도, 그 실행 컨텍스트에 바인딩 됐던 렉시컬 환경은 자신이 어떠한 곳에서도 참조되지 않을 때 까지는 소멸되지 않는다고 설명했었다.

위의 경우도 같은 맥락인데, getInner에 의해 outer함수보다 inner 함수가 오래 생존한다. 이에 따라 outer 함수의 렉시컬 환경은 inner에 의해 참조가 지속되고, 이러한 이유로 가비지 컬렉터는 outer 함수의 렉시컬 환경을 제거하지 않는다.

이 때문에, getInner의 호출 결과는 이미 소멸된 outer의 지역변수인 x = 100 을 참조해 100이 그대로 반환되는 것이다.

이처럼 중첩 함수인데, 상위 함수보다 더 오래 지속되며, 상위 스코프를 참조함에 따라 상위 렉시컬 환경이 제거되는 것을 미루는 함수를 “클로저”라고 한다.


클로저는 어디에 어떻게 사용될까?

클로저는 “상태 은닉” & “캡슐화” & “정보 은닉”을 위해 활용된다.

상태 은닉

클로저는 상태(state)를 특정 함수를 통해서만 변경 가능하도록 하고, 그 상태를 안전하게 은닉하기 위해 사용된다.

개념이 직관적으로 와닿지 않을 수도 있는데, 아래의 코드를 통해 다시 상태 은닉의 개념을 이해해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
let count = 0;

const add = function () {
return ++count;
};

count += 100;

console.log(add()); // 101
console.log(add()); // 102
console.log(add()); // 103

위의 코드 예시는 상태 은닉이 안전하게 된 경우라고 볼 수 없다. 아래와 같은 이유 때문이다.

count라는 변수가 add라는 함수에 의해서만 조작 가능하고, 외부에서 해당 상태(count)에 접근 불가능할 때 우리는 “상태가 안전하게 은닉됐다”라고 표현한다. 하지만 위 사진의 경우는 전역에서 언제든지 count라는 변수에 접근 가능하고 조작 가능하다. 따라서 이는 상태 은닉이 안전하게 적용된 사례가 아니다.

상태 은닉을 위해서 우리는 클로저를 이하와 같이 활용한다.

위의 코드 예시에서 우리는 더이상 count라는 상태에 접근할 수 없다. 그리고 add() 함수를 호출하지 않는 이상 우리는 count라는 상태를 변경할 수 없다. 이는 정확히 “클로저를 통해 상태를 안전하게 은닉한 예시“다. 그럼 이제 클로저에 의한 상태 은닉의 프로세스를 차근차근 짚고 넘어가보도록 하자.

먼저, add에 대한 평가 → 할당 프로세스에 의해 외부 즉시실행 함수가 호출되어 중첩함수가 add에 할당된다.

하지만, 즉시실행 함수는 중첩함수를 반환한 뒤 모든 코드의 실행을 마치고 종료된다. 그리고 이에 따라 즉시실행 함수의 실행컨텍스트 또한 소멸된다.

여기서 주의할 점은, add에 반환된 중첩함수에 의해 중첩함수의 상위 렉시컬 환경이 지속적으로 참조가 되고 있어, 외부 함수의 실행컨텍스트는 소멸됐더라도 외부함수의 렉시컬 환경은 아직 생존해있다는 것이다.

즉, 중첩함수는 클로저의 역할을 하고 있으며, 이 덕분에 클로저를 통해서만 count에 접근하여 그 값을 변환하고 리턴받을 수 있는 것이다. 위의 코드를 보면 알 수 있듯, add()함수를 호출하지 않는 이상 우리는 count라는 상태에 접근하거나 조작할 수 없다.

캡슐화 & 정보 은닉

정보은닉은 상태 은닉과 거의 비슷한 개념을 갖고있다.
우선, 캡슐화란 프로퍼티와 메소드를 하나로 묶어 안전하게 은닉하는 것을 의미하는데,
이런 캡슐화에 의한 결과와 의도를 “정보 은닉”이라고 한다.

다시 말해, 정보 은닉은 캡슐화에 의해 프로퍼티와 메소드가 하나로 묶여 안전하게 은닉하는 것을 의미한다. 즉, 상태 은닉은 은닉의 대상이 상태라는 것, 정보 은닉은 프로퍼티와 메소드라는 점에서의 미세한 차이점을 보유한다고 볼 수 있는 것이다.

우리는 정보 은닉을 통해 객체의 정보를 보호하고, 객체 간의 결합도를 낮출 수 있다. 이러한 정보 은닉은 자바와 같은 언어에서 “public, private, protected, default”와 같은 접근 제어자를 통해 구현된다.

하지만, 자바스크립트에서는 위와 같은 접근 제어자가 존재하지 않기 때문에, 객체의 프로퍼티와 메소드는 기본적으로 public하게 공개되어 있다.

아래의 코드를 통해 자바스크립트에서 클로저를 활용하여 어떻게 캡슐화 & 정보은닉을 구현하는지 확인해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Person = (function () {
let _country;

function Person(name, country) {
this.name = name;
_country = country;
}
Person.prototype.introduce = function () {
console.log(`안녕하세요 저는 ${this.name}이고 ${_country}에서 왔습니다.`);
};

return Person;
})();

const first = new Person('철수', '한국');
first.introduce(); // 안녕하세요 저는 철수이고 한국에서 왔습니다.
console.log(first.name); // 철수
console.log(first.country); // undefined

const second = new Person('조나단', '콩고');
second.introduce(); // 안녕하세요 저는 조나단이고 콩고에서 왔습니다.
console.log(second.name); // 조나단
console.log(second.country); // undefined

first.introduce(); // 안녕하세요 저는 철수이고 콩고에서 왔습니다.

우선 전체 코드는 위와 같다.

가장 먼저, Person 변수에 즉시실행 함수가 실행되며 Person 생성자 함수(클로저)가 할당된다.

그리고 위와 같이, 클로저에 의해 country_country라는 변수 활용을 통해 은닉된다. 이러한 이유로, Person 중첩 생성자 함수는 클로저인 것이며, 즉시실행 함수가 종료된 이후에도 즉시실행 함수의 지역변수를 참조 가능하도록 해준다. (물론 name 프로퍼티는 은닉된 것이 아니다)

여기까지 봤을 때는, 원하는 정보들이 잘 은닉된 것 처럼 보인다.
하지만, 실제로는 정보들이 모두 private하게 은닉되지 못했다.

위의 예시와 같이, second에 의해 인스턴스가 재생성 됨에 따라 철수의 _country가 기존 값을 유지하지 못한 채 변경된 모습을 확인할 수 있다. 이는 prototype.introduce가 단 한번만 생성되기 때문이다.

prototype.introduce는 즉시실행 함수가 실행될 때 딱 한 번 생성된다. 그리고 이 또한 메소드이기 때문에 이 introduce 메소드는 [[Environment]] 내부슬롯에 상위 렉시컬 환경의 참조를 저장한다(즉시 실행 함수의 렉시컬 환경). 따라서, 새로운 인스턴스가 생성될 때마다 이 메소드가 참조하는 _country의 값은 변경된다. 이에 따라, 위의 예시는 완벽한 정보의 은닉이라고 볼 수 없다.

이처럼, 자바스크립트는 완벽한 정보 은닉을 지원하지 않는다.
(최근 ”private 필드”라는 새로운 사양이 ES에 추가되었다고 한다)


정리 및 요약

“클로저는 함수와 그 함수가 정의된 렉시컬 환경의 조합이다”

  1. [[Environment]]와 “외부 렉시컬 환경에 대한 참조”를 통해 스코프 체인 구현이 가능해진다.
  2. 렉시컬 스코프는 함수가 정의된 위치에 의해 결정된다.
  3. 클로저를 통해 제거된 실행컨텍스트의 렉시컬 환경을 참조할 수 있다.
    1. 렉시컬 환경은 피참조되고 있을 때는, 가비지 컬렉터에 의해 제거되지 않는다.
    2. 외부 함수보다 클로저(중첩함수)가 더 오래 지속되어야 하며, 외부함수의 지역변수를 참조해야 한다.
  4. 클로저와 즉시실행 함수의 활용을 통해 상태 은닉, 캡슐화에 따른 정보 은닉 구현을 기대할 수 있다.

참조한 자료

도서 : 모던 자바스크립트 Deep Dive .이웅모

ECMAScript® 2020 Language Specification

클로저 - JavaScript | MDN


Author

Hoonjoo

Posted on

2022-04-18

Updated on

2022-04-20

Licensed under

Comments