Pandaman Blog

[JS] Javascript의 클로저 (Closures) 본문

Front end/Javascript

[JS] Javascript의 클로저 (Closures)

oyg0420 2020. 2. 14. 23:20

Javascript 클로저(Closures)란

클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다.

MDN에서는 위와 같이 클로저를 정의한다.
아래 예제를 통해서 클로저가 무엇인지 확인해보고 위의 정의를 이해해보자.

function handleSound() { // 1
  const sound = '안녕하세요. 반갑습니다.'; // 2
  function playSound() { // 3
   alert(sound); 
  }
  return playSound; // 4
}

const myFunc = handleSound(); // 5

myFunc(); // 6

위의 예제를 순서대로 살펴보자.

  1. handleSound 함수를 선언했다.
  2. 함수 내부에는 const키워드의 sound라는 이름의 지역변수를 선언하고 문자열 '안녕하세요. 반갑습니다.'를 할당했다.
  3. 함수 내부에 playSound 함수를 선언했고, 그 내부에는 handleSound 함수의 지역변수 sound을 참조해 알림으로 띄우는 기능을 구현했다.
  4. playSound 함수를 리턴한다.
  5. 전역 변수 myFunc에 함수 handleSound()를 할당한다
  6. myFunc()를 실행한다.

5번 설명에서 함수 handleSound를 실행되고 함수 playSound를 반환하고 종료되었다. 일반적으로 함수 handleSound실행이 끝나면 더 이상 함수 handleSound의 지역변수sound에 접근이 가능하지 않다고 생각한다. 하지만 함수playSound를 참조한 myFunc를 실행하면 handleSound의 지역변수 name이 참조되어 '안녕하세요. 반갑습니다.'가 노출된다. 그 이유는 myFunc가 참조한 함수 playSound는 선언될 때 자신의 상위 스코프가 결정된다.(Lexical scope) 함수 playSound의 변수 sound는 상위 스코프에 있는 함수 handleSound의 지역변수인 sound를 참조하게 되어 실행이 끝나도 변수 sound에 접근이 가능하다.

다시 MDN에서 정의한 클로저에 대해서 생각해보자. 클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다.라는 말은
함수 playSound와 함수 playSound가 선언되었을 때의 스코프에 있는 모든 지역변수로 구성된 어휘적 환경(Lexical Environment)의 조합이라는 말이다.
따라서 자신이 선언되었을때의 환경 밖에서 호출되어도 그 환경에 접근할 수 있게 되었다. 이처럼 클로저는 자신이 선언되었을때 환경(Lexical Environment)을 기억하는 함수라고 정의 내릴 수 있다.
클로저에 의해 참조되는 외부의 변수, 즉 handleSound의 함수의 지역변수 sound자유 변수라고 부른다.

실행컨텍스트로 클로저 이해하기

위의 예제를 실행컨텍스트로 다시 설명해보면 아래와 같다.

GlobalContext = {
 LexicalEnvironment: { 
 EvironmentRecord: { handleSound: <function>, myFunc: undefined },
 OutterEnvironment: null,
 this: window
}

HandleSoundFunctionContext = {
 LexicalEnvironment: {
  EvironmentRecord: { playSound: <function>, sound: '안녕하세요. 반갑습니다.', arguments: null }
  OutterEnvironment: <GlobalLexicalEnvironment>,
  this: window
 }
}

myFuncFunctionContext = { // myFunc 실행
 LexicalEnvironment: {
  EvironmentRecord: { arguments: null }
  OutterEnvironment: <HandleSoundLexicalEnvironment>,
 }
}

위에서 중요한 점은 handleSound가 호출되고 실행 컨텍스트가 생성된다. 그리고 실행 스택에 푸시되고 함수 PlaySound를 반환하며, 실행 스택에서 handleSound 함수실행컨텍스트는 반환된다. 이때 변수 myFuncPlaySound함수를 참조한다. myFunc를 호출할 때 생성된 실행컨텍스트의 LexicalEnvironmentPlaySoundLexicalEnvironment를 참조한다. 또한 myFunc의 실행컨텍스트의 LexicalEnvironment는 PlaySound함수가 선언되었을 때의 감싸고 있는 실행컨텍스트의 LexicalEnvironment를 참조한다. 즉 HandleSoundFunction 실행컨텍스트의 LexicalEnvironment를 참조하기 때문에 변수 sound에 접근이 가능하다.

클로저는 어떤 경우에 쓰이는가?

상태 유지

클로저를 유용하게 사용할 수 있는 방법은 현재 상태를 기억하고 최신 상태로 유지하기 위해 사용될 수 있다.
아래 예제를 통해서 클로저를 사용함으로 현재 상태를 기억하고 변경된 상태를 유지되는 경우를 살펴보자.

function outterToggle() {
  let freeVariable = false;

  // 1. 클로저 생성 및 반환
 return function closureToggle() {
    // 3. 상태변경
    freeVariable = !freeVariable;
    alert(freeVariable);
  }
};
// 2. 클로저 할당
const onToggle = outterToggle();
console.log(onToggle()); // true
console.log(onToggle()); // false
console.log(onToggle()); // true
  1. 함수 outterToggle이 반환한 함수closureToggle는 함수 closureToggle와 자신이 선언되었을 때의 LexicalEnvironment가 조합인 클로저이다.
  2. 클로저를 변수onToggle에 할당했다. 클로저를 onToggle에서 제거하지 않는 클로저의 LexicalEnvironment는 소멸하지 않는다. 그 의미는 함수 closureToggleLexicalEnvironment가 참조한 함수 outterToggleLexicalEnvironment도 소멸하지 않는다 라는 의미를 포함한다.
  3. onToggle를 호출하면 onToggle가 참조한 함수 closureToggle의 3번 내용이 실행되어 변수 freeVariable의 상태가 변경된다. 다시 말해서, onToggleLexicalEnvironment가 참조한 outterToggleLexicalEnvironment의 변수 freeVariable의 상태가 변경된다. 따라서, 클로저는 freeVariable를 참조하기 때문에 변경된 최신 상태를 유지하게 되는 것이다.

정보의 은닉

자바와 같은 몇몇 언어들을 메소드를 프라이빗으로 선언할 수 있는 기능을 제공한다. 자바스크립트는 클로저를 이용하여 private 변수 private 메소드를 흉내 내는 것이 가능하다.

아래는 클로저를 이용해 어떻게 private 메서드, private 변수를 만들고 활용하는지에 대해서 알아보자.

const counter = function () {
  let privateCount = 0;
  function privateChangeCount(number) {
    privateCount += number;
    alert(privateCount);
  }
  return {
    increase: function() {
      privateChangeCount(1);
    },
    decrease: function() {
      privateChangeCount(-1);
    },
  }
};

const counterCloser = counter();
counterCloser.increase(); // 1
counterCloser.increase(); // 2
counterCloser.decrease(); // 1

counter의 함수 내부의 변수privateCount와 메소드 privateChangeCount는 함수 외부에서 직접 접근할 수 없다. 대신 반환된 함수(increase, decrease)를 통해서만 접근이 가능하다.
반환된 함수 increase, decrease는 같은 환경을 공유하는 클로저이다. '같은 환경을 공유한다'라는 것은 increase, decrease가 선언되었을 때의 감싸진 실행컨텍스트의 Lexical Environment를 공유한다는 의미이다. 따라서 함수increase, decrease는 변수 privateCount와 함수privateChangeCount를 공유한다.

변수counter를 활용해 여러 개의 카운터를 만들 수 있다.

const counterCloser1 = counter();
const counterCloser2 = counter();
counterCloser1.increase(); // 1
counterCloser2.increase(); // 1
counterCloser2.decrease(); // 0
counterCloser1.increase(); // 2

counterCloser1counterCloser2가 독립성을 유지하는 것을 확인할 수 있다. counter()가 호출할 때 각각의 실행컨텍스트가 생성되고, 따라서, LexicalEnviroment도 다른 버전을 갖게 된다. increase, decrease가 참조하는 함수가 선언되었을 때의 감싸진 실행컨텍스트의 Lexical Environment의 버전이 다르기 때문에 서로의 영향을 받지 않게 되어 독립적으로 사용할 수 있게 된다.

문제

function count() {
  var i;
  for (i = 0; i < 10; ++i) {
    setTimeout(function() {
      console.log(i);
    }, 100);
  }
}

count();

위의 코드의 결과는 어떻게 될까요? 위의 코드를 실행컨텍스트 관점으로 아래와 같이 표현했다.

// 1) count의 함수실행컨텍스
countExectionCOntext = {
  LexicalEnvironment: {
    environmentRecord: { i: 0, arguments: null} // i는 for문을 거치면서 1씩 증가하여 최종적으로 10으로 변경됨
    outterEnvironment: <GlobalLexicalEnvironment>
    ...
  }
}

// 2) setTimeout의 함수실행컨텍스트
setTimeoutExecutionContext = {
  LexicalEnvironment: {
    environmentRecord: { arguments: { 0: <function>, 1: 100, length: 2 } }
    outterEnvironment: <GlobalLexicalEnvironment>
    ...
  }
}

// 3) 익명함수의 함수실행컨텍스트
AnonymousExecutionContext = {
   LexicalEnvironment: {
    environmentRecord: { arguments: null }
    outterEnvironment: <countLexicalEnvironment>
     ...
  }
}
  1. count함수가 호출되면 1번과 같이 함수컨텍스트가 생성된다.
  2. count함수에는 변수 var 키워드의 i가 선언되어 있으며 for문이 실행된다. for문은 총 10번 반복되고 종료된다.
  3. for문 내부의 setTimeout함수는 인자로 함수와 시간을 받는다. 그리고 인자로 받은 시간이 지나면 인자로 받은 함수가 실행되는 함수이다. setTimeout함수가 실행된다.
  4. i는 0.1초 이전 익명 함수가 실행되기 전에 1씩 증가하여 최종적으로 10으로 변경된다. 즉 count 함수의 Lexical environment의 변수 i의 값은 10이 되었다.
  5. 0.1초가 되면 익명 함수가 호출되고 3번과 같이 실행컨텍스트가 생성된다. 10번의 loop를 거치면서 10개의 다른버전의 익명 함수의 실행컨텍스트가 생성되었다.
  6. 10개의 다른 버전의 익명함수 실행컨텍스트의 LexicalEnvironment는 모두 동일한 count 함수의 Lexical environment를 참조하기 때문에 값이 10으로 변경된 i을 참조하게 된다.
  7. 따라서 변경된 i의 값, 즉 10이 로그로 열 번 찍히는 것을 확인할 수 있다.

해결방법

일단 해결을 하기 위해서는 익명 함수가 선언될 때의 LexicalEnvironment가 동일한 환경을 공유하지 않도록 해야 한다.

아래는 즉각 호출 함수를 이용해 for문이 실행되고 익명 함수가 선언될 때의 환경을 다르게 하는 것이다.

function count() {
  var i;
  for (i = 0; i < 10; ++i) {
    (function outter(y) {
      setTimeout(function() {
        alert(y);
      }, 100);
    })(i);
  }
}
count(); 
  1. for문이 실행되면 변수 i가 즉각 호출 함수의 인자로 할당된다.
  2. 할당된 인자는 다시 outter함수의 매개변수 y로 전달된다.
  3. 전달된 y는 그대로 익명 함수의 인수로 전달된다.
    여기서 주목해야 할 점은 익명 함수가 선언될 때 감싸고 있는 실행컨텍스트는 otter함수의 실행 컨텍스트인데, 익명 함수의 실행컨텍스트의 LexicalEnvironment는 익명 함수가 선언될 때 감싸고 있는 실행컨텍스트의 LexicalEnvironment를 참조한다.
    for문에 의해 반복될 때마다 함수 outter는 다른 버전의 실행컨텍스트를 생성하게 된다. 그러므로 outter의 실행컨텍스트의 LexicalEnvironment도 다른 버전을 갖게 되고 y역시 달라지게 된다. 따라서, 반복문마다 익명 함수가 선언될 때 참조하는 환경이 다르게 되며, 결론적으로 익명 함수에서 참조하는 변수 y도 다르게 되어 우리가 원하는 값을 얻을 수 있다.

다음은 블록 스코프를 이용하여 위 문제를 해결할 수 있다.

function count() {
  for (let i = 0; i < 10; ++i) {
    setTimeout(function() {
      console.log(i);
    }, 100);
  }
}

count();

for문이 반복될 때마다 다른 버전의 LexicalEnvironment를 생성하게 된다. for (let i...에서 선언한 변수 역시, 해당 LexicalEnvironment환경에 국한된다.
따라서 반복문마다 생성된 익명 함수의 실행컨텍스트의 LexicalEnvironment는 익명 함수가 선언될 때 블록의 LexicalEnvironment를 참조를 하게 되어 우리가 원하는 i을 얻을 수 있다.

위 코드를 for문의 스코프를 쉽게 풀이한다면 아래와 같이 표현할 수 있다.

 {
  let i = 0;
  setTimeout(function() {
    console.log(i);
  }, 100);
}

{
  let i = 1;
  setTimeout(function() {
    console.log(i);
  }, 100);
}
...
{
  let i = n - 1;
  setTimeout(function() {
    console.log(i);
  }, 100);
}

블록에서 익명 함수의 Lexical Environment는 각각 다르다는 것을 한눈에 확인할 수 있다.

마치며

자바스크립트는 참 신기하면서도 재밌는 언어이다. 하지만 자바스크립트 개발자라면 신기하게만 바라보면 안 된다. 왜 이러한 현상들이 일어나는지 철저히 파악해야 한다!!
클로저를 개념으로만 알고 있었지만, 실행컨텍스트를 통해서 분석하고, 이해하니 속이 다 후련하다. 여러분들도 깊게 이해하고 싶다면 필자처럼 분석하는 것도 좋은 방법이라고 생각한다.

여러분들의 Feedback은 적극 환영합니다. 감사합니다.

 

파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음

Comments