본문 바로가기

05. 클로저

본 내용은 책 코어 자바스크립트(2019, 정재남)를 세 번 정독 후, 글쓴이가 이해한 내용과 실제 업무에서 겪은 경험을 기술하였습니다. 다소 형식적이지 못합니다.

 

 

1. 클로저의 원리

 

MDN에서는 클로저를 다음처럼 설명하였다.

 

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

 

클로저는 원리를 알아야 그 의미를 이해하기 수월하다.

 

다음 예시를 보면

var outer = function () {
  var a = 1;
  var inner = function () {
  	console.log(++a);
  }
  inner();
}

outer(); // 2

 

outer함수와 inner 함수를 호출 시, 실행 컨텍스트는 다음과 같다.

 

※ 실행컨텍스트를 모른다면 2장을 미리 학습해야 합니다.

2장에서 배웠듯이 함수가 호출되면, 콜스택에 LexicalEnvironment 라는 정보가 저장되고 그 안에는 environmentRecord(위의 env.Rec.)와 outerEnvironmentReference(위의 outerEnv.Ref.) 정보가 저장된다.

 

- environmentRecord: 현재 컨텍스트 내부의 매개변수, 식별자, 함수 정보

- outerEnvironmentReference: 자신이 선언될 당시의 LexicalEnvironment

 

앞에서 보았듯이 자바스크립트에서는 outerEnvironmentReference 덕분에 변수의 스코프가 결정되고, 스코프 체이닝이 가능해졌다. 

 

다른 말로, outer함수의 환경 정보를 inner 함수가 접근이 가능하기 때문에 스코프 체인이 가능하다.

 

여기서 위의 MDN 정의에서 combination의 의미를 파악할 수 있다.

 

항상 내부 함수가 외부 변수를 참조하는 것은 아니므로, 클로저는 내부 함수가 외부 변수를 참조할 경우에 발생할 것이다.

 

이번엔 함수 자체를 리턴해본다.

var outer = function () {
  var a = 1;
  var inner = function () {
  	console.log(++a);
  }
  return inner;
}

var foo = outer();
foo(); // 2
foo(); // 3
foo(); // 4

 

이 때의 실행 컨텍스트는 다음과 같다.

이번엔 outer 실행이 곧 inner 함수 실행과 같다. inner 함수가 실행될 때 outer는 콜스택에서는 제거되지만 a변수만은 inner에서 계속 참조하고 있기 때문에 가비지 컬렉팅되지 않고 메모리 상에 계속 존재한다.

 

inner함수의 실행 컨텍스트가 활성화되면 outerEnvironmentReference가 outer 함수의 LexicalEnvironment를 필요로 할 것이므로 수집 대상에서 제외됩니다.

 

2. 클로저란?

 

이제 위의 원리에서 클로저의 의미를 알 수 있다.

 

클로저란, 외부함수의 변수를 내부 함수가 참조하고 있고, 그 내부함수를 외부로 전달할 경우, 외부 함수가 종료되어도, 내부함수가 참조하고 있는 변수는 계속 남아 있는 현상을 말한다.

 

이 때 외부로 전달한다는 것은 반드시 return만 있는 것은 아니다.

 

setTimeout, setInterval, addEventListener의 콜백함수에서 외부 변수를 참조하고 있다면 이것 또한 클로저이다.

 

※ 클로저는 메모리 누수다?

 클로저를 너무 많이 쓰면 메모리 누수의 위험이 있다고 하는 의견 또한 있다.

 

그러나, 클로저를 메모리 누수로 볼 수 없는 까닭은 메모리 누수는 의도와 다르게 어떤 값의 참조 카운트가 0이 되지 않아 GC되지 못하고 메모리에 계속 남아있는 것을 말하지만, 클로저는 개발자가 의도적으로 설계하였기 때문에 누수로 볼 수 없다.

 

그래도, 메모리 소모에 대한 관리를 할 때에는 클로저가 참조하고 있는 변수를 참조해제하면 된다.

 

따라서 클로저를 기본형 데이터 null 혹은 undefined로 할당하면 참조 카운트는 0이 되어 GC될 것이다.

function () {
  var cnt = 0;
  var timer = null;
  var inner = function () {
    if (cnt++ > 10) {
      clearInterval(timer);
      inner = null; // inner 참조 카운트를 0으로
    }
  }
  
  timer = setInterval(inner, 1000);
}

 

3. 클로저 활용 사례

3.1 콜백함수 내부에서 외부 값을 사용하고 싶을 때

콜백함수 등에서 외부 데이터를 참조하여 사용할 경우에 해당한다.

var lunch = ['샌드위치', '국밥', '도시락', '돈까스'];
var $ul = document.createElement('ul');

lunch.forEach(function (elem) { // (A)
  var $li = document.createElement('li');
  
  $li.innerText = elem;
  $li.addEventListener('click', function () { // (B)
    alert(`오늘은 ${elem}(으)로 먹읍시다!`);
  });
  
  $ul.appendChild($li);
});

document.body.appendChild($ul);

[예제 1]

 

(A)와 (B) 중, 클로저가 사용된 함수는 (B)로, 외부 데이터 lunch의 각 엘리먼트를 참조하고 있다.

 

반복문을 순회하면서 (A)가 lunch 배열 개수만큼 호출되어 실행컨텍스트가 생성될 것이고, 콜백함수 (B)는 각 (A)의 실행 컨텍스트를 outerEnvironmentReference로 참조하고 있을 것이다.

 

따라서 (A)의 elem은 (B)에서 계속 참조하고 있으므로 GC되지 않는다.

 

이와 비슷하게 다음의 경우를 살펴보자.

// 출처: https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Closures
function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp(); 

[예제 2]

 

위와 마찬가지로 각 helpText에 포커스 이벤트가 발생할 때마다 id가 'help'인 엘리먼트 내에서 해당 help 메세지를 보여주고 싶다.

 

그렇게 될까? 생각하는 시간을 조금만 갖자 :)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

정답은 아니다. var item = helpText[i]를 보면 var는 함수 스코프에서 호이스팅되기 때문에 item은 항상 마지막 값인 { 'id': 'age', 'help': '{'id': 'age', 'help': 'Your age (you must be over 16)'}를 가리킬 것이다.

 

사실 클로저의 문제는 아니지만 02장의 실행컨텍스트와 호이스팅에 대한 이해가 부족하면 왜 저렇게 되는지 이해하는 데 오래 걸릴것이다.

 

이제 [예제 1]을 [예제 2]처럼 콜백함수를 바깥으로 분리해볼것이다.

var lunch = ['샌드위치', '국밥', '도시락', '돈까스'];
var $ul = document.createElement('ul');

function selectMenu (menu) { // 콜백함수 분리
  alert(`오늘은 ${menu}(으)로 먹읍시다!`);
}

lunch.forEach(function (elem) {
  var $li = document.createElement('li');
  
  $li.innerText = elem;
  $li.addEventListener('click', selectMenu);
  
  $ul.appendChild($li);
});

document.body.appendChild($ul);

[예제 1]처럼 동작할까? 그렇지 않다.

 

콜백함수의 첫번째 인자는 event 객체이기 때문이다.

 

따라서 selectMenu를 함수로 감싸서 주어야 한다.

var lunch = ['샌드위치', '국밥', '도시락', '돈까스'];
var $ul = document.createElement('ul');
      
function selectMenu (menu) { // 콜백함수 분리
  alert(`오늘은 ${menu}(으)로 먹읍시다!`);
}
      
lunch.forEach(function (elem) {
  var $li = document.createElement('li');
        
  $li.innerText = elem;
  $li.addEventListener('click', e => selectMenu(elem));
        
  $ul.appendChild($li);
});
      
document.body.appendChild($ul);

 

혹은 selectMenu 함수를 함수를 리턴하는 고차함수로 만드는 방법도 있다.

var lunch = ['샌드위치', '국밥', '도시락', '돈까스'];
var $ul = document.createElement('ul');
      
function selectMenu (menu) { // 콜백함수 분리
  return function () {
    alert(`오늘은 ${menu}(으)로 먹읍시다!`);
  }
}
      
lunch.forEach(function (elem) {
  var $li = document.createElement('li');
        
  $li.innerText = elem;
  $li.addEventListener('click', selectMenu(elem));
        
  $ul.appendChild($li);
});
      
document.body.appendChild($ul);

 

3.2 정보 은닉

정보 은닉은 객체 지향 프로그래밍에서의 캡슐화의 개념과 같다.

 

캡슐화는 필요에 따라 데이터를 외부로 노출시키면 안되는 경우에 변수 및 함수를 특정 목적이나 기능에 따라 묶은 것을 말한다. 여기 참조하기

 

책에서는 다음과 같이 설명한다.

정보 은닉(information hiding)은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서 모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 현대 프로그래밍 언어의 중요한 개념 중 하나입니다.

자바에서는 public, private 키워드를 지원하고 있지만 js에서는 변수 접근 권한을 직접 설정하도록 설계되어있다.

 

접근 권한 제어를 구현할 때는 클로저를 활용할 수 있다.

 

제일 처음 예제를 다시 보면, 

var outer = function () {
  var a = 1;
  var inner = function () {
  	console.log(++a);
  }
  return inner;
}

var foo = outer();
foo(); // 2
foo(); // 3
foo(); // 4

변수 a는 외부에서 접근이 불가능함을 알 수  있다.

 

외부에서는 outer 함수가 리턴한 inner에 대해서만 접근할 수 있다.

 

따라서 외부에서 접근할 수 있는 정보들만 return하고, 내부에서만 사용할 정보는 return하지 않으면 정보 은닉을 구현할 수 있다.

 

- public: return함

- private: return하지 않음

 

책에서는 간단한 자동차 경주 게임을 구현하여 접근 권한을 제어하는 연습을 했다.

 

이것을 응용하여, 숫자 야구 게임을 만들어보겠다.

 

규칙은 다음과 같다.

- 숫자는 맞지만, 위치가 틀렸을 때는 볼

- 숫자와 위치가 모두 맞을 때는 스트라이크

 

우선 1과 9 사이의 랜덤한 세자리 숫자와 횟수는 10번으로 제한한다. 그리고 이 두 값은 외부에서 접근이 불가능해야 한다.

 

오직 숫자를 check하는 함수와 게임을 초기화하는 함수만 접근할 수 있게 허용한다.

function BaseballGame () {
  let answer = Math.floor(Math.random() * (999 - 100) + 100).toString();
  let cnt = 10;
  let isFinish = false;
  
  function compareNumber (A, B, N) {
    if (A == B) {
      return 'strike'
    } else if (A !== B && N.includes(B)) {
      return 'ball';
    } else {
      return;
    }
  }

  return Object.freeze({
    check: function (num) {
      if (isFinish) return console.log("이미 정답을 맞추셨습니다. reset()해주세요.");
      const N = num.toString();

      if (cnt < 1) { // closure
        return console.log(`Game Over! The answer is ${answer}`)
      }

      if (N.length !== 3) {
      	isFinish = true;
      	return console.log("3자리 숫자입니다. 3자리 숫자를 입력하세요.");
      }

      let result = {
        strike: 0,
        ball: 0
      }

      for (let i=0;i<3;i++) {
        const type = compareNumber(answer[i], N[i], answer); // closure
        !!type
        ? result[type]++
        : undefined
      }

      if (result.strike === 3) {
        this.check = null; // memory free
        this.reset = null;
        return console.log(`정답입니다! 게임을 종료합니다.`);
      }

      return console.log(`${result.strike} strike and ${result.ball} ball. ${--cnt}번 남았습니다.`) // closure
    },
    reset: function () {
      answer = Math.floor(Math.random() * (999 - 100) + 100).toString();
      cnt = 10;
    }
  })
}

외부에서 접근 가능한 함수는 answer과 cnt라는 외부 데이터를 참조하고 있는 클로저로 구현함으로써 정보 은닉을 구현할 수 있다.

 

또한 함수 자체를 변경해버리는 어뷰징을 막기 위해 Object.freeze로 객체를 동결하였다.

Object.freeze() 메서드는 객체를 동결합니다. 동결된 객체는 더 이상 변경될 수 없습니다. 즉, 동결된 객체는 새로운 속성을 추가하거나 존재하는 속성을 제거하는 것을 방지하며 존재하는 속성의 불변성, 설정 가능성(configurability), 작성 가능성이 변경되는 것을 방지하고, 존재하는 속성의 값이 변경되는 것도 방지합니다. 또한, 동결 객체는 그 프로토타입이 변경되는것도 방지합니다. freeze()는 전달된 동일한 객체를 반환합니다.

 

3.3 부분 적용 함수

부분적용함수란, M개의 인자 중 일부 N개만 미리 넘겨 기억해두었다가, M-N개의 인자들이 마저 들어오면 본 함수의 실행 결과를 받을 수 있는 함수다.

 

thisBinding을 명시적으로 할 수 있는 bind 메소드 또한 부분 적용 함수이다.

 

3.4 커링 함수

커링 함수는 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 쪼개서 순차적으로 호출하는 함수이다.

 

위의 부분 적용 함수와 의미가 비슷하지만 두 가지 다른 점이 있다.

 

- 커링함수는 한번에 하나의 인자만을 받을 수 있다.

- 부분 적용함수는 재실행 시 원본 함수가 실행되자만, 커링함수는 중간 과정까지 실행하고 마지막 인자가 들어와서야 원본 함수를 실행한다.

 const curry = (f) => (a, ..._) =>
    _[0] === undefined ? (..._) => f(a, ..._) : f(a, ..._);
    
// example

const getUrl = curry((baseUrl, path, id) => {
  fetch(`${baseUrl}/${path}/${id}`);
});

const getImage = getUrl("https://www.image.com");

const getIcon = getImage("icon"); // https://www.image.com/icon

const icon01 = getIcon("100"); // https://www.image.com/icon/100

함수형 프로그래밍에서는 커링 함수처럼 필요한 정보가 모두 들어올 떄까지 함수 실행을 미루는 것을 지연 실행이라고 한다.

 

[참고]

https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Closures

'Books > 코어 자바스크립트' 카테고리의 다른 글

07. 클래스  (0) 2020.05.22
06. 프로토타입  (0) 2020.04.16
04. 콜백 함수  (0) 2020.04.04
03. this와 객체  (0) 2020.03.05
02. 실행 컨텍스트  (0) 2019.12.31