| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- html
- 단위테스트
- 문자열
- 정규식
- list
- javascript
- 자바
- Visual Studio Code
- Eclipse
- math
- 스프링부트
- vscode
- input
- 자바스크립트
- CSS
- 인텔리제이
- junit
- 배열
- js
- string
- Java
- Array
- junit5
- HashMap
- ArrayList
- 자바문법
- 테스트자동화
- IntelliJ
- SpringBoot
- java테스트
- Today
- Total
어제 오늘 내일
[JavaScript] 클로저(Closure) 완벽 가이드: 동작 원리부터 실전 패턴까지 본문
면접 단골 질문이자, 설명은 들어봤지만 "그래서 이걸 어디에 쓰지?"가 남는 개념. 클로저입니다.
사실 클로저는 특별한 문법이 아닙니다. 자바스크립트 함수라면 거의 다 클로저이고, 우리는 이미 매일 클로저를 쓰고 있습니다. 다만 그 동작 방식을 정확히 모르면 반복문에서, 이벤트 핸들러에서, React 훅에서 이해할 수 없는 버그를 만나게 됩니다.
이 글은 원리 → 함정 → 실전 패턴 순서로 정리합니다.
클로저란 무엇인가
한 줄 정의는 이렇습니다.
클로저 = 함수 + 그 함수가 선언된 렉시컬 환경(lexical environment)의 조합
말이 어려우니 코드로 봅시다. 먼저 평범한 경우부터요.
function normal() {
const message = "안녕하세요"; // normal의 지역 변수
}
normal(); // 함수 실행이 끝나면 message는 정리된다
console.log(message); // ReferenceError — 바깥에서는 접근할 방법이 없다함수가 끝나면 그 안의 지역 변수도 함께 사라집니다. 여기까지는 예상대로입니다.
이번엔 안쪽 함수를 바깥으로 꺼내 보겠습니다.
function outer() {
const message = "안녕하세요"; // outer의 지역 변수
function inner() {
console.log(message); // 자기 스코프에 없는 변수를 바깥에서 찾아 씀
}
return inner; // 함수를 "실행"하지 않고 "반환"
}
const fn = outer();
// ① outer()가 실행된다 → 그 안에서 message가 만들어지고, inner 함수가 정의된다
// ② outer()는 inner를 "반환"한다. 따라서 fn은 inner 함수 자체를 가리킨다
// (아직 실행한 게 아니라, 함수를 변수에 담아둔 상태다)
// ③ 여기서 outer() 호출은 완전히 끝났다
// → 방금 normal()에서 봤듯, 지역 변수 message도 함께 정리될 차례다
fn();
// ④ 이제야 inner를 실행한다. inner는 console.log(message)를 하려고 한다
// outer는 이미 끝났으니 message는 없어져서 에러가 나야 할 것 같지만...
// ⑤ "안녕하세요"가 정상 출력된다 → message가 아직 메모리에 살아 있다는 뜻!normal()에서는 사라졌던 지역 변수가, outer()에서는 왜 남아 있을까요? 반환된 inner가 message를 참조하고 있고 그 inner가 fn이라는 이름으로 살아남았기 때문입니다. inner가 살아 있는 한 message도 버려지지 않습니다.
이렇게 함수가 자신이 태어난 환경을 기억하는 현상, 그게 클로저입니다.
전제: 렉시컬 스코프
클로저를 이해하려면 자바스크립트의 스코프 규칙을 먼저 알아야 합니다. 자바스크립트는 렉시컬 스코프(정적 스코프) 를 씁니다. 변수를 찾는 기준이 "어디서 호출됐는가"가 아니라 "어디에 작성됐는가" 라는 뜻입니다.
const name = "전역";
function printName() {
console.log(name); // 이 함수가 "작성된" 위치 기준으로 찾음
}
function run() {
const name = "지역"; // 호출하는 쪽에 같은 이름이 있어도
printName(); // 무시된다
}
run(); // "전역" — "지역"이 아님!printName은 전역에 작성됐으므로, 어디서 호출되든 전역의 name을 봅니다. 이 규칙이 클로저의 토대입니다.
핵심: 값이 아니라 "변수"를 기억한다
가장 많이 오해하는 지점입니다. 클로저는 값을 복사해서 스냅샷으로 저장하지 않습니다. 변수 자체를 참조합니다.
function makeCounter() {
let count = 0; // 이 변수를 아래 함수들이 공유한다
return {
increment() { count += 1; return count; },
decrement() { count -= 1; return count; },
getValue() { return count; },
};
}
const counter = makeCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getValue()); // 1
// 외부에서 count에 직접 접근할 방법이 없다
console.log(counter.count); // undefined여기서 두 가지가 확인됩니다.
count는 선언 시점의 0으로 고정된 게 아니라 계속 변합니다 → 참조를 유지한다는 증거- 세 함수가 같은
count를 공유합니다 → 하나의 렉시컬 환경을 함께 쓴다는 증거
그리고 count는 외부에서 접근할 수 없습니다. 자바스크립트에서 프라이빗 변수를 만드는 고전적인 방법이 바로 이겁니다.
인스턴스마다 환경은 분리된다
makeCounter를 두 번 호출하면 어떻게 될까요?
const counterA = makeCounter();
const counterB = makeCounter(); // 새로운 호출 = 새로운 환경
counterA.increment();
counterA.increment();
counterB.increment();
console.log(counterA.getValue()); // 2
console.log(counterB.getValue()); // 1 — 서로 영향 없음함수를 호출할 때마다 새로운 렉시컬 환경이 만들어집니다. 그래서 클로저는 "함수로 만드는 인스턴스"처럼 동작합니다.
고전 함정: 반복문 속 클로저
클로저를 다룰 때 거의 모두가 한 번은 밟는 지뢰입니다.
// ❌ 의도: 0, 1, 2 / 실제: 3, 3, 3
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}왜 전부 3일까요?
var는 함수 스코프라 반복문 전체에서i가 딱 하나만 존재합니다- 세 개의 콜백이 모두 같은
i변수를 참조합니다 - 100ms 뒤 콜백이 실행될 때, 반복문은 이미 끝나서
i는3입니다
값이 아니라 변수를 기억한다는 성질이 그대로 드러난 사례죠.
해결 1: 쓰기 (권장)
// ✅ 0, 1, 2
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}let을 쓰면 자바스크립트 엔진이 반복할 때마다 i를 새로 하나씩 만들어 줍니다. 이름은 똑같이 i지만, 실제로는 서로 다른 변수 세 개입니다.
그림으로 비교하면 이렇습니다.
var 사용 → 변수는 딱 1개, 모두가 그걸 공유
[i] ←── 콜백1
←── 콜백2 반복문이 끝나면 i는 3
←── 콜백3 → 셋 다 3을 본다
let 사용 → 반복마다 변수가 새로 생성
[i=0] ←── 콜백1
[i=1] ←── 콜백2 각자 자기 변수를 본다
[i=2] ←── 콜백3 → 0, 1, 2엔진이 내부적으로 하는 일을 억지로 코드로 옮기면 이런 느낌입니다.
// for (let i = 0; ...) 는 대략 이렇게 동작한다고 상상하면 된다
{
let i = 0;
{ let i_사본 = i; setTimeout(() => console.log(i_사본), 100); } // i_사본 = 0
}
{
let i = 1;
{ let i_사본 = i; setTimeout(() => console.log(i_사본), 100); } // i_사본 = 1
}
{
let i = 2;
{ let i_사본 = i; setTimeout(() => console.log(i_사본), 100); } // i_사본 = 2
}반복마다 변수가 따로 생기니, 콜백들이 참조하는 대상도 따로따로가 됩니다. 앞서 "클로저는 값이 아니라 변수를 기억한다"고 했죠. 기억하는 방식이 바뀐 게 아니라, 기억할 변수 자체가 세 개로 늘어난 것입니다.
이 특별 대우는 for 문의 let에만 적용됩니다. 아래처럼 바깥에 선언하면 var와 똑같이 하나뿐이라 다시 3, 3, 3이 나옵니다.
// ❌ let을 썼는데도 3, 3, 3
let i;
for (i = 0; i < 3; i++) { // 초기화 부분에 let이 없다
setTimeout(() => console.log(i), 100);
}해결 2: IIFE로 스코프 만들기 (ES5 시절 방식)
// ✅ 0, 1, 2
for (var i = 0; i < 3; i++) {
(function (captured) { // 즉시 실행 함수로 새 스코프 생성
setTimeout(function () {
console.log(captured); // 매개변수는 호출 시점 값으로 복사됨
}, 100);
})(i); // 현재 i 값을 인자로 넘겨 고정
}let이 없던 시절의 관용구입니다. 요즘 새로 쓸 이유는 없지만, 오래된 코드를 읽을 때 알아둘 만합니다.
실전 패턴
1. 모듈 패턴 — 상태 은닉
const bankAccount = (function () {
// ── private 영역 ──────────────────
let balance = 0;
const history = [];
function record(type, amount) {
history.push({ type, amount, at: new Date() });
}
// ── public 영역 ───────────────────
return {
deposit(amount) {
if (amount <= 0) throw new Error("금액은 0보다 커야 합니다");
balance += amount;
record("deposit", amount);
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("잔액 부족");
balance -= amount;
record("withdraw", amount);
return balance;
},
getBalance() {
return balance; // 읽기 전용으로만 노출
},
getHistory() {
return [...history]; // 복사본 반환 → 원본 배열 보호
},
};
})();
bankAccount.deposit(10000);
bankAccount.withdraw(3000);
console.log(bankAccount.getBalance()); // 7000
console.log(bankAccount.balance); // undefined — 직접 접근 불가
bankAccount.balance = 999999; // 이렇게 해도
console.log(bankAccount.getBalance()); // 7000 — 내부 balance와 무관마지막 부분이 중요합니다. 외부에서 balance 속성을 만들어봐야 클로저 안의 balance와는 완전히 다른 변수입니다. 진짜 캡슐화죠.
요즘은 클래스의
#프라이빗 필드로도 같은 목적을 달성할 수 있습니다. 다만 클로저 방식은 별도 문법 없이 어디서나 동작합니다.
2. 함수 팩토리 — 설정이 고정된 함수 만들기
값을 반환하는 함수는 익숙하죠. 그런데 함수가 함수를 반환하면, 매번 조금씩 다르게 동작하는 함수를 필요한 만큼 만들어낼 수 있습니다. 공장에서 같은 틀로 제품을 찍어내되 색만 바꾸는 것과 비슷해서 "팩토리(factory)"라고 부릅니다.
예를 들어 "2를 곱하는 함수"와 "3을 곱하는 함수"가 필요하다고 해봅시다. 따로따로 쓰면 이렇습니다.
// 곱하는 수만 다르고 나머지는 똑같은 코드의 반복
function double(number) { return number * 2; }
function triple(number) { return number * 3; }다른 부분(곱할 수)만 매개변수로 빼면, 함수를 만들어 주는 함수가 됩니다.
// multiplier는 숫자가 아니라 "함수"를 반환한다
function multiplier(factor) {
// 반환된 함수는 자기가 태어날 때의 factor를 계속 기억한다 ← 클로저
return function (number) {
return number * factor;
};
}
// multiplier(2)를 호출하면 "2를 곱하는 함수"가 만들어져 double에 담긴다
const double = multiplier(2); // 이 함수 안에서 factor는 영원히 2
const triple = multiplier(3); // 이 함수 안에서 factor는 영원히 3
console.log(double(10)); // 20 — 10 * 2
console.log(triple(10)); // 30 — 10 * 3
console.log(double(7)); // 14 — factor는 계속 2로 유지된다실무에서는 이런 식으로 씁니다. 매번 반복해서 넘겨야 하는 설정값을 한 번만 넣어두고, 그게 적용된 함수를 받아 쓰는 겁니다.
// 실전 예: API 클라이언트 생성기
function createApi(baseUrl, token) {
// baseUrl과 token은 클로저에 갇혀 매 호출마다 재사용된다
return {
get(path) {
return fetch(`${baseUrl}${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
},
post(path, body) {
return fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
},
};
}
const api = createApi("https://api.example.com", "abc123");
// 이후로는 baseUrl과 token을 다시 넘길 필요가 없다
api.get("/users");3. 메모이제이션 — 계산 결과 캐싱
// memoize: 함수를 받아서, "결과를 기억하는 버전"의 함수로 바꿔 반환한다
function memoize(fn) {
// 캐시 저장소. memoize를 호출할 때 딱 한 번 만들어지고,
// 아래 반환된 함수가 참조하므로 계속 살아남는다 ← 클로저
// 바깥에서는 이 Map에 손댈 방법이 없다
const cache = new Map();
// 실제로 우리가 사용할 함수. 원본 fn과 인자 형태가 같다
return function (...args) {
// 인자 조합을 문자열로 바꿔 캐시 키로 쓴다
// 예: 인자가 (9) 이면 키는 "[9]", (2, 3) 이면 "[2,3]"
const key = JSON.stringify(args);
// ① 같은 인자로 이미 계산한 적이 있으면 → 계산 생략하고 저장된 값 반환
if (cache.has(key)) {
console.log(`캐시 히트: ${key}`);
return cache.get(key);
}
// ② 처음 보는 인자면 → 원본 함수를 실제로 실행
// apply(this, args)는 this와 인자를 원본에 그대로 넘겨주기 위한 것
const result = fn.apply(this, args);
// ③ 다음번을 위해 결과를 저장해 둔다
cache.set(key, result);
return result;
};
}
// 캐싱 효과를 눈으로 보려고 일부러 느리게 만든 함수
const slowSquare = (n) => {
for (let i = 0; i < 1e7; i++) {} // 아무 일도 안 하면서 시간만 끄는 반복문
return n * n;
};
// slowSquare를 감싸서 "기억하는 버전"으로 만든다. 원본은 그대로 유지된다
const fastSquare = memoize(slowSquare);
console.log(fastSquare(9)); // 느림 — 캐시가 비어 있어 ②번 경로로 실제 계산 후 저장
console.log(fastSquare(9)); // 즉시 — 키 "[9]"가 있으므로 ①번 경로로 바로 반환
console.log(fastSquare(3)); // 느림 — 키 "[3]"은 처음이라 다시 계산
console.log(fastSquare(3)); // 즉시 — 이제 "[3]"도 캐시에 있다cache가 전역에 노출되지 않으면서도 호출 사이에 유지된다는 점이 핵심입니다.
4. 디바운스 — 마지막 호출만 실행
function debounce(fn, delay) {
let timerId = null; // 호출 간에 유지되어야 하는 상태
return function (...args) {
// 이전에 예약된 실행이 있으면 취소
clearTimeout(timerId);
// 새로 예약. delay 동안 추가 호출이 없어야 실제로 실행된다
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const onSearch = debounce((keyword) => {
console.log("검색 요청:", keyword);
}, 300);
// 사용자가 빠르게 타이핑하는 상황
onSearch("자");
onSearch("자바");
onSearch("자바스");
onSearch("자바스크립트");
// → 300ms 뒤 "검색 요청: 자바스크립트" 한 번만 출력timerId를 전역 변수로 뺐다면 디바운스 함수를 두 개 만드는 순간 서로 간섭했을 겁니다. 클로저 덕분에 인스턴스마다 독립적인 타이머를 갖습니다.
5. 스로틀 — 일정 간격으로만 실행
function throttle(fn, interval) {
let lastTime = 0; // 마지막 실행 시각을 기억
return function (...args) {
const now = Date.now();
// 마지막 실행 후 interval이 지났을 때만 통과
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => {
console.log("스크롤 위치:", window.scrollY);
}, 200);
window.addEventListener("scroll", onScroll); // 최대 200ms에 한 번만 실행6. — 딱 한 번만 실행되는 함수
function once(fn) {
let called = false;
let result; // 첫 호출의 결과를 보관
return function (...args) {
if (called) return result; // 두 번째부터는 캐시된 결과 반환
called = true;
result = fn.apply(this, args);
return result;
};
}
const initialize = once(() => {
console.log("초기화 실행!");
return { ready: true };
});
initialize(); // "초기화 실행!" 출력
initialize(); // 아무것도 출력 안 됨
initialize(); // 아무것도 출력 안 됨7. ID 생성기
function createIdGenerator(prefix = "id") {
let seq = 0;
return function () {
seq += 1; // 호출될 때마다 증가하고 유지됨
return `${prefix}-${seq}`;
};
}
const userId = createIdGenerator("user");
const postId = createIdGenerator("post");
console.log(userId()); // "user-1"
console.log(userId()); // "user-2"
console.log(postId()); // "post-1" — 카운터가 독립적클로저와 메모리
클로저가 변수를 붙잡고 있다는 건, 그 변수가 가비지 컬렉션되지 않는다는 뜻이기도 합니다.
function leaky() {
const bigData = new Array(1_000_000).fill("무거운 데이터");
return function () {
// bigData를 실제로 쓰지 않아도, 참조하면 계속 메모리에 남는다
console.log(bigData.length);
};
}
const fn = leaky(); // bigData가 fn이 살아있는 동안 유지됨대응 방법은 두 가지입니다.
필요한 것만 캡처하기
function better() {
const bigData = new Array(1_000_000).fill("무거운 데이터");
const size = bigData.length; // 필요한 값만 미리 추출
return function () {
console.log(size); // 이제 bigData 전체를 붙잡지 않는다
};
}다 쓴 클로저는 참조를 끊기
let handler = createHeavyHandler();
element.addEventListener("click", handler);
// 정리 시점
element.removeEventListener("click", handler);
handler = null; // 참조 해제 → GC 대상이 됨특히 SPA에서 컴포넌트가 사라질 때 이벤트 리스너나 setInterval을 정리하지 않으면, 클로저가 붙잡은 데이터가 계속 쌓여 누수가 됩니다.
React에서 만나는 "stale closure"
리액트를 쓴다면 이 버그를 반드시 만나게 됩니다.
// ❌ 카운터가 1에서 멈춘다
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 이 count는 "첫 렌더의 count", 즉 항상 0
}, 1000);
return () => clearInterval(id);
}, []); // 의존성이 비어 있어 effect가 한 번만 실행됨
return <p>{count}</p>;
}useEffect가 처음 한 번만 실행되므로, 안에서 만들어진 콜백은 첫 렌더 시점의 count(=0) 를 영원히 기억합니다. 그래서 매초 setCount(0 + 1)을 반복해 1에서 멈춥니다. 이걸 stale closure(낡은 클로저) 라고 부릅니다.
// ✅ 함수형 업데이트로 해결
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // 클로저 대신 최신 상태를 인자로 받음
}, 1000);
return () => clearInterval(id);
}, []);setCount에 함수를 넘기면 리액트가 항상 최신 값을 인자로 전달합니다. 클로저에 갇힌 옛날 값을 참조할 필요가 없어지죠.
흔한 오해 정리
"클로저는 함수를 반환할 때만 생긴다" → 아닙니다. 외부 변수를 참조하는 함수는 어떤 형태로든 클로저입니다.
function setup() {
let clicks = 0;
// 반환하지 않고 콜백으로 넘겨도 클로저
document.addEventListener("click", () => {
clicks += 1;
console.log(`클릭 횟수: ${clicks}`);
});
}"클로저는 무거워서 피해야 한다" → 아닙니다. 자바스크립트 엔진은 클로저를 매우 흔한 패턴으로 최적화합니다. 문제는 클로저 자체가 아니라 불필요하게 큰 데이터를 오래 붙잡는 것입니다.
"클로저는 값을 복사한다" → 아닙니다. 참조를 유지합니다. 앞의 반복문 예제가 그 증거입니다.
정리
| 개념 | 설명 |
| 정의 | 함수 + 선언된 렉시컬 환경의 조합 |
| 생성 시점 | 함수가 정의될 때 (호출될 때가 아님) |
| 캡처 방식 | 값 복사가 아니라 변수 참조 |
| 환경 분리 | 외부 함수를 호출할 때마다 새 환경 생성 |
| 주 용도 | 상태 은닉, 팩토리, 캐싱, 디바운스/스로틀 |
| 주의점 | 붙잡은 데이터의 메모리 누수, stale closure |
클로저는 결국 "함수가 자기만의 기억을 갖는다" 는 이야기입니다. 전역 변수 없이 상태를 유지하고, 외부에서 건드릴 수 없게 숨기고, 설정이 미리 적용된 함수를 찍어낼 수 있게 해 주죠.
'IT > Javascript' 카테고리의 다른 글
| JavaScript Object.freeze 총정리: 얕은 동결의 함정과 deepFreeze 구현법 (1) | 2026.08.08 |
|---|---|
| [Javascript] "use strict"란? — 자바스크립트 엄격 모드 완전 정리 (0) | 2026.08.08 |
| JavaScript 비동기 처리 완전 정복: async/await 사용법 (0) | 2026.08.03 |
| [Javascript] HTML에서 외부 js 파일의 함수 호출하기 (0) | 2025.02.27 |
| [Javascript] 삼항연산자 사용법 및 활용 예제, 중첩 삼항연산자 (0) | 2025.02.26 |