[JavaScript] Closure에 갇혀버렸다... 살려줘

hyeonbin·2023년 4월 25일
0

JS 계란반 스터디

목록 보기
3/12
post-thumbnail
🎵 오늘의 음악 🎵

Closure와 아무 상관없는 '오마이걸의 Closer' 추천~!


✏️ 오늘의 끄적끄적 ✏️

Closure를 알려면 렉시컬 Scope를 알아야 하고,
렉시컬 Scope를 알려면 Scope를 알아야 하고,
이 모든 것을 이해하려면, 실행 컨텍스트와 스코프 체인까지 알아야 한다.

📃 클로저 (Closure)

💡 클로저란?


  • 클로저는 함수를 지칭하고 또 그 함수가 선언됐을 때의 어휘적 환경(렉시컬 환경)과의 조합
    → 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.
    → 클로저는 자신이 생성될 당시의 환경을 기억하고, 함수가 호출되는 환경과 별개로 기존에 선언되어 있던 환경을 기준으로 변수를 조회한다.

    => 즉, 내부함수가 자신이 선언됐을 때의 환경인 렉시컬 스코프(Lexical Scope)를 기억하여, 렉시컬 스코프 밖에서 호출하거나 실행될 때도 그 환경에 접근할 수 있게 해준다.

  • 클로저의 핵심은 스코프를 이용해서, 변수의 접근 범위를 닫는 것
    → 내부함수에서는 외부함수 스코프에서 선언된 지역변수에 접근이 가능하다.
    → 외부함수의 실행이 끝나서 소멸된 이후에도 내부함수가 외부함수 내 변수를 사용할 수 있다.
    → 특정 데이터를 스코프 안에 가두어 둔 채로 계속 사용할 수 있게하는 폐쇄성을 가진다.

    => 클로저에 참조되는 외부함수의 지역변수를 자유변수(Free variable)라고 부른다.



클로저 기본 예제 01

function outer() {
  const name = 'Bin';

  function inner() {
    console.log(name); // Bin
  }
  
  inner();
}

outer(); // Bin
  • 변수 name은 외부함수에 정의되어 있는 지역변수(자유변수)다.
  • 내부함수 inner()가 호출되었을 때, inner() 내부에서 변수 검색을 실패하니까 외부함수 outer()에 접근해 변수를 찾게 되고 검색에 성공한다.

why?

- 내부에 선언된 내부 함수니까, 자신이 속한 렉시컬 스코프를 참조할 수 있다!
- 내부함수 inner()의 상위 스코프는 외부함수 outer()이다.
- 따라서 함수 outer()를 호출하면 'bin' 을 잘 출력한다.

=> 내부함수에서 외부함수의 변수에 접근하는 것이 바로 클로저의 핵심이다.


클로저 기본 예제 02

function outer() {
  const x = 'Hello';
  const y = 'Bin!';
  const inner = function () {
    console.log(x + ' ' + y);
  };
  
  inner();
}

outer(); // Hello Bin!
  • 외부함수 outer() 내에 내부함수 inner가 선언되고 호출된다.
  • 내부함수 inner 내에서 변수 xy를 찾지만 실패한다.
  • 내부함수 inner는 자신을 포함하고 있는 외부함수 outer()의 변수 xy에 접근해서 변수 찾기에 성공한다.

< 실행 컨텍스트 관점에서 설명 >

그 전에 실행 컨텍스트스코프 체인을 먼저 알아야 한다. 클릭!

  1. 위 예제 01, 02는 내부함수를 외부함수 내에서 호출할 때이다.
  2. 내부함수가 호출되면 자신의 실행 컨텍스트가 스택에 쌓이고 변수 객체 , 스코프 체인 , this에 바인딩할 객체가 결정된다.
  3. 이때 스코프 체인은 전역 스코프를 가리키는 전역 객체와 외부함수의 활성 객체, 그리고 함수 자신의 스코프를 가리키는 활성 객체를 순차적으로 바인딩한다.

=> 스코프 체인이 바인딩한 객체가 바로 렉시컬 스코프의 실체다.

다시 말하자면 :

내부함수가 외부함수의 변수에 접근할 수 있는 것 즉, 상위 스코프에 접근할 수 있는 것은 렉시컬 스코프의 레퍼런스를 차례대로 저장하고 있는 실행 컨텍스트의 스코프 체인을 자바스크립트 엔진이 검색하였기에 가능한 것이다.


클로저 기본 예제 03

function outer() {
  const x = 'Hello';
  const y = 'Bin!';
  const inner = function () {
    console.log(x+ ' ' + y);
  };
  return inner;
}

const myFunc = outer();
myFunc(); // Hello Bin!
  • 함수 outer()를 호출하면 내부함수 inner를 반환하고 종료한다.
  • 함수myFunc은 함수 outer()의 리턴값이다.
  • 함수 outer()는 실행된 이후 콜스택(실행 컨텍스트 스택)에서 제거되었기에 함수 outer()의 변수 x , y 또한 더이상 유효하지 않게 되어 변수에 접근할 방법이 없어 보인다.

but!

- 위 코드의 실행 결과는 변수 x , y의 값인 'Hello Bin!' 이 출력된다.
- 이미 Life-Cycle ( 생성되고 사용되며 소멸되는 메커니즘 )이 종료되어 실행 컨텍스트 스택에서 제거된 함수 outer()의 지역변수가 다시 동작하고 있다.

why?

- 클로저라면 가능하다.
- 자신을 포함하고 있는 외부함수보다 내부함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부함수가 호출되더라도 외부함수의 지역변수에 접근할 수 있기 때문이다.

=> 외부함수가 종료된 이후에도 내부함수를 통해 변수에 접근 가능한 것이 클로저 특성이다.

< 실행 컨텍스트 관점에서 설명 >

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

아래 그림을 보면서 이해해 보자 ↓




💡 클로저 활용

상태 유지

  • 클로저를 가장 유용하게 사용하는 상황은 현재 상태를 기억하고 변경된 최신 상태를 유지하는 것

  • 아래의 부분 예제는 토글버튼을 통해 박스가 보였다 안보였다 하는 결과를 출력한다.

  <body>
      <div class="container">
        <button class="toggle">toggle</button>
        <div class="box"></div>
      </div>

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

        let toggle = (function () {
          let showPurpleBox = false;

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

        // 2) 이벤트 프로퍼티에 클로저를 할당
        toggleBtn.onclick = toggle;
      </script>
    </body>


들어가기에 앞서 즉시실행함수란?

말 그대로 즉시 실행되는 함수 즉, 함수를 정의하는 동시에 호출되는 함수다.
익명 함수를 사용하는 것이 일반적이며, 한 번 밖에 실행할 수 없다.

< 코드 설명 1 >

- 일단, 즉시실행함수는 먼저 실행되고 함수를 반환하고 즉시 소멸한다.
- 즉시실행함수가 반환한 함수는 자신이 생성됐을 때의 렉시컬 환경에 속한 변수 showPurpleBox를 기억하는 클로저다.

< 코드 설명 2 >

- 클로저가 기억하는 변수 showPuurpleBox는 .box 요소의 표시 상태를 나타낸다.
- 그 다음, 이 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했다.
- 이벤트 프로퍼티에서 이벤트 핸들러인 클로저를 제거하지 않는 이상 클로저가 기억하는 렉시컬 환경의 변수 showPurpleBox는 소멸하지 않는다. ( 즉, 현재 상태를 기억하는 것 )

< 코드 설명 3 >

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

결론을 말하자면 :

클로저는 현재 상태를 기억하고 이 상태가 변경되어도 최신 상태를 유지해야 하는 상황에 매우 유용하다.

참고 :

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



전역 변수의 사용 억제

  • 아래의 부분 예제는 버튼이 클릭될 때마다 클릭한 횟수가 누적되어 화면에 표시되는 카운터이다.
    → 위 예제에서 했던 상태 유지가 바로 여기서 클릭된 횟수가 유지해야할 상태이다.



전역 변수와 지역 변수를 사용한 Counting

< 전역 변수 >

- 코드는 잘 동작하지만, 오류를 발생시킬 가능성을 내포하고 있다.
- plus 함수는 호출되기 직전에 전역변수 counter의 값이 반드시 0이어야 제대로 동작한다.
- 하지만, 변수 counter는 전역변수이기에 언제든지 누구나 접근할 수 있고 변경할 수 있다.
- 만약 누군가 의도치 않게 전역변수 값을 변경했다면 곧바로 오류로 이어진다.

=> 변수 counter는 카운터를 관리하는 plus 함수가 관리하는 것이 바람직하다.

< 지역 변수 >

- 전역변수를 지역변수로 변경해 일단 의도치 않은 상태 변경은 방지했다.
- 하지만, plus 함수가 호출될 때마다 지역변수 counter를 0으로 초기화하기 때문에 언제나 1이 표시된다.
- 즉, 변경되기 이전 상태를 기억하지 못하는 것이다.

=> 이전 상태를 기억하도록 클로저를 사용해 문제를 해결해 보자!


<body>
    <div class="container">
      <p>클로저를 사용한 Counting</p>
      <button id="plus">+</button>
      <p id="count">0</p>
    </div>
    <script>
      let plusBtn = document.getElementById('plus');
      let count = document.getElementById('count');

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

      plusBtn.onclick = function () {
        count.innerHTML = plus();
      };
    </script>
 </body>


클로저를 사용한 Counting

  1. 스크립트가 실행되면 즉시실행함수가 호출되고
    변수 plus에는 함수 function () { return ++conunter; }가 할당된다.
  1. 이 함수는 자신이 생성됐을 때의 렉시컬 환경을 기억하는 클로저다.
  1. 즉시실행함수는 호출된 이후 소멸되지만, 즉시실행함수가 반환한 함수는 변수 plus에 할당되어 plus 버튼을 클릭하면 클릭 이벤트 핸들러 내부에서 호출된다.
  1. 이때 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 (자유변수) counter를 기억한다.
  1. 따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때가지 유지된다.
  1. 즉시실행함수는 한번만 실행되므로 plus 함수가 호출될 때마다 변수 counter가 재차 초기화될 일은 없을 것이다.
  1. 변수 counter는 외부에서 직접 접근할 수 없는 private 변수이므로 전역변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문에 보다 안정적인 프로그래밍이 가능하다.



정보의 은닉

  • 아래 예제는 생성자 함수 Counter를 생성하고, 이를 통해 counter 객체를 만드는 코드이다.
  function Counter() {
    // 카운트를 유지하기 위한 자유 변수
    let counter = 0;

    // 클로저
    this.increase = function () {
      return ++counter;
    };

    // 클로저
    this.decrease = function () {
      return --counter;
    };
  }

  const counter = new Counter();

  console.log(counter.increase()); // 1
  console.log(counter.decrease()); // 0

< 코드 설명 >

  1. 생성자 함수 Counter는 increase, decrease 메소드를 갖는 인스턴스를 생성한다.
  1. 이 메소드들은 모두 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 스코프에 속한 변수 counter를 기억하는 클로저이며 렉시컬 환경을 공유한다,
  1. 생성자 함수가 생성한 객체의 메소드는 객체의 프로퍼티에만 접근할 수 있는 것이 아니며 자신이 기억하는 렉시컬 환경의 변수에도 접근할 수 있다.
  1. 이때 생성자 함수 Counter의 변수 counter는 this에 바인딩된 프로퍼티가 아니라 변수다.
  1. counter가 this에 바인딩된 프로퍼티라면 생성자 함수 Counter가 생성한 인스턴스를 통해 외부에서 접근이 가능한 public 프로퍼티가 되지만, 생성자 함수 Counter 내에서 선언된 변수 counter는 생성자 함수 Counter 외부에서 접근할 수 없다.
  1. 하지만 생성자 함수 Counter가 생성한 인스턴스의 메소드인 increase, decrease는 클로저이기 때문에 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 변수 counter에 접근할 수 있다.
  1. 이러한 클로저의 특징을 사용해 클래스 기반 언어의 private 키워드를 흉내낼 수 있다.



자주 발생하는 실수

  • 아래 예제는 클로저를 사용할 때 자주 발생할 수 있는 실수에 관한 예제다.
  // 입력
  var arr = [];

  for (var i = 0; i < 5; i++) {
    arr[i] = function () {
      return i;
    };
  }

  for (var j = 0; j < arr.length; j++) {
    console.log(arr[j]());
  }

  // 출력
  5
  5
  5
  5
  5

< 코드 설명 >

  1. 첫번째 for문의 코드 블록 내에서 함수가 arr 배열 요소로 추가된다.
  1. 두번째 for문의 코드 블록 내에서 arr 배열의 요소로 추가된 함수를 순차적으로 호출한다.
  1. 이때 arr 배열의 요소로 추가된 5개의 함수가 0, 1, 2, 3, 4 를 반환할 것 같지만, 결과는 그렇지 않다.
  1. for문에서 사용한 변수 i는 블록 레벨 스코프가 아닌 함수 레벨 스코프를 갖기 때문에 전역변수다. ( 변수 호이스팅 발생 )
  1. 전역변수 i에 0, 1, 2, 3, 4 가 순차적으로 할당된다.
  1. 따라서 arr 배열의 요소로 추가한 함수를 호출하면, 전역변수 i를 참조하여 i 의 값 5가 출력된다.


  • 클로저를 사용해 위 예제를 바르게 동작하는 코드로 만들어보자.
  var arr = [];

  for (var i = 0; i < 5; i++){
    arr[i] = (function (id) { // ②
      return function () {
        return id; // ③
      };
    }(i)); // ①
  }

  for (var j = 0; j < arr.length; j++) {
    console.log(arr[j]());
  }

< 코드 설명 >

  1. 배열 arr에는 즉시실행함수에 의해 함수가 반환된다.
  1. 이때 즉시실행함수는 i를 인자로 전달받고 매개변수 id에 할당한 후 내부 함수를 반환하고 종료된다. 매개변수 id는 자유변수가 된다.
    ( 함수의 매개변수는 자동으로 지역변수 취급 )
  1. 배열 arr에 할당된 함수는 id를 반환한다. 이때 id는 상위 스코프의 자유변수이므로 그 값이 유지된다.

=> 위 예제는 자바스크립트의 함수 레벨 스코프 특성으로 인해 for 루프의 초기문에서 사용된 변수의 스코프가 전역이 되기 때문에 발생하는 현상이다.


  • ES6의 let 키워드를 사용하면 이와 같은 문제를 말끔하게 해결할 수 있다.
  const arr = [];

  for (let i = 0; i < 5; i++) {
    arr[i] = function () {
      return i;
    };
  }

  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]());
  }

< 코드 설명 >

  1. for문의 변수 선언문에서 let 키워드로 선언한 변수를 사용하면 for문의 코드 블록이 반복 실행될 때마다 for문 코드 블록의 새로운 렉시컬 환경이 생성된다.
  1. 만약 for문의 코드 블록 내에서 정의한 함수가 있다면, 이 함수의 상위 스코프는 for문의 코드 블록이 반복 실행될 때마다 생성된 for문 코드 블록의 새로운 렉시컬 환경이다.
  1. 이때 함수의 상위 스코프는 for문의 코드 블록이 반복 실행될 때마다 식별자 ( for문의 변수 선언문에서 선언한 초기화 변수 및 for문의 코드 블록 내에서 선언한 지역 변수 ) 값을 유지해야 한다.
  1. 이를 위해 for문이 반복될 때마다 독립적인 렉시컬 환경을 생성하여 식별자의 값을 유지한다.

=> 이처럼 let , const 키워드를 사용하는 반복문은 코드 블록을 반복 실행할 때마다 새로운 렉시컬 환경을 생성하여 반복할 당시의 상태를 마치 스냅샷을 찍는 것처럼 저장한다.

단, 이는 반복문의 코드 블록 내부에서 함수를 정의할 때 의미가 있다.

반복문의 코드 블록 내부에 함수 정의가 없는 반복문이 생성하는 새로운 렉시컬 환경은 반복 직후, 아무도 참조하지 않기 때문에 가비지 컬렉션의 대상이 된다.



💡 클로저 단점

  • 메모리 소모
  • Scope 생성에 따른 퍼포먼스 손해

결론을 말하자면 :

클로저는 자신이 생성될 때의 환경을 기억해야하므로 퍼포먼스 저하 및 메모리 적인 측면에서 손해를 보지만... 네버 더 레스! 과연... 자바스크립트에서 클로저를 빼면 자바스크립트라고 할 수 있을까? 따라서! 클로저는 단점보다 장점이 훨씬 많고 강력한 기능이다.

profile
할 수 있다고 믿는 사람은 결국 그렇게 된다 😄😊

0개의 댓글