JavaScript
Closure

Closure

자바스크립트에 관심이 있거나 공부한다면 한번쯤 들어봤을 개념이다.

클로저가 그럼 무엇일까?

  • execution context(실행컨텍스트)에 대한 사전 지식이 있으면 이해하기 어렵지 않은 개념이다. 클로저는 자바스크립트 고유의 개념이 아니라 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어(Functional Programming language: 얼랭(Erlnag), 스칼라(Scala), 하스켈(Haskell), 리스프(Lisp)…)에서 사용되는 중요한 특성이다. (PoiemaWeb 참조)
  • 클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다. 클로저를 이해하려면 자바스크립트가 어떻게 변수의 유효범위를 지정하는지(Lexical scoping)를 먼저 이해해야 한다. (MDN 참조)

“A closure is the combination of a function and the lexical environment within which that function was declared.” 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

이렇다고 한다. 말이 너무 어렵다. 실행 컨텍스트는 뭐지. 렉시컬 스코프는 또 뭐지??

실행컨텍스트(execution context)?

자바스크립트에서 젤 중요한 개념이다.

개념

  • 실행할 코드에 제공할 환경 정보들을 모아놓은 객체
  • 자바스크립트의 동적 언어로서의 성격을 가장 잘 파악할 수 있는 개념

구성

실행 컨텍스트는 다음과 같은 것들을 이용하면 콜스택(call stack)에 쌓이게 된다.

  • 전역공간은 자동으로 컨텍스트로 구성된다.
  • 함수를 실행한다.
  • eval()함수를 실행한다.
  • block을 만든다 (ES6+)

잠깐만 그럼 콜스택은 뭐지??

자바스크립트 엔진

  • 가장 대중적인 자바스크립트의 엔진은 구글의 V8 엔진입니다. V8 엔진은 크롬과 노드 안에서 동작합니다.

자바스크립트 엔진은 다음과 같은 두 가지 주요 구성 요소로 이루어져 있습니다.

  • 메모리 힙(Memory Heap) — 객체는 힙, 대부분 구조화되지 않은 메모리 영역에 할당된다. 변수와 객체에 대한 모든 메모리 할당은 여기서 발생한다.
  • 호출 스택(Call Stack) — 코드가 실행될 때 호출 스택이 쌓인다.

실행 환경

  • 브라우저에는 개발자가 사용하는 거의 모든 API가 있다. 이 API에는 DOM, AJAX, setTimeout 등 브라우저에서 제공하는 API라고 하는 것들이 있다.

자바스크립트는 단일 스레드 프로그래밍 언어이다. 그렇기 때문에 단일 호출 스택이 있는데 단일 호출 스택이라 함은 한 번에 하나의 일만 처리할 수 있다는 말이다.

호출 스택의 구조

  • 함수를 실행하면 해당 함수의 기록을 스택 맨 위에 추가(Push) 합니다. 우리가 함수를 결과 값을 반환하면 스택에 쌓여있던 함수는 제거(Pop) 됩니다.
function multiply(x, y) {
  return x * y;
}
function printSquare(x) {
  var s = multiply(x, x);
  console.log(s);
}
printSquare(5);

그렇다. 자바스크립트는 대충 이런 원리로 실행된다.

그렇다면 다시 돌아와서

실행 컨텍스트(execution context)란 ?

var a = 1; // 전역 컨텍스트
function outer() {
  // outer 컨텍스트
  function inner() {
    // inner 컨텍스트
    console.log(a); // undefined
    var a = 3;
    console.log(a); // 3
  }
  inner();
  console.log(a); // 1
}
outer();
console.log(a); // 1

위와 같이 코드를 구성했을 때 실행 컨텍스트의 스택은 다음과 같은 순서로 실행된다.

  • 프로그램 실행: [전역컨텍스트]
  • outer 실행: [전역컨텍스트, outer]
  • inner 실행: [전역컨텍스트, outer, inner]
  • inner 종료: [전역컨텍스트, outer]
  • outer 종료: [전역컨텍스트]

그리고 이러한 정보들이 생성된다.

  • VariableEnvironment
    • 현재 컨텍스트 내의 식별자(변수)들에 대한 정보
    • 외부 환경 정보
    • 선언 시점의 LexicalEnvironment의 스냅샷(변경사항 반영 X)
  • LexicalEnvironment
    • 처음에는 VariableEnvironment와 같음
    • 변경 사항이 실시간으로 반영됨
  • ThisBinding
    • 식별자가 바라봐야 할 대상 객체

Variable Environment

  • VariableEnvironment에 담기는 내용은 LexicalEnvironment와 같지만, 최초 실행 시의 스냅샷을 유지한다. 실행 컨텍스트를 생성할 때 VariableEnvironment에 정보를 먼저 담은 다음, 이를 복사해서 LexicalEnvironment를 만든다.
  • 주로 활용하는 것은 LexicalEnvironment이다. 즉, VariableEnviroment는 스냅샷 유지를 목적으로 사용한다.

Lexcial Environment

  • LexicalEnvironment의 내부에는 environmentRecord와 outerEnvironmentReference로 구성돼 있다.
  • environmentRecord로 인하여 호이스팅이 발생한다.
  • outerEnvironmentReference로 인하여 스코프와 스코프체인이 형성된다.

environmentRecord와 Hoisting(호이스팅)

자바스크립트는 코드를 실행하기전에 식별자를 수집한다.

environmentRecord

현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장된다.

  • 매개변수 식별자
  • 함수 자체
  • 함수 내부의 식별자

Host Object(호스트 객체)

  • 전역 실행 컨텍스트는 변수 객체를 생성하는 대신 전역 객체를 활용한다.
  • 브라우저의 Window 객체, Node의 Global 객체 등이 이에 해당한다.
  • 이들은 Host Object로 분류된다.

즉, 코드가 실행 되기 전에 자바스크립트의 엔진은 이미 실행 컨텍스트에 속한 변수명들을 모두 알고 있게 되는 셈이다.

개발자 황준일님 감사합니다.. 블로그 글 보고 공부 많이 했어요..

호이스팅이란?

  • 자바스크립트 엔진이 실행 컨텍스트를 구성할 때 environmentRecord 에 식별자의 정보를 수집한다. 이러한 과정을 통해 엔진은 함수를 실행하기도 전에 해당 컨텍스트 내부의 변수명들을 이미 알고있다.

이렇기에 ' 식별자들을 코드의 최상단으로 끌어올린다.' 라는 호이스팅이라는 개념이 생겨나는데, 물리적으로 끌어올린 것이 아닌, 실행 컨텍스트 관점에선 이미 식별자들의 정보를 알고 있으니 식별자 정보를 수집하는 과정을 이해하기 쉬운 방법으로 나타낸 추상화한 가상 개념이다.

실행 컨텍스트는 여기 까지 알아보도록 하자

그렇다면 멀리 돌아왔는데 렉시컬 스코프는 뭐지??

  • 함수를 어디서 호출하는지가 아니라 어디에 선언하였는지에 따라 결정되는 것을 말한다.
  • 즉, 함수를 어디서 선언하였는지에 따라 상위 스코프를 결정한다는 뜻이며, 가장 중요한 점은 함수의 호출이 아니라 함수의 선언에 따라 결정된다는 점이다.
  • 다른 말로, 정적 스코프(Static scope)라 부르기도 하다.

근데 그럼 스코프란!?

var x = "global";
 
function foo() {
  var x = "function scope";
  console.log(x);
}
 
foo(); // ? ->function-scope
console.log(x); // ? -> global
  • 스코프는 참조 대상 식별자(identifier, 변수, 함수의 이름과 같이 어떤 대상을 다른 대상과 구분하여 식별할 수 있는 유일한 이름)를 찾아내기 위한 규칙이다. 자바스크립트는 이 규칙대로 식별자를 찾는다.
  • 위 예제에서 전역에 선언된 변수 x는 어디에든 참조할 수 있다. 하지만 함수 foo 내에서 선언된 변수 x는 함수 foo 내부에서만 참조할 수 있고 함수 외부에서는 참조할 수 없다. 이러한 규칙을 스코프라고 한다.

스코프의 구분

전역 스코프 (Global scope)

  • 코드 어디에서든지 참조할 수 있다.

지역 스코프 (Local scope or Function-level scope)

  • 함수 코드 블록이 만든 스코프로 함수 자신과 하위 함수에서만 참조할 수 있다.

모든 변수는 스코프를 갖는다. 변수의 관점에서 스코프를 구분하면 다음과 같이 2가지로 나눌 수 있다.

전역 변수 (Global variable)

  • 전역에서 선언된 변수이며 어디에든 참조할 수 있다.

지역 변수 (Local variable)

  • 지역(함수) 내에서 선언된 변수이며 그 지역과 그 지역의 하부 지역에서만 참조할 수 있다.

자바스크립트는 함수 레벨 스코프(function-level scope)를 따른다. 함수 레벨 스코프란 함수 코드 블록 내에서 선언된 변수는 함수 코드 블록 내에서만 유효하고 함수 외부에서는 유효하지 않다(참조할 수 없다)는 것이다.

단, ECMAScript 6에서 도입된 let keyword를 사용하면 블록 레벨 스코프를 사용할 수 있다.
var x = "global";
 
function foo() {
  var x = "local";
  console.log(x);
}
 
foo(); // local
console.log(x); // global

전역변수 x와 지역변수 x가 중복 선언되었다. 전역 영역에서는 전역변수만이 참조 가능하고 함수 내 지역 영역에서는 전역과 지역 변수 모두 참조 가능하나 위 예제와 같이 변수명이 중복된 경우, 지역변수를 우선하여 참조한다.

다음은 함수 내에 존재하는 함수인 내부 함수의 경우를 살펴보자

var x = "global";
 
function foo() {
  var x = "local";
  console.log(x);
 
  function bar() {
    // 내부함수
    console.log(x); // ?
  }
 
  bar();
}
foo();
console.log(x); // ?

또한 중첩 스코프의 경우 가장 인접한 지역을 우선 참조한다.

var foo = function () {
  var a = 3,
    b = 5;
 
  var bar = function () {
    var b = 7,
      c = 11;
 
    // 이 시점에서 a는 3, b는 7, c는 11
 
    a += b + c;
 
    // 이 시점에서 a는 21, b는 7, c는 11
  };
 
  // 이 시점에서 a는 3, b는 5, c는 not defined
 
  bar();
 
  // 이 시점에서 a는 21, b는 5
};

근데 갑자기 띠용한게 있다.

var x = 1;
 
function foo() {
  var x = 10;
  bar();
}
 
function bar() {
  console.log(x);
}
 
foo(); // ?
bar(); // ?

결과를 에측해보자. 나는 10,1로 예측했다. 근데 결과는 !? 1, 1 이다. 대체 왜?

렉시컬 스코프

  • 렉시컬 스코프는 함수를 어디서 호출하는지가 아니라 어디에 선언하였는지에 따라 결정된다. 자바스크립트는 렉시컬 스코프를 따르므로 함수를 선언한 시점에 상위 스코프가 결정된다.

  • 함수를 어디에서 호출하였는지는 스코프 결정에 아무런 의미를 주지 않는다. 위 예제의 함수 bar는 전역에 선언되었다. 따라서 함수 bar의 상위 스코프는 전역 스코프이고 위 예제는 전역 변수 x의 값 1을 두번 출력한다.

알아두면 좋은 개념

즉시 실행 함수(IIFE)

전역변수 사용을 억제하기 위해, 즉시 실행 함수(IIFE, Immediately-Invoked Function Expression)를 사용할 수 있다. 이 방법을 사용하면 전역변수를 만들지 않을 수 있다.

즉시실행함수를 왜 사용할까?

  1. 필요없는 전역 변수의 생성을 줄일 수 있다. 함수를 생성하면 그 함수는 전역 변수로써 남아있게 되고, 많은 변수의 생성은 전역 스코프를 오염시킬 수 있다.
  • 즉시실행함수를 선언하면 내부 변수가 전역으로 저장되지 않기 때문에 전역 스코프의 오염을 줄일 수 있다.
  1. private한 변수를 만들 수 있다. 즉시실행함수는 외부에서 접근 할 수 없는 자체적인 스코프를 가지게된다. 이는 클로저의 사용 목적과도 비슷하며 내부 변수를 외부로부터 private하게 보호 할 수 있다는 장점이 있다.

클로저를 공부하려고 했는데 실행컨텍스트부터 콜스택, 엔진, 스코프, 렉시컬 스코프, 호이스팅, 즉시실행함수 등등 갑자기 어려운 개념들이 나온다. 정말 멀리 돌아왔지만 이제 진짜 클로저에 대해 시작해보자

그럼 포이마웹 클로저부터 시작해보자.(시작만 몇번째냐.....)

function outerFunc() {
  var x = 10;
  var innerFunc = function () {
    console.log(x);
  };
  innerFunc();
}
 
outerFunc(); // 10

위에 코드에서 당연히 결과값은 10입니다. innerfunc은 자신을 포함하고 있는 outerfunc 변수 x에 당연히 접근할 수 있다. 근데 바로 x에 접근이 성공한 걸까?? _아니다. _

    1. innerfunc은 첫번째로 자신의 스코프에서 x를 검색한다 -> 실패.
    1. 자신을 포함하는 외부함수. 즉, outerfunc에서 변수 x를 검색하고 성공한다. 이렇게 동작한다. 왜냐면 렉시컬 스코프의 레퍼런스를 저장하고 있는 실행 컨텍스트의 스코프 체인을 자바스크립트 엔진이 검색했기 때문이다.

말이 너무 어렵다. 클로저를 이해할려면 1편에 있는 개념들을 최소한 찾아보고 오자. 내 블로그만으로는 이해가 안간다.. 정말 좋은 개발자분들의 블로그를 참고해서 공부하고 오자.

자 함수를 살짝 바꿔보자.

function outerFunc() {
  var x = 10;
  var innerFunc = function () {
    console.log(x);
  };
  return innerFunc;
}
 
/**
 *  함수 outerFunc를 호출하면 내부 함수 innerFunc가 반환된다.
 *  그리고 함수 outerFunc의 실행 컨텍스트는 소멸한다.
 */
var inner = outerFunc();
inner(); // 10

outerfunc은 innerfunc을 반환하고 죽었다. 즉 실행컨텍스트 -> 콜스택에서 함수는 제거 되었다. 그렇다면 당연히 변수 x또한 없어졌고 접근할수 있는 방법이 없는게 맞아보인다. 근데?? 이상하게도 동작한다. 정말 이상하다. 진짜 이상하다. 마법이다.

이걸 클로저라고 부른다고 한다.

진짜 멀리 돌아왔다. MDN 클로저 문서이다.

“A closure is the combination of a function and the lexical environment within which that function was declared.” 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

즉, 클로저는 자신이 선언됐을 때의 렉시컬 스코프를 기억한다. 그래서 자신의 스코프 밖에서 호출되어도 그 렉시컬 스코프를 기억하고 있기 때문에 그 환경에 접근할 수 있느 함수를 말한다.

첨봤을때 어려웠지만 이제 좀 이해가 간다.

조금 더 추가해서 말해보자면 내부함수가 유효한 상태에서 외부함수가 종료되어도 실행컨텍스트 객체 정보를 가지고 있고 스코프 체인을 통해서 참조할 수 있다는것이다.

  • 즉 외부함수가 이미 반환되었어도 이를 필요로 하는 내부함수가 하나 이상 존재하는 경우 계속 유지된다(MDN)
  • 더 신기한것은 변수의 복사본에 접근하는 것이 아니라 실제 변수에 접근한다는 것이다.(변수가 죽지 않고 살아 있다는게 신기하다..)

코드로 한 번 봐보자. 이해한다면 진짜 신기하다. 왜 클로저를 쓰는지 진짜 이해가 간다. 봐보자. 걍 보자

    var box = document.querySelector('.box');
    var toggleBtn = document.querySelector('.toggle');
 
    var toggle = (function () {
      var isShow = false;
 
      // ① 클로저를 반환
      return function () {
        box.style.display = isShow ? 'block' : 'none';
        // ③ 상태 변경
        isShow = !isShow;
      };
    })();
 
    // ② 이벤트 프로퍼티에 클로저를 할당
    toggleBtn.onclick = toggle;
 
<!DOCTYPE html>
 
<html>
<body>
  <button class="toggle">toggle</button>
  <div class="box" style="width: 100px; height: 100px; background: red;"></div>
 
  <script>
    var box = document.querySelector('.box');
    var toggleBtn = document.querySelector('.toggle');
 
    var toggle = (function () {
      var isShow = false;
 
      // ① 클로저를 반환
      return function () {
        box.style.display = isShow ? 'block' : 'none';
        // ③ 상태 변경
        isShow = !isShow;
      };
    })();
 
    // ② 이벤트 프로퍼티에 클로저를 할당
    toggleBtn.onclick = toggle;
  </script>
 
</body>
</html>

토글 버튼을 누르면 없어졌다가 나타났다가 하는 그런 코드이다. 진짜 기초적이고 많이 쓰이는 코드이다. 여태까지 let 으로 전역변수를 만들어서 불리언값을 줘서 같은 기능을 만들었었다. 쓰면서 let은 어디서나 접근 가능하고 변경할 수 있기 때문에 오류의 원인이 된다고 알고 있었다. 쓰면서도 let을 쓰는게 맞을까? 이런 기초적인 것도 let을 남발하는데 실제 코드에서도 이걸 쓰는게 맞을까 생각했었는데 클로저를 쓰면 매우 유용하다.

하나만 더 봐보자. 재밌다.

버튼을 누를때마다 숫자를 1씩 증가하는 카운트 버튼을 만들어보자.

var incleaseBtn = document.getElementById("inclease");
var count = document.getElementById("count");
 
// 카운트 상태를 유지하기 위한 전역 변수
var counter = 0;
 
function increase() {
  return ++counter;
}
 
incleaseBtn.onclick = function () {
  count.innerHTML = increase();
};

클로저를 알고 나니까 부끄럽다. 맨날 이렇게 만들었다. 이 코드에서는 문제가 무엇일까? 전역 변수 counter의 값이 반드시 0 또는 숫자여야만 동작할 것이다. 근데 전역변수는 어디서나, 누구나 접근이 가능한 변수이고 의도치 않게 변경될 수 있다. 이는 오류로 이어진다. 그렇기 때문에 counter변수는 increase 함수가 관리하는것이 바람직한 방향이다.

지역 변수로 변경하면?

function increase() {
  // 카운트 상태를 유지하기 위한 지역 변수
  var counter = 0;
  return ++counter;
}
 
incleaseBtn.onclick = function () {
  count.innerHTML = increase();
};

_0에서 1은 변경이 되지만 함수가 호출될 때마다 counter가 0으로 초기화 되기 때문에 언제나 1이 표시된다. _

클로저를 써보자

var increase = (function () {
  // 카운트 상태를 유지하기 위한 자유 변수
  var counter = 0;
  // 클로저를 반환
  return function () {
    return ++counter;
  };
})();
 
incleaseBtn.onclick = function () {
  count.innerHTML = increase();
};

즉시실행함수가 호출되고 변수 increase 카운터를 올려주는 함수가 할당된다. 이 함수는 렉시컬 스코프, 즉 자신이 만들어졌을때의 환경을 기억하는 클로저다. 그렇기 때문에 counter변수를 기억하고 있고 접근이 가능하다. 즉, 내부함수가 counter를 참조하는 한 죽지 않고 살아서 계속 유지된다.

  • 변수의 값은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적 원인이 될 수 있다. 상태 변경이나 가변(mutable) 데이터를 피하고 불변성(Immutability)을 지향하는 함수형 프로그래밍에서 부수 효과(Side effect)를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저는 적극적으로 사용된다

**그렇다. 클로저 어렵다. 이해하고 나면 재밌다. 공부하고 보면 써보고 싶다. 다음에는 let, var, const에 대해 더 공부해보면서 기록해야겠다. 하지만 잘 쓴다면 좀 간지나는 개발자가 될 것 같다. **

갑자기 이런 생각이 들었다. 리액트를 하면서 usestate는 왜 const로 변수를 만들까?? state값을 계속 바꿔주고 여러가지 별거 다하는데 왜 let보다 const일까?? 궁금해서 검색해봤는데 클로저 때문이라고 한다.... 그냥 검색해봤는데 갑자기 클로저가 튀어나왔다. 클로저 키워드 보자마자 일단 도망쳤는데 다음에는 날잡고 공부해봐야겠다.

poimaWeb 감사합니다. 예제는 거의 다 가져왔지만 그래도 정말 열심히 공부했어요.