JavaScript의 클로저(Closure)란? (feat. React의 useState)

2023.08.25
13분
댓글

글을 시작하며

클로저란 JavaScript에서 중요한 개념 중 하나이지만 그만큼 이해하기도 쉽지 않은 개념입니다.

230826-225800

혹시 React의 useState도 클로저를 이용해서 구현되었다는 것 알고 계셨나요?
클로저라는 개념은 낯설게 느껴질 수 있지만 활용 사례들을 보시면 그동안 클로저를 사용하고 있었다는 것을 느끼게 되실 겁니다!

이번 포스팅을 통해서는 클로저의 원리를 쉽게 배워보고 어떤 상황에 사용되는지 알아보도록 하겠습니다.


클로저(Closure)

클로저란 함수와 함수가 선언되었을 때의 렉시컬 환경의 조합입니다.

정의가 다소 어렵게 느껴지죠?
이렇게 생각해보면 조금 더 이해가 되실 겁니다!

  • 클로저란, 자신이 선언된 당시의 환경을 기억하는 함수입니다.
  • 클로저란, 생명 주기가 끝난 외부 함수의 변수접근할 수 있는 내부 함수입니다.
function outerFunc() {
  // 외부 함수의 변수
  var x = 10;

  // 내부 함수에서 외부 함수의 변수에 접근할 수 있습니다.
  var innerFunc = function () {
    console.log(x);
  };

  return innerFunc;
}

var inner = outerFunc();
inner(); // 10

위의 코드에서 outerFunc는 내부 함수 innerFunc를 반환하고 생을 마감했습니다 😇
즉, 실행 후 콜스택에서 제거가 되었기 때문에 생명 주기가 끝난 상태입니다. 따라서 outerFunc가 호출된 후에는 내부 변수 x도 유효하지 않을 것이라고 생각할 수 있습니다.

하지만 inner 함수를 호출하면 내부 함수 innerFunc가 실행되고,
innerFunc는 선언된 당시의 환경을 기억하고 있기 때문에 (내 상위 스코프에는 var x가 선언 됐었지..) 변수 x의 값인 10이 출력됩니다.

이와 같이 생명 주기가 끝난 외부 함수의 변수에 접근할 수 있는 함수를 클로저라고 합니다.

위의 예시만 읽어본다면 이해가 잘 안 되실 수 있습니다.

당연합니다! 쉽지 않은 개념입니다.
지금부터 클로저를 이해하는데 필요한 선행 지식들을 살펴보면서 내용을 보충해보도록 하겠습니다.


선행 지식

스코프

  • 변수에 접근할 수 있는 범위를 뜻합니다. 자바스크립트에는 전역 스코프와 지역 스코프 타입이 있습니다.

렉시컬 스코프

  • 함수를 어디에 선언하였는지에 따라 상위 스코프가 결정되는 것입니다. 이를 정적 스코프라고 부르기도 합니다.
var x = 1;

function foo() {
  var x = 10;
  bar();
}

function bar() {
  console.log(x);
}

foo(); // 1
bar(); // 1

문제 하나 드리겠습니다!
foo 함수 안에서 호출된 bar 함수는 전역 변수 x와 지역 변수 x중 어떤 값을 가질까요?

정답은 바로 전역 변수 x입니다!

렉시컬 스코프는 호출이 아니라 선언에 집중합니다.
bar 함수는 전역에 선언되었기 때문에 전역 변수 x를 참조하게 되는 것입니다.


동작 원리

이제 클로저를 이해하기 위한 준비가 끝났습니다!
클로저는 어떻게 동작하게 되는 걸까요? 방금 배웠던 렉시컬 스코프를 활용해서 그 원리에 대해 알아봅시다.

  • 클로저는 자신이 선언되었을 때의 환경(=렉시컬 스코프)을 기억하는 함수입니다.
  • 자바스크립트의 모든 함수는 [[Environment]]라 불리는 숨김 프로퍼티를 갖습니다. 이곳에 렉시컬 스코프에 대한 참조값이 저장됩니다.
  • 함수 본문에서 [[Environment]]를 사용해서 외부 함수의 변수에도 접근할 수 있게 됩니다.

😇 쉽게 이해하기

  • 클로저 = 함수 + 렉시컬 스코프
  • 자바스크립트의 모든 함수는 자신이 선언된 환경의 주소를 저장하고 있습니다. 즉, 상위 스코프의 주소를 가지고 있는 것이죠.
  • 함수 본문에서 상위 스코프의 주소를 참조하여 외부 함수의 변수에도 접근할 수 있게 됩니다.

클로저를 왜 사용하는 걸까요?

1. 상태 유지

  • 현재 상태를 기억하고 변경된 최신 상태를 유지할 수 있습니다.
function debounce(callback, delay) {
  let timer = null;

  return function () {
    clearTimeout(timer);
    timer = setTimeout(() => {
      callback.apply(this, arguments);
    }, delay);
  };
}
  • 익명 함수는 debounce 내에서 선언되었으므로 debounce 함수가 상위 스코프가 됩니다.
  • 함수는 선언된 환경의 주소를 기억하기 때문에 상위 스코프의 변수에 접근할 수 있게 되고, timer 변수에 접근할 수 있게 됩니다
  • timer는 디바운스가 실행될 함수와 다른 스코프에 있기 때문에 timer에 대한 최신 상태를 유지할 수 있습니다.

2. 정보 은닉

  • 변수 값을 은닉할 수 있습니다. 클래스 기반 언어의 private 키워드를 흉내낼 수 있습니다.

3. 전역 변수 사용 억제

  • 프로그래밍 초보자들은 전역 변수를 통해서 공유할 변수를 작성하고는 합니다. 하지만 전역 변수는 의도치 않게 값이 변경될 위험이 있습니다.
  • 클로저를 사용하면 변수를 공유하는 특성은 유지하되 데이터를 은닉화할 수 있기 때문에, 전역 변수를 대체하여 안전한 코드를 작성할 수 있습니다.

클로저와 React의 useState

함수형 컴포넌트의 경우 렌더링이 발생하면 함수 자체가 다시 호출됩니다.
따라서 상태를 관리하기 위해서는 함수가 재호출 되었을 때 이전 상태를 기억하고 있어야 합니다.

어랏 이 개념 익숙하지 않으신가요?

맞습니다!
React는 이 문제를 클로저의 상태 유지 특성을 활용해서 해결했습니다.

useState 구현하기

function useState(initialValue) {
  let _value = initialValue;

  const state = () => _value;
  const setState = (newValue) => {
    _value = newValue;
  };

  return [state, setState];
}

const [count, setCount] = React.useState(1);
console.log(count()); // 1
setCount(2);
console.log(count()); // 2

클로저를 통해 간단하게 구현한 useState 함수입니다.
[state, setState]가 선언되는 시점에서 useState의 호출은 끝나게 되지만, 클로저가 내부의 _value 값을 기억하고 있기 때문에 이후에도 접근이 가능합니다.

하지만 변수 역할을 해야하는 count가 함수 형태로 호출되고 있기 때문에 변수 형태로 수정해주는 것이 필요합니다.

React 모듈 안에서 useState 구현

state를 변수로 표현하면서도 값을 유지하도록 만들기 위해 state를 useState 외부에 선언하였습니다.

const React = (function () {
  let state;

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

      const setState = (newValue) => {
        state = newValue;
      };

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

const Counter = () => {
  const [count, setCount] = React.useState(0);

  console.log(count); // 0
  setCount(1);
  console.log(count); // 1
};

setState가 실행되면 외부 스코프의 state를 변경하게 되고 클로저의 원리에 따라 state 값은 사라지지 않습니다.

따라서 컴포넌트가 리렌더링 되어서 useState가 새로 실행되어도 state 값을 유지할 수 있게 됩니다!


주의할 점

클로저의 장점과 사용 사례에 대해서 살펴봤습니다.
그렇다면 클로저를 사용할 때 주의할 점은 없을까요?

클로저를 사용하면 메모리 측면에서 손해를 볼 수 있습니다.

  • 클로저에 의해 내부 함수는 외부 함수의 변수를 참조하고 있습니다.
  • 따라서 외부 함수의 생명 주기가 끝났음에도 가비지 콜렉터에 의해 메모리가 해제되지 않습니다.

해결 방법

  • 클로저를 할당한 변수에 null을 할당해줌으로써 메모리를 해제시키는 방법이 있습니다.

이러한 단점은 의도적으로 null을 할당하여 개선할 수 있지만, 클로저는 개발자가 의도적으로 참조를 만들어서 사용하는 것이기 때문에 null을 할당하는 것이 오히려 유지보수 측면에서 좋지 않을 수도 있겠다는 생각이 듭니다.

위의 단점은 성능적인 면에서 치명적인 영향을 주지는 않지만
기술을 사용할 때는 그 단점들을 파악하고 사용해야 더 잘 활용할 수 있기 때문에 함께 다루게 되었습니다.


글을 마치며

다소 어렵고 낯선 개념인 클로저에 대해서 쉽게 풀어보고 싶어서 많은 예시를 들다보니 내용이 꽤나 길어진 것 같습니다.

긴 글 읽으시느라 고생 많으셨습니다.
위에서 다뤘던 개념들을 정리하면서 글을 마치도록 하겠습니다 😀

  • 렉시컬 스코프
    • 선언된 위치에 따라서 상위 스코프가 결정되는 것
  • 클로저
    • 선언된 당시의 환경(렉시컬 스코프)을 기억하기 때문에 생명 주기가 끝난 외부 함수의 변수에 접근할 수 있는 함수
  • 클로저의 동작 원리
    • 자바스크립트의 모든 함수는 렉시컬 스코프에 대한 참조값을 저장합니다. 해당 참조값을 통해 외부 함수의 변수에도 접근할 수 있게 됩니다.
  • 클로저 사용 이유
    • 상태 유지, 정보 은닉, 전역 변수 사용 억제
  • useState와 클로저
    • 리렌더링 되었을 때 이전 상태를 기억하기 위해서 클로저의 상태 유지 특성을 사용하였습니다.
  • 클로저의 단점
    • 메모리 누수