본문 바로가기
JavaScript/모던 자바스크립트(Deep Dive)

[Deep Dive] 클로저(Closer)란 무엇인가?

by junvely 2023. 8. 13.

먼저 클로저를 알아보기 전에 이해해야 할 개념은 실행 컨텍스트와 렉시컬 환경이다. 이 개념을 다시 한번 되새기며 클로저에 대해 알아보도록 하자.

 

[Deep Dive] 자바스크립트 실행 컨텍스트란 무엇인가?

실행 컨텍스트(Execution context)란 무엇인가? 💡 실행 컨텍스트란? 코드를 실행하는데 필요한 환경 정보를 제공하는 객체로, 코드 실행 결과와 순서를 관리하는 영역입니다. 식별자와 *스코프는 실

junvelee.tistory.com

실행 컨텍스트와 렉시컬 환경을 이해했다면, 클로저라는 것은 무엇일까?

 

클로저(Closer)와 렉시컬 환경

먼저 기억해야 할 것은 렉시컬 환경의 특징이다.

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

즉, 함수를 어디서 호출했는지는 상위 스코프의 결정에 어떠한 영향도 주지 못한다. 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정된다. 

 

클로저(Closer)란?

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


다음 예제를 살펴보자.

// 클로저
const x = 1;

function outer() {
  const x = 3;
  const inner = function () {
    console.log(x); // x는 inner의 outer환경으로 outer의 렉시컬환경을 참조하고 있음
  };

  return inner;
}

const innerFunc = outer(); // 1. outer의 함수 실행 후 inner반환, 실행컨텍스트는 실행 후 사라짐 -> 런타임에 의해 inner가 평가됨 -> innerFunc에 전달됨
innerFunc(); // 2. 3을 출력 

// 클로저 => 중첩 함수 inner는 외부 함수 outer보다 더 오래 유지되며 상위 스코프의 식별자를 참조한다.
// 외부 함수보다 중접 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 (여전히) 참조할 수 있다.
// 이 개념에서 중첩 함수가 바로 클로저다.

1. outer의 함수 실행 후 inner반환, 실행컨텍스트는 실행 후 사라짐 -> 런타임에 의해 inner가 평가됨 -> innerFunc에 전달됨

2. innerFunc(); -> 3을 출력 -> outer의 실행컨텍스트가 사라지더라도 x는 여전히 outer의 렉시컬환경을 참조하고 있음
-> 결론 : 즉, outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아니다. 가비지 컬렉터가 outer 함수의 렉시컬 환경은 참조하는 곳이 있으므로 수거하지 않음(참조하는 곳이 아무데도 없을 때 수거)

여기서 클로저란 ? => 중첩 함수 inner는 외부 함수 outer보다 더 오래 유지되며 상위 스코프의 식별자를 참조한다.

즉, 외부 함수보다 중접 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 (여전히) 참조할 수 있다. 이 개념에서 중첩 함수가 바로 클로저다.

 

 

클로저(Closer)가 아닌 경우

  • 중첩 함수가 상위 스코프의 렉시컬 환경을 참조하지 않을 경우
  // 클로저가 아닌 경우 1
  const x = 1;
  function outer() {
    const x = 3;
    const inner = function () {
      const z = 5;
      console.log(z); // 상위 스코프의 렉시컬 환경을 참조하지 않기 때문
    };

    return inner;
  }

  const innerFunc = outer();
  innerFunc();
  console.log("innerFunc:", innerFunc);

 

  • 선언 후 바로 실행 + 소멸되는 경우, 이러한 함수는 일반적으로 클로저라고 하지 않는다.
// 클로저가 아닌 경우 2
  function foo() {
    const x = 1;

    // bar 함수는 클로저였지만 곧바로 소멸한다.
    // 외부로 나가서 따로 호출되는게 아니라, 선언 후 바로
    // 실행 + 소멸
    // 이러한 함수는 일반적으로 클로저라고 하지 않는다.
    function bar() {
      debugger;
      //상위 스코프의 식별자를 참조한다.
      console.log(x);
    }
    bar();
  }

  foo();

 

 

클로저(Closer)의 활용

클로저는 주로 ‘상태를 안전하게 변경하고 유지하기 위해 사용’한다. 의도치 않은 상태의 변경을 막기 위해서 상태를 안전하게 `은닉한다(특정 함수에게만 상태 변경을 허용한다)`
  • 클로저의 잘못된 예시
// 상태값을 유지하지 못함
{
  // 카운트 상태 변경 함수 #2
  const increase = function () {
    // 카운트 상태 변수
    let num = 0;

    // 카운트 상태를 1만큼 증가시킨다.
    return ++num;
  };

  // 이전 상태값을 유지 못함
  console.log(increase()); //1
  console.log(increase()); //1
  console.log(increase()); //1
}
  • 클로저의 올바른 예시
// 카운트 상태 변경 함수
const increase = (function () {
  // 카운트 상태 변수
  let num = 0;

  // 클로저
  return function () {
    return ++num;
  };
})();

// 이전 상태값을 유지 하면서도, 밖에서 접근하여 상태를 변경하지 못하도록 은닉함
// 해당 함수를 통해서만 상태값을 조작 가능함
num = 10; // 접근 불가능
console.log(increase()); //1
console.log(increase()); //2
console.log(increase()); //3
  • 클로저를 더 확장시켜 보기
// 클로저 카운트 기능 확장(값 감소 기능 추가)
  const counter = (function () {
    //카운트 상태 변수
    let num = 0;

    // 클로저인 메서드(increase, decrease)를 갖는 객체를 반환한다.
    // property는 public -> 은닉되지 않는다.
    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

 

캡슐화와 정보 은닉

💡 캡슐화란 무엇인가?
캡슐화는 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶는 것을 말한다. 캡슐화는 객체의 특정 프로퍼티나 메서드를 감출 목적으로 사용하기도 하는데 이를 정보 은닉 이라 한다.

 

자주 발생하는 실수

  • 클로저 사용 시 자주 발생할 수 있는 실수를 보여주는 예제다. 실행 컨텍스트의 관점에서 바라보아야 한다. 
var funcs = [];

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

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

결과는 0, 1, 2를 예상했지만 3, 3, 3이 나온다.

var 키워드로 선언한 i 변수는 블록 레벨 스코프가 아닌, 함수 레벨 스코프를 갖기 때문에 전역 변수다, 함수 호출 시 전역 변수 i를 참조하여 i의 값 3이 출력된다.

-> 전역 실행 컨텍스트 생성 -> for문 컨텍스트 생성 하지만, for문 컨텍스트가 참조하는 outer는 전역 컨텍스트의 i를 참조하여 변경하기 때문에 결과가 0 1 2가 아니라 3 3 3으로 나온다.

1. 전역 실행 컨텍스트가 생성
2. 첫 번째 for 루프의 실행 컨텍스트가 생성되며, 변수 i가 선언. 반복문 내부에서 i 값이 0에서 3까지 변경.
3. 두 번째 for 루프의 실행 컨텍스트가 생성되며, 변수 j가 선언. funcs 배열의 함수들이 호출되고, 이때 클로저 현상으로 인해 각 함수는 i 변수의 최종 값인 3을 반환.

따라서 결과적으로 for 문의 실행 컨텍스트는 하나이며, i 변수의 값이 반복문 내에서 변경되면서 클로저에 영향을 미치는 것이 원인으로 "3 3 3"이 출력된다.

-> var는 함수 레벨 스코프이기 때문에, for문이 종료한 후에도 for문 외부에서 i를 참조할 수 있다.

 

  • 클로저를 사용해 위 예제를 바르게 동작하도록 수정
var funcs = [];

  for (var i = 0; i < 3; i++) {
    funcs[i] = (function (id) {
      return function () {
        return id;
      };
    })(i);
  }

  for (var j = 0; j < funcs.length; j++) {
    console.log(funcs[j]());
  }
  
  // 0 1 2

 

 

  • let을 사용하면 이 같은 번거로움이 깔끔히 해결된다.
const funcs = [];

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

  for (let j = 0; j < funcs.length; j++) {
    console.log(funcs[j]());
  }
  
  // 0 1 2

for문의 변수 선언문에서 let 키워드로 선언한 초기화 변수를 사용한 for문이 평가되며 새로운 렉시컬 환경을 생성하고 초기화 변수 식별자와 값을 등록한다. 그리고 새롭게 생성된 렉시컬 환경을 현재 실행 컨텍스트의 렉시컬 환경으로 교체한다.

for문의 코드 블록이 반복 실행되면 새로운 렉시컬 환경을 생성하고 for문 코드 블록 내의 식별자와 값을 등록한다. 그리고 새롭게 생성된 렉시컬 환경을 현재 실행 중인 실행 컨텍스트의 렉시컬 환경으로 교체한다. 

for문 코드 블록 반복이 종료되면 for문 실행 전 렉시컬환경으로 되돌린다.

-> let이나 const를 이용한 반복문은 반복직 후 아무도 참조하지 않기 때문에 가비지 컬렉션의 대상이 된다. 블록 레벨 스코프이기 때문에 반복문 외부에서 참조도 불가능 하다.

 

 

정리 ✅

💡 클로저란 무엇인가?

클로저란 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다. 외부 함수보다 중접 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 (여전히) 참조할 수 있는데, 이 개념에서 중첩 함수가 바로 클로저다.

클로저는 주로 ‘상태를 안전하게 변경하고 유지하기 위해 사용’한다. 의도치 않은 상태의 변경을 막기 위해서 상태를 안전하게 `은닉한다(특정 함수에게만 상태 변경을 허용한다)`