ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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 정의

    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 

     

Designed by Tistory.