JS Study 5주차 ( 클로저 )

jaehan·2023년 5월 15일
0

JavaScript

목록 보기
26/33
post-thumbnail

클로저

클로저는 자바스크립트 고유의 개념이 아닌 함수를 일급 객채로 취급하는 함수형 프로그래밍 언어에서 사용되는 특성이다.
"클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다" - MDN

렉시컬 스코프

자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 정의한다.

또한 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값. 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경에 의해 결정된다.

이를 렉시컬 스코프라고 한다.

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

함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경인 상위 스코프의 참조를 저장한다.

함수내부에서 정의된 함수 표현식외부 함수 코드가 실행되는 시점에 평가되어 함수 객체를 생성한다.
(블록 단위로 실행 컨텍스트가 생성되기 때문에 외부 함수가 실행 컨텍스트 스택위에서 실행 되고 있을 때 내부 함수 표현식이 평가된다.)

따라서 함수 객체의 내부 슬롯 [[Environment]]에 저장될 현재 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조가 바로 상위 스코프다.

또한 자신이 호출되었을 때 생설될 함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장될 참조값이다.

함수 객체는 내부 슬롯 [[Environment]]에 저장한 렉시컬 환경의 참조, 즉 상위 스코프를 자신이 존재하는 한 기억한다.

정리해보면

  • 내부 함수는 외부 함수가 실행될때 평가되기 때문에 내부 함수의 상위 스코프는 외부 함수이다.
  • 함수 객체 내부 슬롯 [[Environment]]에는 상위 렉시컬 환경(현재 실행중인 실행 컨텍스트의 렉시컬 환경)이 저장된다.
  • 내부 함수의 외부 렉시컬 환경에 대한 참조에는 위에서 말한 [[Environment]]에 저장된 참조 값으로 연결된다.

클로저와 렉시컬환경

const x = 1;

function outer() {
  const x = 10;
  const inner = function () {console.log(x)};
  return inner;
}

const innerFunc = outer();
innerFunc();

위 코드의 결과는 10이 나온다.

그냥 기본적으로 생각해보면 innerFunc에는 outer 내부의 inner 함수가 return 되어 들어가게 된다.

따라서 innerFunc를 실행했을때 x는 1일거 같다.

하지만 여기서 이걸 다시 상기시켜야 한다.
자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 정의한다.

이 말에 따른다면 inner의 상위스코프는 outer이다. 그렇기 때문에 여기서 x는 outer의 x를 항상 가리키고 있는다.
따라서 결과는 10이 나오게 되는 것이다.

이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다.

위 코드의 동작 과정은

  • 전역 실행 컨텍스트가 생성되고 평가과정을 거친다.
  • 이때 outer 함수의 [[Environment]]에 전역 렉시컬 환경을 저장한다.
  • 이제 실행과정을 거치면서 x에 1을 할당하고. outer함수가 호출된다.
  • outer 함수의 실행 컨텍스트가 생성되고 평가과정을 거친다.
  • 이때 외부 렉시컬 환경에 대한 참조에 outer 함수의 [[Environment]]에 저장된 렉시컬 환경인 전역 렉시컬 환경을 할당한다.
  • 다음으로 inner가 평가되는데(함수 표현식이기 때문에 런타임에 평가됨) 이때 [[Environment]]에 현재 실행중인 실행컨텍스트인 outer 함수의 렉시컬 환경을 저장한다.
  • outer 함수가 종료되면서 inner를 반환하고 전역의 innerFunc는 이 inner를 참조한다.
  • 이때 outer 실행 컨텍스트는 실행 컨텍스트스택에서 제거되지만 렉시컬 환경까지 소멸하진 않는다.
  • 다음으로 inner함수가 호출되면서 inner 실행 컨텍스트가 생성되고 내부 코드가 실행이 되는데 이때
    외부 렉시컬 환경에 대한 참조에는 inner 함수의 [[Environment]]에 저장된 렉시컬 환경인 outer 렉시컬 환경이 저장된다.
  • 따라서 console.log(x)의 x는 가장 가까운 outer 렉시컬 환경의 10을 가지게 되고 10이 출력되게 되는 것이다.


📌 실행컨텍스트 스택의 순서는 전역 -> outer -> outer pop -> inner 이 순서이다

❓ 이렇게 보면 모든 함수가 다 클로저인가라고 생각할 수 있지만 일반적으로는 모두를 클로저라고 부르진 않는다.

<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      function foo() {
        const x = 1;
        function bar() {
          const z = 3;

          debugger;
          console.log(z);
        }
        return bar;
      }
      const bar = foo();
      bar();
    </script>
  </body>
</html>

위 코드를 실행시켜보면 개발자도구에서 아래와 같은 결과가 나온다.
그 이유는 foo가 bar를 반환하지만 bar는 상위 스코프의 값을 참조하지 않기 떄문이다.

그럼 closer가 나오게 코드를 구현해보면

<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      function foo() {
        const x = 1;
        function bar() {
          debugger;
          console.log(x);
        }
        bar();
      }
      foo();
    </script>
  </body>
</html

이 코드는 bar가 상위스코프인 foo의 값을 참조하고 있기 때문에 closer라고 나오지만 일반적으로 클로저라고 하진 않는다.
📌 bar는 상위스코프인 foo보다 생명주기가 짧다 (bar를 반환하지 않기 때문에)

❗️ 그렇다면 이 아래 코드의 bar를 진짜 클로저라고 할 수 있다

<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      function foo() {
        const x = 1;
        function bar() {
          debugger;
          console.log(x);
        }
        return bar;
      }
      const bar = foo();
      bar();
    </script>
  </body>
</html>

📌 이처럼 외부 함수보다 중첨 함수가 더 오래 유지되는 경우 중첩함수는 이미 생명주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다.
📌 클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에만 한정하는 것이 일반적이다.
📌 이 참조되는 변수를 자유변수라고 부르고 클로저란 "함수가 자유변수에 대해 닫혀있다." 즉 "자유변수에 묶여있는 함수"라고 할 수 있다.

❓ 전역변수를 지양해야 하는 것처럼 그럼 클로저는 상위 스코프를 기억해야 하기 떄문에 상위 스코프의 렉시컬 환경이 계속 남아있는 것이기 때문에 메모리적으로 안좋지 않을까.
❗️ 자바스크립트 엔진은 클로저가 참조하고 있지 않는 식별자는 기억하지 않기 때문에 메모리 걱정은 안해도 된다고 한다.

클로저의 활용

이러한 클로저는 상태를 안전하게 변경하고 유지하기 위해 사용한다.
즉 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용되는 것이 클로저이다.

안좋은 코드들을 먼저 보면

// 안좋은 예시 1
let num = 0;

const increase = function (){
  return ++num;
}

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3


// 안좋은 예시 2
const increase = function(){
  let num = 0;
  return ++num;
}

console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1
  1. 첫번째 코드가 안좋은 예시인 이유는
  • increase 함수가 사용하는 num 변수는 전역변수이기 때문에 호출하기 전까지 유지되지 않을 수 있다.
  • num 값은 전역이기 때문에 누구나 접근할 수 있고 변경할 수 있다.

📌 따라서 이 num 값을 increase만 접근할 수 있도록 숨겨야 한다.

  1. 두번째 코드가 안좋은 예시인 이유는
  • num값은 잘 숨겼고 increase만 접근할 수 있게 했지만 호출될때 마다 num이 선언되고 0으로 초기화 되기 때문에 이전 상태를 유지하지 못한다.

❗️ 이 두가지 경우를 모두 해결하기 위해 클로저를 사용할 수 있다.

const counter = (function (){
  let num = 0;
  return function(){
    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

이렇게 하면 counter 내부에 num을 숨겨 상태를 보존할 수 있고 즉시 실행함수이기 때문에 여러번 실행 되지 않아 상태를 유지할 수 있다.

생성자 함수로 나타내면 아래와 같다

const Counter = (function() {
  let num = 0;
  function Counter(){
    // this.num = 0; 이렇게 하면 은닉되지 않는다.
  }
  Counter.prototype.increase = function(){
    return ++num;
  }
  Counter.prototype.decrease = function(){
  	return num > 0 ? --num : 0;
  }console.log(counter.decrease()); // 3
  return Counter
}());

함수형 프로그래밍은 외부 상태 변경이나 가변 데이터를 피하고 불변성을 지향하기 때문에 클로저를 적극적으로 사용한다.

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

위의 코드는 클로저를 사용해서 counter 변수를 잘 숨겼다 하지만 makeCounter 함수를 두번 호출했기 때문에 렉시컬 환경이 두개가 생겨서 counter를 공유하지 않는다.

이건 아래 그림을 보면 이해가 잘 될것이다.


간단히 설명하면

  • 전역 실행 컨텍스트가 생성되고 평가된다.
  • increase함수를 매개변수로 가지는 makeCounter 함수가 호출되어 실행 컨텍스트가 생성된다.
  • 평가와 실행과정을 거쳐 클로저를 반환해 increaser에 할당되고 이 클로저는 첫번째로 생성된 makeCounter의 렉시컬 환경을 가리킨다. 따라서 increaser의 [[Environment]]에 이 렉시컬 환경이 저장된다.
  • 그렇기 떄문에 increaser를 호출하게 되면 이 렉시컬 환경의 counter를 가지고와서 사용한다.
  • decrease함수를 매개변수로 가지는 makeCounter 함수가 호출되어 실행 컨텍스트가 생성된다.
  • 평가와 실행과정을 거쳐 클로저를 반환해 decreaser에 할당되고 이 클로저는 두번째로 생성된 makeCounter의 렉시컬 환경을 가리킨다. 따라서 decreaser의 [[Environment]]에 이 렉시컬 환경이 저장된다.
  • 그렇기 떄문에 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

캡슐화와 정보은닉

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

자바와 같은 객체지향 프로그래밍 언어는 클래스를 정의할때 public, private, protected같은 접근 제한자를 선언하여 공개 범위를 지정할 수 있지만 자바스크립트는 없기 때문에 모든 프로퍼티와 메서드가 public 이다.
❗️최근에는 private할수 있는 방법이 나왔다고 한다.

이를 비슷하게 구현하기 위해선 아래와 같이 할 수 있다.

function Person(name, age){
  this.name = name;
  let _age = age;
  
  this.sayHi = function(){
    console.log(`${this.name} ${_age}`);
  }
}

const me = new Person('Lee', 20);
const you = new Person('Kim', 30);

me.sayHi() // Lee 20 
you.sayHi() // Kim 30
console.log(me._age) // undefined

이렇게 하면 _age 변수를 private하게 만들 수 있다.

📌 이 과정에서 sayHi 메서드가 두번 생성되기 때문에 중복을 방지하기 위해 prototype을 사용하면 프로토타입은 또 한번만 생성되기 때문에 _age변수의 값을 하나로만 밖에 못가지기 때문에 방법이 없다.
-> 따라서 최근에 나온 private 만드는 방법을 봐야할 것 같다.

클로저 대표적 예시 두개

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]());
}

이렇게 코드를 짜는 사람은 없겠지만 클로저를 활용해서 해결하는 것과 var가 왜 없어졌는지를 설명할 수 있는 좋은 예시인것 같다.

그냥 보면 0, 1, 2 잘 나올것 같지만 3, 3, 3 이렇게 출력된다. 이유는

  • var는 함수 스코프이다 -> for 내부의 i는 전역 상태인 전역 객체에 저장된다.
  • 함수 내부의 i는 전역 객체에 저장되어 있는 i를 참조한다.
  • 첫번째 for문이 끝난후 상태는 전역변수 i는3 funcs에는 함수 3개가 들어가 있다.
  • 두번째 for문을 실행하면 funcs의 i는 현재 3이기 때문에 3, 3, 3 이렇게 출력된다.

이걸 클로저로 해결해보면 아래와 같다.

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]());
}

해결된 방법은

  • 즉시실행 함수로 i를 넘겨주면 그 함수는 return 값 함수에 i값을 id로 넘겨주고 그
    함수를 반환하며 종료된다.
  • 반환된 함수의 id값은 상위 스코프에 존재하고 생명주기가 더 길기 때문에 클로저이다 -> id 즉 자유변수는 반환된 함수가 가리키며 유지된다.

근데 이건 그냥 var가 문제인 거라서 var를 let으로 바꾸면 해결된다.

그 이유는 실행컨텍스트에서 배운것 처럼 for문은 실행될때 마다 렉시컬 환경을 계속 만들기 때문에 funcs에 들어있는 함수가 그 렉시컬 환경을 참조하기 때문에 0, 1, 2를 참조하게 되는 것이다.

그후에 아무도 참조하지 않으면 가비지컬렉터가 가져가기 때문에 메모리 걱정은 할필요가 없다.

📌 함수형 프로그래밍에서 클로저를 활용을 많이 한다고 하는데 사실 리액트 하면서 사용해 본적이 없다. 더 다양한 예시를 찾아보면서 클로저를 한번 사용해 봐야겠다.

추가 문제

제로초님의 유튜브에서 나온 예시인데 좋은 예시 같아서 추가한다.

function a() {
    for(var i = 0; i < 3; i++){
        setTimeout(()=>{
            console.log(i);
        }, i * 1000)
    }
}
a();

위 코드의 결과는 0, 1, 2가 되었으면 좋겠지만 3, 3, 3이 나온다.

그 이유는

  • setTimeout은 콜스택에 있다가 나중에 실행되게 된다.
  • 또한 for문안에 var i는 함수 스코프를 따르기 때문에 a 함수의 프로퍼티 i가 된다.
  • 따라서 이 둘의 합작으로 3, 3, 3이 나온다.
  • 이걸 정리하면 i는 a의 프로퍼티가 되어 for문이 끝나면 3이 되고 setTimeout의 콜백함수의 i는 a의 프로퍼티인 i를 참조하기 때문에 결과는 3, 3, 3이 된다.

이것도 그냥 let으로 바꾸면 해결되지만 클로저 배운김에 해결해 보면

function a() {
    for(var i = 0; i < 3; i++){
        (function(id){
          setTimeout(()=>{
            console.log(id);
        }, i * 1000)
        }(i))
    }
}
a();

이렇게 하면 setTimeout의 콜백함수는 즉시실행 함수를 상위 스코프로 기억하고 상위 스코프인 function은 0, 1, 2라는 값을 가지고 있기 때문에 0, 1, 2가 출력된다.

참고 :
https://www.inflearn.com/course/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EB%94%A5%EB%8B%A4%EC%9D%B4%EB%B8%8C
https://www.youtube.com/watch?v=4hhlfq3Uy6U

0개의 댓글