[JavaScript] 변수의 스코프와 클로저

변수의 유효범위와 렉시컬 환경

자바스크립트에서 변수의 유효 범위(Scope)는 해당 변수가 어디서부터 어디까지 유효하게 사용될 수 있는지를 결정합니다. 특히, let과 const 키워드를 사용하면 블록 스코프(block scope)를 갖는 변수를 선언할 수 있습니다. 즉, 함수나 조건문, 반복문 등 { ... } 안에서 선언한 변수는 해당 블록 외부에서는 접근할 수 없습니다.

{
  let message = "안녕하세요!";
  console.log(message); // 출력: 안녕하세요!
}

console.log(typeof message); // 출력: undefined (message 변수는 블록 밖에서 접근 불가)

자바스크립트의 렉시컬 환경(Lexical Environment)은 코드가 작성된 구조를 기반으로 식별자와 변수의 유효 범위를 결정합니다. 각 실행 컨텍스트(execution context)는 자신만의 렉시컬 환경을 가지며, 이를 통해 변수의 접근과 생명 주기를 관리합니다.

클로저의 개념

클로저(Closure)는 함수가 선언되었을 때의 렉시컬 환경을 기억하여, 함수가 외부Scope 밖에서 호출되더라도 그 환경에 접근할 수 있는 기능을 말합니다. 즉, 함수와 그 함수가 선언된 상태에서의 주변 변수들로 구성된 패키지라고 할 수 있습니다.

function makeCounter() {
  let count = 0; // 클로저가 기억할 변수

  function counter() {
    count += 1;
    return count;
  }

  return counter;
}

const counter1 = makeCounter();
console.log(counter1()); // 출력: 1
console.log(counter1()); // 출력: 2

const counter2 = makeCounter();
console.log(counter2()); // 출력: 1 (counter2는 독립적인 클로저)

makeCounter 함수는 내부에 count 변수를 선언하고, 이 변수를 접근하는 counter 함수를 반환합니다. 이 counter 함수는 count에 대한 클로저를 형성하여, 외부에서 count에 직접 접근하지 않고도 값을 변경하고 유지할 수 있습니다.

클로저의 활용: 데이터 은닉과 상태 관리

클로저는 외부에 노출하고 싶지 않은 데이터를 은닉(encapsulation)하고, 상태(state)를 유지하는 데에 유용합니다. 이를 통해 모듈 패턴이나 정보 은닉을 구현할 수 있습니다.

은행 계좌 객체 생성 예제

function createBankAccount(initialBalance) {
  let balance = initialBalance; // 비공개 변수

  return {
    deposit(amount) {
      balance += amount;
      console.log(`입금: ${amount}원, 잔액: ${balance}원`);
    },
    withdraw(amount) {
      if (amount <= balance) {
        balance -= amount;
        console.log(`출금: ${amount}원, 잔액: ${balance}원`);
      } else {
        console.log('잔액이 부족합니다.');
      }
    },
    getBalance() {
      return balance;
    }
  };
}

const myAccount = createBankAccount(1000);
myAccount.deposit(500);   // 출력: 입금: 500원, 잔액: 1500원
myAccount.withdraw(200);  // 출력: 출금: 200원, 잔액: 1300원
console.log(myAccount.getBalance()); // 출력: 1300

위 코드에서 balance 변수는 외부에서 직접 접근할 수 없으며, 반환된 객체의 메서드를 통해서만 조작할 수 있습니다. 이를 통해 데이터의 무결성을 유지하고, 예기치 않은 변경을 방지할 수 있습니다.

반복문에서의 클로저 주의점

클로저를 반복문 내에서 사용할 때 변수의 스코프에 유의해야 합니다. 특히 var 키워드는 함수 스코프를 가지므로, 반복문에서 예상치 못한 동작을 일으킬 수 있습니다.
반면 let을 사용하면 각 반복마다 새로운 변수가 생성되어 올바른 값을 캡처합니다.

// 문제 발생: var 사용 시
function createCallbacks() {
  var callbacks = [];
  for (var i = 0; i < 3; i++) {
    callbacks.push(function() {
      console.log(`i의 값: ${i}`);
    });
  }
  return callbacks;
}

var callbacksVar = createCallbacks();
callbacksVar.forEach(function(cb) {
  cb(); // 모두 "i의 값: 3" 출력
});

// 해결 방법: let 사용하기
function createFixedCallbacks() {
  var callbacks = [];
  for (let i = 0; i < 3; i++) {
    callbacks.push(function() {
      console.log(`i의 값: ${i}`);
    });
  }
  return callbacks;
}

var callbacksLet = createFixedCallbacks();
callbacksLet.forEach(function(cb) {
  cb(); // "i의 값: 0", "i의 값: 1", "i의 값: 2" 출력
});

var로 선언된 i는 함수 스코프를 가지므로, 루프가 끝난 후의 값인 3이 클로저에 저장됩니다. 반면, let으로 선언하면 블록 스코프를 가지므로, 각 반복마다 새로운 i 변수가 생성되어 클로저에 저장됩니다. 따라서 의도한 대로 각기 다른 i 값을 출력할 수 있습니다.

클로저와 메모리 관리

클로저는 함수가 필요로 하는 변수와 함수를 메모리에 유지하기 때문에, 불필요한 클로저가 많아지면 메모리 누수가 발생할 수 있습니다.

function createHeavyCounter() {
  let count = 0;
  // 내부 함수를 여러 곳에 저장하면, count는 계속 유지됩니다.
  return function() {
    return count++;
  };
}

let heavyCounter = createHeavyCounter();
console.log(heavyCounter()); // 0

// 이후, heavyCounter에 대한 참조를 제거하면 그 클로저가 가비지 컬렉션 대상이 됩니다.
heavyCounter = null;