메뉴 버튼
클로저 학습 썸네일 이미지
2026년 3월 3일

Core Javascript : 클로저 톺아보기

javascript
closure
frontend
study

그동안 클로저라는 용어는 많이 썼지만 깊이 공부를 했다는 생각이 들지 않아 다시 조사해본 클로저의 개념들. 공부하면서 모던 자바스크립트, 코어 자바스크립트 그리고 You don’t know js yet 문서를 많이 참고했습니다.

아래 추가적인 예시를 통해 더 공부해보려 합니다.


1) 클로저의 개념을 먼저 알아보자!

클로져란 JS에서 함수가 일급객체로 취급되기 때문에 발생하는 자연스러운 현상입니다.

이 일급객체를 만족하는 특성으로 함수의 리턴값으로 사용할 수 있다는 특성으로 외부 함수에서 리턴된 내부 함수가 외부 함수의 변수를 참조할 수 있기 때문에 클로저라는 현상이 일어나게 됩니다. (자바스크립트 에서 모든 함수들은 클로저를 형성할 수 있습니다.)

이 클로저의 개념을 한마디로 정리하자면 함수가 선언된 렉시컬 환경을 기억하는 것 인데요. 이로 인해 함수의 실행 컨텍스트가 종료된 이후에도 해당 함수의 내부 자원을 참조하고 있는 함수가 있다면 해당 내부 자원에 접근 할 수 있게 됩니다.

그 이유는 모든 함수들은 선언될 때 자신이 선언된 렉시컬 환경에 대한 참조를 실행 컨텍스트의 outerEnvironmentReference의 값으로 갖게되기 때문입니다.

추가로 정리하자면 “어떤함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 함수A의 실행 컨텍스트가 종료된 이후에도 A가 사라지지 않는 현상” 을 말합니다.

이에 따라서 외부 렉시컬 환경의 변수를 참조할 수 있고, 참조를 하게 된다면 실행컨텍스트가 종료되도 가비지 컬렉터의 수집 대상에서 제외되기 때문에 클로저란 현상이 발생하게 됩니다.



2) 클로저가 발생하는 이유를 조금 더 깊이 파보자!

클로저가 발생하는 이유는 위의 말처럼 가비지 컬렉터의 동작방식 때문이기도 합니다.

만약, 어떤 함수에서 외부 환경의 자원을 참조하고 있다면 그 자원은 실행 컨텍스트가 종료된 이후에도 가비지 컬렉터의 수집 대상에서 제외되게 됩니다.

가비지 컬렉팅을 간략히 알아보자면

V8엔진 기준으로 가비지 컬렉팅은 Mark-Sweep-Compact 알고리즘과 Tri-color 알고리즘을 사용하는데 마킹, 스위핑, 압축 단계로 진행됩니다.

단계별 가비지 컬렉팅 진행 과정


단계별 가비지 컬렉팅 진행 과정

  1. 마킹 단계에서는 가비지컬렉터는 객체들을 DFS로 순회하며, 객체들을 white, gray, black 3가지 컬러로 마킹을 합니다.

이라고 불리는 자료구조에 참조를 따라가며 객체들을 push와 pop을 하며 참조를 확인하는데, 덱에서 꺼낸 객체는 검은색으로 마킹하고, 꺼낸 객체가 참조하는 객체들은(인접 객체) 회색으로 마킹 후 덱에 push를 합니다. 이런 과정을 root부터 시작하여 덱이 빌때까지 반복하게 되면 최종적으로 모든 객체들이 흰색 or 검은색으로 마킹이 되게 됩니다.

  1. 이후 스위핑 단계로 들어가서 여전히 흰색으로 마킹된 객체들의 메모리 주소를 free-list라고 부르는 자료구조에 추가하여 메모리를 해제하게 됩니다.

  2. 마지막으로 압축 단계에서는 메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보합니다.

메모리 단편화란? 메모리가 할당•해제를 반복하게 되면서 중간중간에 빈 공간이 발생하게 되는데, 총 여분 메모리 공간은 충분하지만, 연결된 메모리가 부족해서 실제로 어떤 테스크에 대해 메모리를 할당할 수 없는 경우를 말합니다.

참고: https://fe-developers.kakaoent.com/2022/220519-garbage-collection/



3) 언제 클로저를 활용할까?


1️⃣ 클로저가 필요한 이유

클로저가 프로그램의 코드 구조와 조직을 어떻게 개선할 수 있는지를 간단한 예시로 먼저 살펴볼 수 있었습니다.

페이지에 클릭할 때 Ajax 요청을 통해 데이터를 가져오고 전송해야 하는 버튼이 있는 코드를 본다면 클로저 없이 코드를 작성하면 다음과 같습니다.

var APIendpoints = {
    studentIDs: "<https://some.api/register-students>",
    // ..
};

var data = {
    studentIDs: [14, 73, 112, 6],
    // ..
};

function makeRequest(event) {
    var btn = event.target;
    var recordKind = btn.dataset.kind;

    ajax(APIendpoints[recordKind], data[recordKind]);
}

// <button data-kind="studentIDs">
//    Register Students
// </button>
btn.addEventListener("click", makeRequest);

해당 방식은 이벤트 핸들러가 매번 트리거될 때마다 DOM 속성을 읽어야 한다는 점에서 비효율적일 수 있습니다.

이벤트 핸들러가 이 값을 기억할 수 있도록 클로저를 사용해 코드를 개선해본다면

var APIendpoints = {
    studentIDs: "<https://some.api/register-students>",
    // ..
};

var data = {
    studentIDs: [14, 73, 112, 6],
    // ..
};

function setupButtonHandler(btn) {
    var recordKind = btn.dataset.kind;

    btn.addEventListener(
        "click",
        function makeRequest(event) {
            ajax(APIendpoints[recordKind], data[recordKind]);
        }
    );
}

// <button data-kind="studentIDs">
//    Register Students
// </button>

setupButtonHandler(btn);

이 패턴을 조금 더 발전시키면, 설정 시 한 번에 URL과 데이터를 조회할 수 있습니다.

function setupButtonHandler(btn) {
    var recordKind = btn.dataset.kind;
    var requestURL = APIendpoints[recordKind];
    var requestData = data[recordKind];

    btn.addEventListener(
        "click",
        function makeRequest(evt) {
            ajax(requestURL, requestData);
        }
    );
}

이제 makeRequest(..)requestURLrequestData를 클로즈하므로, 코드가 조금 더 이해하기 쉬워졌고 성능 면에서도 개선될 수 있는 등 클로저를 활용할 수 있습니다.

참고: https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/ch7.md


2️⃣ 클로저의 대표적인 활용 사례

그리고 클로저는 개인 정보를 보호하는 캡슐화, 모듈 패턴 내 비공개(private) 변수 및 함수 생성, 고차 함수에서 콜백 함수로 사용되는 경우 등 중요한 역할을 합니다. 클로저를 통해 외부에서 접근할 수 없는 변수를 만들어 데이터를 안전하게 보호하고, 글로벌 스코프의 오염을 방지할 수 있습니다.

클로저를 활용하는 이유가 스코프 범위를 제어하고 변수의 접근성을 제한하여 보안성을 높일 수 있다는 특성이 있습니다. 또 스토어를 생성하는 함수를 호출할 때 클로저를 활용합니다.


3️⃣ 그 외의 활용 사례



4) react, zustand와 같은 프레임워크/라이브러리에서도 사용하고 있다.

가장 주변에서 많이 사용되는 활용 사례로는 저희가 자주 쓰는 프레임워크인 react와 zustand 라이브러리에서 클로저를 활용하고 있는 걸 알 수 있습니다.


1️⃣ 리액트에서 클로저 활용하는 방법

react 내에서 내부 코드로 구현된 훅들은 클로저를 활용해 관리되고 있었습니다.

react의 hook 중 useState에서는

// ReactHooks.js

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

const ReactCurrentDispatcher = {
  // ...
  current: (null: null | Dispatcher),
};

resolveDispatcher의 리턴 값을 dispatcher에 할당합니다.

이후 dispatcher의 useState 메서드에 nitialState를 인자로 전달합니다.

const ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: (null: null | Dispatcher),
};

→ React의 useState는 클로저를 이용해 상태값을 관리하며, 상태값이 변경되면 컴포넌트를 리렌더링합니다.


2️⃣ Zustand에서 클로저를 활용하는 방법

스토어를 생성하는 함수를 호출할 때 클로저를 활용하는데 스토어의 상태는 스토어를 조회하거나 변경하는 함수 바깥 스코프에 항상 유지되도록 만들어진 것을 볼 수 있습니다.

상태의 변경, 조회, 구독 등의 인터페이스를 통해서만 스토어를 다루고 실제 상태는 애플리케이션의 생명 주기 처음부터 끝까지 의도치 않게 변경되는 것을 막을 수 있습니다. 아래에 zustand의 내부 코드에서 어떻게 클로저를 활용하는지 알 수 있습니다.

(1) 상태의 부분을 담아두자!

api는 코어 부분에서 살펴봤던 create 함수로 만들어진 스토어입니다.

그 안에 클로저로 가두어진 상태가 있고, 리액트 컴포넌트는 이 변수의 존재를 모른 채 원하는 상태를 꺼내 쓸 수 있게 만드는 것이 목표입니다.

const useStore = (selector = api.getState, equalityFn = Object.is) => {
  // ...

  // 훅이 실행되는 시점의 상태를 가져옵니다.
  const state = api.getState();

  // 훅이 실행될 때마다 갱신되는 Ref들입니다.
  const stateRef = useRef(state);
  const selectorRef = useRef(selector);
  const equalityFnRef = useRef(equalityFn);
  const erroredRef = useRef(false);

  // 훅을 최초에 실행할 때 selector를 사용하여 상태 슬라이스를 뜬다.(상태의 일부를 가져온다)
  // 이 값이 훅을 사용하는 컴포넌트에 전달됩니다.
  const currentSliceRef = useRef();

  if (currentSliceRef.current === undefined) {
    currentSliceRef.current = selector(state);
  }

  // ...
};

(2) 흐름을 따라가보면

  1. 상태는 전역에서 관리됩니다. (이하 전역상태)
  2. useGlobalState라는 훅을 통해서 해당 상태를 클로저를 이용해서 조작합니다.
    1. 이때 내부에서 useState를 통해서 전역상태를 훅 내부의 local state로 관리합니다.
  3. useGlobalState 훅이 마운트가 되면 전역상태를 local state로 setting한 뒤, listener를 등록합니다.(이때 listener는 상태가 변경되면 알려주는 역할을 합니다.)
    1. listener는 listeners라는 Set 자료구조로 관리됩니다.
  4. setGlobalState는 변경될 상태를 받아서 전역상태 값을 바꾼 뒤, listener들을 호출합니다.
  5. listener가 호출됨에 따라서 변경된 전역상태가 local state에 반영됩니다.

참고: Zustand 내부코드 톺아보기



5) 이런 클로저를 사용했을 때 어떤 이점과 문제점이 있을까?

활용예시와 개념을 통해 알아보며 프로그램에 클로저를 사용하는 이점은 다음과 같은 이점이 있습니다.


1️⃣ 클로저의 장점

  1. 클로저는 함수 인스턴스가 이전에 결정된 정보를 기억하게 함으로써 매번 다시 계산할 필요 없이 효율성을 높입니다.
  2. 클로저는 변수를 함수 인스턴스 내부에 캡슐화하여 스코프 노출을 제한하고 코드 가독성을 개선합니다.

2️⃣ 클로저의 단점

클로저의 단점으로 메모리 누수의 위험을 단점으로 하는 예시가 많지만 메모리 소모는 언어와 클로저의 하나의 특성이라고 생각합니다.

클로저를 사용하는 개발자(사용자)가 클로저를 더 잘 이해하고 활용할 수 있도록 노력하는 것이 중요하다 생각하는데 클로저를 활용하고 의도대로 설계한 “메모리 소모” 관리법에 맞게 잘 적용하는 것이 중요한 것 같습니다.

클로저는 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생합니다. 필요성이 사라졌을 때는 메모리를 소모하지 않게 해주면 되는데 참조 카운트를 0으로 만들면 GC가 수거하기 때문에 소모했던 메모리가 회수될 수 있습니다.

→ 식별자에 참조형이 아닌 기본형 데이터( null이나 undefined)를 할당하면 참조 카운트를 0으로 만들 수 있습니다.

다만, 클로저의 단점이 메모리 누수라는 점은 사실 코드를 쓰는 개발자가 잘 판단한다면 일어나지 않을 단점이기 때문에 단점으로 보기가 어려울 수도 있겠다는 생각이 들었습니다.



▶️ 다음 코드를 읽고 결과를 설명해보자!

마지막으로 이런 클로저의 개념에 따라 아래 퀴즈를 풀면서 클로저를 더 이해할 수 있었습니다.

var counter = (function() {
  var privateCounter = 0;

  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    },
  };
})();

console.log(counter.value());

counter.increment();
counter.increment();

console.log(counter.value());

counter.decrement();
console.log(counter.value());

1) 출력 결과

  1. 처음에 counter.value()를 호출하면, privateCounter의 초기값은 0입니다.
console.log(counter.value());  // 0이 출력됩니다.
  1. increment()를 두 번 호출하면 privateCounter가 두 번 1씩 증가하여 2가 됩니다.
counter.increment();  // privateCounter = 1
counter.increment();  // privateCounter = 2
console.log(counter.value());  // 2가 출력됩니다.
  1. decrement()를 한 번 호출하면 privateCounter가 1이 감소해 1이 됩니다.
counter.decrement();  // privateCounter = 1
console.log(counter.value());  // 1이 출력됩니다.

2) 코드 상세

즉시 실행 함수 표현식(IIFE)을 사용해서 실행을 하게 되면 함수가 실행되고, 클로저를 통해 privateCounter라는 변수를 외부에서 직접 접근할 수 없게 함수가 작성되어 있습니다.

각 함수(increment(), decrement(), value())는 클로저를 통해 privateCounter에 접근하여 값을 변경하거나 반환하는 형태로 작성되어 있기 때문에 increment(), decrement() 함수를 호출해 값이 변경하는 시점에 변경된 counter.value() 값이 출력되고 있었습니다.


즉시 실행 함수(IIFE)
var counter = (function() {
  // ...
})();

3) 실행 흐름 순서

  1. 클로저를 사용해 privateCounter 값을 외부에서 직접 접근할 수 없게 보호하고,
  2. increment, decrement 함수는 privateCounter를 수정하고, value 함수는 그 값을 반환하는 식으로 상태를 관리하며,
  3. privateCounter는 외부에서 직접 변경되지 않고, 오직 메서드를 통해 안전하게 접근할 수 있도록 캡슐화된 방식으로 코드가 구성되어 있습니다.


🔚 끝으로

정리하면, 클로저는 단순 문법 트릭이 아니라 스코프와 실행 컨텍스트, 메모리 관리 방식이 함께 만드는 핵심 동작이라는 점이 가장 중요했습니다.

이번 글에서는 다음 내용을 중심으로 다시 정리했습니다.

다음 글에서는 실행 컨텍스트와 스코프 체인을 조금 더 깊게 이어서 정리해보겠습니다. 읽어주셔서 감사합니다.