IT/JavsScript

클로저 이번에 재대로 이해하기

라임웨일 2022. 6. 2. 14:13
반응형

 

자바스크립트를 공부하게 되면 한 번쯤은 들어보게 되는 단어가 '클로저'입니다. 하지만 막상 클로저를 설명하려고 하면 어렴풋이 개념은 알고 있어도 쉽게 설명을 하지 못하는 경우가 많습니다. 

👍 클로저란?

클로저에 대해 MDN은 아래와 같이 정의하고 있습니다.

“A closure is the combination of a function and the lexical environment within which that function was declared.”
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

MDN에 정의되어 있는 말을 보아도 문맥이 어려워서 선뜻 이해가 되지 않습니다.  위 정의에서 중요한 키워드는 “함수가 선언됐을 때의 렉시컬 환경(Lexical environment)”입니다.

 

🤚 잠. 깐. 만!!!!!!!(왠지 BGM가 흘러야 할 것 같은 기분)

아... 전 클로저 알고 싶어서 들어왔는데 렉시컬 환경은 또 뭔가요???라고 생각하실 수 있어서 렉시컬 환경이 뭔지 빠르게 알아보겠습니다.

 

✔ 렉시컬 환경(Lexical Environment)

자바스크립트에선 실행 중인 함수, 코드 블록 {...}, 스크립트 전체는 렉시컬 환경(Lexical Environment)이라 불리는 내부 숨김 연관 객체(internal hidden associated object)를 갖습니다.

렉시컬 환경 객체는 두 부분으로 구성됩니다.

  1. 환경 레코드(Environment Record) : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체입니다. this 값과 같은 기타 정보도 여기에 저장됩니다.
  2. 외부 렉시컬 환경(Outer Lexical Environment)에 대한 참조 : 외부 코드와 연관됨

렉시컬 변화를 나타내는 이미지

위의 이미지는 코드가 한 줄, 한 줄 실행될 때마다 전역 렉시컬 환경이 어떻게 변화하는지 보여줍니다.

  1. 스크립트가 시작되면 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라갑니다(pre-populated).
    • 이때 변수의 상태는 특수 내부 상태(special internal state)인 'uninitialized’가 됩니다. 자바스크립트 엔진은 uninitialized 상태의 변수를 인지하긴 하지만, let을 만나기 전까진 이 변수를 참조할 수 없습니다.
  2. let phrase가 나타났네요. 아직 값을 할당하기 전이기 때문에 프로퍼티 값은 undefined입니다. phrase는 이 시점 이후부터 사용할 수 있습니다.
  3. phrase에 값이 할당되었습니다.
  4. phrase의 값이 변경되었습니다.

아직까진 어려운 게 없어 보이네요. 지금까지 배운 내용을 요약해 봅시다.

  • 변수는 특수 내부 객체인 환경 레코드의 프로퍼티입니다. 환경 레코드는 현재 실행 중인 함수와 코드 블록, 스크립트와 연관되어 있습니다.
  • 변수를 변경하면 환경 레코드의 프로퍼티가 변경됩니다.
👍 렉시컬 환경은 명세서에만 존재합니다.

'렉시컬 환경’은 명세서에서 자바스크립트가 어떻게 동작하는지 설명하는 데 쓰이는 ‘이론상의’ 객체입니다. 따라서 코드를 사용해 직접 렉시컬 환경을 얻거나 조작하는 것은 불가능합니다. 
자바스크립트 엔진들은 명세서에 언급된 사항을 준수하면서 엔진 고유의 방법을 사용해 렉시컬 환경을 최적화합니다. 사용하지 않는 변수를 버려 메모리를 절약하거나 다양한 내부 트릭을 써서 말이죠.

 

👍 클로저 이해하기

function outerFunc() {
  var x = 10;
  var innerFunc = function () { console.log(x); };
  innerFunc();
}

outerFunc(); // 10

함수 outerFunc 내에서 내부함수내부 함수 innerFunc가 선언되고 호출되었습니다. 이때 내부 함수 innerFunc는 자신을 포함하고 있는 외부 함수 outerFunc의 변수 x에 접근할 수 있는데. 이는 함수 innerFunc가 함수 outerFunc의 내부에 선언되었기 때문입니다.

스코프는 함수를 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정되며 이를 렉시컬 스코핑(Lexical scoping)라 합니다. 위 예제의 함수 innerFunc는 함수 outerFunc의 내부에서 선언되었기 때문에 함수 innerFunc의 상위 스코프는 함수 outerFunc이고 함수 innerFunc가 전역에 선언되었다면 함수 innerFunc의 상위 스코프는 전역 스코프가 됩니다.

 

함수 innerFunc가 함수 outerFunc의 내부에 선언된 내부함수이므로 함수 innerFunc는 자신이 속한 렉시컬 스코프(전역, 함수 outerFunc, 자신의 스코프)를 참조할 수 있습니다. 이건 자바스크립트의 실행 컨택스트 와 관계가 있습니다.

 

내부함수 innerFunc가 호출되면 자신의 실행 컨텍스트가 실행 컨텍스트 스택에 쌓이고 변수 객체(Variable Object)와 스코프 체인(Scope chain) 그리고 this에 바인딩할 객체가 결정되는데 이때 스코프 체인은 전역 스코프를 가리키는 전역 객체와 함수 outerFunc의 스코프를 가리키는 함수 outerFunc의 활성 객체(Activation object) 그리고 함수 자신의 스코프를 가리키는 활성 객체를 순차적으로 바인딩합니다.

내부 함수 innerFunc가 자신을 포함하고 있는 외부 함수 outerFunc의 변수 x에 접근할 수 있는 것은 스코프 체인(자신의 스코프에 참조 값이 없는 경우 상위 스코프로 참조 값을 찾으면서 값을 참조하는 것)을 통해 값을 참조했기 때문입니다.

 

이번에는 내부함수 innerFunc를 함수 outerFunc 내에서 호출하는 것이 아니라 반환하도록 변경해 보겠습니다.

function outerFunc() {
  var x = 10;
  var innerFunc = function () { console.log(x); };
  return innerFunc;
}

/**
 *  함수 outerFunc를 호출하면 내부 함수 innerFunc가 반환된다.
 *  그리고 함수 outerFunc의 실행 컨텍스트는 소멸한다.
 */
var inner = outerFunc();
inner(); // 10

함수 outerFunc는 내부 함수 innerFunc를 반환하고 생을 마감했습니다.🙏

즉, 함수 outerFunc는 실행된 이후 콜스택(실행 컨텍스트 스택)에서 제거되었으므로 함수 outerFunc의 변수 x 또한 더 이상 유효하지 않게 되어 변수 x에 접근할 수 있는 방법은 달리 없어 보입니다. 그러나 위 코드의 실행 결과는 변수 x의 값인 10입니다.

 

이미 life-cycle이 종료되어 실행 컨텍스트 스택에서 제거된 함수 outerFunc의 지역변수 x가 다시 부활이라도 한 듯이 동작하고 있습니다. 뭔가 특별한 일이 일어나고 있는 것 같아요. 와우!!!!!!! (🐄오름)

 

이처럼 자신을 포함하고 있는 외부함수보다 내부 함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부 함수가 호출되더라도 외부 함수의 지역 변수에 접근할 수 있는데 이러한 함수를 클로저(Closure)라고 부릅니다. 즉 클로저란 '내부함수가 외부 함수의 맥락(context)에 접근할 수 있는 것'이라고 정의할 수 있습니다.  

 

💡 클로저의 활용

1.상태 유지

클로저가 가장 유용하게 사용되는 상황은 현재 상태를 기억하고 변경된 최신 상태를 유지하는 것입니다. 

 

① 즉시실행함수는 함수를 반환하고 즉시 소멸합니다. 즉시실행함수가 반환한 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)에 속한 변수 isShow를 기억하는 클로저이며 클로저가 기억하는 변수 isShow는 box 요소의 표시 상태를 나타냅니다.

② 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했고 이벤트 프로퍼티에서 이벤트 핸들러인 클로저를 제거하지 않는 한 클로저가 기억하는 렉시컬 환경의 변수 isShow는 소멸하지 않습니다. 다시 말해 현재 상태를 기억합니다.

③ 버튼을 클릭하면 이벤트 프로퍼티에 할당한 이벤트 핸들러인 클로저가 호출되는데 이때. box 요소의 표시 상태를 나타내는 변수 isShow의 값이 변경됩니다. 변수 isShow는 클로저에 의해 참조되고 있기 때문에 유효하며 자신의 변경된 최신 상태를 계속해서 유지합니다.

 

이처럼 클로저는 현재 상태(위 예제의 경우 .box 요소의 표시 상태를 나타내는 isShow 변수)를 기억하고 이 상태가 변경되어도 최신 상태를 유지해야 하는 상황에 매우 유용합니다. 만약 자바스크립트에 클로저라는 기능이 없다면 상태를 유지하기 위해 전역 변수를 사용할 수밖에 없는데 전역 변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 합니다.

 

2. 전역 변수의 사용 억제

버튼이 클릭될 때마다 클릭한 횟수가 누적되어 화면에 표시되는 카운터를 만들어봅시다.

 

전역 변수를 사용한 Counting

 

위 코드는 잘 동작하지만 오류를 발생시킬 가능성을 내포하고 있는 좋지 않은 코드입니다.increase 함수는 호출되기 직전에 전역 변수 counter의 값이 반드시 0이여야 제대로 동작하는데 변수 counter는 전역 변수이기 때문에 언제든지 누구나 접근할 수 있고 변경할 수 있고 이는 의도치 않게 값이 변경될 수 있다는 것을 의미합니다.

만약 누군가에 의해 의도치 않게 전역 변수 counter의 값이 변경됐다면 이는 오류로 이어지게 됩니다. 변수 counter는 카운터를 관리하는 increase 함수가 관리하는 것이 바람직합니다.

 
 

지역 변수를 사용한 Counting

전역변수를 지역변수로 변경하여 의도치 않은 상태 변경은 방지했지만 increase 함수가 호출될 때마다 지역변수 counter를 0으로 초기화하기 때문에 언제나 1만 표시됩니다. 다시 말해 변경된 이전 상태를 전혀 기억하지 못합니다. 

 

클로저를 사용한 Counting

스크립트가 실행되면 즉시실행함수(immediately-invoked function expression)가 호출되고 변수 increase에는 함수 function () { return ++counter; }가 할당됩니다.

이 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)을 기억하는 클로저입니다. 즉시실행함수는 호출된 이후 소멸되지만 즉시실행함수가 반환한 함수는 변수 increase에 할당되어 inclease 버튼을 클릭하면 클릭 이벤트 핸들러 내부에서 호출되는데 이때 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 counter를 기억하게 됩니다.

따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때까지 유지됩니다. 즉시실행함수는 한 번만 실행되므로 increase가 호출될 때마다 변수 counter가 재차 초기화될 일은 없으며 변수 counter는 외부에서 직접 접근할 수 없는 private 변수이므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문이 보다 안정적인 프로그래밍이 가능해집니다.

 

함수형 프로그래밍에서  부수 효과(Side effect)를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저는 적극적으로 사용된다.

 

아래는 함수형 프로그래밍에서 클로저를 활용하는 간단한 예제입니다.

// 함수를 인자로 전달받고 함수를 반환하는 고차 함수
// 이 함수가 반환하는 함수는 클로저로서 카운트 상태를 유지하기 위한 자유 변수 counter을 기억한다.
function makeCounter(predicate) {
  // 카운트 상태를 유지하기 위한 자유 변수
  let counter = 0;
  // 클로저를 반환
  return function () {
    counter = predicate(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는 보조 함수를 인자로 전달받고 함수를 반환하는 고차 함수입니다.

함수 makeCounter가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 함수 makeCounter의 스코프에 속한 변수 counter을 기억하는 클로저입니다.

함수 makeCounter는 인자로 전달받은 보조 함수를 합성하여 자신이 반환하는 함수의 동작을 변경할 수 있습니다. 이때 주의해야 할 것은 함수 makeCounter를 호출해 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖는다는 것이며 이는 함수를 호출하면 그때마다 새로운 렉시컬 환경이 생성되기 때문입니다.

위 예제에서 변수 increaser와 변수 decreaser에 할당된 함수는 각각 자신만의 독립된 렉시컬 환경을 갖기 때문에 카운트를 유지하기 위한 자유 변수 counter를 공유하지 않아 카운터의 증감이 연동하지 않습니다. 따라서 독립된 카운터가 아니라 연동하여 증감이 가능한 카운터를 만들려면 렉시컬 환경을 공유하는 클로저를 만들어야 합니다.

 

3. 정보의 은닉

생성자 함수 Counter를 생성하고 이를 통해 counter 객체를 만들어봅시다

 

function Counter() {
  // 카운트를 유지하기 위한 자유 변수
  let counter = 0;

  // 클로저
  this.increase = function () {
    return ++counter;
  };

  // 클로저
  this.decrease = function () {
    return --counter;
  };
}

const count = new Counter();

console.log(count.increase()); // 1
console.log(count.decrease()); // 0

생성자 함수 Counter는 increase, decrease 메서드를 갖는 인스턴스를 생성합니다.

이 메서드들은 모두 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 스코프에 속한 변수 counter를 기억하는 클로저이며 렉시컬 환경을 공유합니다. 생성자 함수가 함수가 생성한 객체의 메서드는 객체의 프로퍼티에만 접근할 수 있는 것이 아니며 자신이 기억하는 렉시컬 환경의 변수에도 접근할 수 있습니다.

 

이때 생성자 함수 Counter의 변수 count는 this에 바인딩된 프로퍼티가 아니라 변수입니다. count가 this에 바인딩된 프로퍼티라면 생성자 함수 Counter가 생성한 인스턴스를 통해 외부에서 접근이 가능한 public 프로퍼티가 되지만 생성자 함수 Counter 내에서 선언된 변수 count는 생성자 함수 Counter 외부에서 접근할 수 없습니다.

 

하지만 생성자 함수 Counter가 생성한 인스턴스의 메서드인 increase, decrease는 클로저이기 때문에 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 변수 count에 접근할 수 있는데 이러한 클로저의 특징을 사용해 클래스 기반 언어의 private 키워드를 흉내 낼 수 있습니다.

 

💡 자주 발생하는 실수

반복문 클로저 예시

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

간단하게 0-9까지의 정수를 출력하는 코드이지만 실제로 돌려보면 엉뚱하게도 10만 열 번 출력되는 걸 볼 수 있습니다.

왜일까요? 먼저 setTimeout()에 인자로 넘긴 익명 함수는 모두 0.1초 뒤에 호출될 것이고 그 0.1초 동안에 이미 반복문이 모두 순회되면서 i값은 이미 10이 된 상태입니다.

 

이때 익명 함수가 호출되면서 이미 10이 되어버린 i를 참조하기 때문에 10이 출력이 됩니다. 이 경우에도 클로저를 사용하면 원하는 대로 동작하도록 만들 수 있습니다.

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

중간에 IIFE를 덧붙여 setTimeout()에 걸린 익명 함수를 클로저로 만들었습니다.

앞서 말한 대로 클로저는 만들어진 환경을 기억하기 때문에  이 코드에서 i는 IIFE내에 j라는 형태로 주입되고, 클로저에 의해 각기 다른 환경 속에 포함됩니다. 반복문은 10회 반복되므로 10개의 환경이 생길 것이고, 10개의 서로 다른 환경에 10개의 서로 다른 j가 생기게 됩니다.

 

이쯤에서 IIFE 매개변수로 i를 넘기지 않고 그냥 직접 참조해도 되지 않느냐는 의문이 들 수도 있지만 인자로 i를 넘기지 않는다면 당연히 클로저가 참조하는 IIFE의 함수 스코프에서도 i값이 없으므로 생성 당시의 외부 스코프인 글로벌을 탐색하게 되고 결국 모두 같은 i를 참조하게 됩니다. 반면에, 인자로 i를 넘기게 되면 IIFE로 만든 10개의 스코프에 모두 i라는 변수가 다른 값으로 생기므로 정상적으로 동작할 수 있습니다.

 

참고로 여기서 콜백으로 넘기는 함수 자체를 IIFE로 만들면 되지 않느냐는 사람도 있는데, 그렇게 하면 원하는대로 0-9까지 출력은 되지만 함수 내부가 즉시 실행되어 버리므로 setTimeout()의 0.1초 딜레이가 작동하지 않게 됩니다.

 

우리는 지금까지 클로저에 대해 알아보았습니다.  

감사합니다.

 

출처 : 

반응형