본문으로 건너뛰기
기술스택 · stack/javascript/유효범위와 클로저: 함수가 기억하는 상자

JavaScript · 6장 함수 심화

유효범위와 클로저: 함수가 기억하는 상자

함수 안에서 만든 변수는 함수가 끝나면 사라진다고 배웠어요. 그런데 그 함수가 안에서 만든 다른 함수를 돌려주면, 변수는 사라지지 않고 돌려준 함수와 함께 살아남아요. 이것이 클로저예요. 이름은 낯설어도 이벤트 핸들러 · 타이머 · React 훅 어디에나 숨어 있는 원리예요.

#6.3

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이 살아 있어요
①createIdGenerator('ORD')를 부르면 prefix와 n이 든 상자가 생기고, 돌려받은 orderId가 그 상자를 가리켜요. createIdGenerator('USR')는 새 상자를 만들어요. orderId()를 부를 때마다 자기 상자의 n이 1, 2, 3으로 늘고, userId의 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에 담아 둬요.

SIMPLE · 기술스택 / JavaScript — 6장 함수 심화