#closure, #javascript

클로져와 가까워지기

0. 인트로

클로져를 MDN에서 검색해보면

클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다.

라는 문장이 나옵니다. 무슨 뜻인지 단번에 받아들이기 어렵지 않나요?

솔직히 프로그래밍을 하면서 클로져를 활용해볼 일이 그렇게 자주 있지는 않은 것 같습니다.

하지만 클로져를 이해하는 것이 중요하다고 생각하는 이유는 클로져가 자바스크립트 엔진의 동작과 연관이 있기 때문입니다.

오늘은 클로져에 대해서 알아보도록하겠습니다.

1. 클로져란?

코드를 보면 어떤 상황을 클로져라 부르는지 쉽게 이해할 수 있습니다.

const outer = () => {
  const outerVariable = 'outer!'; // 1. outer 함수 안에 지역변수 outerVariable 선언

  const inner = () => {
    console.log(outerVariable); // 2. 바깥의 outerVariable을 참조해 console.log 출력
  };

  return inner; // 3. 바깥의 outerVariable을 참조해 console.log를 출력하는 함수를 반환
};

const fano = outer(); // 4. outer함수 호출 -> 변수 fano에 inner함수의 주소값이 저장됨

fano(); // 5. 'outer!'

이게 클로져의 전부입니다.

클로져는 어떤 함수(outer) 내부에 선언된 함수(inner)가 바깥 함수(outer)의 지역변수(outerVariable)를 참조하는 것이 함수(outer)가 종료된 이후에도 계속 유지되는 현상을 말합니다.

2. 클로져가 발생하는 이유

클로져가 무엇인지 말하기는 간단하지만, 이 현상이 왜 발생하는지 알기 위해서는 자바스크립트에 대해 좀 더 알 필요가 있습니다.

1. 스코프

image

스코프는 우리말로 기회, 범위입니다. 자바스크립트의 스코프는 범위를 뜻합니다.

function scopeA() {
  const dolharubang = '슈-욱';

  function scopeB() {
    const dolharubang = '슈슉';

    console.log(dolharubang);
  }
  scopeB(); // 슈슉
  console.log(dolharubang);
}

scopeA(); // 슈-욱
console.log(dolharubang); // reference error

scopeA를 호출 했을 때 슈슉이 아닌 슈-욱이 출력됐습니다. 이것은 scopeB 함수의 fano 변수 선언 및 할당 과정이 scopeA의 동작에 영향을 주지 않았다는 것입니다. 즉, scopeAscopeB가 고유한 스코프를 가지고 있다고 말할 수 있습니다.

모든 프로그래밍 언어는 코드를 한 줄씩 읽습니다. 그리고 이를 계산(앞으로는 평가한다고 하겠습니다.)해 메모리에 계산된 값을 저장(const dolharubang = '슈-욱')하거나, 특정한 동작을(console.log(dolharubang)) 합니다.

자바스크립트는 프로그램을 평가하기 전과 함수를 평가하기 전에 변수 선언함수 선언 정보를 미리 한 번 쭉 훑어서 수집합니다. (실행컨텍스트의 Environment Record를 수집하는 과정)

프로그램을 시작하기 전에 scopeA함수 선언 정보를 수집하고 한 줄씩 평가를 하다가, scopeA가 호출되는 시점에 scopeA 내부의 (문자열 슈-욱이 할당되는) dolharubangscopeB의 정보를 수집하는 것입니다. 이렇게 정보를 수집하고 다시 처음으로 돌아와 한 줄씩 평가를 시작하는데, 평가하는 코드줄에서 dolharubang에 값이 할당되거나, console.log(dolharubang)과 같이 사용될 때, 미리 수집해둔 정보를 가져와서 값을 새로 저장하고, 사용하는 것입니다.

그렇기 때문에 함수 호출 전에 담긴 정보로는 다른 함수 내부의 변수를 알 수 있는 방법이 없습니다. 그래서 각 함수는 고유한 스코프를 가지게 됩니다. (es6에서는 while, if, for문 같은 함수가 아닌 블록문에서도 새로 스코프를 만듭니다.) 이렇게 만들어진 스코프는 함수가 종료되면서 사라지게 됩니다. (참고: 호출 스택)

2. 스코프체인

스코프를 설명하면서 다른 함수 내부의 변수를 알 수 있는 방법이 없다고 말했는데 사실 이것은 반만 맞는 말입니다. 사실 스코프 내부에 선언된 함수의 식별자는 알 수 없지만, 스코프 바깥의 식별자는 알 수 있기 때문입니다.

function outer() {
  const outerVariable = 'outer!';

  function inner() {
    console.log(outerVariable);
  }

  inner(); // outer!
}

맨 처음 봤던 예제를 살짝 바꿨습니다. inner에서는 outerVariable이라는 변수가 선언되지 않았는데도 값을 잘 불러왔고, 그 값은 다른 함수인 outer의 변수입니다.

감이 오시나요? 자바스크립트는 스코프 내에 참조할 수 있는 변수나 함수가 존재하지 않으면 바깥의 스코프에서 식별자 정보를 찾습니다.

이것이 가능한 이유는, 앞서 말했던 함수 평가 이전에 쭉 훑는 과정(변수 선언, 함수선언 수집) 이외에 바깥 스코프에 대한 정보를 수집하는 과정도 있기 때문입니다 (실행컨텍스트의 outerEnvironmentReference에 정보가 담깁니다.)

function furtherOuter() {
  const furtherOuterVariable = 'further outer!';

  function outer() {
    function inner() {
      console.log(furtherOuterVariable);
    }

    inner(); // further outer!
  }
}

자바스크립트 엔진은 스코프 안에 참조하는 식별자 정보가 없다면 함수 평가 전에 수집했던 바로 바깥 스코프로 가서 식별자를 찾습니다. 바로 바깥 스코프에도 찾는 식별자가 없다면, 그 다음 스코프로 가서 찾고, 마지막엔 전역 스코프까지 가서 찾는데 이때도 존재하지 않는다면 참조에러 를 발생시킵니다. 이렇게 스코프가 체인처럼 연결 되어있는 것을 스코프체인이라고 합니다.

image

이때 주의해야할 것은 바로 바깥 스코프는 함수를 실행하는 시점의 바깥영역이 아닌 선언되는 시점의 바깥 스코프를 가리킨다는 것입니다. (렉시컬 스코프)

function scopeA() {
  const fano = '전환오'; // 선언 시점의 상위 스코프

  function scopeB() {
    console.log(fano);
  }

  return scopeB;
}

const scopeC = scopeA();

const fano = 'hwano jeon'; // 실행 시점의 상위 스코프(?)

scopeC(); // '전환오';

3. 자바스크립트의 함수는 1급 시민

이제 스코프체인으로 어떤 스코프가 갖고있지 않은 변수, 함수는 그 바깥 스코프를 참조한다는 것을 알았습니다. 클로져에 가까워지기 위한 마지막 개념이 하나 남았습니다. 그것은 1급 시민이라는 개념입니다.

이것은 자바스크립트만의 개념이 아닌 프로그래밍 언어 전반에 사용되는 개념입니다.

위키피디아에 따르면

로빈 포플스톤은 일급 시민을 구성하는 요소는 4개의 요구조건이 있다는 정의를 내렸다.

  1. 모든 요소는 함수의 실제 매개변수가 될 수 있다.
  2. 모든 요소는 함수의 반환 값이 될 수 있다.
  3. 모든 요소는 할당 명령문의 대상이 될 수 있다.
  4. 모든 요소는 동일 비교의 대상이 될 수 있다.

자바스크립트의 객체는 1급시민입니다.

const func = obj => {
  return obj; // 모든 요소는 함수의 반환 값이 될 수 있다.
};

const obj1 = func({ a: 1 }); // 모든 요소는 함수의 실제 매개변수가 될 수 있다.

const obj2 = { b: 1 }; // 모든 요소는 할당 명령문의 대상이 될 수 있다.

console.log(obj1 === obj2); // 모든 요소는 동일 비교의 대상이 될 수 있다.

자바스크립트의 함수는 곧 객체이므로, 함수 역시 1급 시민입니다.

4. 돌고돌아 클로져

스코프, 스코프체인, 1급시민. 위에서 알아본 세 가지 개념을 맨처음 보았던 코드를 다시 보면서 클로져를 설명하는데 적용해보겠습니다.

const outer = () => {
  const outerVariable = 'outer!'; // 1. 바깥 함수 outer의 스코프에 변수선언

  const inner = () => {
    console.log(outerVariable); // 2. 내부 함수 inner의 스코프에서 스코프체인을 타고 바깥 함수 스코프의 변수 참조
  };

  return inner; // 3. 1급 시민인 함수 inner를 바깥으로 반환
};

const fano = outer(); // 4.  fano에 inner함수의 주소값이 저장됨

fano(); // 5. outer함수 호출은 종료가 되어서 스코프가 사라져야 하지만 outerVariable은 여전히 잘 참조된다.

예제 코드의 5번째 주석에서 관찰할 수 있는 현상이 클로져입니다. 맨 처음의 정의를 다시 가져와보겠습니다.

클로져는 어떤 함수(outer) 내부에 선언된 함수(inner)가 바깥 함수(outer)의 지역변수(outerVariable)를 참조하는 것이 함수(outer)가 종료된 이후에도 계속 유지되는 현상을 말합니다.

outer 함수 바깥으로 반환된 inner함수가 outer 함수의 outerVariable 변수를 참조하기에 메모리에 outer의 스코프가 여전히 남아있는 것입니다.

3. 클로져를 응용할 수 있는 영역

이런 클로저를 어떻게 활용할 수 있을까요? 예제를 통해서 알아보도록 하겠습니다.

1. 함수를 여러 번 호출하면 상태가 연속적으로 유지되어야할 때

const counterCreator = () => {
  let value = 0;

  return {
    increase() {
      console.log(++value);
    },
    decrease() {
      console.log(--value);
    },
  };
};

const counter = counterCreator();

counter.increase(); // 1
counter.increase(); // 2
counter.decrease(); // 1

위와 같이 함수를 호출하면 이전 함수 호출 상태가 기억되길 바랄 때 사용할 수 있을 것입니다.

실제 사례로 프론트엔드 프레임워크인 React 의 hook API가 클로져를 통해서 구현되었습니다.

hook은 함수를 여러 번 호출하는 상황에서 데이터를 연속적으로 유지하는 기능입니다.

(다음 예제는 React가 생소하신 분은 넘어가셔도 좋습니다.)

const Counter = () => {
  const [value, setValue] = useState(0); // 이 hook함수가 클로져를 통해 구현되었습니다.

  return (
    <div>
      <p>{value}</p>
      <button onClick={() => setValue(value + 1)}>+</button>
      <button onClick={() => setValue(value - 1)}>-</button>
    </div>
  );
};

counter-example

상태value가 바뀌어 렌더링이 계속 일어남에 따라 Counter 함수가 여러 번 호출됩니다. 하지만 useState는 0이 아니라 이전 상태 value의 값을 유지하고 있습니다. 이는 useState 선언 시점의 바깥 변수에 0을 초기화한 다음, setValue로 해당 바깥 변수를 변경하는 것입니다. 다음 Counter가 호출되고 그 안의 useState가 다시 호출되면 변경된 바깥 변수를 value로 반환합니다.

2. 변수를 숨겨야할 때

  let value = 0;

  function increase() {
    console.log(++value);
  };

  function decrease() {
    console.log(--value);
  }

  function unknown() {
    value = -100000;
  }

  increase(); // 1
  unknown(); // value: -100000
  decrease(); // -100001

1번의 케이스는 전역변수로도 해결할 수 있긴 합니다.

하지만 전역변수는 로직이 복잡해질 시, 어디서 변경이 되는지 추적이 어려워져 가독성과 유지보수에 어려움을 겪게 합니다.

그러므로 함수클로저가 더 적절한 선택입니다.

const counterCreator = () => {
  let value = 0;

  return {
      increase() {
        console.log(++value);
      },
      decrease() {
        console.log(--value);
      },
    };
  };

const counter = counterCreator();

function unknown() {
  value = -100000;
}

counter.increase(); // 1
unknown(); // error: Uncaught ReferenceError: value is not defined
counter.decrease(); // 정상적으로 진행된다면 0

위와 같이 클로저로 함수를 만들면 외부에서 변수에 접근시 레퍼런스 에러가 발생하게 됩니다.

3. 함수가 독립적으로 동작해야할 때

카운터가 두 개가 필요한 상황이 있다고 가정하겠습니다.

  let myValue = 0;
  let yourValue = 0;

  function increaseMyCounter() {
    console.log(++myValue);
  };

  function decreaseMyCounter() {
    console.log(--myValue);
  }

  function increaseYourCounter() {
    console.log(++yourValue);
  };

  function decreaseYourCounter() {
    console.log(--yourValue);
  }

카운터 두 개를 만들기 위해서 두 배의 코드를 작성했습니다. 비효율적입니다!

클로저를 활용하게 되면 외부함수를 호출할때마다 새로운 컨텍스트를 생성하기 때문에 클로저함수 하나로 여러 개의 독립적인 카운터를 만들어 줄 수 있습니다.

const counterCreator = () => {
  let value = 0;

  return {
    increase() {
      console.log(++value);
    },
    decrease() {
      console.log(--value);
    },
  };
};

const myCounter = counterCreator();
const yourCounter = counterCreator();

myCounter.increase(); // 1
myCounter.increase(); // 2
yourCounter.increase(); // 1
myCounter.decrease(); // 1

위와 같이 myCounteryourCounter의 상태는 독립적이 되었습니다. 클로저를 활용해 코드를 작성했기 때문에 가독성이 훨씬 좋아졌습니다.