JS - Closure

chu·2021년 5월 12일
0
post-thumbnail

이번 시간에는 자바스크립트에서 중요한 개념 중 하나로 자바스크립트에 관심을 가지고 있다면 한번쯤은 들어보았을 내용인 클로저(Closure)에 대해서 정리를 한다.

먼저 클로저에 대해서 정리하기 전에 실행 컨텍스트에 대해서 정리부터 하려고 한다. 클로저에 대해서 더 이해가 쉽다고 하니 알아보자.


실행 컨텍스트 (Excution Context)

실행 컨텍스트(Execution Context)는 scope, hoisting, this, function, closure 등의 동작원리를 담고 있는 자바스크립트의 핵심원리이다. 실행 컨텍스트를 바로 이해하지 못하면 코드 독해가 어려워지며 디버깅도 매우 곤란해 질 것이다.

ECMAScript 스펙에 따르면 실행 컨텍스트를 실행 가능한 코드를 형상화하고 구분하는 추상적인 개념이라고 정의한다. 좀 더 쉽게 말하자면 실행 컨텍스트는 실행 가능한 코드가 실행되기 위해 필요한 환경 이라고 말할 수 있겠다. 여기서 말하는 실행 가능한 코드는 아래와 같다.

  • 전역 코드 : 전역 영역에 존재하는 코드

  • Eval 코드 : eval 함수로 실행되는 코드 (사용 금지)
    (실제 어떤 브라우저는 eval() 코드를 작성하면 에러를 나타낸다)

  • 함수 코드 : 함수 내에 존재하는 코드

자바스크립트 엔진은 코드를 실행하기 위하여 실행에 필요한 여러가지 정보를 알고 있어야 한다.
실행에 필요한 여러가지 정보란 아래와 같은 것들이 있다.

  • 변수 : 전역변수, 지역변수, 매개변수, 객체의 프로퍼티
  • 함수 선언
  • 변수의 유효범위(Scope)
  • this

그럼 실행 컨텍스트는 어떤 과정으로 진행하는 아래 예시 코드로 확인해보자.

function func1() {
  function func2() {
    console.log('func2');
  }
  func2();
  console.log('func1');
}

func1();

위는 아주 간단한 예시 함수 코드이다. 위 코드를 실행하면 콘솔은 어떻게 출력이 될까?

정답

function func1() {
  function func2() {
    console.log('func2'); // 첫번째 콘솔 출력
  }
  func2();
  console.log('func1'); // 두번째 콘솔 출력
}

func1();

분명 func1() 을 먼저 실행했는데 어째서 func2() 의 콘솔이 먼저 출력 되는걸까?

위 코드를 실행하면 아래와 같이 실행 컨텍스트 스택(Stack) 이 생성하고 소멸한다.
실행 컨텍스트 스택은 LIFO(Last In First Out, 후입 선출)의 구조를 가지는 나열 구조이다.

즉, a -> b -> c로 실행 순이라면, 반대로 c -> b -> a 순서로 나오게 되는 구조다.

  1. 먼저 전역 코드(Global code)로 컨트롤이 진입하면 전역 실행 컨텍스트가 생성된다.
  2. func1 함수가 실행되어 스택에 쌓인다.
  3. func1 함수 내부에서 콘솔 찍히기 전에 func2 함수가 실행되어 스택에 쌓인다. 이 때 func1 중지
  4. func2 함수 내부를 실행 후 func2 함수는 스택에서 빠진다.
  5. 중지된 func1의 함수 내부가 모두 실행된다. 스택에서 빠진다.

대략 이런 순서를 생각하면 위 코드의 콘솔 출력 순서를 이해할 수 있다.
전역 실행 컨텍스트는 애플리케이션이 종료될 때(웹 페이지에서 나가거나 브라우저를 닫을 때)까지 유지된다.

위 내용은 실행 컨텍스트에서 정말 간단하게 정리한 내용이다. 더욱 깊게 다가가면 많은 내용이 있다.
하지만 앞으로 소개할 클로저도 내용이 많기 때문에 여기까지 실행 컨텍스트에 대해서 정리를 하겠다.

나머지 궁금한 내용은 MDN이나 여기서 참고하면 좋을 것 같다. 여러 곳에서 지식을 얻는 것이 좋은 것 같다.

클로저 (Closure)

클로저는 자바스크립트 고유의 개념이 아니라 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다. 클로저는 자바스크립트 고유의 개념이 아니므로 ECMAScript 명세에 클로저의 정의가 등장하지 않는다. 클로저에 대해 MDN은 아래와 같이 정의하고 있다.

클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

말이 무척이나 난해하니 우선 예제부터 살펴보자. “The Art of Computer Programming”의 저자 도널드 커누스의 말처럼 우리 모두는 자신의 힘으로 발견한 내용을 가장 쉽게 익힌다.

한 마디로 직접 해보면서 어떤건지 알아보자!

function outerFunc() {
  const number = 10;
  const innerFunc = function () { console.log(number); };
  innerFunc();
}

outerFunc(); // 10

함수 outerFunc 내에서 내부함수 innerFunc가 선언되고 호출되었다. 이때 내부함수 innerFunc는 자신을 포함하고 있는 외부함수 outerFunc의 변수 number에 접근할 수 있다. 이는 함수 innerFunc가 함수 outerFunc의 내부에 선언되었기 때문이다.

스코프는 함수를 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정된다. 이를 렉시컬 스코핑(Lexical scoping)라 한다. 위 예제의 함수 innerFunc는 함수 outerFunc의 내부에서 선언되었기 때문에 함수 innerFunc의 상위 스코프는 함수 outerFunc이다. 함수 innerFunc가 전역에 선언되었다면 함수 innerFunc의 상위 스코프는 전역 스코프가 된다.

위 코드 예제는 맨 위에서 소개했던 실행 컨텍스트 통해 과정을 머리로 알 수 있다.
하지만 이 스코프라는 것은 실행 컨텍스트에서 조금 더 깊게 다가가면 스코프 체인 이라는 키워드로 이어진다.

innerFunc에서는 number 변수가 없다. 하지만 콘솔로 찍히는 이유는 innerFunc 내부에서 number 변수를 검색을 한다. 하지만 없기 때문에 검색에 실패한다. 그럼 외부의 outerFunc에서 number 변수를 검색을 한다. 그러면 검색에 성공하여 접근이 가능하다. 그렇기 때문에 innerFunc에서는 number를 콘솔로 출력이 가능하다.

만약 outerFunc에서도 검색을 실패하면, 전역으로 넘어가서 검색을 실행한다 그래도 없다면 우리가 흔히 아는 Reference 에러가 발생한다. 즉 정의되지 않은 변수에 접근하려고 했기 때문이다.

이번에는 내부함수 innerFunc를 함수 outerFunc 내에서 호출하는 것이 아니라 반환하도록 변경해 보자.

function outerFunc() {
  const number = 10;
  const innerFunc = function () { console.log(number); };
  return innerFunc;
}

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

위 코드 예제에서는 outerFunc가 inner 변수에 내부의 innerFunc를 반환하고 생을 마감했다.
즉, 콜 스택(실행 컨텍스트 스택)에서 쌓이고 바로 빠진다는 얘기다. 그럼 현재 outerFunc는 존재하지 않는데 어떻게 innerFunc는 number 변수에 접근이 가능할까?

이처럼 자신을 포함하고 있는 외부함수보다 내부함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부함수가 호출되더라도 외부함수의 지역 변수에 접근할 수 있는데 이러한 함수를 클로저(Closure)라고 부른다.

즉, 클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다.

실행 컨텍스트의 관점에 설명하면, 내부함수가 유효한 상태에서 외부함수가 종료하여 외부함수의 실행 컨텍스트가 반환되어도, 외부함수 실행 컨텍스트 내의 활성 객체(Activation object)(변수, 함수 선언 등의 정보를 가지고 있다)는 내부함수에 의해 참조되는 한 유효하여 내부함수가 스코프 체인을 통해 참조할 수 있는 것을 의미한다.

즉 외부함수가 이미 반환되었어도 외부함수 내의 변수는 이를 필요로 하는 내부함수가 하나 이상 존재하는 경우 계속 유지된다. 이때 내부함수가 외부함수에 있는 변수의 복사본이 아니라 실제 변수 에 접근한다는 것에 주의하여야 한다.

클로저의 활용

클로저는 자신이 생성될 때의 환경(Lexical environment)을 기억해야 하므로 메모리 차원에서 손해를 볼 수 있다. 하지만 클로저는 자바스크립트의 강력한 기능으로 이를 적극적으로 사용해야 한다. 클로저가 유용하게 사용되는 상황에 대해 살펴보자.

상태 유지

상태 유지라고 하니 리액트의 useState가 떠오른다... 추후 리액트와 클로저의 연관에 대해서도 정리를 하는게 좋겠다.

클로저가 가장 유용하게 사용되는 상황은 현재 상태를 기억하고 변경된 최신 상태를 유지하는 것이다. 아래 예제를 살펴보자.

<!DOCTYPE html>
<html>
  <body>
    <button class="toggle">toggle</button>
    <div class="box"></div>

    <script>
      const box = document.querySelector('.box');
      const toggleBtn = document.querySelector('.toggle');

      const toggle = (function () {
      let isShow = false;
        // 1. 클로저를 반환
        return function () {
          box.style.display = isShow ? 'block' : 'none';
          // 3. 상태 변경
          isShow = !isShow;
        };
      })();

      // 2. 이벤트 프로퍼티에 클로저를 할당
      toggleBtn.onclick = toggle;
    </script>
  </body>
</html>
  1. 즉시실행함수는 함수를 반환하고 즉시 소멸한다. 즉시실행함수가 반환한 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)에 속한 변수 isShow를 기억하는 클로저다. 클로저가 기억하는 변수 isShow는 box 요소의 표시 상태를 나타낸다.

  2. 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했다. 이벤트 프로퍼티에서 이벤트 핸들러인 클로저를 제거하지 않는 한 클로저가 기억하는 렉시컬 환경의 변수 isShow는 소멸하지 않는다. 다시 말해 현재 상태를 기억한다.

  3. 버튼을 클릭하면 이벤트 프로퍼티에 할당한 이벤트 핸들러인 클로저가 호출된다. 이때 .box 요소의 표시 상태를 나타내는 변수 isShow의 값이 변경된다. 변수 isShow는 클로저에 의해 참조되고 있기 때문에 유효하며 자신의 변경된 최신 상태를 게속해서 유지한다.

만약 자바스크립트에 클로저라는 기능이 없다면 상태를 유지하기 위해 전역 변수를 사용할 수 밖에 없다. 전역 변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 한다.

전역 변수 사용 억제

<!DOCTYPE html>
<html>
<body>
  <p>전역 변수를 사용한 Counting</p>
  <button id="inclease">+</button>
  <p id="count">0</p>
  <script>
    const incleaseBtn = document.getElementById('inclease');
    const count = document.getElementById('count');

    // 카운트 상태를 유지하기 위한 전역 변수
    let counter = 0;

    function increase() {
      return ++counter;
    }

    incleaseBtn.onclick = function () {
      count.innerHTML = increase();
    };
  </script>
</body>
</html>

위 처럼 전역 변수를 사용할 경우에는 흔히 말하는 의도치 않게 값을 변경할 수 있거나, 누군가 접근하기 쉽기 때문에 전역 변수 사용을 최대한 하면 안된다고 한다. 그렇기 때문에 함수 내부에서 counter를 관리할 수 있도록 다음 예제를 통해서 알아보자.

const incleaseBtn = document.getElementById('inclease');
const count = document.getElementById('count');

function increase() {
  // 카운트 상태를 유지하기 위한 전역 변수
  let counter = 0;
  return ++counter;
}

incleaseBtn.onclick = function () {
  count.innerHTML = increase();
};

이렇게 보면 전역 변수도 없고, 함수 내부에서 실행하니까 안전하겠지? 하지만 현재까지는 문제가 있다. 버튼을 클릭할 때마다 counter는 0으로 초기화가 되기 때문에 클로저를 활용해야 한다.

const incleaseBtn = document.getElementById('inclease');
const count = document.getElementById('count');

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

incleaseBtn.onclick = function () {
  count.innerHTML = increase();
};

스크립트가 실행되면 즉시실행함수가 호출되고 변수 increase에는 함수 function () { return ++counter; }가 할당된다. 이 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)을 기억하는 클로저다.

즉시실행함수는 호출된 이후 소멸되지만 즉시실행함수가 반환한 함수는 변수 increase에 할당되어 inclease 버튼을 클릭하면 클릭 이벤트 핸들러 내부에서 호출된다.

이때 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 counter를 기억한다. 따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때가지 유지된다.

즉시 실행 함수를 왜 써야할까?
여기서 즉시 실행 함수를 사용하지 않고 진행했다는 가정을 해보면 count에는 increase 함수의 반환 값인function return ++ counter 자체가 innerHTML로 들어가게 된다.

그렇다면 즉시 실행 함수를 사용 후 increase에 접근하면 무엇이 있을까!
return ++counter가 출력이 된다. 즉 리턴되는 ++counter(값)이 innerHTML로 들어가게 된다.

위 자문자답은 실제로 프로젝트에서 즉시 실행 함수를 사용한 적이 없어서 궁금해서 알아봤다.

사용 예제 - 고차함수

// 함수를 인자로 전달받고 함수를 반환하는 고차 함수
// 이 함수가 반환하는 함수는 클로저로서 카운트 상태를 유지하기 위한 자유 변수 counter을 기억한다.
function makeCounter(predicate) {
  // 카운트 상태를 유지하기 위한 자유 변수
  let counter = 0;
  // 클로저를 반환
  return function () {
    counter = predicate(counter);
    return counter;
  };
}

// 보조 함수
function increase(n) {
  return ++n;
}

// 보조 함수
function decrease(n) {
  return --n;
}

// 함수로 함수를 생성한다.
// makeCounter 함수는 보조 함수를 인자로 전달받아 함수를 반환한다
const increaser = makeCounter(increase);
console.log(increaser()); // 1
console.log(increaser()); // 2

// increaser 함수와는 별개의 독립된 렉시컬 환경을 갖기 때문에 카운터 상태가 연동하지 않는다.
const decreaser = makeCounter(decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2

함수 makeCounter는 보조 함수를 인자로 전달받고 함수를 반환하는 고차 함수이다.

함수 makeCounter가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 함수 makeCounter의 스코프에 속한 변수 counter을 기억하는 클로저다.

함수 makeCounter는 인자로 전달받은 보조 함수를 합성하여 자신이 반환하는 함수의 동작을 변경할 수 있다. 이때 주의해야 할 것은 함수 makeCounter를 호출해 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖는다는 것이다.

위 예제에서 변수 increaser와 변수 decreaser에 할당된 함수는 각각 자신만의 독립된 렉시컬 환경을 갖기 때문에 카운트를 유지하기 위한 자유 변수 counter를 공유하지 않아 카운터의 증감이 연동하지 않는다.

따라서 독립된 카운터가 아니라 연동하여 증감이 가능한 카운터를 만들려면 렉시컬 환경을 공유하는 클로저를 만들어야 한다.

뜬금없는 이야기지만 이런 함수형 프로그래밍을 해야한다면, 고차함수, 콜백함수 등 다양한 경험이 필요해보인다. 물론 제 얘기...

이렇게 간단하게 클로저와 이를 이해하기 비교적 쉽게 도움을 주는 실행 컨텍스트까지 정리를 했다.
클로저가 무엇인지 정도로만 정리된 내용이므로 꼭 응용을 해보면서 직접 경험을 해야 더 이해가 될 것 같다.

또한 리액트에서는 클로저를 어떻게 활용하는지에 대해서 빨리 알아보도록 해보자!

[출처]

클로저 - https://poiemaweb.com/js-closure
실행컨텍스트 - https://poiemaweb.com/js-execution-context

profile
한 걸음 한걸음 / 현재는 알고리즘 공부 중!

0개의 댓글