-
JS 클로저Front-end 개발 2023. 8. 18. 08:34
멋사 프론트엔드 스쿨에서 1시간을 할애해서 이야기하고 넘어간 JS 면접 단골 질문 주제 클로저(Closures).
클로저가 무엇이고 어떻게 쓰이는 것이는지 감이 제대로 잡히지 않아서 더 찾아보고 공부한 내용을 정리한다.
아래 내용은 모던 자바스크립트 딥 다이브 스터디 영상을 보고 작성했다.
모던 자바스크립트 딥다이브 스터디 #5-1 (CH.24 클로저)
1. 요약
- JS 함수는 렉시컬 스코프를 가진다
: JS 엔진은 함수 호출 위치가 아닌 함수를 정의 위치를 기준으로 상위 스코프를 결정한다.- JS 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저다.
- 중첩 함수 중에 이미 생명 주기가 종료한 외부 함수의 변수를 참조한 경우 클로저(Closure) 라고 부른다.
- 클로저라고 부르지 않는 경우는 아래 두 가지 경우다.
(1) 상위 스코프의 변수를 참조하지 않을 때
(2) 클로저였지만 곧바로 소멸하는 중첩된 내부 함수
- 클로저는 상태(State, 참조 변수)가 변경되지 않도록 안전하게 유지(은닉)하고, 특정 함수로만 상태 변경을 하기 위해 사용한다.
2. 정의
2-1. MDN 에서 찾아본 Closures 정의
A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.
클로저는 다발로 묶여진 함수와 그 함수가 선언된 렉시컬 환경('둘러싸여진 상태의 참조')과의 조합이다.
바꿔 말하자면 클로저는 내부 함수로부터 외부함수에의 접근권한을 준다.
클로저는 함수 생성(선언) 시점에 언제나 생긴다.
* 렉시컬 환경: 특정 코드가 작성, 선언된 환경(장소)
2-2. 클로저의 정의
const x = 1; function outerFunc() { const x = 10; function innnerFunc() { console.log(x); // 10 } // innerFunc와 outerFunc 사이의 closure (oER, outer Environment Reference) innerFunc(); } // outerFunc와 전역컨텍스트 사이의 closure (oER) outerFunc();
- 자바스크립트 함수가 다이나믹 스코프가 아닌 렉시컬 스코프 (Lexical Scope) 를 가진다는 의미를 이해할 수 있는 코드
- 자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다.
const x =1; function foo() { const x = 10; bar(); } function bar() { console.log(x); } foo(); // 1 bar(); // 1
- 상위 스코프(렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저정할 참조값)에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정된다. 이것이 바로 렉시컬 스코프다.
- 함수가 정의된 환경(위치)과 호출되는 환경(위치)은 다를 수 있다. 따라서 렉시컬 스코프가 가능하려면 함수는 자신이 호출되는 환경과는 상관없이 자신이 정의된 환경. 즉 상위 스코프(함수 정의가 위치하는 스코프)를 기억해야 한다.
- 이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경. 즉 상위 스코프의 참조를 저장한다.
- 스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다. 이 렉시컬 환경은 자신의 "외부 렉시컬 환경에 대한 참조(outer Lexical Environment Reference)"를 통해 상위 렉시컬 환경과 연결된다. 이것이 바로 스코프 체인이다.
- 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저(Closure)라고 부른다.
const x = 1; function outer() { const x = 10; const inner = function() { console.log(x); } } // outer 함수를 호출하면 중첩 함수 inner를 반환한다. // 그리고 outer 함수의 실행 컨텍스트는 실행 컨텐스트 스택에서 팝되어 제거된다. const innerFunc = outer(); // inner() {...} innerFunc(); // 10 // innerFunc 는 자신의 내부 슬롯 [[Environment]] 에 outer 의 Lexical Environment 가 담김 // outer 가 호출되고 innerFunc 이 반환되었을 때, x는 outer 의 L.E 을 참조하게 됨 (oER)
- outer 함수의 실행 컨텐스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 랙시컬 환경까지 소멸하는 것은 아니다. outer 함수의 렉시컬 환경은 inner 함수의 [[Environment]] 내부 슬록에 의해 참조되고 있고 inner 함수는 전역 변수 innerFunc 에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않기 때문이다. 가비지 컬렉터는 누군가 참조하고 있는 메모리 공간을 함부로 해제하지 않는다.
- 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저다. 하지만 일반적으로 모든 함수를 클로저라고 하지 않는다. 특별한 현상이 일어날 때문만 국한하여 클로저라고 명명하는데, 그것이 위 예제와 같이 외부 함수의 실행 컨텐스트가 종료 됬음에도 불구하고, 내부 함수가 외부 함수의 변수를 여전히 참조할 수 있는 현상이 있을 때 클로저라고 한다.
- 아래 두 가지 경우에서는 클로저라고 하지 않는다.
(1) 상위 스코프의 변수를 참조하지 않을 때
(2) 클로저였지만 곧바로 소멸하는 중첩된 내부 함수
function foo() { const x = 1; const y = 2; // 일반적으로 클로저라고 하지 않는다. function bar() { const z =3; debugger; // 상위 스코프의 식별자를 참조하지 않는다. console.log(z); } return bar; } const bar = foo(); bar();
function foo() { const x = 1; // bar 함수는 클로저였지만 곧바로 소멸한다. // 이러한 함수는 일반적으로 클로저라고 하지 않는다. function bar() { debugger; // 상위 스코프의 식별자를 참조한다. console.log(x); } bar(); } foo();
- 아래와 같은 경우를 모두 만족하는 경우에 한정하여 클로저라고 부른다.
(1) 중첩 함수가 상위 스코프의 식별자를 참조하고 있을 것
(2) 중첩 함수가 외부 함수보다 더 오래 유지되는 경우
function foo() { const x = 1; const y = 2; // 클로저 // 중첩 함수 bar는 외부 함수보다 더 오래 유지되며 상위 스코프의 식별자를 참조한다. function bar() { debugger; // 상위 스코프의 식별자를 참조한다. console.log(x); } return bar; } const bar = foo(); bar();
- 클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수(free variable) 라고 부른다.
- 클로저(closure) 란 "함수가 자유 변수에 의해 닫혀 있다(Closed)"라는 의미다. (자유 변수에 묶여있는 함수)
- 모던 자바스크립트 엔지은 최적화가 잘 되어 있어서 상위 스코프의 식별자 중에서 기억해야 할 식별자만 기억한다.
- 클로저의 메모리 점유는 필요한 것을 기억하기 위한 것이므로 이는 걱정할 대상이 아니다.
3. 클로저의 활용
3-1. 클로저 활용의 핵심 개념
- 클로저는 상태(state)를 안전하게 변경하고 유지하기 위해 사용한다.
- 클로저는 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉(information hiding)하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.
// 카운트 상태 변수 let num = 0; // 카운트 상태 변경 함수 const increase = function() { // 카운트 상태를 1만큼 증가시킨다. return ++num; }; console.log(increase()); //1 console.log(increase()); //2 num = 1; // 중간에 카운트 상태 변수가 변경 되었다. console.log(increase()); //2
- 카운트 상태(num 변수의 값)는 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 한다.
- 이를 위해 카운트 상태(num 변수의 값)는 increase 함수만이 변경할 수 있어야 한다.
// 카운트 상태 변경 함수 const increase = (function() { // 카운트 상태 변수 let num = 0; // 클로저 return function () { // 카운트 상태를 1만큼 증가시킨다. return ++num; } }()); console.log(increase()); //1 console.log(increase()); //2 console.log(increase()); //3
- increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저다.
- 즉시 시행할 함수는 호출된 이후 소멸되지만 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출된다. 이 때 즉시 시행할 함수가 반환한 클로저는 자신이 정의한 위치에 의해 결정된 상위 스크포트인 즉시 실행함수의 렉시컬 환경을 기억하고 있다.
- 따라서 즉시 실행 함수가 반환한 클로저는 카운트 상태가 유지하기 위한 자유 변수 num을 언제 어디서 호출하든지 참조하고 변경할 수 있다.
- 즉시 실행 함수는 한 번만 실행되므로 increase가 호출될 때마다 num 변수가 재차 초기화될 일은 없다. 또한 num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문에 더 안정적인 프로그래밍이 가능하다.
3-2. 두가지 이상의 기능을 Object 형태로 클로저에 담기
- 아래 코드와 같이 클로저인 메서드를 갖는 객체를 반환하여 새로운 기능을 추가할 수 있다.
// 카운트 상태 변경 함수 const counter = (function() { // 카운트 상태 변수 let num = 0; // 클로저인 메서드를 갖는 객체를 반환한다. // 객체 리터럴은 스코프를 만들지 않는다. // 따라서 아래 메서드들의 상위 스코프는 즉시 실행 함수의 렉시컬 환경이다. return { // num: 0, // 프로퍼티는 public 하므로 은닉되지 않는다. increase() { return ++num; }, decrease() { return num > 0 ? --num : 0; } }; }()); console.log(counter.increase()); //1 console.log(counter.increase()); //2 console.log(counter.decrease()); //1 console.log(counter.decrease()); //0
- 객체 리터럴의 중괄호는 코드 블록이 아니므로 별도의 스코프를 생성하지 않는다.
- 위 예제의 increase, decrease 메서드의 상위 스코프는 실행 중인 실행 컨텍스트인 즉시 실행 컨텍스트의 렉시컬 환경이다.
- 따라서 increase, decrease 메서드는 언제 어디서 호출되든 상관없이 즉시 실행 함수의 식별자를 참조할 수 있다.
- 변수 num 은 여전히 외부로 노출되지 않으며, increase, decrease 메서드를 활용해 수정할 수 있다.
3-3. 두가지 이상의 기능을 생성자 함수를 사용해 클로저에 담기
- 생성자 함수란(constructor function) 아래와 같은 관례를 따른다.
(1) 생성자 함수와 일반 함수에 기술적인 차이는 없다.
(2) 함수 이름의 첫 글자는 대문자로 시작한다.
(3) 반드시 'new' 연산자를 붙여 실행한다.
- 생성자 함수로 정의된 변수 const Counter 에는 Counter() 함수가 담긴다.
- Counter() 함수에는 아래 정의되어 있는 프로토타입의 정보가 들어가 있으며,
- 클로저로써 프로토타입 함수는 변수 num을 참조하여 num 은 외부로 노출되지 않고 생성자 함수만이 사용 가능하다.const Counter = (function () { // 카운트 상태 변수 let num = 0; function Counter() { // this.name = 0; // 프로퍼티는 public 하므로 은닉되지 않는다. } Counter.prototype.increase = function () { return ++num; }; Counter.prototype.decrease = function () { return num>0 ? --num : 0; }; return Counter; }()); const counter = new Counter(); console.log(counter.increase()); //1 console.log(counter.increase()); //2 console.log(counter.decrease()); //1 console.log(counter.decrease()); //0
- 그리고 카운터를 console.dir() 로 실행해보면, 자기 자신에겐 아무것도 없고 프로토타입 체이닝으로 클로저인 increase(), decrease()만 노출된다.
3-4. 함수형 프로그래밍에서 클로저의 활용
- 변수 갑은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적인 원인이 될 수 있다.
- 상태 변경이나 가변(mutable) 데이터를 피하고 불변성(immutablility)을 지향하는 함수형 프로그래밍에서 부수효과를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저를 적극적으로 사용한다.
- 여기서 부수효과(side effects)란 함수가 만들어진 목적과는 다른 효과 또는 부작용이다.
- 아래 예제의 makeCounter 는 보조 함수를 넘겨주고 counter 를 참조해서 연산한다.
- 이 함수는 함수를 인자로 전달받고 함수를 반환하는 고차 함수로, 카운트 상태를 유지하기 위한 자유 변수 counter 를 기억하는 클로저를 반환한다.
// 함수를 인자로 전달받고 함수를 반환하는 고차 함수 // 이 함수는 카운트 상태를 유지하기 위한 자유 변수 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()); // 0
- decreaser 는 makeCounter 를 다시 처음부터 실행시키기 때문에 변수 counter는 다시 0에서부터 시작한다.
- 결과적으로 decreaser 는 increaser 함수와 카운터 상태가 연동되지 않는다. (처음 개발 의도와 다르다)
- increaser 와 decreaser 상태를 연동하기 위해서는 makeCounter 가 한번만 실행되도록 즉시 실행함수를 활용하여 해결할 수 있다.
// 함수를 반환하는 고차 함수 // 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter 를 기억하는 클로저를 반환한다. count counter = (function () { // 카운트 상태를 유지하기 위한 자유 변수 let counter = 0; // 함수를 인수로 전달받는 클로저를 반환 return function (predicate) { // 인수로 전달 받은 보조 함수에 상태 변경을 위임한다. counter = predicate(counter); return counter; }; }()); // 보조 함수 function increase(n) { return ++n; } // 보조 함수 function decrease(n) { return --n; } // 보조 함수를 전달하여 호출 console.log(counter(increase)); // 1 console.log(counter(increase)); // 2 // 자유 변수를 공유한다. const decreaser = makeCounter(decrease); console.log(counter(decrease)); // 1 console.log(counter(decrease)); // 0
4. 결론
오늘 클로저를 공부하면서 실행 컨텍스트와 엮여 설명된 부분이 정말 많았다. 모르는 부분에 대해서는 일단 훓고 지나가는 바람에 설명글은 대부분 책을 복붙한게 되었다. 모던 자바스크립트 딥 다이브의 실행 컨텍스트, 프로토타입에 대해 공부를 하면 좀 더 심도 깊은 이해가 가능할 것 같다.
출처: https://www.youtube.com/watch?v=pTVbFD5kpOI&list=PLjQV3hketAJnP_ceUiPCc8GnNQ0REpCqr&t=27s
'Front-end 개발' 카테고리의 다른 글
[멋쟁이사자처럼] 주말 JS 기초 특강 코드 모음 - 230819 (0) 2023.08.19 [멋쟁이사자처럼] 프론트엔드스쿨 7기 - 31일차 기록 및 복습 (JSON, DOM) (0) 2023.08.18 [멋쟁이사자처럼] 프론트엔드 스쿨 7기 - 29일차 기록 및 복습 (0) 2023.08.16 [멋쟁이사자처럼] JS 실무자 노하우 코드 DB - typeof, isNaN, sort (0) 2023.08.08 [이력서] 직무(JD)분석 - 캐치테이블 (2) 2023.08.05