클로저를 살펴보자!

8939 단어 closureclosure

🌈 시작하며

💡 삐빅! 아주~ 난해한 개념 중 하나입니다!

저는 옛날부터 되게 클로저가 싫었어요. 사실 그냥 괄호 하나 더 붙인다~의 느낌으로만 알았었죠.

왜냐하면, 그때는 '구현'을 '원리'보다 우선했기 때문이었습니다.
그런데 결국에 어느 순간 '구현'이 '원리'보다 우선되는 시점이 오더라구요.

그런 순간에, 클로저를 언젠가 포스팅해야겠다고 다짐했는데! 오늘이 됐네요 😊
좋은 원리 하나 알아가서 기분이 홀가분합니다. 그럼, 시작해봅시다!


💬 본론

클로저는 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성입니다.

여기서 일단, 알 수 있는 것은 JS에서만 쓰이는 것이 아니라는 것이죠. 클로저를 이해한다면, 지원하는 다른 언어에서도 충분히 사용할 수 있습니다!


일단 클로저를 이해하려면 선행되는 것이 있어요.
그것은 바로, 실행 컨텍스트의 렉시컬 환경입니다. 다음 예제를 살펴볼게요.

const x = 1;

function outerFunc() {
    const x = 10;

    function innerFunc() {
        console.log(x); // 10
    }
    innerFunc();
}

outerFunc();

다음 결과는 10입니다.

💬 왜 10이 나오죠?!

10이 출력될 수 있는 이유는 렉시컬 스코프 때문인데, 이는 함수를 어디서 정의했는지에 따라 상위 스코프를 결정하는 JS 구조 때문입니다.

즉 실행 시점에서 유동적으로 변하는 것이 아니라, 어떤 스코프 구조를 결정하는 것은 정적인 체계에서 진행된다는 것!

그리고 이러한 상위 스코프는 [[Environment]] 내부 슬롯에 의해 참조된 위치를 저장하는 것이죠!

따라서 정리를 한 번 해봅시다.

  • 함수가 정의된 환경(위치)에 의해 상위 스코프 참조를 결정하고
  • 이는 내부슬롯에 의해 참조되어 연결된다는 점.

이 두 가지 (어떻게 보면 한 가지)과정을 꼭 기억해야 해요. 여기서부터 클로저가 출발하기 때문이죠.


그렇다면, 출발해봅시다!
다음 예제를 살펴봅시다.

const x = 1;

function outer() {
    const x  = 10;
    const inner = function() { console.log(x); }
    return inner;
}

const innerFunc = outer();
innerFunc(); // 10
함수를 반환하고 실행 컨텍스트 스택에서 제거 되면, 지역 변수 x는 유효하지 않다. 그런데 innerFunc를 호출한 값은 10이다!

우리가 기존에 생각했던 거와는 좀 다르지 않나요!

실행컨텍스트 측면에서 살펴본다면,
1. outer()을 실행 컨텍스트 스택에 추가
2. inner()을 실행 컨텍스트 스택에 추가
3. outer()가 종료되면 innerinnerFunc에 반환, 함수 종료
4. 그러면 inner을 실행하는데, 현재 outer의 지역 변수는 함수 실행 컨텍스트가 종료됨. 그러면 1을 넣어야 되지 않나...?
5. 그런데 결과는 10 도출

이상하지 않나요! 그런데 이게 실제로 일어났습니다!
이렇게 난해한 게 바로 클로저의 특성입니다.
이러한 이유가 가능한 이유는 다음과 같은 실행 컨텍스트의 특성 때문이에요.

외부 함수보다 중첩 함수가 더 오래 유지되는 경우, 중첩 함수는 생명주기를 종료한 외부 함수의 변수를 참조합니다.

우리가 여태까지 기억하던 것은, 실행 컨텍스트가 끝나면, 스택에서 제거된다는 거였습니다. 물론, 함수가 제 역할을 다하면, 실제로 스택에서 제거됩니다. 그런데 클로저 동작의 핵심 포인트는

실행 컨텍스트 스택에서는 제거되지만, 누군가가 특정 렉시컬 환경을 참조하고 있을 시에는 렉시컬 환경 자체가 소멸하는 것은 아니라는 점이죠!

그렇다면 위의 과정을 클로저를 접목하여, 다시 해석해봅시다.

  1. 해당 반환된 함수가 다시 실행 컨텍스트에 올려질 때, outer함수는 inner함수의 [[environment]] 내부 슬롯에 참조
  2. 또한 전역 컨텍스트에서 inner함수는 innerFunc에서 참조되
  3. 어떤 변수에 의해 참조될 때에는 가비지 컬렉션의 대상에 포함 X
  4. 따라서 메모리에 남겨지고, inner은 실행 컨텍스트가 유지되는 동안 [[environment]] 내부 슬롯으로부터 상위 스코프를 계속해서 참조

💡 결국에는 누군가가 참조하고 있기 때문에 가비지 컬렉션 대상이 아니며, 내부 슬롯에 의해 이어졌기에 클로저가 가능합니다!

그러면 모든 함수는 클로저일까요?
이론적으로는 그렇습니다. 하지만 사실은 그렇지 않습니다.

function foo() {
    const x = 1;
    const y =  2;
    function bar() {
        const z = 3;

        debugger;
        // 상위 스코프의 식별자를 참조 X
        console.log(z);
    }
    return bar;
}

const bar = foo();
bar();

위 예제는 클로저가 아닙니다. 그 이유는 상위 스코프의 어떤 변수를 참조하지 않기 때문이죠.

만약 foo()bar을 반환하면, 이때에는 foo()의 렉시컬 환경을 계속 갖고 있는 것은 메모리 낭비라고 판단하게 되는데요, 이때 외부 함수보다 중첩 함수의 생명 주기가 짧기 때문에 이는 클로저의 본질에 부합하지 않는 것이죠.

따라서 bar()은 자신의 실행 컨텍스트에서 알아서 실행하도록 하고, foo()는 종료합니다.

클로저의 정의를 정리해볼까요? (말에 운율이 느껴지네요!)

클로저는
1. 중첩 함수가 상위 스코프의 식별자를 참조하고 있고,
2. 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정한다.
3. 즉, 자유변수(클로저에 의해 참조되는 상위 스코프 변수)에 묶인 함수인 것이다!

클로저의 활용

우리의 클로저는 말이죠, 상태를 안전하게 변경하고, 유지하기 위해 사용할 수 있어요.
상태를 은닉, 특정 함수에게만 상태 변경을 시킬 수 있게 됩니다.

const increase = function() {
    let num = 0;

    return ++ num;
};

console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1

다음은 항상 1로만 출력될 수밖에 없습니다. 함수가 호출될 때마다 지역변수 num이 새롭게 선언되고 0으로 초기화되기 때문이죠.
(사실 너무 지극히 간단해서, 이하 내용은 생략하겠습니다.)

그런데, 다음을 봅시다. 상당히 어썸한 일이 일어난다구요! 👏

const increase = (function() {
    // 카운트 상태 변수
    let num = 0;

    // 클로저
    return function () {
        // 카운트 상태를 1만큼 증가
        return ++num;
    }
}());

console.log(increase());
console.log(increase());
console.log(increase());

과정을 살펴보면

  1. 즉시 실행 함수가 반환한 함수가 increase변수에 할당,
    해당 할당된 함수는 즉시 실행 함수이며, 여기서 반환된 함수가 또 increase변수에 할당 (반환값이니까요!)
  2. 그런데 이 반환한 클로저는 자신의 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 참조
  3. 참조하고 있기 때문에 상위 스코프인 즉시 실행 함수는 가비지 컬렉션의 대상에 포함되지 않아 렉시컬 환경이 남아 있는 상태!
  4. 그 상태에서 num은 자유변수이므로 계속해서 해당 렉시컬 환경을 참조 중인 클로저를 통해 변경이 가능
  5. 그래서 한 번하고 끝날 줄 알았는데... 3번이나 있네?!
    3번 끝날 때까지 num이 있는 렉시컬 환경은 종료가 되지 않는다. 따라서 1 2 3 이 출력.

결과적으로 이러한 과정을 통해 어떤 변수 등의 상태를 안전하게 보호하고, 은닉을 해줄 수 있게 되는 것이죠.

클로저의 활용 - 생성자 함수

다음과 같이 하면, num 프로퍼티를 은닉할 수 있습니다.

const Counter = (function () {
    // 1. 카운트 상태 변수
    let num = 0;

    function Counter() {
        //this.num = 0; // 이때의 property 는 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());
console.log(counter.increase());

console.log(counter.decrease());
console.log(counter.decrease());
console.log(counter.decrease());

함수형 프로그래밍에서의 클로저 활용

다음과 같이 처리하면, 독립된 렉시컬 환경을 갖게 되므로 자유변수 counter이 독립적으로 연산된다.

// 함수를 인자로 받고 함수를 반환하는 고차함수

function makeCounter(predicate) {
    let counter = 0;
    return function() {
        // 콜백이 참조된 상위 스코프의 counter 변경
        counter = predicate(counter);
        return counter;
    }
}

function increase(n) {
    return ++n;
}

function decrease(n) {
    return --n;
}

const increaser = makeCounter(increase);
console.log(increaser());
console.log(increaser());

// 독립된 렉시컬 환경을 가지므로 따로 처리됨.
const decreaser = makeCounter(decrease);
console.log(decreaser());
console.log(decreaser());
console.log(decreaser());

따라서 이를 해결하기 위해서는, makeCounter을 두 번 호출해서는 안 됩니다. 따라서 렉시컬 환경을 공유하는 클로저를 만들어야 하죠.

const 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));
console.log(counter(increase));
console.log(counter(decrease));
console.log(counter(decrease));
console.log(counter(decrease));

기타 - 흔히 발생하는 실수

var arr = [];

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

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

/* result
    3
    3
    3
*/

사실 다 비슷한데, 응용의 느낌이죠. 결국에 실수방지의 핵심은 스코프겠죠?!

  1. i는 var 키워드로 선언됐으므로 블록레벨 스코프가 아닌 함수레벨 스코프
  2. arr[i]의 값으로 익명함수를 넣음. 따라서 익명함수를 실행할 당시에 함수가 호출됨.
  3. 결과적으로 다른 for문을 통해 함수를 실행할 시점에서는 i는 3이 계산됨.

해결 방법

  1. 클로저로 만들어봅시다.
var arr = [];

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

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

/* result
    0
    1
    2
*/
  1. let 키워드를 생활화합시다. (함수레벨 -> 블록레벨 스코프)
var arr = [];

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

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

/* result
    0
    1
    2
*/
  1. 고차함수를 사용합니다.
const arr = Array.from(new Array(3), (_, i) => () => i);
arr.forEach(f => console.log(f()));

👏 마치며

저는 모던 자바스크립트 Deep Dive라는 책으로 이 내용을 살펴봤는데요,
확실히 기초가 좀 생긴다면 클로저를 충분히 이해할 만 한 듯합니다.

모든 개발 공부가 그렇듯이, 클로저도 특히나 기초가 중요한 것 같아요.
천천히 실행 컨텍스트부터 차근히 나간다면 충분히 친해질 수 있는 아이인 거 같습니다!

좋은 웹페이지 즐겨찾기