오늘 알아볼 것은 JavaScript의 클로저(Closure)이다. 자바스크립트를 공부하면서 한번 쯤은 들어봤을만한 중요한 개념이지만, 사실 클로저는 자바스크립트의 고유 개념이 아니라 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.
함수형 프로그래밍(Functional Programming)?
함수형 프로그래밍이란, 프로그램을 메소드와 변수의 묶음인 객체의 집합으로 바라보는 객체 지향형 프로그래밍과 다르게 자료처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임이다. 함수형 프로그래밍은 부작용(부산물)이 없고 순수함수를 통해 입출력이 순수해야 한다는 특징이 있다. 자바스크립트는 this라는 개념때문에 순수함수를 사용하기 힘들고, 함수형 패러다임을 기반으로 하면서 객체지향의 문법을 쓰는 독특한 언어라는 특징이 있다.
즉, 자바스크립트는 멀티 패러다임 언어이기 때문에 순수 함수형 프로그래밍 언어라고 보기에는 힘들다. 함수형 프로그래밍 언어에는 얼랭(Erlnag), 스칼라(Scala), 하스켈(Haskell), 리스프(Lisp)등이 있다. 그러나 자바스크립트에서도 클로저는 중요한 개념이기 때문에 한번 짚고 넘어가야 할 필요성이 있다.
우선, MDN에서 정의한 클로저를 한번 살펴보자.
“A closure is the combination of a function and the lexical environment within which that function was declared.”
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.
MDN은 항상 어려운 말 투성이다. 친절하게 풀어서 설명 좀 해주지.. 하지만 징징거릴 시간에 내가 풀어서 공부해야 한다. 학습에는 끝이 없는 법!
우선 함수와 그 함수가 선언됐을 때의 렉시컬 환경, 이 렉시컬 환경이라는 단어에 집중해야 한다. 렉시컬 환경이란, 식별자와 식별자에 바인딩된 값, 그리고 상위 스코프에 대한 참조를 기록하는 자료구조로 실행 컨텍스트를 구성하는 컴포넌트다. 여기서 실행 컨텍스트란, 식별자를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 메커니즘을 말한다. 실행 컨텍스트가 식별자와 스코프를 관리할 때 사용하는 컴포넌트가 바로 렉시컬 환경인 것이다.
const x = 'Kyeom';
function outerFunc() {
const x = 'Kim';
function innerFunc() {
console.log(x); // 'Kim'
}
innerFunc();
}
outerFunc();
위 예시를 살펴보면, outerFunc 함수 내부에서 innerFunc가 정의되고 호출되었다. 이때 중첩 함수 innerFunc의 상위 스코프는 외부 함수 outerFunc의 스코프이기 때문에 innerFunc 내부에서 자신을 포함하고 있는 외부 함수 outerFunc의 x 변수에 접근할 수 있다.
이 같은 현상이 발생하는 이유는 자바스크립트가 렉시컬 스코프를 따르는 프로그래밍 언어이기 때문이다.
렉시컬 스코프란, 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 자바스크립트 엔진이 상위 스코프를 결정하는 것을 뜻한다.
즉, 함수를 어디서 호출하는지는 함수의 상위 스코프 결정에 어떠한 영향도 주지 못하며, 함수의 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정되고 변하지 않는다.
스코프의 실체는 위에서 설명한 것 처럼 실행 컨텍스트의 렉시컬 환경인데, 렉시컬 환경은 자신의 "외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference)"를 통해 상위 렉시컬 환경과 연결된다. 이것이 바로 스코프 체인이다.
렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장할 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의되는 위치에 의해 결정된다. 이것이 바로 렉시컬 스코프이다.
클로저를 알아보자고 했는데 계속 렉시컬 환경만 설명하는 이유는 뭘까? 다음 예제를 살펴보면서 한번 알아보도록 하자.
const x = 1;
// (1)
function outer() {
const x = 10;
const inner = function () { console.log(x); }; // (2)
return inner;
}
// outer 함수를 호출하면 중첩 함수 inner 반환
// outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거
const innerFunc = outer(); // (3)
innerFunc(); // (4) 10
위 예제의 3번에서 outer 함수는 호출되면서 중첩 함수 inner를 반환하고 실행 컨텍스트가 실행 컨텍스트 스택에서 제거되며 라이프 사이클을 마감하게 된다. 이때 outer 함수의 지역 변수 x와 변수 값 10을 저장하고 있던 실행 컨텍스트가 제거되었기 때문에 outer 함수의 지역 변수 x는 더는 유효하지 않게 되어 접근할 수 없어 보인다.
하지만 4번을 보게 되면 실행 결과가 outer 함수의 지역 변수 값인 10이 나오게 된다. 이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 라이프 사이클이 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저(Closure)라고 부른다.
자바스크립트의 모든 함수는 자신의 상위 스코프를 기억하고, 모든 함수가 기억하는 상위 스코프는 함수를 어디서 호출하든 상관없이 유지된다. 따라서 함수를 어디서 호출하든 상관없이 함수는 언제나 자신이 기억하는 상위 스코프의 식별자를 참조할 수 있으며 식별자에 바인딩 된 값을 변경할 수도 있다.
위 예제에서 outer 함수가 평가되어 함수 객체를 생성할 때(1),실행 컨텍스트는 outer 함수 객체의 내부 슬롯에 전역 렉시컬 환경을 상위 스코프로서 저장한다. 그리고 중첩 함수 inner가 평가될 때(2), inner 역시 outer 함수의 렉시컬 환경을 상위 스코프로서 저장한다.
outer 함수의 실행이 종료되면 inner 함수를 반환하면서 outer 함수의 라이프 사이클이 종료되며 실행 컨텍스트가 실행 컨텍스트 스택에서 제거되지만 (3), 이때 outer 함수의 렉시컬 환경까지 소멸되는 것은 아니다. outer 함수의 렉시컬 환경은 inner 함수에 의해 참조되고 있고 inner 함수는 전역 변수 innerFunc에 의해 참조되고 있으므로 가비지 컬렉션의 대상이되지 않기 때문이다.
outer 함수가 반환한 inner 함수를 호출(4)하면 inner 함수의 실행 컨텍스트가 생성되고 실행 컨텍스트 스택에 푸시되게 된다. 그리고 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 inner 함수 내부에 저장되어 있는 참조값(outer 함수의 렉시컬 환경)이 할당되게 된다.
이렇듯 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저라고 할 수 있지만, 일반적으로 모든 함수를 클로저라고 하지는 않는다. 클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에만 한정하는 것이 일반적이다.
클로저는 상태(state)를 안전하게 변경하고 유지하기 위해 사용한다. 다시 말해, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.
// 카운트 상태 변수
let num = 0;
// 카운트 상태 변경 함수
const increase = function () {
// 카운트 상태 1만큼 증가
return ++num;
};
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
위 예제는 바르게 동작하려면 다음의 두 가지 전제 조건이 지켜져야 한다.
카운트 상태(num 변수의 값)은 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 한다.
이를 위해 카운트 상태는 increase 함수만이 변경할 수 있어야 한다.
하지만 카운트 상태는 전역 변수를 통해 관리되고 있는 암묵적 결합 상태이기 때문에, 누구나 접근할 수 있고 언제든지 변경될 수 있다. 따라서 카운트 상태를 안전하게 변경하고 유지하기 위해서는 increase 함수만이 num 변수를 참조하고 변경할 수 있게 해야한다.
const increase = function() {
let num = 0;
return ++num;
};
console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1
위 예제는 전역 변수 num을 increase 함수의 지역 변수로 변경하여 의도치 않은 상태 변경을 방지했지만, increase 함수가 호출될 때마다 지역 변수 num이 다시 선언되고 0으로 초기화되기 때문에 상태가 변경되기 이전 상태를 유지하지 못한다. 이때 사용해야 할 것이 바로 클로저이다.
const increase = (function () {
let num = 0;
// 클로저
return function () {
return ++num;
};
}());
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
위 코드가 실행되면 즉시 실행 함수가 호출되고 즉시 실행 함수가 반환한 함수가 increase 변수에 할당된다.
즉시 실행 함수(IIFE)
즉시 실행 함수는 정의되자마자 즉시 실행되는 함수를 말한다.
(function () { console.log("IIFE"); })();
와 같이 소괄호로 함수를 감싸서 실행하는 문법을 사용한다. 즉시실행함수는 선언과 동시에 호출되어 반환되어 재사용 할 수 없기 때문에 익명 함수로 사용하는 것이 일반적이다.
increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저다. 즉시 실행 함수는 호출된 이후 소멸되지만 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출된다. 이때 즉시 실행 함수가 반환한 클로저는 즉시 실행 함수의 렉시컬 환경을 기억하고 있기 때문에, 카운트 상태를 유지하기 위한 자유 변수 num을 언제 어디서 호출하든지 참조하고 변경할 수 있다.
즉시 실행 함수는 한 번만 실행되므로 increase가 호출될 때마다 num 변수가 재차 초기화 될 일도 없고, num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수와 같이 의도되지 않는 변경을 걱정할 필요도 없기 때문에 더 안정적인 프로그래밍이 가능하다.
이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용한다.
사실 이 부분은 예전에 모던 자바스크립트 딥 다이브를 통해 공부하면서 velog에 기록했던 부분이다. 하지만 그때는 거의 받아쓰기 수준으로 이해하지 못하고 적어 놓기만 했었는데, 이번에 다시 짚어보며 공부해보니 클로저에 대해 이해할 수 있게 되었다. 이번에도 모던 자바스크립트 딥 다이브를 참고하며 작성하긴 했지만.. 실무에서 데이터 은닉화나 상태변경 방지를 위해 꼭 알아두어야 할 개념이니 이번 기회를 통해 확실히 짚고 넘어가보자!