본문 바로가기
javascript

자바스크립트 클로저가 아직도 난해한 개념으로 다가오는 사람들 모여라!

by 윤-찬미 2021. 3. 5.

📋 목차

  • 이 포스팅을 읽으면 도움이 되는 사람들
  • 클로저의 정의
  • 렉시컬 스코프
  • 함수객체의 내부슬롯 [[Environment]]
  • 클로저와 렉시컬 환경
  • 클로저의 활용
  • 정리

✍🏻 이 포스팅을 읽으면 도움이 되는 사람들

이 포스팅은 클로저에 대한 완벽정리를 도와주기위해 작성했습니다.

 

포스팅을 읽으면 도움이 되는 분들은 아래와 같습니다. 내가 아래 해당사항 중 하나라도 해당이 된다면 이 포스팅을 꼭 읽어보세요!

  • MDN에 나온 클로저 사전 정의는 알고 있지만 제대로 이해하지 못하고 있다.
  • 클로저가 왜생긴지 모르겠고, 대체 어디에 써먹으란건지 모르겠다.
  • 클로저가 뭔지 모른다.
  • 클로저관련 예제라곤 for문안에서 쓰는 그(?) 예제 밖에 모른다.
  • 클로저에 대해 설명하라 하면 말문이 막힌다.

아래 개념은 미리 알고 오시는게 좋습니다.

  • 스코프
  • 스코프체인
  • 즉시실행함수

✍🏻 클로저의 정의

우선 MDN에 나와 있는 클로저의 정의는 아래와 같습니다.

클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다.

맞는말이긴 한데 처음 이것만 딱 보면 참으로 난해 합니다. 이해하기도 어렵구요.

지금부터 이말이 무슨뜻인지 차근차근 알아보겠습니다.

참고로 클로저란? 이라고 구글에 검색하면 아래와 같이 나오는데,

컴퓨터 언어에서 클로저는 일급 객체 함수의 개념을 이용하여 스코프에 묶인 변수를 바인딩 하기 위한 일종의 기술이다.

정의된 내용과 같이 클로저는 자바스크립트 고유의 개념은 아닙니다.

함수를 일급객체로 취급하는 모든 언어에서 사용되는 중요한 특성입니다.

 

클로저에 대해 이해하려면 렉시컬환경을 이해해야 합니다. 차근차근 알아보도록 합시다!

 

✍🏻 렉시컬 스코프

자바스크립트엔진은 함수를 어디서 "호출" 했는지가 아니라 어디에 "정의" 했느냐에 따라 상위 스코프를 결정합니다.

이를 렉시컬 스코프라 합니다.

코드로 살펴보겠습니다.

var name = 'dumbo';

function a() {
  var name = 'yoon';
  b();
}

function b() {
  console.log(name); // 'dumbo'
}

a();

코드에서 보시다 싶이 함수를 호출하는 위치는 함수의 상위 스코프에 아무런 영향을 주지 못합니다.

정의되는 시점에 의해 정적으로 결정됩니다. a함수안에서 b가 호출됐지만, a 함수블록의 스코프를 따르지 않고 있는게 보이시죠?

"상위 스코프에 대한 참조는 함수정의가 되는 시점에 정의된 환경에 의해 결정된다."

이게 바로 렉시컬 스코프 입니다!

 

✍🏻 함수객체의 내부슬롯 [[Environment]]

이어서 봐야할 부분이 내부슬롯 [[Environment]] 입니다.

 

함수는 정의 되는 시점에 환경을 기억 해야합니다! 함수는 이곳저곳에서 호출해서 쓰니깐요.

즉, 상위 스코프를 기억해야합니다.

그럼 상위스코프를 어디 저장해 둘까요?

바로 함수 자신의 내부슬롯 [[Environment]]에 상위 스코프의 참조를 저장합니다!

 

다시 코드를 보겠습니다.

var name = 'dumbo';

function a() {
  var name = 'yoon';
  b();
}

function b() {
  console.log(name); // 'dumbo'
}

a();

a함수와 b함수는 함수 선언문으로 작성이 됐으므로 전역코드가 평가되는 시점에 함수객체를 생성하고

전역객체 window의 메서드가 됩니다.

 

이때 a와 b의 내부 슬롯 [[Environment]] 에는 전역 렉시컬 환경의 참조가 저장됩니다.

따라서 a 안에서 b를 호출하면 b에서는 [[Environment]]에 저장된 렉시컬 환경참조를 통해

name 변수를 찾아 console에 출력합니다.

여기서는 b함수가 정의된 시점인 환경 (전역객체 window) 가 상위 스코프가 되겠지요?

 

따라서 yoon이 아닌 dumbo가 출력되는 것 입니다!

 

✍🏻 클로저와 렉시컬 환경

자, 그럼 이제 클로저를 알아볼 준비를 마쳤습니다!

이제 클로저와 렉시컬환경의 관계에 대해 알아보겠습니다.

 

아래 코드를 한번 볼까요?

var name = 'dumbo';

function a() {
  var name = 'yoon';
  var innerFunc = function () {
    console.log(name);
  }
  return innerFunc;
}
var b = a();
b() // 'yoon'

자 이부분에서 함수를 실행했기때문에 a함수의 실행컨텍스트는 실행컨텍스트 스택에서 팝되어 제거 됩니다.

var b = a();

그래서 a함수의 name지역변수는 더이상 유효하지 않습니다.

 

근데 어떻게 b를 호출하면 a함수의 지역변수 name를 참조할 수 있는 것일까요??

b() // 'yoon'

이처럼 외부함수(a)보다 중첩함수(innerFunc) 가 더 오래 유지되는 경우중첩함수는 이미 생명주기가 종료한 외부함수의 변수를 참조할 수 있습니다. 이러한 중첩함수를 클로저라 부릅니다.

 

이게 가능한 이유는 바로 위에서 설명했던 렉시컬스코프 랑 관련이 있습니다.

 

외부함수(a)가 호출되어 종료되었지만, 종료되면서 함수(innerFunc)를 반환했습니다.

이 innerFunc는 선언될 당시의 환경을 기억합니다. 여기서 선언될 당시의 환경은 상위스코프를 말합니다.

a함수의 렉시컬 환경을 innerFunc함수 내부슬롯 [[Environment]]에 저장합니다.

 

"a함수의 실행컨텍스트가 스택에서 제거 되어도 a함수의 렉시컬 환경까지 소멸하는 것은 아닙니다."

innerFunc함수에서 a의 지역변수를 참조할 수 있는 이유가 바로 그러합니다.

 

위 코드에서는 innerFunc함수가 종료되면 그제서야 a함수의 렉시컬 환경이 소멸됩니다.

 

✔️ 그럼 자바스크립트의 모든 함수는 상위스코프를 기억하므로 모든 함수가 클로저 일까요?

이론상으로는 클로저가 맞습니다. 하지만, 일반적으로 모든 함수를 클로저라 하지 않습니다.

그럼 언제 클로저라고 부를까요? 바로 "상위 스코프의 식별자를 참조할때" 입니다.

 

코드로 한번 보여드리겠습니다.

var name = 'dumbo';

function a() {
  var name = 'yoon';
// 상위 스코프의 식별자를 참조하지 않으므로 클로저가 아니다!
  var innerFunc = function () {
	const age = 10;
	console.log(age); 
  }
  return innerFunc;
}
var b = a();
b() // 10

따라서 위의 코드의 innerFunc은 클로저라 부르지 않습니다.

 

✍🏻 클로저를 활용한 정보은닉과 캡슐화

위의 내용을 모두 이해했다면 클로저의 전반적인 개념은 잡혔을 것 같다는 생각이 듭니다.

이번에는 클로저를 왜 쓰는지, 그리고 언제 유용하게 쓸 수 있는 것인지 알아보겠습니다.

 

결론부터 말씀 드리자면,

"상태를 안전하게 변경하고 유지하기 위해 사용할 수 있다. 즉 정보은닉과 캡슐화를 할 때 유용하게 쓸 수 있다"

입니다. 아래에서 조금 더 자세하게 설명드리겠습니다.

 

let num = 0;

const increase = function () {
  return ++num;
};

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

위의 코드는 잘 동작하지만 오류를 발생시킬 가능성을 내포하고 있습니다.

  • num변수값이 increase이 호출되기 전까지 변경되지 않고 유지된다는 보장이 없다.
  • 이를 위해 카운트 상태는 increase로만 변경될 수 있도록 해야하는데 그러지 못한다.

위의 코드를 조금 더 개선해보겠습니다.

const increase = function () {
  let num = 0;
  return ++num;
};

console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1

이제 num 변수는 increase의 지역변수로 외부에서 변경이 불가능합니다.

하지만, 함수가 호출될때마다 num이 0으로 다시 선언되기 때문에 언제나 1이 됩니다.

 

클로저를 이용해서 개선해보겠습니다.

const increase = (function () {
  let num = 0;
  // 렉시컬환경을 기억하는 클로저
  return function () {
	  return ++num
  }
})();

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

위코드가 실행되면 즉시실행함수가 반환한 함수가 increase변수에 할당됩니다.

이때 increase에 할당된 함수가 자신이 정의된 환경을 기억하는 클로저 입니다.

 

즉시실행함수는 실행즉시 컨텍스트가 팝되기 때문에 즉시실행안에 num이 재선언 될일은 없습니다.

하지만 increase에 할당된 함수는 자신이 정의된 환경을 내부슬롯[[Environment]]에 참조하고 있기 때문에

num변수에 접근할 수 있습니다.

 

이로써 저희가 얻을 수 있는 이점은 다음과 같습니다.

  • num변수값이 increase이 호출되기 전까지 변경되지 않고 유지된다는 보장이 생긴다.
  • 카운트 상태는 increase로만 변경될 수 있다.

이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정함수에게만 상태변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용됩니다.

 

💊 이번에는 캡슐화와 정보은닉을 클로저로 구현할 수 있는 방법에 대해 알아보겠습니다.

💊 캡슐화?

객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶는것

🔐 정보은닉?

캡슐화는 객체의 특정프로퍼티나 메서드를 감출 목적으로 사용하기도 하는데 이를 정보은닉이라 한다.

 

자바와 같은 객체 지향 프로그래밍언어를 다뤄 보신 분들이라면 아마 다음과 같은 키워드에 익숙하실 겁니다.

  • public
  • private
  • protected

하지만 자바스크립트는 위와 같은 접근제한자를 제공하지 않습니다.

즉, JS 객체에서의 모든 프로퍼티와 메서드는 public합니다.

따라서 따로 구현이 필요합니다.

 

아래와 같이 구현해 볼 수 있습니다.

const Dog = (function () {
  let _age = 0; // private
  //생성자 함수
  function Dog(name, age) {
	this.name = name; // public
	_age = age;
  }
  //객체가 생성될때마다 중복생성을 막기위해 prototype 사용
  Dog.prototype.sayHi = function () {
	console.log(`my dog name is ${this.name}. age ${_age}.`);
  }
//생성자함수 반환
  return Dog;
}());

const myDog = new Dog('복실이', 2);
myDog.satHi() // my dog name is 복실이. age 2.
console.log(myDog.name) // 복실이
console.log(myDog._age) // undefined

위 코드에서 보다싶이 생성자 함수 Dog를 반환하고 즉시실행함수는 종료됩니다.

이때 Dog는 자신이 정의된 환경인 렉시컬환경에 대해 내부슬롯[[Environment]]에 참조합니다.

따라서 _age 변수에 접근 할 수 있습니다.

위에서 Dog 생성자 함수와 sayHi메서드는 이미 종료되어 소멸한 즉시실행함수의 지역변수 _age를 참조하는 클로저입니다.

따라서 _age처럼 private하게 this.name처럼 public하게 구현 할 수 있습니다.

 

💢 하지만 클로저는 정보은닉을 완벽하게 지원하지 않는다.

 

Dog생성자 함수가 여러개의 인스턴스를 생성할 경우 _age 변수상태가 유지되지 않습니다.

const mydog = new Dog('lai', 1);
mydog.sayHi(); // my dog name is lai. age 1.

const yourDog = new Dog('lacoon', 5);
yourDog.sayHi(); // my dog name is lacoon. age 5.

// _age가 변했다!!!!!!
mydog.sayHi(); // my dog name is lai. age 5.

이유는 satHi메서드가 단한번 생성되는 클로저이기때문입니다.

 

Dog.prototype.sayHi 메서드는 즉시실행함수가 호출될때 생성이 됩니다.

Dog.prototype.sayHi 메서드는 상위스코프인 즉시실행함수의 실행컨텍스트의 렉시컬 환경을 내부슬롯[[Environment]]에 저장합니다.

 

따라서 Dog생성자 함수의 모든인스턴스가 상속을 통해 호출할 수 있는 Dog.prototype.sayHi 메서드의 상위 스코프는 어떤 인스턴스로 호출하더라도 동일한 상위 스코프를 사용하게 됩니다.

 

이처럼 JS 는 정보은닉을 완벽하게 지원하지 않습니다.

 

🖐🏻 잠깐! 개선할 수 있는 방안이 추가 되었어요!

1. 클래스에 private필드 정의 제안이 나왔습니다.


아래와 같이 private필드 앞에 #을 붙여 구현할 수 있습니다.

class Dog {
  #name = '';
  constructor(name) {
	this.#name = name;
  }
  //private 필드 #name은 클래스외부에서 참조할 수 없어 접근자 프로퍼티를 통해 간접적으로 접근 가능하다.
  get name() {
	return this.#name.trim();
  }
}

const myDog = new Dog('lai');
console.log(myDog.name) // lai

 

2. typescript에 는 클래스 기반 객체지향언어가 지원하는 public, private, protected를 모두 지원합니다!

 

아래 간단하게 설명해놓은 글이 있어 첨부하겠습니다!

https://hyunseob.github.io/2016/10/17/typescript-class/

 

TypeScript: 클래스(Class)

이전 글 - TypeScript: Basic Type 클래스는 JavaScript 생태계 속에서도 TypeScript에만 있는 개념이 아니다. CoffeeScript나 ES2015를 사용해봤다면 이미 클래스를 몇 번 쯤은 사용해보았을 것이다. 이…

hyunseob.github.io

✍🏻 정리

자바스크립트클로저가 조금이나마 머릿속에 정리가 되셨길 바랍니다.

혹시 글 중 이해가 안되는 부분이 있으시면 언제든 댓글 달아주시면 아는 선내에 답변을 드리도록 하겠습니다.

 

참고자료

정보공유, 다양한 의견과 피드백은 언제나 환영입니다!