그동안 클로저라는 용어는 많이 썼지만 깊이 공부를 했다는 생각이 들지 않아 다시 조사해본 클로저의 개념들. 공부하면서 모던 자바스크립트, 코어 자바스크립트 그리고 You don’t know js yet 문서를 많이 참고했습니다.
아래 추가적인 예시를 통해 더 공부해보려 합니다.
클로져란 JS에서 함수가 일급객체로 취급되기 때문에 발생하는 자연스러운 현상입니다.
이 일급객체를 만족하는 특성으로 함수의 리턴값으로 사용할 수 있다는 특성으로 외부 함수에서 리턴된 내부 함수가 외부 함수의 변수를 참조할 수 있기 때문에 클로저라는 현상이 일어나게 됩니다. (자바스크립트 에서 모든 함수들은 클로저를 형성할 수 있습니다.)
이 클로저의 개념을 한마디로 정리하자면 함수가 선언된 렉시컬 환경을 기억하는 것 인데요.
이로 인해 함수의 실행 컨텍스트가 종료된 이후에도 해당 함수의 내부 자원을 참조하고 있는 함수가 있다면 해당 내부 자원에 접근 할 수 있게 됩니다.
그 이유는 모든 함수들은 선언될 때 자신이 선언된 렉시컬 환경에 대한 참조를 실행 컨텍스트의 outerEnvironmentReference의 값으로 갖게되기 때문입니다.
추가로 정리하자면 “어떤함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 함수A의 실행 컨텍스트가 종료된 이후에도 A가 사라지지 않는 현상” 을 말합니다.
이에 따라서 외부 렉시컬 환경의 변수를 참조할 수 있고, 참조를 하게 된다면 실행컨텍스트가 종료되도 가비지 컬렉터의 수집 대상에서 제외되기 때문에 클로저란 현상이 발생하게 됩니다.
클로저가 발생하는 이유는 위의 말처럼 가비지 컬렉터의 동작방식 때문이기도 합니다.
만약, 어떤 함수에서 외부 환경의 자원을 참조하고 있다면 그 자원은 실행 컨텍스트가 종료된 이후에도 가비지 컬렉터의 수집 대상에서 제외되게 됩니다.
가비지 컬렉팅을 간략히 알아보자면
V8엔진 기준으로 가비지 컬렉팅은 Mark-Sweep-Compact 알고리즘과
Tri-color 알고리즘을 사용하는데 마킹, 스위핑, 압축 단계로 진행됩니다.

마킹 단계에서는 가비지컬렉터는 객체들을 DFS로 순회하며,
객체들을 white, gray, black 3가지 컬러로 마킹을 합니다.덱이라고 불리는 자료구조에 참조를 따라가며 객체들을 push와 pop을 하며 참조를 확인하는데, 덱에서 꺼낸 객체는 검은색으로 마킹하고, 꺼낸 객체가 참조하는 객체들은(인접 객체) 회색으로 마킹 후 덱에 push를 합니다. 이런 과정을 root부터 시작하여 덱이 빌때까지 반복하게 되면 최종적으로 모든 객체들이 흰색 or 검은색으로 마킹이 되게 됩니다.
이후 스위핑 단계로 들어가서
여전히 흰색으로 마킹된 객체들의 메모리 주소를 free-list라고 부르는 자료구조에 추가하여 메모리를 해제하게 됩니다.
마지막으로 압축 단계에서는
메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보합니다.
메모리 단편화란? 메모리가 할당•해제를 반복하게 되면서 중간중간에 빈 공간이 발생하게 되는데, 총 여분 메모리 공간은 충분하지만, 연결된 메모리가 부족해서 실제로 어떤 테스크에 대해 메모리를 할당할 수 없는 경우를 말합니다.
참고: https://fe-developers.kakaoent.com/2022/220519-garbage-collection/
클로저가 프로그램의 코드 구조와 조직을 어떻게 개선할 수 있는지를 간단한 예시로 먼저 살펴볼 수 있었습니다.
페이지에 클릭할 때 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);
makeRequest(..) 함수는 클릭 이벤트로부터 event 객체만 받습니다.data-kind 속성에서 값을 가져와 API 엔드포인트의 URL과 Ajax 요청에 포함할 데이터를 조회합니다.해당 방식은 이벤트 핸들러가 매번 트리거될 때마다 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);
data-kind 속성이 초기 설정 시 한 번만 가져와져 recordKind 변수에 할당됩니다.recordKind는 내부 makeRequest(..) 클릭 핸들러에 의해 클로즈되고, 그 값은 이벤트가 발생할 때마다 API URL과 전송할 데이터를 조회하는 데 사용됩니다.이 패턴을 조금 더 발전시키면, 설정 시 한 번에 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(..)는 requestURL과 requestData를 클로즈하므로, 코드가 조금 더 이해하기 쉬워졌고 성능 면에서도 개선될 수 있는 등 클로저를 활용할 수 있습니다.
참고: https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/ch7.md
그리고 클로저는 개인 정보를 보호하는 캡슐화, 모듈 패턴 내 비공개(private) 변수 및 함수 생성, 고차 함수에서 콜백 함수로 사용되는 경우 등 중요한 역할을 합니다. 클로저를 통해 외부에서 접근할 수 없는 변수를 만들어 데이터를 안전하게 보호하고, 글로벌 스코프의 오염을 방지할 수 있습니다.
클로저를 활용하는 이유가 스코프 범위를 제어하고 변수의 접근성을 제한하여 보안성을 높일 수 있다는 특성이 있습니다. 또 스토어를 생성하는 함수를 호출할 때 클로저를 활용합니다.
public(외부에서 접근 가능), private(내부에서만 사용), protected 세 종류가 있는데 클로저를 이용해 함수 차원에서 public한 값과 private 값을 구분할 수 있습니다.가장 주변에서 많이 사용되는 활용 사례로는 저희가 자주 쓰는 프레임워크인 react와 zustand 라이브러리에서 클로저를 활용하고 있는 걸 알 수 있습니다.
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),
};
ReactCurrentDispatcher.current는 전역에 선언된 객체의 프로퍼티입니다.→ React의 useState는 클로저를 이용해 상태값을 관리하며, 상태값이 변경되면 컴포넌트를 리렌더링합니다.
스토어를 생성하는 함수를 호출할 때 클로저를 활용하는데 스토어의 상태는 스토어를 조회하거나 변경하는 함수 바깥 스코프에 항상 유지되도록 만들어진 것을 볼 수 있습니다.
상태의 변경, 조회, 구독 등의 인터페이스를 통해서만 스토어를 다루고 실제 상태는 애플리케이션의 생명 주기 처음부터 끝까지 의도치 않게 변경되는 것을 막을 수 있습니다. 아래에 zustand의 내부 코드에서 어떻게 클로저를 활용하는지 알 수 있습니다.
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);
}
// ...
};
전역상태)listener는 상태가 변경되면 알려주는 역할을 합니다.)
listeners라는 Set 자료구조로 관리됩니다.listener들을 호출합니다.활용예시와 개념을 통해 알아보며 프로그램에 클로저를 사용하는 이점은 다음과 같은 이점이 있습니다.
클로저의 단점으로 메모리 누수의 위험을 단점으로 하는 예시가 많지만 메모리 소모는 언어와 클로저의 하나의 특성이라고 생각합니다.
클로저를 사용하는 개발자(사용자)가 클로저를 더 잘 이해하고 활용할 수 있도록 노력하는 것이 중요하다 생각하는데 클로저를 활용하고 의도대로 설계한 “메모리 소모” 관리법에 맞게 잘 적용하는 것이 중요한 것 같습니다.
클로저는 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생합니다. 필요성이 사라졌을 때는 메모리를 소모하지 않게 해주면 되는데 참조 카운트를 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());
counter.value()를 호출하면, privateCounter의 초기값은 0입니다.console.log(counter.value()); // 0이 출력됩니다.
increment()를 두 번 호출하면 privateCounter가 두 번 1씩 증가하여 2가 됩니다.counter.increment(); // privateCounter = 1
counter.increment(); // privateCounter = 2
console.log(counter.value()); // 2가 출력됩니다.
decrement()를 한 번 호출하면 privateCounter가 1이 감소해 1이 됩니다.counter.decrement(); // privateCounter = 1
console.log(counter.value()); // 1이 출력됩니다.
즉시 실행 함수 표현식(IIFE)을 사용해서 실행을 하게 되면 함수가 실행되고, 클로저를 통해 privateCounter라는 변수를 외부에서 직접 접근할 수 없게 함수가 작성되어 있습니다.
각 함수(increment(), decrement(), value())는 클로저를 통해 privateCounter에 접근하여 값을 변경하거나 반환하는 형태로 작성되어 있기 때문에 increment(), decrement() 함수를 호출해 값이 변경하는 시점에 변경된 counter.value() 값이 출력되고 있었습니다.
var counter = (function() {
// ...
})();
즉시 실행 함수는 코드가 실행될 때 한 번 호출되고, 함수 내부의 변수를 외부에서 접근하지 못하도록 캡슐화할 수 있는데, 즉시 실행 함수를 사용해 전역 변수 오염을 방지하도록 사용할 수 있습니다. (애플리케이션 내에서 다양한 소스 파일의 많은 함수와 전역 변수를 포함할 수 있기 때문에, 전역 변수의 수를 제한하는 것이 중요할 수 있을 경우에 활용)
ref : https://developer.mozilla.org/en-US/docs/Glossary/IIFE#avoid_polluting_the_global_namespace
해당 함수가 실행된 후, 반환된 객체(increment, decrement, value 메서드)를 counter라는 변수에 저장되어 있습니다.
privateCounter는 IIFE 내부에 선언된 변수라 외부에서는 직접 접근할 수 없는데 increment, decrement, value 함수는 클로저 현상을 활용해 해당 변수에 접근하고 조작할 수 있게 설계되어 있습니다.
privateCounter 값을 외부에서 직접 접근할 수 없게 보호하고,increment, decrement 함수는 privateCounter를 수정하고, value 함수는 그 값을 반환하는 식으로 상태를 관리하며,privateCounter는 외부에서 직접 변경되지 않고, 오직 메서드를 통해 안전하게 접근할 수 있도록 캡슐화된 방식으로 코드가 구성되어 있습니다.정리하면, 클로저는 단순 문법 트릭이 아니라 스코프와 실행 컨텍스트, 메모리 관리 방식이 함께 만드는 핵심 동작이라는 점이 가장 중요했습니다.
이번 글에서는 다음 내용을 중심으로 다시 정리했습니다.
다음 글에서는 실행 컨텍스트와 스코프 체인을 조금 더 깊게 이어서 정리해보겠습니다. 읽어주셔서 감사합니다.