JavaScript · 6장 함수 심화
유효범위와 클로저: 함수가 기억하는 상자
함수 안에서 만든 변수는 함수가 끝나면 사라진다고 배웠어요. 그런데 그 함수가 안에서 만든 다른 함수를 돌려주면, 변수는 사라지지 않고 돌려준 함수와 함께 살아남아요. 이것이 클로저예요. 이름은 낯설어도 이벤트 핸들러 · 타이머 · React 훅 어디에나 숨어 있는 원리예요.
01. 변수는 안쪽에서 바깥쪽으로 찾아요
변수가 보이는 범위를 유효범위(스코프)라고 해요. 중괄호 블록 · 함수 · 파일(모듈)마다 변수를 담는 상자가 하나씩 생기고, 상자는 바깥 상자 안에 겹쳐 있어요. 코드가 변수를 찾을 때는 지금 상자부터 시작해 바깥으로 한 칸씩 올라가요. 바깥에서는 안쪽 상자를 들여다볼 수 없어요(함수 안팎의 변수).
const rate = 0.1; // 바깥 상자
function priceWithTax(price) { // 함수 상자: price, label
const label = '부가세 포함';
return `${price * (1 + rate)}원 (${label})`; // rate는 바깥에서 찾아요
}
console.log(priceWithTax(1000)); // 1100원 (부가세 포함)
const name = '바깥';
{
const name = '안쪽'; // 같은 이름이면 가까운 상자가 이겨요
console.log(name); // 안쪽
}
console.log(name); // 바깥
console.log(label); // 함수 안의 변수를 바깥에서 읽으면?
중요한 점은 이 상자 구조가 코드를 쓴 위치로 정해진다는 거예요. 함수를 어디서 부르든, 그 함수가 바깥으로 찾아 올라가는 길은 함수를 만든 자리를 따라가요. 그래서 이 규칙을 "렉시컬(글자로 쓴 위치) 스코프"라고 불러요. 부르는 순간 정해지는 this(this)와 정반대예요.
02. 함수는 태어난 곳의 상자를 기억해요
함수는 만들어질 때 자기가 태어난 상자를 가리키는 연결을 함께 가져요. 바깥 함수가 끝나도, 안에서 만든 함수가 살아 있는 한 그 상자는 버려지지 않아요(닿을 수 있는 값). 함수와 그 함수가 기억하는 상자를 합쳐 클로저라고 해요.
function createIdGenerator(prefix) {
let n = 0; // 이 호출만의 상자에 있어요
return () => `${prefix}-${++n}`; // 상자를 기억하는 함수를 돌려줘요
}
const orderId = createIdGenerator('ORD');
const userId = createIdGenerator('USR'); // 부를 때마다 새 상자
console.log(orderId(), orderId()); // ORD-1 ORD-2
console.log(userId()); // USR-1 — 따로 세요
console.log(orderId()); // ORD-3 — 끝난 함수의 n이 살아 있어요
03. 실무에서 쓰는 세 가지 모양
비공개 상태. 상자 안의 변수는 돌려준 함수들로만 바꿀 수 있어요. 아무 데서나 잔액을 고치지 못하게 막고, 규칙(잔액보다 많이 뺄 수 없음)을 한곳에서 지키게 해요. 클래스의 #비공개 필드(9장)가 나오기 전부터 써 온 방식이고, 지금도 함수 중심 코드에서 많이 써요.
function createWallet(initial) {
let balance = initial; // 밖에서는 직접 못 건드려요
return {
deposit(amount) { balance += amount; return balance; },
withdraw(amount) {
if (amount > balance) return '잔액이 부족해요';
balance -= amount;
return balance;
},
getBalance: () => balance,
};
}
const wallet = createWallet(1000);
console.log(wallet.deposit(500), wallet.withdraw(300)); // 1500 1200
console.log(wallet.balance, wallet.getBalance()); // undefined 1200
console.log(wallet.withdraw(5000)); // 잔액이 부족해요
계산 캐시(메모이제이션). 함수를 감싸서, 같은 인자로 부르면 상자 안의 Map에 저장해 둔 답을 돌려줘요(Map 캐시). 같은 하위 문제를 반복해서 푸는 재귀(재귀)에서 효과가 커요.
function memoize(fn) {
const cache = new Map(); // 감싼 함수마다 자기 캐시
return n => {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
let plainCalls = 0;
function fib(n) { plainCalls++; return n < 2 ? n : fib(n - 1) + fib(n - 2); }
fib(25);
console.log(plainCalls); // 242785 — 같은 값을 계속 다시 계산해요
let memoCalls = 0;
const fastFib = memoize(n => { memoCalls++; return n < 2 ? n : fastFib(n - 1) + fastFib(n - 2); });
console.log(fastFib(25), memoCalls); // 75025 26
설정을 기억하는 함수 만들기. 설정값을 받아 그 설정을 기억하는 함수를 돌려주면, 비슷한 함수를 여러 개 찍어 낼 수 있어요. 입력 검사, 포맷터, 이벤트 핸들러를 만들 때 흔한 모양이에요.
const minLength = min => text => text.length >= min; // 화살표 두 번 = 함수를 돌려주는 함수
const isPassword = minLength(8);
const isNickname = minLength(2);
console.log(isPassword('abc123'), isNickname('하늘')); // false true
04. 값이 아니라 변수를 기억해요
클로저는 만들어진 순간의 값을 찍어 두는 게 아니라 변수 자체를 가리켜요. 그래서 나중에 변수가 바뀌면 함수가 읽는 값도 바뀌어요. 의도한 동작이면 편하지만, 모르고 쓰면 "옛날 값"이나 "새 값"을 엉뚱하게 읽는 버그가 돼요.
let discount = 0.1;
const finalPrice = price => price * (1 - discount); // discount 변수를 가리켜요
console.log(finalPrice(10000)); // 9000
discount = 0.3;
console.log(finalPrice(10000)); // 7000 — 바뀐 값을 읽어요
- 반복문과
var:let은 반복마다 새 상자를 만들지만var는 하나를 함께 써서, 반복문 안에서 만든 함수들이 모두 마지막 값을 봐요. 다음 글(var와 즉시 실행 함수)에서 다뤄요. - React의 오래된 값: 컴포넌트 함수 안에서 만든 콜백은 그 렌더링 때의 상태 변수를 기억해요. 타이머 안에서 상태를 읽으면 옛날 값이 보이는 이유예요. 그래서
setCount(c => c + 1)처럼 최신 값을 받는 함수를 넘겨요. - 메모리: 클로저가 살아 있으면 상자 안의 값도 함께 살아 있어요. 큰 배열을 기억하는 리스너를 지우지 않으면 누수가 돼요(누수).
설정 불러오기처럼 처음 한 번만 실행하고, 그 뒤에는 처음 결과를 그대로 돌려주는 함수를 만드는 once(fn)을 완성해 보세요. "초기화"가 한 번만 찍히고 42 42 42가 나와야 해요.
function once(fn) {
// 여기를 채워요
return fn;
}
const init = once(() => {
console.log('초기화');
return 42;
});
console.log(init(), init(), init());
해답 보기
"이미 실행했는지"와 "처음 결과"를 상자 안 변수에 두고, 돌려준 함수가 그 변수를 확인해요. 넘어온 인자는 나머지 매개변수로 받아 그대로 전해 줘요(나머지 매개변수).
function once(fn) {
let done = false;
let result;
return (...args) => {
if (!done) {
done = true;
result = fn(...args);
}
return result;
};
}
// 초기화
// 42 42 42
05. 실전 포인트
- 언제 쓰나요? 상태를 숨기고 정해진 함수로만 바꾸게 할 때, 계산 결과를 캐시할 때, 설정을 기억하는 함수(검사 · 포맷 · 핸들러)를 찍어 낼 때,
once· 디바운스(이 장 끝) 같은 도우미를 만들 때 써요. - 대신 쓰는 것: 상태와 메서드가 많고 여러 개를 찍어 낸다면 클래스(9장)가 더 읽기 쉬울 수 있어요. 파일 전체에서 하나만 있으면 되는 상태는 모듈 변수로 충분해요.
- 흔한 실수: 클로저가 값을 찍어 둔다고 생각하는 것, 캐시 클로저를 지우지 않아 계속 커지게 두는 것, 반복문에서
var로 만든 함수들이 같은 값을 보는 것, React 타이머 안에서 옛날 상태를 읽는 것이에요.
정리
- 변수는 지금 상자부터 바깥으로 찾아요. 상자 구조는 코드를 쓴 위치로 정해져요.
- 함수는 태어난 상자를 기억해요. 바깥 함수가 끝나도 상자는 함께 살아남아요. 이것이 클로저예요.
- 비공개 상태 · 캐시 · 설정을 기억하는 함수가 클로저의 대표적인 쓰임이에요.
- 클로저는 값이 아니라 변수를 기억해요. 변수가 바뀌면 읽는 값도 바뀌어요.
createIdGenerator('A')를 두 번 불러 만든 두 함수는 같은 번호를 이어서 셀까지요?
아니에요. 바깥 함수를 부를 때마다 새 상자가 생겨서, 두 함수는 각자의 n을 따로 세요.
wallet.balance가 undefined인 이유는?
balance는 돌려준 객체의 속성이 아니라 createWallet 호출의 상자 안에 있는 변수라서요. 그 상자를 기억하는 deposit · getBalance 같은 함수로만 닿을 수 있어요.
클로저가 기억한 변수를 나중에 바꾸면, 함수가 읽는 값도 바뀔까요?
네, 바뀌어요. 클로저는 만들 때의 값을 복사해 두는 게 아니라 변수 자체를 가리키기 때문이에요. 그때 값을 고정하고 싶으면 그 값을 인자로 넘기거나 새 const에 담아 둬요.