JavaScript 클로저와 실행 컨텍스트 완벽 이해

클로저란?

클로저(Closure)는 함수와 그 함수가 선언된 렉시컬 환경의 조합입니다. 쉽게 말해, 함수가 자신이 만들어진 환경의 변수를 기억하고 접근할 수 있는 특성입니다. 함수가 외부 스코프의 변수를 참조하면, 해당 스코프가 종료된 이후에도 변수에 접근할 수 있습니다.

클로저는 JavaScript의 가장 핵심적인 개념 중 하나이며, 데이터 캡슐화, 팩토리 패턴, 이벤트 핸들러, React의 hooks 등 곳곳에서 활용됩니다.

스코프 체인과 렉시컬 환경

JavaScript 엔진은 코드를 실행할 때 **실행 컨텍스트(Execution Context)**를 생성합니다. 각 실행 컨텍스트에는 변수를 저장하는 **렉시컬 환경(Lexical Environment)**이 포함되며, 외부 환경에 대한 참조를 통해 스코프 체인을 형성합니다.

const globalVar = "전역";

function outer() {
  const outerVar = "외부";

  function inner() {
    const innerVar = "내부";
    // 스코프 체인: inner → outer → global
    console.log(innerVar);   // "내부"   (자신의 스코프)
    console.log(outerVar);   // "외부"   (외부 함수 스코프)
    console.log(globalVar);  // "전역"   (전역 스코프)
  }

  inner();
}

outer();
// 내부
// 외부
// 전역

변수를 찾을 때 현재 스코프에서 시작하여 상위 스코프로 올라가며 탐색합니다. 이 체인이 스코프 체인입니다.

스코프 종류생성 시점예시
전역 스코프프로그램 시작const globalVar
함수 스코프함수 호출var, function 선언
블록 스코프블록 {} 진입let, const 선언

클로저의 동작 원리

클로저는 함수가 정의된 위치의 스코프를 기억합니다. 호출된 위치가 아닙니다.

function createCounter(initial = 0) {
  let count = initial;  // 이 변수가 클로저에 의해 유지됨

  return {
    increment() {
      count += 1;
      return count;
    },
    decrement() {
      count -= 1;
      return count;
    },
    getCount() {
      return count;
    },
  };
}

const counter = createCounter(10);
console.log(counter.increment());  // 11
console.log(counter.increment());  // 12
console.log(counter.decrement());  // 11
console.log(counter.getCount());   // 11

// count 변수에 직접 접근 불가 — 캡슐화
// console.log(count);  // ReferenceError

createCounter()가 실행을 마쳐도 count 변수는 반환된 메서드들이 참조하고 있으므로 가비지 컬렉션되지 않습니다. 이것이 클로저의 핵심입니다.

실전 패턴 1 — 데이터 캡슐화

클로저는 프라이빗 변수를 구현하는 데 사용됩니다. 외부에서 직접 접근할 수 없는 상태를 만들 수 있습니다.

function createWallet(ownerName, initialBalance = 0) {
  // 프라이빗 변수 — 외부에서 직접 접근 불가
  let balance = initialBalance;
  const transactions = [];

  function recordTransaction(type, amount) {
    transactions.push({
      type,
      amount,
      balance,
      timestamp: new Date().toISOString(),
    });
  }

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("양수만 입금 가능합니다");
      balance += amount;
      recordTransaction("입금", amount);
      return `${amount}원 입금 완료. 잔액: ${balance}원`;
    },
    withdraw(amount) {
      if (amount <= 0) throw new Error("양수만 출금 가능합니다");
      if (amount > balance) throw new Error("잔액이 부족합니다");
      balance -= amount;
      recordTransaction("출금", amount);
      return `${amount}원 출금 완료. 잔액: ${balance}원`;
    },
    getBalance() {
      return balance;
    },
    getHistory() {
      return [...transactions];  // 복사본 반환
    },
    getOwner() {
      return ownerName;
    },
  };
}

const wallet = createWallet("홍길동", 10000);
console.log(wallet.deposit(5000));
// 5000원 입금 완료. 잔액: 15000원
console.log(wallet.withdraw(3000));
// 3000원 출금 완료. 잔액: 12000원
console.log(wallet.getBalance());
// 12000

// balance, transactions에 직접 접근 불가
// console.log(wallet.balance);  // undefined

실전 패턴 2 — 함수 팩토리

클로저를 사용하면 설정값을 기억하는 특화된 함수를 생성할 수 있습니다.

// 멱등성 체크 함수 팩토리
function createRateLimiter(maxCalls, windowMs) {
  const calls = [];

  return function rateLimiter() {
    const now = Date.now();
    // 윈도우 시간이 지난 호출 제거
    while (calls.length > 0 && calls[0] <= now - windowMs) {
      calls.shift();
    }
    if (calls.length >= maxCalls) {
      return { allowed: false, retryAfter: calls[0] + windowMs - now };
    }
    calls.push(now);
    return { allowed: true, remaining: maxCalls - calls.length };
  };
}

// 1초에 3번까지 허용하는 제한기
const limiter = createRateLimiter(3, 1000);

console.log(limiter());  // { allowed: true, remaining: 2 }
console.log(limiter());  // { allowed: true, remaining: 1 }
console.log(limiter());  // { allowed: true, remaining: 0 }
console.log(limiter());  // { allowed: false, retryAfter: ... }

흔한 실수 — 루프에서의 클로저

var와 루프를 함께 사용하면 예상치 못한 동작이 발생합니다. 이는 클로저와 관련된 가장 유명한 함정입니다.

// 잘못된 예: var는 함수 스코프
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);  // 모두 3 출력 (루프 종료 후의 i 값)
  }, 100);
}
// 3, 3, 3

// 올바른 해결 1: let은 블록 스코프 (각 반복마다 새 바인딩)
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);  // 0, 1, 2 정상 출력
  }, 100);
}

// 올바른 해결 2: IIFE로 클로저 생성 (ES6 이전 방법)
for (var i = 0; i < 3; i++) {
  (function (captured) {
    setTimeout(() => {
      console.log(captured);  // 0, 1, 2
    }, 100);
  })(i);
}

var는 함수 스코프이므로 루프 전체에서 하나의 i를 공유합니다. let은 블록 스코프이므로 각 반복마다 독립적인 i가 생성됩니다.

메모리 고려사항

클로저가 참조하는 변수는 가비지 컬렉션되지 않으므로 메모리 누수에 주의해야 합니다.

function createHeavyClosure() {
  // 큰 데이터를 클로저가 참조
  const hugeArray = new Array(1000000).fill("data");

  return function process() {
    // hugeArray 전체가 메모리에 유지됨
    return hugeArray.length;
  };
}

// 개선: 필요한 데이터만 클로저에 포함
function createLightClosure() {
  const hugeArray = new Array(1000000).fill("data");
  const length = hugeArray.length;  // 필요한 값만 추출

  // hugeArray는 더 이상 참조되지 않아 GC 대상
  return function process() {
    return length;
  };
}

정리

클로저는 JavaScript의 핵심 메커니즘이며 올바르게 이해하면 강력한 패턴을 구현할 수 있습니다.

  • 정의: 함수와 그 함수가 선언된 렉시컬 환경의 조합
  • 스코프 체인: 현재 스코프에서 상위 스코프로 변수를 탐색하는 체인
  • 캡슐화: 프라이빗 변수를 구현하여 외부 접근을 제한
  • 팩토리: 설정값을 기억하는 특화된 함수 생성
  • 주의점: var + 루프 함정, 메모리 누수에 주의
  • 현대 활용: React hooks, 이벤트 핸들러, 커링, 메모이제이션 등에 폭넓게 활용

이 글이 도움이 되었나요?