• 클로저 (Closure)
  • 2025. 2. 6. 16:06
  • 클로저는 자바스크립트 개발을 하다보면 한번쯤은 들어보는 개념입니다. 하지만 자바스크립트만의 개념은 아닌데요.

    함수를 일급 객체(인자로도 활용하고, 변수에도 할당하고, 반환할 수도 있는)로 취급하는 함수형 프로그래밍에서 사용되는 특성 입니다.

     

    클로저를 자세히 이해하기 위해서는, 실행 컨텍스트에 대한 이해가 선행되면 좋습니다. 

    아직 실행 컨텍스트에 대해 잘 모른다면, 아래의 게시글을 먼저 읽고 오시는 것을 추천합니다.

     

    https://ocahs.tistory.com/23

     

    실행 컨텍스트 (Execution Context)

    실행 컨텍스트를 한마디로 정의할 수 있다면 좋겠지만, 실행 컨텍스트는 한 줄로 정리될만큼 단순한 개념은 아닙니다.자바스크립트 동작원리의 핵심 개념이 바로 실행 컨텍스트이기 때문입니

    ocahs.tistory.com

     

     


    클로저 정의 이해하기

     

    MDN에서 정의하는 클로저는 다음과 같습니다.

    클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.

     

    정의 자체가 참 난해합니다. 이 정의를 이해하기 위해서는 '함수가 선언된 렉시컬 환경' 이라는 키워드를 이해해야겠네요.

     

    '함수가 선언된 렉시컬 환경'은 뭘까요? 렉시컬 환경은 곧 스코프이고, 함수가 선언된(정의된) 스코프는 결국 상위 스코프가 되겠네요.

    함수가 선언된 렉시컬 환경은 상위 스코프를 의미합니다. 

     

    그런데 이때, 실행 컨텍스트의 렉시컬 환경의 구성 요소 중에 '외부 렉시컬 환경에 대한 참조' 가 있었던 걸 기억하시죠? 그리고 '외부 렉시컬 환경에 대한 참조' 는 곧 상위 스코프를 의미한다고 했습니다. 

     

    여기까지 왔다면 클로저에 대한 정의가 비교적 간단해집니다.

    클로저는 함수와 상위스코프(외부 렉시컬 환경에 대한 참조)의 조합이다.

     

    그럼 외부 렉시컬 환경에 대한 참조값은 어떻게 결정되며, 렉시컬 스코프의 실체는 무엇일까요?
    (렉시컬 스코프(렉시컬 환경이 아님!!)는 함수의 호출 위치가 아닌, 함수의 정의 위치에 따라 상위 스코프를 결정하는 방식이라고 했습니다. )

    이는 함수가 평가되어 함수 객체가 생성될 때 함수 객체 내부에 존재하는 [[Environment]] 슬롯을 통해 이해할 수 있습니다. 

     

    함수 객체의 내부 슬롯 [[Environment]] 에는 함수 정의가 평가되는 시점(코드 평가 시점)에 실행 중인 실행 컨텍스트의 렉시컬 환경에 대한 참조 값이 들어갑니다. 즉, 상위 스코프에 대한 참조가 들어가죠. 결국 함수는 본인의 상위 스코프를 본인이 존재하는 한 [[Environment]] 슬롯을 통해 기억할 수 있습니다.

     

    이렇게  [[Environment]] 슬롯을 통해 기억해두었다가, 함수가 실행되며 함수 렉시컬 환경이 생성될 때면,

    해당 함수 렉시컬 환경의 '외부 렉시컬 환경에 대한 참조' 에  [[Environment]] 슬롯의 값을 그대로 할당합니다. 

    (이제 외부 렉시컬 환경에 대한 참조값이 결정되는 원리를 이해했네요!)

     

    이렇게 함수의 위치에 따라 정적으로 [[Environment]] 값을 저장해두었다가  외부 렉시컬 환경에 대한 참조에 할당하는 원리로 인해 정적으로 상위 스코프가 결정될 수 있었습니다. 이것이 렉시컬 스코프의 실체입니다!

     


    클로저 판별하기

     

    결국 클로저는 상위 스코프(외부 렉시컬 환경에 대한 참조 - 렉시컬 환경) 와 뗄레야 뗄 수가 없는 관계였네요.

    그럼 이론적으로는, JS의 모든 함수는 상위 스코프를 기억하므로 모든 함수가 클로저일까요? 아닙니다!

     

    클로저는 중첩 함수가 1. 상위 스코프의 스코프를 참조하고 있고 2. 외부 함수보다 더 오래 유지되는 경우로 한정합니다.

     

    상위 스코프의 어떤 식별자도 참조하지 않는 경우, 대부분의 모던 브라우저에서는 최적화를 통해 상위 스코프를 기억하지 않습니다. 따라서 [[Environment]] 로 상위 스코프를 알고 있는 것 같아도, 상위 스코프의 식별자를 참조하지 않으므로 가바지 컬렉터에 의해 메모리를 회수당합니다. (또한, 자바스크립트 엔진은 최적화가 잘되어 있어 사용하지 않는(참조하지 않는) 식별자는 굳이 기억하지 않습니다.) 이런 경우에는 클로저라고 할 수 없습니다.

     

    또한 중첩 함수(내부 함수)가 외부 함수보다 일찍 소멸되어 생명 주기가 종료된 경우, 외부 함수의 식별자를 참조할 수 있다는 클로저의 본질에 부합하지 않기 때문에 클로저라고 할 수 없습니다.

     

    참고로, 클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수라고 하며, 클로저란 자유 변수에 대해 닫혀있다라는 뜻입니다.


    클로저가 유용하게 사용되는 경우

    1. 캡슐화: 상태의 안전한 변경 및 유지

    2. 은닉 : 특정 함수에게만 상태 변경 허용 (직접적인 변경 불허)

     

    다음은 클로저를 활용하는 예시입니다.

    // 카운트 상태 변경 함수
    const increase = (function () {
      // 카운트 상태 변수
      let num = 0;
    
      // 클로저를 활용하여 상태 유지
      return function () {
        // 카운트 상태를 1만큼 증가시킴
        return ++num;
      };
    })();

     

    이 경우에는 즉시 실행 함수를 통해 반환되는 클로저를 increase에 할당하여 사용하는 예시입니다. 

    위의 예시는 외부에서 num을 접근 불가능하고, 오직 클로저가 할당되어 있는 Increase를 통해서만 해당 값에 접근(변경)가능합니다.

    이젠 increase와 decrease가 동시에 존재하는 코드를 작성해보죠.

    const counter = (function () {
      // 클로저를 사용하여 내부 상태를 유지하는 객체를 반환 (은닉)
      let num = 0;
    
      return {
      	//num : 0 // 프로퍼티는 public하므로 은닉되지 않는다.
        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 () {
      // 카운트 상태 변수 (private)
      let num = 0;
    
      // 생성자 함수 -> new를 통해 호출하면 생성자 함수로 작동한다.
      function Counter() {}
    
      // 증가 메서드
      Counter.prototype.increase = function () {
        return ++num;
      };
    
      // 감소 메서드 (num이 0 이하로 내려가지 않도록 처리)
      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

     

    위의 코드에서 num은 Counter가 생성할 인스턴스가 아니라, 즉시 실행 함수 내에서 선언된 지역 변수임을 기억해주세요.

    이 변수는 즉시 실행 함수가 실행된 이후에는 오직 increase, decrease를 상속받는 인스턴스를 통해서만 접근이 가능해집니다.

    자바스크립트에서 객체의 프로퍼티는 (혹은 인스턴스의 프로퍼티)는 항상 public인데, 이런 식으로 값을 private 접근자를 쓴 것처럼(실제로 js에서는 접근자가 존재하지 않습니다) 값을 다룰 수 있습니다.

     


     

    클로저를 온전히 이해하지 못하고 잘못 사용하는 경우

    아래의 코드를 봅시다.

    // 함수를 인수로 전달받고 함수를 반환하는 고차 함수
    // 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.
    function makeCounter(aux) {
      // 카운트 상태를 유지하기 위한 자유 변수
      let counter = 0;
    
      // 클로저를 반환
      return function () {
        // 인수로 전달받은 보조 함수에 상태 변경을 위임한다.
        counter = aux(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가 2번 실행되면서 각기 다른(새로운) 실행 컨텍스트가 생깁니다.

    즉, 함수를 호출할 때마다 함수의 새로운 실행 컨텍스트의 렉시컬 환경이 생성됩니다.

    따라서 위의 코드는 counter 값을 공유하지 않습니다.

     

    위의 문제점을 개선하면, 아래와 같은 코드가 탄생합니다.

    // 함수를 반환하는 고차 함수
    // 이 함수는 카운트 상태를 유지하기 위한 자유 변수 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

     

    이 코드는 제대로 동작합니다. (let counter의 값을 공유합니다)

    그 이유는 즉시 실행 함수로 counter 지역 변수를 참조하는 function(aux)를 반환하고, 그 반환한 값은 counter에 할당해두었기 때문입니다. 언제든지 counter에 콜백함수(보조함수)를 넣어주면서 지금은 은닉된 let counter 변수의 값을 조절할 수 있게 되었습니다.


     

    여기까지 클로저에 대해 살펴봤습니다. 

    이 정도면 클로저의 핵심 개념과 사용 예시를 이해했으니, 앞으로 활용할 일이 있었으면 좋겠네요!

    긴 글 읽어주셔서 감사합니다.

     

     

     

     

    참고 서적 ) 모던 자바스크립트 Deep Dive (저자-이웅모)

    COMMENT