JavaScript - 클로저

김민기·2022년 9월 13일
0

JavaScript-Study

목록 보기
10/12

일전에 데브코스를 수강하면서 클로저에 대해 정리를 해본적이 있다.
사실상 정리라기 보다는 그냥 책의 내용을 그대로 배껴쓰거나 다른 블로그의 좋은 글들을 그대로 가져왔을 뿐이었다.
그때 당시에도 명확하게 클로저란 무엇인가 명확하게 이해하지 못하고 그냥 소멸된 함수의 변수를 가져와서 쓸 수 있는 특수한 함수? 이런 말도 안되는 방식으로 이해하고 넘어갔다.

하지만 이번에는 실행 컨텍스트를 비롯한 자바스크립트의 중요한 개념들을 학습한 만큼 명확하게 이해하고 넘어가려 한다.

리마인드

자바스크립트는 소스코드를 실행하기 전에 '평가'를 한다.
'평가' 후 '실행'하기 때문에 함수, 변수 모두 호이스팅이 발생한다.
소스코드를 '평가'할때 실행 컨텍스트를 생성하고 렉시컬 환경을 생성한다.
소스코드가 모두 실행을 완료했다면 실행 컨텍스트는 실행 컨텍스트 스택에서 사라진다. 이때 평가할 때 생성했던 렉시컬 환경은 실행 컨텍스트 스택이 제거되었다고해서 반드시 삭제되는 것이 아니다. 외부에서 A라는 실행 컨텍스트의 렉시컬 환경을 참조하고 있다면 A라는 실행 컨텍스트가 제거되어도 A의 렉시컬 환경은 제거되지 않는다.

렉시컬 환경이란 식별자와 식별자에 바인딩된 값, 상위 스코프에 대한 참조를 기록하는 자료구조로 실행 컨텍스트를 구성하는 컴포넌트다.
렉시컬 환경은 스코프를 구분하여 식별자를 등록하고 관리하는 저장소 역할을 하는 렉시컬 소코프의 실체다.

함수를 호출하면 함수 실행 컨텍스트가 생성되고 함수 렉시컬 환경이 만들어진다.
또한 자바스크립트 엔진은 함수 정의를 평가하여 함수 객체를 생성할 때 현재 실행 중인 실행 컨텍스트의 렉시컬 환경,즉 평가하는 함수의 상위 스코프를 평가하여 생성하는 함수 객체의 내부 슬롯 [[Environment]]에 저장한다.

클로저란?

클로저는 자바스크립트 고유의 개념이 아니다.

함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 공통적으로 사용되는 중요한 특성이다.
MDN에서는 클로저를 다음과 같이 정의한다.

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

그 함수가 선언된 렉시컬 환경이란 어떤 것일까

렉시컬 환경

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

함수의 상위 스코프는 정적이다. 함수를 정의한 위치에 의해 정적으로 결정되고 변하지 않는다.
자바스크립트가 소스코드를 평가할 때 함수 실행 컨텍스트를 생성하고 함수 렉시컬 환경을 생성한다고 했었다. 이때 함수의 렉시컬 환경의 외부 렉시컬 환경에대한 참조는 상위 스코프(현재 함수 정의를 평가하고 있는 함수 또는 전역)을 가리키게 되고 이것이 스코프 체인이 된다는 것이다.

종합해보면 함수는 정의된 곳에서 상위 스코프가 정적으로 결정되는 렉시컬 스코프를 갖는다. 함수를 호출하면 자바스크립트는 실행 컨텍스트를 생성하고 렉시컬 환경을 생성한다. 이 렉시컬 환경이 렉시컬 스코프의 실체이며, 렉시컬 환경은 상위 렉시컬 환경을 참조하는 스코프 체인을 만들어 낸다.

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

함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다.
이 상위 스코프의 참조는 실행 중인 실행 컨텍스트의 렉시컬 환경을 가리킨다.
또한 상위 스코프는 자신이 호출되었을 때 생성될 함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장될 참조 값이다.

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

클로저와 렉시컬 환경

const x = 1;

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

const innerFunc = outer();
innerFunc();

outer 함수를 호출하면 outer 함수는 중첩 함수 inner를 반환하고 생명주기를 마감한다. 즉 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다. 이때 outer 함수의 지역변수 x또한 생명 주기를 마감한다. 따라서 outer 함수의 지역 변수 x는 더는 유효하지 않게 되어 접근할 수 없는 것으로 보인다.

하지만 위 코드를 실행한 결과는 outer 함수의 지역 변수가 저장하고 있는 값인 10이 나오게 된다.
분명 생명주기를 마감하고 실행 컨텍스트 스택에서 제거 되었음에도 정상적으로 참조하고 있다.

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

왜 생명 주기를 마감하고 실행 컨텍스트에서 제거되었음에도 참조할 수 있는 것일까? 미리 말했듯이 렉시컬 환경과 관련이 있다. 렉시컬 환경은 실행 컨텍스트를 구성하는 컴포넌트이지만 생명 주기는 다를 수 있다. 외부에서 렉시컬 환경을 참조하고 있다면 실행 컨텍스트가 제거되더라도 렉시컬 환경은 제거되지 않는다.

outer 함수를 호출하면 outer 함수의 렉시컬 환경이 생성되고 outer 함수 객체의 [[Environment]] 내부 슬롯에 저장된 전역 렉시컬 환경을 outer 함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 할당한다.
그리고 중첩 함수 inner 함수를 평가한다. inner 함수는 자신의 [[Environment]] 내부 슬롯에 현재 실행 중인 실행 컨텍스트의 렉시컬 환경, 즉 outer 함수의 렉시컬 환경을 상위 스코프로서 저장한다.

스코프 체인에 의해 전역 변수에도 접근이 가능하다.

outer 함수의 실행이 종료되면 inner 함수를 반환하면서 outer 함수의 생명 주기는 종료된다.
outer 함수의 렉시컬 환경은 inner 함수의 [[Environment]] 내부 슬롯에 의해 참조되고 있고 inner 함수는 전역 변수 innerFunc에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않는다.

outer가 반환한 inner 함수를 호출하면 inner 함수의 실행 컨텍스트가 생성되고 실행 컨텍스트 스택에 푸시된다. 그리고 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 inner 함수 객체가 [[Environment]] 내부 슬롯에 저장되어 있는 참조값이 할당된다.

중첩 함수 inner는 외부 함수 outer 보다 더 오래 생존했다. 이때 외부 함수보다 더 오래 생존한 중첩 함수는 외부 함수의 생존 여부와 상관없이 자신이 정의된 위치에 의해 결정된 상위 스코프를 기억한다. inner의 내부에서는 상위 스코프를 참조 할 수 있으므로 상위 스코프의 식별자를 참조할 수 있고 식별자의 값을 변경할 수도 있다.

자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저다.

클로저 예시

function foo() {
  const x = 1;
  const y = 2;
  function bar() {
    const z = 3;
    console.log(z);
  }
  return bar;
}
const bar = foo();
bar();

하지만 모던 브라우저에서는 상위 스코프의 어떤 식별자도 참조하지 않는 경우 최적화를 통해 상위 스코프를 기억하지 않는다. 이 예제에서 foo 함수는 중첩함수 bar를 반환하지만 bar에서는 상위 스코프인 foo 함수 스코프에 있는 어떤 식별자도 참조하고 있지 않다.

function foo() {
  const x = 1;
  function bar() {
    console.log(x);
  }
  bar();
}
foo();

중첩 함수 bar는 상위 스코프의 식별자를 참조하고 있으므로 클로저다. 하지만 외부 함수 foo의 외부로 중첩함수를 반환하지는 않는다. 외부 함수 foo보다 중첩 함수 bar의 생명 주기가 짧다.
이런 경우 중첨 함수 bar는 클로저였지만 외부 함수보다 일찍 소멸되기 때문에 생명 주기가 종료된 외부 함수의 식별자를 참조할 수 있다는 클로저의 본질에는 부합하지 않는다.

function foo() {
  const x = 1;
  const y = 2;
  
  function bar() {
    console.log(x);
  }
  return bar;
}

const bar = foo();
bar();

중첩 함수 bar는 외부 함수 foo의 식별자를 참조하고 있고, foo 함수가 중첩 함수를 반환하기 때문에 클로저다. 이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료된 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다.
다만 bar의 경우 상위 스코프의 식별자 중 x만을 참조하고 있다. 이런 경우 대부분의 모던 브라우저는 최적화를 통해 상위 스코프의 식별자 중에서 클로저가 참조하고 있는 식별자만을 기억한다.

클로저에 의해 참조되는 상위 스코프의 변수(foo 함수의 x)를 자유 변수라고 부른다.
클로저란 함수가 자유 변수에 대해 닫혀 있다라는 의미다.(자유 변수에 묶여 있는 함수.)

클로저 때문에 메모리 낭비가 발생하지는 않을까? 이론적으로 모든 함수는 클로저이고, 클로저는 상위 스코프의 식별자를 기억해야하기 때문에 메모리가 낭비될 수도 있다는 생각이 든다.
하지만 모던 자바스크립트 엔진은 최적화가 잘 되어 있어서 클로저가 참조하고 있지 않은 식별자는 기억하지 않는다. 즉 기억해야할 식별자만 기억한다.

마무리

클로저의 활용법이나 캡슐화와 정보 은닉등 클로저를 자바스크립트에서 활용할 수 있는 방법들에 대한 내용은 여기 포함되지 않았다. 간단하게 정리를 해보려고 했는데 양이 계속해서 늘어나는 기분이다. 물론 완벽한 정리라고 생각되지는 않는다. 앞으로도 계속해서 공부하면서 부족한 내용을 채워야겠다.

0개의 댓글