4.2 스코프와 클로저
스코프와 클로저는 JavaScript를 깊이 이해하는 데 핵심적인 개념입니다. 클로저를 이해하면 데이터 은닉, 상태 관리, 모듈 패턴을 자유롭게 구현할 수 있습니다.
렉시컬 스코프 (Lexical Scope)
JavaScript는 렉시컬 스코프(정적 스코프)를 사용합니다. 함수가 어디서 호출되는지가 아니라 어디서 정의되는지에 따라 스코프가 결정됩니다.
const x = "전역 x";
function outer() {
const x = "outer x";
function inner() {
// inner는 outer 내부에 정의되었으므로
// outer의 x에 접근 가능 (어디서 호출되든 상관없음)
console.log(x); // "outer x"
}
return inner;
}
const fn = outer();
fn(); // "outer x" — outer() 실행이 끝났어도 outer의 x를 참조
동적 스코프와 비교
// 렉시컬 스코프 (JavaScript의 방식)
const value = "global";
function showValue() {
console.log(value); // 항상 "global" (정의 시점 기준)
}
function caller() {
const value = "local";
showValue(); // "global" — 호출 위치가 아닌 정의 위치 기준
}
caller();
스코프 체인 (Scope Chain)
변수를 찾을 때 현재 스코프에서 시작해서 외부 스코프로 순차적으로 탐색합니다. 이를 스코프 체인이라 합니다.
const level1 = "레벨1 전역";
function outer() {
const level2 = "레벨2 outer";
function middle() {
const level3 = "레벨3 middle";
function inner() {
const level4 = "레벨4 inner";
// 스코프 체인 탐색 순서: inner → middle → outer → 전역
console.log(level4); // 현재 스코프에서 찾음
console.log(level3); // middle 스코프에서 찾음
console.log(level2); // outer 스코프에서 찾음
console.log(level1); // 전역 스코프에서 찾음
}
inner();
}
middle();
}
outer();
변수 섀도잉 (Variable Shadowing)
const name = "전역 이름";
function greet() {
const name = "로컬 이름"; // 전역 name을 섀도잉
console.log(name); // "로컬 이름"
}
greet();
console.log(name); // "전역 이름" (전역은 그대로)
클로저 (Closure)
클로저는 외부 함수의 실행이 끝난 후에도 외부 함수의 변수에 접근할 수 있는 내부 함수입니다. 내부 함수는 자신이 정의된 환경(렉시컬 환경)을 "닫아서(close over)" 기억합니다.
function makeCounter(initialValue = 0) {
let count = initialValue; // 이 변수를 클로저가 기억함
return {
increment() { return ++count; },
decrement() { return --count; },
reset() { count = initialValue; return count; },
value() { return count; },
};
}
const counter = makeCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.value()); // 11
counter.reset();
console.log(counter.value()); // 10
// 독립된 클로저 인스턴스
const counter2 = makeCounter(0);
console.log(counter2.increment()); // 1
console.log(counter.value()); // 10 (서로 독립적)
클로저 활용 패턴
패턴 1: 데이터 은닉 (Private Variables)
function createBankAccount(owner, initialBalance) {
// 외부에서 직접 접근 불가능한 비공개 변수
let balance = initialBalance;
const transactions = [];
function recordTransaction(type, amount) {
transactions.push({
type,
amount,
balance,
date: new Date().toISOString(),
});
}
return {
get owner() { return owner; },
deposit(amount) {
if (amount <= 0) throw new Error("입금액은 0보다 커야 합니다");
balance += amount;
recordTransaction("입금", amount);
return this;
},
withdraw(amount) {
if (amount > balance) throw new Error("잔액이 부족합니다");
balance -= amount;
recordTransaction("출금", amount);
return this;
},
getBalance() { return balance; },
getHistory() { return [...transactions]; }, // 복사본 반환
};
}
const account = createBankAccount("홍길동", 10000);
account.deposit(5000).withdraw(3000);
console.log(account.getBalance()); // 12000
console.log(account.getHistory().length); // 2
// balance에 직접 접근 불가
// account.balance; // undefined
패턴 2: 팩토리 함수 (Factory Function)
// 설정을 클로저로 캡처하는 함수 팩토리
function createMultiplier(factor) {
return (number) => number * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const tenTimes = createMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50
// 실전: 단위 변환기
function createConverter(fromUnit, toUnit, rate) {
return {
convert: (value) => +(value * rate).toFixed(4),
toString: () => `${fromUnit} → ${toUnit} (×${rate})`,
};
}
const kmToMile = createConverter("km", "mile", 0.621371);
const celsiusToFahrenheit = createConverter("°C", "°F", 1.8);
console.log(kmToMile.convert(100)); // 62.1371
console.log(`기준: ${kmToMile}`); // 기준: km → mile (×0.621371)
패턴 3: 모듈 패턴 (Module Pattern)
// IIFE + 클로저로 모듈 구현 (ES 모듈 이전 패턴)
const UserStore = (function() {
// 비공개 상태
const users = new Map();
let nextId = 1;
// 비공개 함수
function validate(user) {
if (!user.name?.trim()) throw new Error("이름은 필수입니다");
if (!user.email?.includes("@")) throw new Error("유효한 이메일을 입력하세요");
}
// 공개 API
return {
add(userData) {
validate(userData);
const id = nextId++;
const user = { id, ...userData, createdAt: new Date() };
users.set(id, user);
return user;
},
get(id) {
return users.get(id) ?? null;
},
getAll() {
return [...users.values()];
},
remove(id) {
return users.delete(id);
},
get size() { return users.size; },
};
})();
const alice = UserStore.add({ name: "Alice", email: "alice@example.com" });
const bob = UserStore.add({ name: "Bob", email: "bob@example.com" });
console.log(UserStore.size); // 2
console.log(UserStore.get(alice.id).name); // "Alice"
패턴 4: 메모이제이션 (Memoization)
function memoize(fn) {
const cache = new Map(); // 클로저로 캐시 유지
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`캐시 히트: ${key}`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// 피보나치 수열 (재귀 + 메모이제이션)
const fibonacci = memoize(function fib(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(40)); // 102334155 (빠름!)
console.log(fibonacci(40)); // 캐시 히트: [40]
클로저와 반복문: var vs let
클로저의 대표적인 함정 중 하나입니다.
// ❌ var 사용 — 모든 클로저가 같은 변수를 참조
const funcsVar = [];
for (var i = 0; i < 3; i++) {
funcsVar.push(function() { return i; });
}
console.log(funcsVar[0]()); // 3 (기대: 0)
console.log(funcsVar[1]()); // 3 (기대: 1)
console.log(funcsVar[2]()); // 3 (기대: 2)
// var i는 루프 종료 후 3이 되어 있고, 모든 클로저가 같은 i를 참조
// ✅ let 사용 — 각 반복마다 새로운 블록 스코프 생성
const funcsLet = [];
for (let i = 0; i < 3; i++) {
funcsLet.push(function() { return i; });
}
console.log(funcsLet[0]()); // 0 ✓
console.log(funcsLet[1]()); // 1 ✓
console.log(funcsLet[2]()); // 2 ✓
// ✅ IIFE로 var 문제 해결 (레거시 코드 패턴)
const funcsIIFE = [];
for (var j = 0; j < 3; j++) {
funcsIIFE.push((function(capturedJ) {
return function() { return capturedJ; };
})(j));
}
console.log(funcsIIFE[0]()); // 0 ✓
클로저와 메모리 누수
클로저가 대용량 데이터를 참조하면 GC(가비지 컬렉션)가 해당 데이터를 수거하지 못합니다.
// ❌ 메모리 누수 위험
function createLeakyClosure() {
const largeData = new Array(1_000_000).fill("데이터"); // 약 8MB
return function() {
// largeData를 사용하지 않지만 클로저가 참조를 유지
return "결과";
};
}
// ✅ 필요 없는 참조 해제
function createSafeClosure() {
let largeData = new Array(1_000_000).fill("데이터");
const summary = largeData.length; // 필요한 정보만 추출
largeData = null; // 대용량 데이터 참조 해제
return function() {
return `총 ${summary}개 처리됨`;
};
}
const safe = createSafeClosure();
console.log(safe()); // 총 1000000개 처리됨
실전 예제: 디바운스와 스로틀
클로저를 활용한 이벤트 최적화 유틸리티입니다.
// 디바운스: 연속 호출 시 마지막 호출만 실행
function debounce(fn, delay) {
let timeoutId; // 클로저로 타이머 ID 유지
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 스로틀: 일정 시간 간격으로만 실행
function throttle(fn, interval) {
let lastTime = 0; // 클로저로 마지막 실행 시간 유지
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
// 사용 예시
const onSearch = debounce((query) => {
console.log(`검색: ${query}`);
}, 300);
const onScroll = throttle(() => {
console.log(`스크롤 위치: ${window?.scrollY ?? 0}`);
}, 100);
// 연속 호출해도 마지막 것만 실행
onSearch("J");
onSearch("Ja");
onSearch("Jav");
onSearch("Java"); // 300ms 후 "검색: Java"만 실행
고수 팁
팁 1: WeakRef로 클로저 메모리 최적화
// WeakRef: GC 대상이 될 수 있는 약한 참조
function createWeakCache() {
const cache = new Map();
return {
set(key, value) {
cache.set(key, new WeakRef(value));
},
get(key) {
const ref = cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
cache.delete(key); // GC됐으면 캐시에서도 제거
return undefined;
}
return value;
},
};
}
팁 2: 클로저로 안전한 상수 패턴
// Object.freeze + 클로저로 불변 설정 객체
const Config = (function() {
const _config = Object.freeze({
API_URL: "https://api.example.com",
MAX_RETRIES: 3,
TIMEOUT: 5000,
});
return {
get: (key) => _config[key],
getAll: () => ({ ..._config }),
};
})();
console.log(Config.get("API_URL")); // "https://api.example.com"
팁 3: 제너레이터와 클로저 결합
function createIdGenerator(prefix = "id") {
let current = 0;
return {
next: () => `${prefix}_${++current}`,
peek: () => `${prefix}_${current + 1}`,
reset: () => { current = 0; },
};
}
const userIdGen = createIdGenerator("user");
const postIdGen = createIdGenerator("post");
console.log(userIdGen.next()); // "user_1"
console.log(userIdGen.next()); // "user_2"
console.log(postIdGen.next()); // "post_1"