2026. 6. 20. 20:26ㆍFrontend/JavaScript

프론트엔드 개발자 면접에서 자주 나오는 JavaScript 질문 중 하나가 바로 클로저(Closure) 다.
많은 개발자가 클로저를 “함수 안의 함수” 또는 “외부 변수에 접근하는 내부 함수” 정도로 기억한다. 하지만 이 설명은 절반만 맞다. 클로저를 제대로 이해하려면 단순히 코드 패턴을 외우는 것이 아니라, JavaScript가 스코프를 어떻게 결정하는지, 함수가 어떤 환경을 기억하는지, 그리고 그 특성이 실무에서 상태 관리, 이벤트 핸들러, 비동기 처리, React Hooks, 메모리 관리와 어떻게 연결되는지까지 이해해야 한다.
이 글에서는 클로저를 기초부터 깊이 있게 정리해본다.
1. 클로저를 한 문장으로 정의하면
클로저는 함수와 그 함수가 선언될 당시의 렉시컬 환경이 함께 묶인 구조다.
조금 더 쉽게 말하면 다음과 같다.
클로저는 함수가 자신이 만들어진 위치의 변수와 스코프를 기억하고, 나중에 다른 위치에서 실행되더라도 그 변수에 접근할 수 있게 해주는 JavaScript의 특성이다.
가장 기본적인 예시를 보자.
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
createCounter 함수는 이미 실행이 끝났다. 일반적으로 함수 실행이 끝나면 내부 변수도 사라질 것처럼 느껴진다. 그런데 counter()를 호출할 때마다 count 값은 계속 유지된다.
왜 그럴까?
increment 함수가 만들어질 때, 자신이 선언된 환경인 createCounter의 렉시컬 환경을 기억했기 때문이다. 그래서 createCounter의 실행은 끝났지만, increment가 여전히 count에 접근할 수 있다.
이것이 클로저다.
2. 클로저는 “함수 안의 함수”가 아니다
클로저를 처음 배울 때 흔히 이렇게 외운다.
함수 안에 함수가 있고, 내부 함수가 외부 함수의 변수에 접근하면 클로저다.
입문 단계에서는 괜찮은 설명이다. 하지만 정확한 설명은 아니다.
클로저의 핵심은 “중첩 함수” 자체가 아니라, 함수가 선언될 때의 외부 렉시컬 환경을 기억한다는 점이다.
다음 예제를 보자.
let name = "Hyun";
function sayName() {
console.log(name);
}
name = "Kwak";
sayName(); // Kwak
sayName은 전역 스코프에서 선언되었고, 전역 변수 name에 접근한다. 이 함수도 자신이 선언된 렉시컬 환경을 참조한다. 넓은 의미에서 JavaScript의 함수는 모두 자신이 생성된 환경을 기억한다.
하지만 보통 면접이나 실무에서 “클로저”라고 말할 때는 다음과 같은 상황을 주로 가리킨다.
function outer() {
const message = "Hello Closure";
return function inner() {
console.log(message);
};
}
const fn = outer();
fn(); // Hello Closure
즉, 외부 함수의 실행이 끝난 뒤에도 내부 함수가 외부 함수의 변수에 접근하는 상황에서 클로저의 특징이 가장 잘 드러난다.
3. 렉시컬 스코프를 먼저 이해해야 한다
클로저를 이해하려면 먼저 렉시컬 스코프(Lexical Scope) 를 알아야 한다.
렉시컬 스코프란, 변수의 유효 범위가 함수를 어디서 호출했는지가 아니라 어디서 선언했는지에 따라 결정되는 규칙이다.
다음 코드를 보자.
const value = "global";
function outer() {
const value = "outer";
function inner() {
console.log(value);
}
return inner;
}
const fn = outer();
function run() {
const value = "run";
fn();
}
run(); // outer
fn()은 run 함수 안에서 호출되었다. run 함수 안에도 value가 있다. 그런데 출력 결과는 "run"이 아니라 "outer"다.
왜냐하면 inner 함수는 run 안에서 선언된 것이 아니라 outer 안에서 선언되었기 때문이다. JavaScript는 함수가 실행되는 위치가 아니라 선언된 위치를 기준으로 상위 스코프를 결정한다.
이것이 렉시컬 스코프다.
클로저는 이 렉시컬 스코프 규칙 위에서 동작한다.
4. 클로저의 핵심: 값이 아니라 “환경”을 기억한다
클로저를 이해할 때 중요한 포인트가 있다.
클로저는 단순히 외부 변수의 “값”을 복사해서 저장하는 것이 아니다. 함수가 선언될 당시의 변수 바인딩이 들어 있는 환경을 참조한다.
예제를 보자.
let count = 0;
function logCount() {
console.log(count);
}
count = 10;
logCount(); // 10
만약 함수가 변수를 만들 당시의 값을 복사했다면 0이 출력되어야 할 것 같다. 하지만 실제로는 10이 출력된다.
이유는 logCount가 count라는 값을 복사한 것이 아니라, count라는 변수 바인딩에 접근하기 때문이다.
다른 예시도 보자.
function createCounter() {
let count = 0;
return {
increase() {
count += 1;
},
decrease() {
count -= 1;
},
getCount() {
return count;
},
};
}
const counter = createCounter();
counter.increase();
counter.increase();
counter.decrease();
console.log(counter.getCount()); // 1
increase, decrease, getCount는 모두 같은 count를 공유한다. 각 함수가 count 값을 따로 복사해서 가지고 있는 것이 아니라, 같은 렉시컬 환경 안의 count 바인딩을 참조하고 있기 때문이다.
이 특성 덕분에 클로저는 상태를 은닉하고 공유하는 강력한 도구가 된다.
5. 클로저가 만들어지는 과정
다음 코드를 기준으로 클로저가 어떻게 만들어지는지 단계별로 보자.
function createUser(name) {
const createdAt = new Date();
return function getUserInfo() {
return {
name,
createdAt,
};
};
}
const getUserInfo = createUser("Hyun");
console.log(getUserInfo());
실행 흐름은 다음과 같다.
첫째, createUser("Hyun")이 호출된다.
name = "Hyun"
createdAt = new Date()
둘째, getUserInfo 함수가 생성된다.
이 함수는 자신이 선언된 위치의 렉시컬 환경을 기억한다. 즉, name과 createdAt에 접근할 수 있는 환경을 참조한다.
셋째, createUser는 getUserInfo 함수를 반환하고 종료된다.
일반적인 지역 변수라면 createUser 실행이 끝난 뒤 사라져도 이상하지 않다. 하지만 반환된 getUserInfo 함수가 여전히 그 환경을 참조하고 있기 때문에, 해당 환경은 메모리에서 해제되지 않는다.
넷째, 나중에 getUserInfo()를 호출한다.
이 함수는 자신이 기억하고 있던 환경을 통해 name과 createdAt에 접근한다.
즉, 클로저는 다음 구조로 이해할 수 있다.
getUserInfo 함수
└── 자신이 선언된 렉시컬 환경 참조
├── name: "Hyun"
└── createdAt: Date 객체
6. 클로저의 대표적인 활용 1: private state 만들기
JavaScript에서 클로저는 private한 상태를 만드는 데 자주 사용된다.
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
if (amount <= 0) {
throw new Error("입금액은 0보다 커야 합니다.");
}
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) {
throw new Error("잔액이 부족합니다.");
}
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createBankAccount(10000);
account.deposit(5000);
account.withdraw(3000);
console.log(account.getBalance()); // 12000
console.log(account.balance); // undefined
balance는 외부에서 직접 접근할 수 없다.
account.balance = 100000000;
이렇게 해도 내부의 balance 값은 바뀌지 않는다. 외부에서 만든 account.balance는 단지 새로운 프로퍼티일 뿐이고, 클로저 내부의 balance와는 별개다.
이 패턴은 클래스의 private 필드가 널리 쓰이기 전부터 JavaScript에서 캡슐화를 구현하는 대표적인 방식이었다.
7. 클로저의 대표적인 활용 2: 함수 팩토리
클로저는 특정 설정을 기억하는 함수를 만들 때 유용하다.
function createMultiplier(multiplier) {
return function multiply(value) {
return value * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(10)); // 20
console.log(triple(10)); // 30
double은 multiplier = 2인 환경을 기억하고, triple은 multiplier = 3인 환경을 기억한다.
같은 함수 구조를 사용하지만, 각각 다른 환경을 기억하는 별도의 함수가 만들어진다.
실무에서는 다음과 같은 형태로 활용할 수 있다.
function createApiClient(baseUrl) {
return async function request(path, options) {
const response = await fetch(`${baseUrl}${path}`, options);
if (!response.ok) {
throw new Error("API 요청에 실패했습니다.");
}
return response.json();
};
}
const userApi = createApiClient("/api/users");
const orderApi = createApiClient("/api/orders");
userApi("/1");
orderApi("/recent");
userApi는 "/api/users"를 기억하고, orderApi는 "/api/orders"를 기억한다.
이렇게 클로저를 사용하면 반복되는 설정 값을 매번 넘기지 않아도 된다.
8. 클로저의 대표적인 활용 3: 이벤트 핸들러
프론트엔드에서는 이벤트 핸들러에서 클로저를 매우 자주 사용한다.
function bindClickLogger(button, pageName) {
button.addEventListener("click", function handleClick() {
console.log(`${pageName} 페이지에서 버튼 클릭`);
});
}
handleClick 함수는 pageName을 기억한다. 실제 클릭 이벤트는 나중에 발생하지만, 핸들러는 자신이 등록될 당시의 환경에 접근할 수 있다.
React에서도 같은 원리로 동작한다.
function ProductCard({ product }) {
function handleClick() {
console.log(`${product.name} 클릭`);
}
return (
<button onClick={handleClick}>
상품 보기
</button>
);
}
handleClick은 product에 접근한다. 이 역시 클로저다.
우리는 React에서 매일 클로저를 쓰고 있다. 다만 너무 자연스럽게 쓰고 있어서 클로저라고 의식하지 않을 뿐이다.
9. 클로저의 대표적인 활용 4: 디바운스와 쓰로틀
디바운스와 쓰로틀은 클로저의 실무 활용을 보여주는 좋은 예시다.
디바운스
디바운스는 이벤트가 연속으로 발생할 때 마지막 이벤트만 처리하는 기법이다.
function debounce(callback, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
callback(...args);
}, delay);
};
}
사용 예시는 다음과 같다.
const search = debounce((keyword) => {
console.log(`검색어: ${keyword}`);
}, 300);
search("r");
search("re");
search("rea");
search("react");
여기서 반환된 함수는 timerId, callback, delay를 기억한다. debounce 함수 실행은 이미 끝났지만, 반환된 함수가 계속 timerId에 접근하면서 이전 타이머를 취소할 수 있다.
이것이 클로저의 힘이다.
쓰로틀
쓰로틀은 일정 시간 동안 한 번만 실행되도록 제한하는 기법이다.
function throttle(callback, delay) {
let lastExecutedTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastExecutedTime >= delay) {
lastExecutedTime = now;
callback(...args);
}
};
}
스크롤 이벤트나 리사이즈 이벤트처럼 짧은 시간에 매우 자주 발생하는 이벤트를 제어할 때 사용된다.
const handleScroll = throttle(() => {
console.log("스크롤 처리");
}, 1000);
window.addEventListener("scroll", handleScroll);
반환된 함수는 lastExecutedTime을 기억한다. 그래서 이전 실행 시점을 기준으로 다음 실행 여부를 판단할 수 있다.
10. 반복문과 클로저: var가 만드는 고전적인 문제
클로저를 설명할 때 자주 등장하는 문제가 있다.
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
많은 사람이 0, 1, 2가 출력될 것이라고 예상한다. 하지만 실제 결과는 다음과 같다.
3
3
3
왜 그럴까?
var는 함수 스코프를 가진다. 반복문 블록마다 새로운 i가 만들어지는 것이 아니라, 하나의 i를 세 번의 콜백이 공유한다. setTimeout의 콜백이 실행될 때는 이미 반복문이 끝난 뒤이고, 그 시점의 i 값은 3이다.
이를 해결하는 가장 간단한 방법은 let을 사용하는 것이다.
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
결과는 다음과 같다.
0
1
2
let은 블록 스코프를 가지며, 반복문에서는 각 반복마다 새로운 바인딩이 만들어진다. 그래서 각 콜백은 서로 다른 i를 기억한다.
과거에는 IIFE를 사용해서 이 문제를 해결하기도 했다.
for (var i = 0; i < 3; i++) {
(function (currentIndex) {
setTimeout(function () {
console.log(currentIndex);
}, 1000);
})(i);
}
currentIndex는 IIFE가 실행될 때마다 새로 만들어지는 매개변수다. 각 콜백은 서로 다른 currentIndex를 기억하기 때문에 원하는 결과를 얻을 수 있다.
11. 비동기 코드와 클로저
클로저는 비동기 코드에서도 매우 중요하다.
function fetchUser(userId) {
return fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then((user) => {
console.log(`${userId}번 유저 정보`, user);
});
}
마지막 then 콜백은 userId를 사용한다. 이 콜백은 나중에 실행되지만, fetchUser가 호출될 당시의 userId에 접근할 수 있다.
async/await를 사용해도 마찬가지다.
async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
console.log(`${userId}번 유저 정보`, user);
}
비동기 함수 내부의 코드는 중간에 멈췄다가 나중에 이어서 실행된다. 이때도 함수는 자신의 렉시컬 환경을 유지한다.
실무에서는 이 특성 덕분에 요청 당시의 인자, 설정값, 상태값을 비동기 콜백 안에서 사용할 수 있다.
12. React에서 자주 만나는 stale closure
React를 사용한다면 클로저를 반드시 깊게 이해해야 한다. 특히 useEffect, 이벤트 핸들러, 타이머, 비동기 요청에서 stale closure 문제가 자주 발생한다.
stale closure란, 함수가 최신 값이 아니라 함수가 만들어질 당시의 오래된 값을 참조하는 현상이다.
다음 예제를 보자.
function Counter() {
const [count, setCount] = useState(0);
function handleAlert() {
setTimeout(() => {
alert(count);
}, 3000);
}
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
<button onClick={handleAlert}>3초 뒤 알림</button>
</>
);
}
사용자가 count가 0일 때 “3초 뒤 알림” 버튼을 누르고, 바로 증가 버튼을 여러 번 눌렀다고 하자. 3초 뒤 알림에는 최신 count가 아니라 버튼을 누르던 시점의 count가 표시될 수 있다.
왜 그럴까?
React 함수 컴포넌트는 렌더링될 때마다 함수가 다시 실행된다. 각 렌더링은 자신만의 count 값을 가진다. handleAlert 안의 setTimeout 콜백은 그 렌더링 시점의 count를 기억한다.
즉, React의 상태가 바뀌었다고 해서 이미 만들어진 클로저가 자동으로 최신 값을 바라보는 것은 아니다.
이 문제를 해결하는 방법은 상황에 따라 다르다.
방법 1: 함수형 업데이트 사용
상태 업데이트가 이전 상태에 의존한다면 함수형 업데이트를 사용한다.
setCount((prev) => prev + 1);
예를 들어 다음 코드는 문제가 생길 수 있다.
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
이 경우 의도는 3 증가일 수 있지만, 실제로는 같은 count 값을 기준으로 업데이트가 계산될 수 있다.
더 안전한 방식은 다음과 같다.
function handleClick() {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
}
방법 2: 최신 값을 ref에 저장
타이머나 이벤트 리스너에서 최신 값을 계속 참조해야 한다면 useRef를 사용할 수 있다.
function Counter() {
const [count, setCount] = useState(0);
const latestCountRef = useRef(count);
useEffect(() => {
latestCountRef.current = count;
}, [count]);
function handleAlert() {
setTimeout(() => {
alert(latestCountRef.current);
}, 3000);
}
return (
<>
<p>{count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
<button onClick={handleAlert}>3초 뒤 알림</button>
</>
);
}
ref.current는 렌더링 사이에서도 같은 객체를 유지한다. 그래서 비동기 콜백 안에서 최신 값을 읽어야 할 때 유용하다.
방법 3: 의존성 배열을 정확히 작성
useEffect에서 외부 값을 사용한다면 의존성 배열을 정확히 작성해야 한다.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
count를 의존성 배열에 넣지 않으면, effect 내부의 콜백이 오래된 count를 계속 참조할 수 있다.
하지만 의존성 배열에 값을 넣으면 effect가 자주 재실행될 수 있다. 이때는 최신 값을 ref로 관리할지, effect 구조를 바꿀지, 상태 업데이트 방식을 바꿀지 판단해야 한다.
React에서 클로저를 이해한다는 것은 단순히 JavaScript 개념을 아는 것을 넘어, 렌더링과 상태의 스냅샷 모델을 이해한다는 뜻이기도 하다.
13. 클로저와 메모리 관리
클로저는 매우 유용하지만, 잘못 사용하면 예상보다 오래 메모리를 잡고 있을 수 있다.
다음 예제를 보자.
function registerHandler() {
const largeData = new Array(1000000).fill("data");
const button = document.querySelector("#button");
button.addEventListener("click", function handleClick() {
console.log(largeData.length);
});
}
registerHandler();
registerHandler 함수 실행은 끝났다. 하지만 handleClick 이벤트 핸들러가 largeData를 참조하고 있다. 그리고 버튼에 이벤트 핸들러가 등록되어 있는 동안 handleClick은 살아 있다.
그 결과 largeData도 메모리에서 해제되지 않을 수 있다.
클로저 자체가 메모리 누수는 아니다. 문제는 더 이상 필요 없는 데이터가 클로저를 통해 계속 참조되는 상황이다.
이벤트 리스너를 제거하면 참조를 끊을 수 있다.
function registerHandler() {
const largeData = new Array(1000000).fill("data");
const button = document.querySelector("#button");
function handleClick() {
console.log(largeData.length);
}
button.addEventListener("click", handleClick);
return function cleanup() {
button.removeEventListener("click", handleClick);
};
}
const cleanup = registerHandler();
// 필요 없어졌을 때
cleanup();
React에서는 effect cleanup이 같은 역할을 한다.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
클로저를 사용할 때는 다음 질문을 해봐야 한다.
- 이 함수가 어떤 외부 값을 참조하고 있는가?
- 그 외부 값이 큰 객체나 DOM 노드인가?
- 이 함수는 언제까지 살아 있는가?
- 필요 없어진 뒤 참조를 끊는 코드가 있는가?
이 질문을 할 수 있다면 클로저로 인한 메모리 문제를 훨씬 잘 다룰 수 있다.
14. 클로저는 상태 관리의 가장 작은 단위다
클로저를 단순히 면접용 개념으로만 보면 아쉽다. 클로저는 상태 관리의 가장 작은 원리 중 하나다.
다음 코드를 보자.
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function setState(nextState) {
state =
typeof nextState === "function"
? nextState(state)
: nextState;
listeners.forEach((listener) => listener(state));
}
function subscribe(listener) {
listeners.add(listener);
return function unsubscribe() {
listeners.delete(listener);
};
}
return {
getState,
setState,
subscribe,
};
}
간단한 store를 만들었다.
const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((state) => {
console.log("state changed:", state);
});
store.setState((prev) => ({ count: prev.count + 1 }));
store.setState((prev) => ({ count: prev.count + 1 }));
console.log(store.getState()); // { count: 2 }
unsubscribe();
여기서 state와 listeners는 외부에서 직접 접근할 수 없다. 하지만 getState, setState, subscribe는 같은 렉시컬 환경을 공유하면서 상태를 읽고 변경한다.
Redux, Zustand 같은 상태관리 라이브러리의 내부 구현은 훨씬 복잡하지만, 기본적인 아이디어는 이와 크게 다르지 않다.
클로저는 단순한 문법 기능이 아니라, JavaScript에서 상태와 행위를 묶어 추상화하는 핵심 메커니즘이다.
15. 클로저와 모듈 패턴
ES Module이 보편화되기 전에는 클로저를 활용한 모듈 패턴이 많이 사용되었다.
const userModule = (function () {
let currentUser = null;
function login(user) {
currentUser = user;
}
function logout() {
currentUser = null;
}
function getCurrentUser() {
return currentUser;
}
return {
login,
logout,
getCurrentUser,
};
})();
userModule.login({ id: 1, name: "Hyun" });
console.log(userModule.getCurrentUser());
console.log(userModule.currentUser); // undefined
IIFE를 실행해 private한 스코프를 만들고, 외부에 필요한 메서드만 반환한다. 이 메서드들은 모두 같은 private 상태인 currentUser를 공유한다.
현대 JavaScript에서는 ES Module과 #private class field 등 다른 선택지도 있다. 그래도 클로저 기반 모듈 패턴을 이해하면 JavaScript가 추상화를 어떻게 구현하는지 더 잘 이해할 수 있다.
16. 클로저와 this는 다르다
클로저를 공부하다 보면 this와 헷갈릴 때가 있다. 하지만 둘은 완전히 다른 개념이다.
클로저는 함수가 선언된 렉시컬 환경을 기억하는 것이다.
this는 함수가 어떻게 호출되었는지에 따라 결정되는 실행 컨텍스트의 값이다.
예를 들어보자.
const user = {
name: "Hyun",
sayLater() {
setTimeout(function () {
console.log(this.name);
}, 1000);
},
};
user.sayLater(); // undefined 또는 예상과 다른 값
setTimeout 안의 일반 함수에서 this는 user를 가리키지 않는다. 이 문제는 클로저와 직접적인 문제가 아니라 this 바인딩 문제다.
과거에는 다음처럼 this를 변수에 담아 클로저로 해결했다.
const user = {
name: "Hyun",
sayLater() {
const self = this;
setTimeout(function () {
console.log(self.name);
}, 1000);
},
};
user.sayLater(); // Hyun
현대 JavaScript에서는 화살표 함수를 사용해 더 간단히 해결할 수 있다.
const user = {
name: "Hyun",
sayLater() {
setTimeout(() => {
console.log(this.name);
}, 1000);
},
};
user.sayLater(); // Hyun
화살표 함수는 자신만의 this를 만들지 않고, 바깥 스코프의 this를 사용한다.
정리하면 다음과 같다.
클로저: 함수가 선언된 환경의 변수에 접근하는 능력
this: 함수가 호출되는 방식에 따라 결정되는 값
17. 클로저를 사용할 때 흔히 하는 오해
오해 1. 클로저는 내부 함수가 반환될 때만 생긴다
아니다. 함수가 외부 스코프의 값을 참조하면 클로저의 성격이 나타난다. 반환되는 내부 함수는 클로저를 가장 쉽게 관찰할 수 있는 대표적인 패턴일 뿐이다.
function outer() {
const message = "hello";
setTimeout(function () {
console.log(message);
}, 1000);
}
여기서 내부 함수는 반환되지 않는다. 하지만 setTimeout 콜백은 message를 기억한다. 이것도 클로저다.
오해 2. 클로저는 값을 복사한다
아니다. 클로저는 값을 복사하는 것이 아니라 변수 바인딩이 들어 있는 환경에 접근한다.
let value = 1;
function logValue() {
console.log(value);
}
value = 2;
logValue(); // 2
오해 3. 클로저는 항상 메모리 누수를 만든다
아니다. 클로저는 정상적인 언어 기능이다. 문제는 필요 없어진 함수나 이벤트 리스너가 큰 객체를 계속 참조할 때 발생한다.
오해 4. 클로저는 면접용 개념일 뿐이다
아니다. 디바운스, 쓰로틀, 이벤트 핸들러, React Hooks, 상태관리, 모듈 패턴, 함수형 프로그래밍 등 프론트엔드 실무 전반에서 사용된다.
18. 면접에서 클로저를 어떻게 설명하면 좋을까?
면접에서는 너무 길게 설명하기보다, 핵심을 정확히 말하고 예시로 증명하는 것이 좋다.
주니어 레벨 답변
클로저는 함수가 선언될 당시의 외부 변수에 접근할 수 있는 JavaScript의 특성입니다. 외부 함수의 실행이 끝난 뒤에도 내부 함수가 외부 함수의 변수에 접근할 수 있습니다.
function outer() {
let count = 0;
return function inner() {
count += 1;
return count;
};
}
const counter = outer();
counter(); // 1
counter(); // 2
미드 레벨 답변
클로저는 함수와 그 함수가 생성될 당시의 렉시컬 환경이 함께 묶인 구조입니다. JavaScript는 렉시컬 스코프를 따르기 때문에 함수가 어디서 호출되는지가 아니라 어디서 선언되었는지를 기준으로 외부 변수에 접근합니다. 클로저는 디바운스, 쓰로틀, 이벤트 핸들러, private state 구현 등에 자주 사용됩니다. 다만 클로저가 큰 객체를 계속 참조하면 메모리 해제가 지연될 수 있어 이벤트 리스너 정리 같은 처리가 필요합니다.
시니어 레벨 답변
클로저는 함수 객체가 생성될 때 자신의 외부 렉시컬 환경을 참조하면서 발생하는 구조입니다. 중요한 점은 값을 복사하는 것이 아니라 변수 바인딩이 있는 환경을 참조한다는 것입니다. 이 특성 덕분에 상태 은닉, 함수 팩토리, 모듈 패턴, 비동기 콜백, React Hooks 같은 패턴을 구현할 수 있습니다. 반대로 React에서는 각 렌더링이 별도의 상태 스냅샷을 만들기 때문에 stale closure 문제가 발생할 수 있고, 타이머나 이벤트 리스너에서는 오래된 상태를 참조하지 않도록 의존성 배열, 함수형 업데이트, ref 등을 적절히 선택해야 합니다. 또한 클로저가 참조하는 객체는 함수가 살아 있는 동안 GC 대상이 되지 않을 수 있으므로, 장기적으로 살아 있는 핸들러에서는 참조 범위와 cleanup을 신경 써야 합니다.
19. 클로저를 제대로 이해했는지 확인하는 질문
다음 질문에 답할 수 있다면 클로저를 꽤 깊게 이해한 것이다.
- 클로저는 값을 저장하는가, 변수 바인딩을 참조하는가?
- 함수가 호출되는 위치와 선언되는 위치 중 무엇이 스코프를 결정하는가?
- var 반복문에서 setTimeout 콜백이 모두 같은 값을 출력하는 이유는 무엇인가?
- let은 왜 반복문 클로저 문제를 해결할 수 있는가?
- React에서 stale closure는 왜 발생하는가?
- useEffect 의존성 배열을 잘못 작성하면 어떤 문제가 생기는가?
- 클로저가 메모리 누수로 이어지는 경우는 언제인가?
- 디바운스 함수는 어떤 변수를 클로저로 기억하는가?
- private state와 클로저는 어떤 관계가 있는가?
- 클로저와 this는 어떻게 다른가?
20. 마무리
클로저는 JavaScript의 어려운 개념 중 하나로 자주 소개된다. 하지만 본질은 단순하다.
함수는 자신이 선언된 환경을 기억한다.
이 한 문장을 정확히 이해하면 많은 JavaScript 동작이 자연스럽게 연결된다. 비동기 콜백이 외부 변수에 접근하는 이유, 이벤트 핸들러가 특정 데이터를 기억하는 이유, 디바운스가 이전 타이머를 취소할 수 있는 이유, React에서 오래된 상태가 참조되는 이유가 모두 클로저와 연결된다.
클로저는 단순히 면접 질문 하나를 통과하기 위한 지식이 아니다. JavaScript로 상태를 다루고, 추상화를 만들고, UI 이벤트와 비동기 흐름을 설계하기 위한 핵심 기반이다.
프론트엔드 개발자로 성장하고 싶다면 클로저를 “외워야 할 개념”이 아니라 “코드를 읽는 렌즈”로 이해해야 한다.
'Frontend > JavaScript' 카테고리의 다른 글
| async/await의 실행 원리: Job, 마이크로태스크, 실행 컨텍스트 (0) | 2026.06.23 |
|---|---|
| Promise의 의미론: 비동기 작업이 아닌 단일 할당 상태 기계 (0) | 2026.06.22 |
| [JS] 값(value)에 의한 전달과 참조(reference)에 의한 전달 (0) | 2023.01.03 |
| [JS] 자바스크립트의 깊은 복사와 얕은 복사 (0) | 2023.01.02 |
| JavaScript 프로젝트에서 TypeScript를 사용해야 할까? - 장단점 (0) | 2022.09.14 |