본문으로 건너뛰기
Advertisement

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"
Advertisement