대략적으로만 알고 있던 개념이였는데, 공부를 할수록 더 헷갈려서 많은 강의와 자료를 찾아보며 이해하는데 많은 시간을 썼습니다. 공부를 하면서 클로저가 렉시컬 환경 객체를 참조하는 구조라는 것을 이해하게 되었고, 메모리 누수 가능성이나 성능 최적화에도 활용할 수 있을 것 같다는 생각이 들었습니다. 특히 React Hook 내부에서도 사용되는 개념이라는 부분에서, 실제 코드를 작성하면서도 무의식적으로 사용하는 경우가 많은 것 같아 더 주의하면서 개발해야겠다고 느꼈습니다.
클로저란?
클로저는 함수가 선언될 때의 렉시컬 스코프(Lexical Scope)를 기억해, 해당 함수의 실행 컨텍스트가 종료된 이후에도 외부 변수에 접근할 수 있는 현상을 의미합니다.
함수는 자신이 선언된 시점의 스코프를 기억하기 때문에, 함수 외부에 있는 변수를 내부에서 계속 참조하고 사용할 수 있습니다. 실무에서는 주로 상태를 은닉하거나 캡슐화하고, 콜백 내부에서 상태를 유지할 때 활용됩니다. 하지만 상태를 계속 유지하는 특성으로 메모리 누수(memory loak)를 주의해야 한다는 단점이 있습니다.
클로저가 메모리 누수를 만드는 상황은?
클로저 자체가 누수를 만드는건 아니지만, 불필요하게 큰 데이터를 참조한 채로 오래 살아있을 때 GC가 수거하지 못해 누수가 발생할 수 있습니다.
자바스크립트의 GC는 도달 가능한 객체만 메모리에 유지합니다. 그런데 클로저는 선언 당시의 렉시컬 환경을 기억하고 있기 때문에, 이 클로저를 전역 변수, 이벤트 리스너, 타이머 같은 장수 객체가 참조하면 클로저가 붙잡고 있는 외부 변수들도 계속 도달 가능한 상태가 됩니다.
이러한 상황을 방지하려면, 필요 없는 리스너나 타이머를 명시적으로 해제하고 클로저 내부에서 불필요하게 큰 객체를 참조하지 않도록 설계하는 것이 중요합니다.
클로저의 특징
1. 렉시컬 환경(Lexical Environment) 보존
- 클로저는 함수가 선언된 위치의 스코프 체인을 내부 슬롯 [[Environment]]에 저장합니다.
- 따라서 함수 실행 컨텍스트가 종료되어도, 클로저가 해당 환경 객체를 참조하고 있으면 변수는 GC 대상에서 제외되고 계속 살아남습니다.
- 이 특성 덕분에 함수 호출 이후에도 외부 변수를 읽거나 수정할 수 있습니다.
2. 변수 은닉과 캡슐화
- 외부에서 직접 접근할 수 없는 변수를 “프라이빗 변수”처럼 활용할 수 있습니다.
- 모듈 패턴이나 카운터 함수처럼 내부 상태를 안전하게 보호하면서도 제한된 API를 통해서만 상태를 제어할 수 있습니다.
3. 상태 지속성 (State Persistence)
- 이벤트 핸들러, 비동기 콜백, setTimeout 같은 환경에서 함수 실행이 끝나도 변수를 기억하고 있어야 할 때 유용합니다.
- React의 useState, useEffect 같은 훅도 내부적으로 클로저를 이용해 상태를 보존합니다. 덕분에 컴포넌트가 리렌더링되더라도 이전 상태를 참조하거나 업데이트할 수 있습니다.
4. 메모리 관리와 주의사항
- 클로저 자체가 메모리 누수를 일으키지는 않지만, 불필요한 참조가 남아 있으면 문제가 됩니다.
ex) DOM 요소를 참조하는 클로저가 이벤트 리스너에 바인딩된 채 제거되지 않은 경우 - 따라서 사용이 끝난 리스너 해제, 큰 객체 참조 피하기 등의 관리가 필요합니다.
- 실제 대규모 애플리케이션에서는 ‘Stale Closure’ 문제나 의도치 않은 메모리 점유로 성능 병목이 발생할 수 있습니다.
사용 예시
1. 카운터 함수
외부에서 직접 접근할 수 없는 변수를 내부 함수에서만 제어할 수 있도록 하여, 상태를 은닉하는 용도로 사용합니다.
function createCounter() {
let count = 0; // 외부에서 접근 불가
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
2. 이벤트 핸들러 내부 상태 유지
버튼 클릭 횟수처럼 특정 이벤트와 함께 변수를 계속 유지해야 하는 경우 클로저가 유용하게 쓰입니다.
function setupButton() {
let clickCount = 0;
document.getElementById('btn').addEventListener('click', function() {
clickCount++;
console.log(`Clicked ${clickCount} times`);
});
}
setupButton();
3. React Hook (useState, useEffect)
React 훅은 상태나 참조값을 유지하기 위해 클로저를 적극적으로 활용합니다. useState 내부 구현이 클로저를 기반으로 하고 있어 리렌더링이 일어나도 상태가 보존됩니다.
const [count, setCount] = useState(0);
useState를 쓰면, 컴포넌트가 재렌더링될 때마다 훅이 호출되지만, 실제 상태 값은 리렌더링 사이에 사라지지 않고 이전 렌더링에서 만든 클로저를 통해 계속 참조됩니다. React 내부는 훅 호출 순서를 기반으로 각 상태를 배열이나 링크드 리스트 형태로 보관하고, 훅이 반환하는 setter 함수가 이 상태를 캡처한 클로저이기 때문에, 이후 호출 시에도 최신 상태를 읽거나 수정할 수 있습니다.
useEffect도 비슷하게, 이펙트 콜백이 정의될 당시의 props와 state를 클로저로 캡처합니다. 그래서 종속성 배열을 잘못 관리하면 이전 값을 계속 참조하는 ‘stale closure’ 문제가 생길 수 있습니다. 결국 훅은 클로저를 활용해서 컴포넌트 함수가 다시 호출돼도 상태와 로직이 끊기지 않게 보장합니다.
프로젝트 실제 적용 예시
튜닝 프로젝트에서 클로저를 어떻게 적용했는지 공유드리겠습니다.
1) useSSE 훅의 동적 이벤트 리스너 클로저
https://github.com/100-hours-a-week/2-hertz-fe/blob/main/src/hooks/useSSE.ts
2-hertz-fe/src/hooks/useSSE.ts at main · 100-hours-a-week/2-hertz-fe
⚡️ 2조 튜닝 FE 레포지토리. Contribute to 100-hours-a-week/2-hertz-fe development by creating an account on GitHub.
github.com
// src/hooks/useSSE.ts (line 63-75)
Object.entries(handlersMap).forEach(([event, callback]) => {
const listener = (e: MessageEvent) => {
try {
const parsed = e.data ? JSON.parse(e.data) : null;
callback(parsed); // 🔥 외부 callback 변수를 캡쳐하는 클로저
} catch (err) {
console.error(`Error parsing event [${event}]`, err); // 🔥 외부 event 변수를 캡쳐하는 클로저
}
};
eventSource.addEventListener(event, listener);
listenerMapRef.current[event] = listener;
});- handlersMap은 SSE 이벤트 이름과 콜백을 매핑한 객체입니다.
- forEach 내부에서 선언된 listener 함수는 외부 스코프의 callback과 event 변수를 그대로 참조합니다.
- 이때 listener 함수는 클로저가 되어, 이벤트가 발생할 때마다 외부 변수(callback, event)를 그대로 사용합니다.
- 덕분에 SSE 이벤트가 발생할 때 각 이벤트에 맞는 동작을 실행할 수 있고, 여러 이벤트를 반복해서 등록할 수 있습니다. 클로저를 사용함으로써 동적 이벤트 매핑과 변수 캡처가 가능해집니다.
2) useSocketIO 훅의 재연결 클로저
https://github.com/100-hours-a-week/2-hertz-fe/blob/main/src/hooks/useSocketIO.ts
2-hertz-fe/src/hooks/useSocketIO.ts at main · 100-hours-a-week/2-hertz-fe
⚡️ 2조 튜닝 FE 레포지토리. Contribute to 100-hours-a-week/2-hertz-fe development by creating an account on GitHub.
github.com
// src/hooks/useSocketIO.ts (connectSocket 내부)
const attemptReconnect = () => {
if (reconnectAttemptRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error('❌ 최대 재연결 시도 횟수 초과', { channelRoomId });
return;
}
reconnectAttemptRef.current += 1;
const delay = RECONNECT_DELAY * reconnectAttemptRef.current;
reconnectTimeoutRef.current = setTimeout(() => {
connectSocket(); // 🔥 외부 connectSocket 함수를 캡처하는 클로저
}, delay);
};- 소켓 연결 실패 시 재연결 로직을 처리하는 함수입니다.
- setTimeout 안에서 호출되는 connectSocket 함수가 외부 스코프에서 캡처된 클로저입니다.
- 클로저 덕분에 attemptReconnect가 실행될 때마다 최신 상태(reconnectAttemptRef, channelRoomId)를 참조할 수 있습니다. 따라서 지연 후 재연결 시에도 정확한 상태와 로직을 유지할 수 있습니다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Closures
클로저
다음을 고려해 봅시다. function init() { var name = "Mozilla"; // name은 init에 의해 생성된 지역 변수이다. function displayName() { // displayName() 은 내부 함수이며, 클로저다. console.log(name); // 부모 함수에서 선
developer.mozilla.org
면접 대비하기
- 클로저란 무엇인가요?
- 클로저를 사용해야 하는 이유는? (장점)
- 클로저를 사용할 때 주의해야 할 점은? (단점)
- 클로저의 특징은?
- 클로저가 메모리 누수를 만드는 상황은?
- 클로저를 코드에 어떻게 적용하나요?
'📚 CS > JavaScript' 카테고리의 다른 글
| [JavaScript] 프로토타입(Prototype)과 클래스(Class) (0) | 2025.09.03 |
|---|---|
| [JavaScript] 스코프 체인 (Scope Chain) (1) | 2025.08.31 |
| [JavaScript] this 바인딩 (0) | 2025.08.30 |
| [JavaScript] V8 엔진과 GC (Garbage Collection) (3) | 2025.08.28 |
| [JavaScript] 실행 컨텍스트와 호이스팅 (1) | 2025.08.27 |