ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Javascript] 클로져
    사부작사부작/Javascript 2022. 3. 29. 14:44

    #모던자바스크립트_딥다이브 스터디를 진행하면서 정리한 내용입니다.

     

    렉시컬 스코프

    자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에서 정의했는지에 따라 상위 스코프를 결정한다. 이를 렉시컬 스코프(정적 스코프)라고 한다.

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

    위 예제에서 bar 함수의 결과 값은 10이 아니라 1이다. 이것은 자바스크립트 엔진이 렉시컬 스코프를 따르기 때문이다. 렉시컬 환경의 외부 렉시콜 환경에 대한 참조에 저장할 값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정된다.

     

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

    렉시컬 스코프가 가능하려면 함수는 자신이 호출되는 환경과는 상관없이 자신이 정의된 환경, 즉 상위 스코프(함수 정의가 위치한 스코프)를 기억해야 한다. 이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신의 상위 스코프의 참조를 저장한다. 즉, 함수가 정의가 평가되어 상위 스코프의 BindingObject에 함수 객체로 저장될때 [[Environment]]에 상위 스코프인 현재 실행 중인 실행 컨텍스의 렉시컬 환경을 저장한다.

    이 함수 객체의 [[Environmnet]]는 함수가 호출되어 실행될 때 생성할 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 해당 저장 값이 참조된다. 함수 객체의 상위 스코프는 함수 자신이 존재하는 한 기억한다.

     

    클로저와 렉시컬 환경

    const x = 1;
    
    // ①
    function outer() {
      const x = 10;
      const inner = function () { console.log(x); }; // ②
      return inner;
    }
    
    // outer 함수를 호출하면 중첩 함수 inner를 반환한다.
    // 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거된다.
    const innerFunc = outer(); // ③
    innerFunc(); // ④ 10

    위 예제에서 innerFunc에 outer함수의 실행 결과가 할당되고 난 뒤, outer 함수는 이미 실행 한 뒤 종료되었지만 inner함수는 innerFunc함수에 할당되어 여전히 실행 가능하다. 이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 한다.

     

    자바스크립트의 모든 함수는 자신의 상위 스코프를 기억한다. 모든 함수가 기억하는 상위 스코프는 함수가 어디서 호출하든 상관없이 유지된다. 즉, 함수를 어디서 호출하든 상관없이 함수는 언제나 자신이 기억하는 상위 스코프의 식별자를 참조할 수 있으며 식별자에 바인딩된 값을 변경할 수도 있다.

     

     

    위 코드 예제의 outer 함수 실행 시 outer 함수는 실행 컨텍스트에 추가 된 뒤 제거되어 사라지지만, innerFunc에 할당한 inner 함수가 실행될 때 inner함수는 자신의 [[Environment]]을 참조하여 상위 스코프인 outer 함수의 렉시컬 환경을 여전히 참조할 수 있다. 자바스크립트의 메모리는 해당 메모리를 다른 누군가가 참조하고 있다면 해제하지 않고 유지한다.

     

    자바스크립트의 모든 함수는 기본적으로 자신의 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저이지만, 상위 스코프를 기억만 할뿐, 상위 스코프의 식별자를 참조하지 않는 함수는 일반적으로 클로저라고 하지 않는다.

    또한 상위 스코프의 식별자를 참조하고 있으나, 외부 함수보다 더 일찍 소멸되는 중첩 함수는 일반적으로 클로저라고 하지 않는다.

    function foo() {
          const x = 1;
          const y = 2;
    
          // 일반적으로 클로저라고 하지 않는다.
          function bar() {
            const z = 3;
    
            debugger;
            // 상위 스코프의 식별자를 참조하지 않는다.
            console.log(z);
          }
    
          return bar;
        }
    
        const bar = foo();
        bar();
     function foo() {
          const x = 1;
    
          // 일반적으로 클로저라고 하지 않는다.
          // bar 함수는 클로저였지만 곧바로 소멸한다.
          function bar() {
            debugger;
            // 상위 스코프의 식별자를 참조한다.
            console.log(x);
          }
          bar();
        }
    
        foo();

    결론적으로 클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적이다. 

    만약, 클로저인 중첩 함수가 외부 함수의 여러개의 식별자 중 일부만 참조하고 있다면 대부분의 브라우저는 최적화를 위해 클로저가 참조하고 있는 식별자만 기억한다. 클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수라고 한다. 클로저란 "함수가 자유 변수에 닫혀있다 => 자유 변수에 묶여있는 함수" 라는 뜻이다.

     

    클로저의 활용

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

    let num = 0;
    
    const increase = function () {
      return ++num;
    };
    
    console.log(increase()); // 1
    console.log(increase()); // 2

    위 예제는 잘 동작하지만 increase 함수 외에도 num을 다른 코드가 참조하고 변경할 수 있으므로 오류를 발생시킬 가능성이 있다. 전역 변수 num을 increase 함수의 지역 변수로 바꾸어 다른 코드에서 변경할 수 없게 해보자.

    const increase = function () {
      let num = 0;
    
      return ++num;
    };
    
    // 이전 상태를 유지하지 못한다.
    console.log(increase()); // 1
    console.log(increase()); // 1
    console.log(increase()); // 1

    위 코드도 문제가 있다. num 변수를 다른 코드에서는 변경하지 못하지만, 함수가 실행될 때마다 num 변수가 초기화되기 때문에 이전 함수 실행문이 변경한 상태를 다음 함수 실행문이 유지하지 못하는 문제가 있다. 이전 상태를 유지할 수 있도록 해보자.

    const increase = (function () {
      let num = 0;
    
      // 클로저
      return function () {
        return ++num;
      };
    }());
    
    console.log(increase()); // 1
    console.log(increase()); // 2

    즉시 실행 함수가 반환한 클로저는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시콜 환경을 기억하고 있기때문에 상위 스코프의 num 변수를 참조하고 변경할 수 있다. 또한 즉시 실행 함수는 한 번만 실행되므로 increase 함수가 호출될 때마다 num 변수가 초기화되지 않는다.

     

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

     

    위 예제 코드에서 감소하는 함수를 추가해보자.

    const counter = (function () {
      let num = 0;
      // 클로저인 메서드를 갖는 객체를 반환한다.
      return {
        increase() {
          return ++num;
        },
        decrease() {
          return num > 0 ? --num : 0;
        }
      };
    }());
    
    console.log(counter.increase()); // 1
    console.log(counter.increase()); // 2
    console.log(counter.decrease()); // 1
    console.log(counter.decrease()); // 0

    즉시 실행 함수가 반환하는 객체는 즉시 실행 함수의 실행 단계에서 평가되어 객체가 된다. 객체의 메서드도 함수 객체로 함께 생성된다. 객체의 중괄호는 코드 블록이 아니므로 별도의 스코프를 생성하지 않는다.  따라서 객체 안의 메서드들의 상위 스코프는 즉시 실행 함수이다.

    위 예제를 생성자 함수로 변경해보자.

    const Counter = (function () {
      let num = 0;
    
      function Counter() { //생성자 함수
      }
    
      Counter.prototype.increase = function () {
        return ++num;
      };
    
      Counter.prototype.decrease = function () {
        return num > 0 ? --num : 0;
      };
    
      return Counter;
    }());
    
    const counter = new Counter();
    
    console.log(counter.increase()); // 1
    console.log(counter.increase()); // 2
    console.log(counter.decrease()); // 1
    console.log(counter.decrease()); // 0

    생성자 함수 Counter는 프로토타입을 통해 increase,decrease 메서드를 상속받는 인스턴스를 생성한다. 두 메서드는 모두 자신의 함수 정의가 평가되어 함수 객체가 될 때 실행 중인 실행 컨텍스트인 즉시 실행 함수 실행 컨텍스트의 렉시컬 환경을 기억하는 클로저이다.

     

    아래 예제는 함수형 프로그래밍에서 클로저를 사용한 예이다.

    // 함수를 인수로 전달받고 함수를 반환하는 고차 함수
    function makeCounter(aux) {
      let counter = 0;
      
      // 클로저를 반환
      return function () {
        // 인수로 전달 받은 보조 함수에 상태 변경을 위임한다.
        counter = aux(counter);
        return counter;
      };
    }
    
    // 보조 함수
    function increase(n) {
      return ++n;
    }
    
    // 보조 함수
    function decrease(n) {
      return --n;
    }
    
    
    const increaser = makeCounter(increase); // ①
    console.log(increaser()); // 1
    console.log(increaser()); // 2
    
    const decreaser = makeCounter(decrease); // ②
    console.log(decreaser()); // -1
    console.log(decreaser()); // -2

    위 예제는 makeCounter 함수를 2번 호출 하여 각각 increaser, decreaser를 만든다, 함수를 호출해 함수를 반환할 때마다 반환된 함수는 자신만의 독립적인 렉시컬 환경을 갖기 때문에, increaser와 decreaser는 서로 분리된 자유 변수 counter를 갖게 되고, 해당 상태 값은 공유되지 않는 문제가 발생하게 된다.

     

    이를 해결하기 위해 함수를 두번 호출하지 않는 방식으로 코드를 작성해보자.

    const counter = (function () {
      // 카운트 상태를 유지하기 위한 자유 변수
      let counter = 0;
    
      // 함수를 인수로 전달받는 클로저를 반환
      return function (aux) {
        // 인수로 전달 받은 보조 함수에 상태 변경을 위임한다.
        counter = aux(counter);
        return counter;
      };
    }());
    
    function increase(n) {
      return ++n;
    }
    
    function decrease(n) {
      return --n;
    }
    
    // 보조 함수를 전달하여 호출
    console.log(counter(increase)); // 1
    console.log(counter(increase)); // 2
    
    // 자유 변수를 공유한다.
    console.log(counter(decrease)); // 1
    console.log(counter(decrease)); // 0

     

    댓글

Designed by Tistory.