듣기만해도 어려운 이름, 클로저의 정체

1

Javascript

목록 보기
11/13
post-thumbnail

클로저라는 개념은 자바스크립트를 공부하는 이들에게 악명높은 이름 중 하나일 거라고 생각합니다. 그만큼 자바스크립트 언어에 대한 많은 지식을, 나아가 일반적인 프로그래밍 언어에 대한 지식이 뒷받침 되어야 제대로 이해할 수 있는 개념입니다. 또한 이해했다 해도 직접 씹고 뜯고 맛보고 즐기며 활용하는 것도 만만치 않습니다. 이번 포스팅에서는 이 난해하기로 유명한 클로저라는 현상에 대해 알아보겠습니다.

렉시컬 환경

클로저를 이해하기 위해서 렉시컬 환경이라는 개념을 정확히 알아야 합니다.

정의

  • verbose한 정의
    • 렉시컬 환경이란, 특정 코드가 작성, 선언된 환경(장소)을 의미한다.
  • 테크니컬한 정의
    • 렉시컬 환경은 실행 컨텍스트의 구성 요소로, 환경 레코드(식별자가 저장되는 공간)와 외부 렉시컬 환경에 대한 참조로 이루어진, 메모리에 존재하는 객체이다.

정의 그대로 입니다. 자바스크립트는 코드 실행을 전역 코드를 제외하고 함수 단위로 하는데, 함수 안에서 여러 환경이 생성되고 그 환경 안에서 코드가 실행됩니다. 그리고 자바스크립트가 렉시컬 스코프를 따르는 언어이기 때문에 이 환경들을 렉시컬 환경이라고 부릅니다.

왜 "렉시컬"인가?

lexical: 어휘의, 어휘적인

자바스크립트는 어휘적인 구조를 가지는 언어라고 합니다. 이 말이 곧 렉시컬 스코프를 따른다는 말과 동일한데요, 어휘적인 구조? 정말 와닿지 않는 표현입니다.
어원에 대해서 잘 찾아보려 해도 어휘적인 구조를 가지기 때문에~ 로 설명하는 자료 외에는 자료가 많이 없어, 저는 어휘적인 구조라는 것은 아래와 같이 이해했습니다.

일반적인 언어에서 어휘라는 것, 즉 단어라는 건 언어가 만들어질 당시 약속에 의해 그 뜻이 정해집니다. (신조어나 역사적인 사건 등에 의해 단어 뜻이 달라지는 경우는 생각하지 말아요...)
누가 어디서 해당 단어를 썼냐에 따라 (약간의 의미나 의도 차이는 있을지언정)본질적인 정의가 변하지 않습니다.
자바스크립트의 실행 환경도 마찬가지입니다. 함수를 정의하는 순간 함수 내부에서 참조하는 식별자들은 일종의 단어집(환경레코드)에 의해 참조할 값이 정해집니다. 식별자들을 단어처럼 취급한다고 볼 수 있습니다.

다이나믹 스코프를 따르는 언어는 코드 실행중 식별자를 만나면 호출 스택을 따라가며 찾습니다. 애초에 식별자를 어떤 "어휘"처럼 생각하지 않는다는 것입니다.
물론 제가 스스로 이해한 내용이지, 실제로 lexical이라는 단어를 붙일 때 정확히 위와 같은 생각으로 붙였는지는 명확하지 않습니다.

모든 함수는 상위 렉시컬 환경을 기억한다

자바스크립트에서 모든 함수는 생성될 당시 [[Environment]]라는 내부 슬롯을 할당받고, 자신이 정의된 위치를 기준으로 상위 스코프, 즉 상위 렉시컬 환경에 대한 참조를 저장합니다.
코드와 그림으로 쉽게 이해해보도록 하죠.

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

foo();

위 코드에 대한 평가 단계에서 다음과 같이 foo함수 객체가 window객체(또는 글로벌 객체)의 프로퍼티로 등록되고, 해당 함수의 내부 슬롯 [[Environment]]는 전역 렉시컬 환경을 참조하게 됩니다.

[[Environment]] 내부 슬롯은 결국 스코프 체인의 물리적인 실체인것을 확인할 수 있습니다.

Closure: 폐쇄된 상태

정의

클로저의 사전적 정의는 "닫힌 상태"입니다. 수학에서 어떤 연산이 닫혀있는 연산이라고 하는 말 들어보셨나요? 이런 표현 또한 closure라고 칭한다고 합니다 (예: Multiplication has closure.) MDN에서는 아래와 같이 클로저를 정의합니다.

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

처음 볼 때는 다소 난해하긴 할 순 있어도, 렉시컬 환경을 제대로 짚고 넘어가니 이해가 되는 정의입니다. MDN에서는 어떠한 함수가 생성되더라도 클로저가 함께 생성된다고 설명합니다. 이것도 앞서 설명드린 모든 함수는 상위 렉시컬 환경을 기억한다는 것을 알고 나면 당연한 얘기라는 것을 알 수 있습니다.

이러한 현상을 왜 "닫힘"이라고 정의했을까요? 함수와 함수가 선언된 어휘적 환경은 외부에서 참조할 수 없도록 닫혀있기 때문입니다.

모든 함수는 클로저다?

그럼 함수가 생성될 때 외부 환경을 참조하고 있으므로 모든 함수는 클로저겠네요?

예리한 질문입니다. 이론적으로는 맞는 말이라 볼 순 있겠지만 일반적으로 모든 함수가 클로저라는 말은 틀렸습니다. 브라우저가 실제로 자바스크립트 코드를 해석할 때, 어떤 함수가 상위 스코프의 식별자를 참조하지 않으면 내부 최적화 로직을 통해 클로저를 생성하지 않습니다. 어디에서도 참조되지 않는데 기억할 필요가 없기 때문입니다.
나아가, 어떤 함수가 상위 스코프의 식별자를 참조하더라도 특정 식별자로 저장되지 않는다면 곧바로 소멸될 운명이기에, 클로저가 생성되긴 하지만 일반적으로는 클로저라고 부르지 않습니다.

예제

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);
const decreaser = makeCounter(decrease);
console.log(increaser()); // 1
console.log(increaser()); // 2
console.log(decreaser()); // -1
console.log(decreaser()); // -2

위 예제는 함수형 프로그래밍에서 counter변수를 은닉화한 예제입니다.
예제를 보면, makeCounter라는 함수가 aux라는 함수를 매개변수로 받고, 내부 counter라는 변수를 aux함수에 인자로 전달한 결과를 counter 할당하여 갱신한 값을 리턴하는 함수를 리턴합니다. 즉, makeCounter함수는 고차함수입니다. aux의 자리에 어떤 함수가 오느냐에 따라 동적으로 동작을 제어할 수 있는 함수입니다.
여기서 makeCounter함수가 반환하는 함수가 자신의 상위 스코프인 makeCounter의 렉시컬 환경을 기억하는 클로저 입니다. counter라는 변수는 makeCounter가 호출되고 함수를 반환해서 실행이 종료되고 나면 어디에서도 접근할 수 없는데, makeCounter가 리턴한 익명함수, 즉 increaser, decreaser라는 식별자에 의해 참조되고 있는 함수는 생성될 당시의 환경을 참조하고 있기에 counter변수에 유일하게 접근할 수 있습니다.

자주 발생하는 실수

이 예제는 실제로 자바스크립트 기술 면접에서 자주 나오는 예제입니다.

var funcs = [];

for (var i = 0; i < 3; i++) {
  funcs[i] = function (){ return i; } 
}

for (var j = 0; j < 3; j++) {
  console.log(funcs[j]()); 
}

위 코드의 실행 결과는 직관적인 해석으로는 0, 1, 2가 나와야 할 것 같지만, 3, 3, 3이 출력됩니다. 왜냐하면 var키워드로 선언된 변수는 함수 레벨 스코프를 따르므로 전역 렉시컬 환경에 등록되기 때문입니다. 또한 각각의 funcs가 할당받은 함수들도 전역 렉시컬 환경을 상위 렉시컬 환경으로 기억하고 있습니다. 그래서 첫 번째 반복문이 끝난 뒤 i값은 3이 저장되어 있기 때문에 funcs들은 모두 3이 된 i를 리턴하게 됩니다.
위 코드를 클로저를 이용하여 아래처럼 수정하면 0, 1, 2를 출력하게 할 수 있습니다.

var funcs = [];

for (var i = 0; i < 3; i++) {
  funcs[i] = (function(id){
    return function () {
      return id;
    };
  }(i));
}

for (var j = 0; j < 3; j++) {
  console.log(funcs[j]()); 
}

수정된 코드에서 첫 번째 반복문에 각각의 funcs에 할당되는 것은 마찬가지로 함수입니다. 하지만 첫 번째 예제 코드와 다른점은 할당되는 함수가 클로저라는 점입니다. 클로저는 자신이 선언될 당시 상위 렉시컬 환경을 기억한다고 했죠? 따라서 각 funcs는 즉시 실행함수가 리턴함 함수이므로, 즉시 실행함수의 렉시컬 환경을 기억할 것입니다. 그리고 즉시 실행함수들은 각 반복문마다 당시의 i값을 id라는 매개변수 자리에 받고 있어서, id라는 식별자를 리턴하는 내부 함수는 간접적으로 생성 당시의 i값을 리턴하게 됩니다. 이 모든게 클로저를 활용하였기 때문에 가능한 것입니다.
이렇게 복잡하게 하지 않고, 다음과 같이 let 키워드를 써도 해결할 수 있습니다.

var funcs = [];

for (let i = 0; i < 3; i++) {
  funcs[i] = function (){ return i; } 
}

for (var j = 0; j < 3; j++) {
  console.log(funcs[j]()); 
}

for문 안에서 let키워드를 iterator로 사용하면, 글로벌 스코프가 아닌 블록 레벨에서 i가 정의됩니다. 그리고 반복을 할 때마다 해당 블록의 렉시컬 환경이 따로 생성됩니다. 그래서 각 funcs들은 var키워드를 사용했을때 처럼 글로벌 렉시컬 환경을 참조하는 것이 아닌, 각 반복의 블록 렉시컬 환경을 참조하게 되고, 해당하는 i값을 리턴하게 되므로 0, 1, 2가 출력되게 됩니다.

결론

이렇게 해서 자바스크립트에서 난해하기로 악명높은 클로저에 대해 알아보았습니다. 제가 클로저를 공부하면서 느낀점은, 클로저라는 것은 내부 함수가 외부 함수의 렉시컬 환경을 참조한다는 어찌보면 당연한 현상입니다. 하지만 이 정의를 붙잡고 클로저를 이해하려고 하는것은 조금 돌아가는 길이라고 생각합니다. 결국 클로저를 활용하는 상황은 오히려 반대의 상황인것 같습니다. 오히려 외부 스코프에서 하위 스코프 환경에 참조하기 위해서 이 현상을 활용해서 중첩된 함수를 만드는 것 같다는 생각이 계속해서 들었습니다.

어쨌든 클로저는 함수가 외부 렉시컬 환경을 기억하는 현상에 의해 파생된 하나의 닫혀있는 스코프라는 점을 유념하시면 되겠습니다!

0개의 댓글