JavaScript | 24장 클로저 (Closure)

설탕·2024년 6월 6일
0
post-thumbnail

클로저(closure)란?

MDN에서는 클로저를 함수와 함수가 선언된 렉시컬 환경의 조합으로 정의한다.

외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저closure라고 부른다.

자바스크립트는 렉시컬 스코프를 따른다. 렉시컬 환경의 “외부 렉시컬 환경에 대한 참조”에 저장할 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경에 의해 결정된다.

const x = 1;

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

const innerFunc = outer();
innerFunc(); // 10

위 예제에서 inner 함수는 자신이 평가될 때 자신이 정의된 위치에 의해 결정된 상위 스코프, 즉 outer 함수의 렉시컬 환경을 함수 객체의 내부 슬롯 [[Environment]]에 저장한다. 이때 저장된 상위 스코프는 함수가 존재하는 한 유지된다.

outer 함수를 호출하면 outer 함수는 중첩 함수 inner를 반환하고 생명 주기를 마감한다. 즉, outer 함수의 실행이 종료되면 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다. 이때 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아니다.

outer 함수의 렉시컬 환경은 inner 함수의 [[Environment]] 내부 슬롯에 의해 참조되고 있고 inner 함수는 전역 변수 innerFunc에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않기 때문이다. 가비지 컬렉터는 누군가가 참조하고 있는 메모리 공간을 함부로 해제하지 않는다.

outer 함수가 반환한 inner 함수를 호출하면 inner 함수의 실행 컨텍스트가 생성되고 실행 컨텍스트 스택에 푸시된다. 그리고 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 inner 함수 객체의 [[Environment]] 내부 슬롯에 저장되어 있는 참조값이 할당된다.

중첩 함수 inner는 외부 함수 outer보다 더 오래 생존했다. 이때, 외부 함수보다 더 오래 생존하는 중첩 함수는 외부 함수의 생존 여부와 상관없이 자신이 정의된 위치에 의해 결정된 상위 스코프를 기억한다. 상위 스코프를 참조할 수 있으므로 상위 스코프의 식별자를 참조할 수 있고 식별자의 값을 변경할 수도 있다.

inner 함수와 같이 (1) 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 (2) 중첩 함수가 외부 함수보다 더 오래 유지되는 경우 그 중첩 함수를 클로저라고 한다.

클로저를 사용하는 이유

클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하여, 상태를 안전하게 변경하고 유지하기 위해 사용한다.

const counter = (function () {
  let num = 0;
  
  return {
    increase() {
      return ++num;
    },
    decrease() {
      return --num;
    }
  };
}());

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

console.log(counter.decrease()); // 1
console.log(counter.decrease()); // 0

위 예제에서 increase, decrease 함수는 즉시 실행 함수(IIFE)의 식별자(num)를 참조하는 클로저다. num 상태는 increase, decrease 함수로만 변경할 수 있고 외부에서 직접 접근하거나 변경할 수 없는 private 변수이므로, 의도되지 않은 변경을 걱정할 필요 없이 안전하게 유지된다.

클로저 예제

클로저 예제 1: var 사용 시 주의

아래 콘솔 출력 결과는 어떻게 될까?

let funcArr = [];

for (var i = 0; i < 5; i++) {
  var num = i * 2;
  funcArr.push(() => console.log(num));
}

funcArr.forEach(fn => fn());

var 키워드로 선언한 변수는 블록 레벨 스코프가 아닌 함수 레벨 스코프를 따르기 때문에 여기서 for문은 지역 스코프로 인정되지 않는다. 즉, var 키워드로 선언한 num은 전역 변수이므로 num에는 for문 반복이 끝난 값인 8이 할당되어 8 8 8 8 8이 출력된다.

클로저를 이용해서 0 2 4 6 8이 출력되도록 바꿔보자.

let funcArr = [];

for (var i = 0; i < 5; i++) {
  (function() {
    var num = i * 2;
    funcArr.push(() => console.log(num));
  })();
}

funcArr.forEach(fn => fn()); // 0 2 4 6 8

즉시 실행 함수(IIFE)로 감싸주면 var 키워드로 선언한 num은 함수 레벨 스코프에 따라 즉시 실행 함수의 지역 변수가 된다. funcArr에 푸시되는 함수는 즉시 실행 함수 내부의 중첩 함수로서, 즉시 실행 함수를 "외부 렉시컬 환경에 대한 참조"에 저장하는 클로저다. 따라서 반복문 루프가 돌 때마다 각각의 렉시컬 환경에 등록되는 num 변수를 참조하므로 0 2 4 6 8이 출력된다.

클로저 예제 2: setTimeout 함수와 var vs let

function fn(){
  for (var i = 0; i < 10; i++) {
    setTimeout(function() {
      console.log(i);
    }, 0);
  }
}

fn(); // 10 10 10 10 10 10 10 10 10 10

setTimeout 함수가 호출되면 setTimeout 함수의 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 된다. 타이머가 만료되면 setTimeout 함수의 콜백 함수는 태스크 큐에 푸시되어 대기하다가 콜 스택이 비었을 때, 즉 전역 코드 실행이 종료되었을 때 호출된다.

이때 setTimeout 함수의 콜백 함수는 fn 함수 스코프를 "외부 렉시컬 환경에 대한 참조"에 저장하는 클로저다. 즉 i 변수는 var 키워드로 선언되어 fn 함수의 렉시컬 환경에 등록된 i를 참조하는데, 이 i 변수에는 반복문이 종료되고 10이 할당되므로 10이 10번 출력된다.

0부터 9까지 출력하려면 var 키워드를 let 키워드로 바꾸면 된다.

function fn(){
  for (let i = 0; i < 10; i++) {
    setTimeout(function() {
      console.log(i);
    }, 0);
  }
}

fn(); // 0 1 2 3 4 5 6 7 8 9

마찬가지로 setTimeout 함수의 콜백 함수는 fn 함수의 중첩 함수로서 i를 참조하는 클로저인데, 이번에 i 변수는 let 키워드로 선언되었으므로 블록 레벨 스코프를 따른다. 따라서 반복문 루프마다 독립적인 렉시컬 환경이 생성되어 각각의 i 값을 참조하므로 0부터 9까지 출력된다.

클로저 예제 3: React useState 직접 구현하기

클로저를 이용해서 React의 useState 훅을 비슷하게(실제로는 더 복잡하지만 여기서는 간단하게) 구현할 수 있다.

useState 훅의 주요 특징은 클로저의 주요 특징과 유사하다.

  1. 컴포넌트가 리렌더링되어도 이전 state 값을 기억한다.
  2. setState 이외 다른 함수로는 state 값을 변경할 수 없다. (정보 은닉)
const MyReact = (function() {
  let state;

  return {
    useState(initialValue) {
      if (state === undefined) state = initialValue;

      function setState(newValue) {
        state = newValue;
      }

      return [state, setState];
    }
  };
})();

function Counter() {
  const [count, setCount] = MyReact.useState(0);
  
  return {
    click() {
      setCount(count + 1);
    },
    render() {
      return `count: ${count}`;
    }
  }
}

Counter().render(); // 'count: 0'
Counter().click();
Counter().render(); // 'count: 1'
Counter().click();
Counter().render(); // 'count: 2'
Counter().click();
Counter().render(); // 'count: 3'
  1. state 값을 기억하기 위해 외부 함수인 MyReact에서 state를 선언한다. useState 함수는 즉시 실행 함수 MyReact의 자유 변수인 state 값을 참조하는 클로저이므로, MyReact 실행 컨텍스트가 종료되어도 state 값은 사라지지 않고 계속 기억할 수 있게 된다.
  2. setState 함수 또한 즉시 실행 함수 MyReact의 자유 변수인 state 값을 참조하는 클로저이다. state 변수는 오로지 setState 함수에 의해서만 변경될 수 있으므로 외부에서의 변경으로부터 안전하다.

참고
[JS]클로져(closure)와 클로져의 사용 예제
Part 12: setTimeout + Closures Interview Questions 🤓
React 의 useState와 closure

profile
공부 기록

0개의 댓글