2021-09-01 클로저

2021-09-01
  • JavaScript

최근 부쩍 깨닫고 있지만 자바스크립트 대부분의 핵심 개념은 정말이지 몽땅 실행 컨텍스트와 연결되어 있다. 클로저(Closure) 역시 마찬가지다.

사실 클로저는 자바스크립트 고유의 개념은 아니고, 함수형 프로그래밍 언어에서 등장하는 보편적 특성이다. 그래서 명세에 정의되어 있지 않고, 문서나 서적마다 각기 달리 설명할 수 밖에 없다. 대체로 MDN의 다음 정의를 인용하는 듯 하다.

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

그러면서 위 설명이 어렵다는 이야기를 하며 lexical environment로 시작하여 이와 관련된 몇 가지 개념 설명으로 넘어간다.

하지만 우리는 앞서 실행 컨텍스트에 대한 내용을 이미 훑어봤다. 바로 본론으로 들어가도록 하자.


무엇과 어떻게 : 클로저란 무엇이고 어떻게 동작하는 걸까?

클로저는 처음 접했을 때 도대체 그 정체를 파악하기가 쉽지 않았다. 얼른 보기에는 자연스럽게 작동하는 자바스크립트의 동작, 그냥 당연한 이야기를 어렵게 돌려하는 것만 같았기 때문이다. 하지만 조금 더 깊이 들어가보도록 하자.

우리가 숨쉬는 공기가 그냥 지천에 널려 있다고 해서 쉽고 간단한 존재가 아니듯, 클로저 또한 그렇다.

“함수 객체와, 함수의 변수가 해석되는 유효범위(변수 바인딩의 집합)“를 아울러 컴퓨터 과학 문헌에서는 클로저라고 일컫는다.” - 데이비드 플래너건, 자바스크립트 완벽 가이드

클로저를 왜 클로저라고 부르는지 또한 이해가 안됐고, 아래 설명을 읽고서도 왜 그런지 와닿지는 않지만 일단 그 이름의 유래에 대해서도 찾아둔 김에 인용으로 남겨본다.

“클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수(free variable)라고 부른다. 클로저(closure)란 ‘함수가 자유 변수에 대해 닫혀있다(closed)‘라는 의미이다. 이를 좀 더 알기 쉽게 의역하자면 ‘자유 변수에 묶여있는 함수’라고 할 수 있다.” - 이웅모, 모던 자바스크립트 Deep Dive

“이는 함수의 변수가 유효범위 체인에 바인딩되어 있고, 따라서 그 함수는 함수의 변수에 ‘따라 닫힌다’는 뜻에서 유래한 용어다.” - 데이비드 플래너건, 자바스크립트 완벽 가이드

대체로 클로저는 변수가 갖고 있는 상태(state)를 안전하게 변경하고 유지하기 위해 사용한다. 상태가 의도치 않게 변경되지 않도록 하는 것이다. 클로저의 성질을 발견한 개발자들은 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용하기 시작했고 함수형 프로그래밍 쪽에서 유용하게 사용되기 시작했다

클로저의 동작에 관해 이야기해보자.

클로저는 대체로 중첩 함수의 참조 범위와 연관이 되어 있다. 함수는 자기 자신은 물론이고 상위 스코프에 속한 식별자 또한 참조할 수 있다. 앞서 우리는 이 현상이 상속 개념과 유사하다고 개념화한 바 있다. 부모 클래스에서 갖고 있는 요소를 상속 받은 자식 클래스에서 사용할 수 있는 것과 유사하게 말이다.

하지만 클래스와 달리 함수는 일반적으로 호출이 종료되면서 메모리에서 스스로를 정리 한다. 정확히는 더 이상 참조하지 않는 식별자들에 대해, 즉 참조 카운트가 0인 식별자에 대해 GC가 메모리를 해제해주면서 함수는 더 이상 메모리에 존재하지 않고, 그와 함께 함수가 실행되면서 생성되었던 실행 컨텍스트 역시 함께 정리된다.

하지만 일반적인 경우에 그렇다는 거고, 함수의 호출은 종료되었지만 함수 내부에 있는 식별자에 대한 참조가 여전히 유효하다면 그 부분만큼은 참조 카운트가 0이 되지 않고, 정리되지 못한다. 이건 클로저를 알아야 할 수 있는 설명이라기 보다는 실행 컨텍스트GC식별자 등에 대한 이해가 있으면 도출할 수 있는, 자바스크립트라는 언어의 메커니즘에 가깝다.

호이스팅 또한 유사한 방식으로 설명할 수 있다. 이런 식으로 언어의 메커니즘을 기반으로 한 설명을 하고 싶었는데, 그렇게 할 수 있게 되어 참 좋은 것 같다. 아무튼.

이러한 동작을 클로저라는 하나의 개념으로 종합했을 때 다음과 같은 정의가 가능해진다.

“외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다.” - 이웅모, 모던 자바스크립트 Deep Dive

“자신이 생성될 때의 스코프에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하여 유지시키는 함수” - 유인동, 함수형 자바스크립트 프로그래밍


이제 간략히 설명했던 클로저의 메커니즘을 보다 적확한 용어를 사용하여 정리해보자.

“스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다. 이 렉시컬 환경은 자신의 ‘외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference)‘를 통해 상위 렉시컬 환경과 연결된다. 이것이 바로 스코프 체인이다.

따라서 ‘함수의 상위 스코프를 결정한다’는 것은 ‘렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값을 결정한다’는 것과 같다.” - 이웅모, 모던 자바스크립트 Deep Dive

실행 컨텍스트의 렉시컬 환경인 스코프는 자기 스스로에 대한 참조 정보를 가지고 있으면서 동시에, 자신이 속해 있는 환경인 상위 스코프의 ‘외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference)‘를 갖고 있다. 마치 노드마다 next를 갖고 있는 링크드 리스트 구조와 같기 때문에 스코프를 단방향 링크드 리스트 구조로 설명하기도 한다. 이 실행 컨텍스트가 링크드 리스트 구조로 이어지며 발생하는 형태를 스코프 체인이라 부른다.

앞서 중첩 함수를 언급했는데, 이는 외부 함수(outer)를 갖고 있는 내부 함수(inner) 형태로 바꾸어 말해볼 수 있다. 핵심은 외부 함수의 생명 주기가 종료되며 실제로 outer의 실행 컨텍스트는 제거되지만, 식별자를 포함하고 있는 렉시컬 환경까지 사라지지는 않는다는 점이다. 앞서 실행 컨텍스트를 설명하며 이 렉시컬 환경식별자를 찾아볼 수 있는 사전과도 같다고 했다.

“outer 함수의 실행이 종료하면 inner 함수를 반환하면서 outer 함수의 생명 주기가 종료된다. 즉, outer 함수의 실행 컨텍스트가 실행 컨텍스트 스택에서 제거된다. 이때 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아니다.” - 이웅모, 모던 자바스크립트 Deep Dive

모든 자바스크립트 요소들은 내부 슬롯을 갖는데, 함수의 경우 여러 내부 슬롯 중 [[Environment]] 내부 슬롯을 갖는다. 개발자가 직접 접근할 수는 없지만 자바스크립트 엔진은 함수의 [[Environment]] 내부 슬롯에 저장되어 있는 상위 스코프에 대한 참조를 활용하여 외부 함수의 렉시컬 환경에 접근한다. 이런 참조 관계 덕분에 상위 렉시컬 환경의 참조 카운트가 0이 되지 않는다.

모든 상위 스코프의 환경이 존재 하더라도 내부 함수에서 참조하고 있지 않을 경우 당연히 메모리에서 해제한다.

“이처럼 상위 스코프의 어떤 식별자도 참조하지 않는 경우 대부분의 모던 브라우저는 최적화를 통해 다음 그림과 같이 상위 스코프를 기억하지 않는다. 참조하지도 않는 식별자를 기억하는 것은 메모리 낭비이기 때문이다.” - 이웅모, 모던 자바스크립트 Deep Dive


클로저의 활용

클로저의 여러 활용으로 은닉 같은 속성들이 많이 언급되는데, 함수형 프로그래밍 측면에서는 맥락을 유지할 수 있도록 내부 변수를 남겨둘 수 있다는 점에서 중요하게 여겨지는 것 같다. 다음은 캐시를 활용한 메모이즈(memoize) 함수의 예시다.

// 함수 f의 결과를 저장한 버전을 반환한다.
// 함수 f의 모든 인자가 서로 구분할 수 있는 문자열 표현일 때만 작동한다.
function memoize(f) {
  const cache = {};

  return function () {
    const key = arguments.length + Array.prototype.join.call(arguments, ",");
    if (key in cache) return cache[key];
    else return (cache[key] = f.apply(this, arguments));
  };
}

물론 외부에 대한 제한된 인터페이스를 구성할 수 있도록 상태를 함수 안쪽으로 은닉하는 것도 중요한 활용 예라고 할 수 있다. 다음은 클로저를 활용해 카운트를 안전하게 증감시키도록 메서드를 구현한 경우다.

let num = 0;

const increase = function () {
  return ++num;
};
// num의 상태가 유지되지 못함
const increase = function () {
  let num = 0;

  return ++num;
};
// num의 상태가 유지됨
const increase = (function () {
  let num = 0;

  return function () {
    return ++num;
  };
})();
// num에 대한 증가, 감소 함수 구현
const increase = (function () {
  let num = 0;

  return {
    increase() {
      return ++num;
    },
    decrease() {
      return num > 0 ? --num : 0;
    },
  };
})();

예상 면접 질문

  • 숫자를 증감시키는 메서드를 가진 카운터를 구현해주세요. 단, 클로저 개념을 활용하여 상태를 안전하게 은닉시켜야 합니다.

  • 클로저를 설명하고, 실행 컨텍스트 개념을 이용해 클로저가 어떻게 구현되는지 설명해주세요


참고 자료

  • 정재남, <코어 자바스크립트>

  • 이웅모, <모던 자바스크립트 Deep Dive>

  • 데이비드 플래너건, <자바스크립트 완벽 가이드>

  • 유인동, <함수형 자바스크립트 프로그래밍>

Profile picture

saengmotmi

'내가 원하는 건 문학이 아닌 기쁨이다.'