JavaScript 클로저 - 함수가 환경을 기억하는 방법

한 줄 요약

클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합으로, 함수가 생성될 때 외부 변수에 대한 참조를 유지하여 외부 함수 실행이 끝난 후에도 외부 변수에 접근할 수 있게 합니다.

다음 코드를 보세요:

function outer() {
  const secret = "비밀번호";

  return function inner() {
    console.log(secret); // outer 함수가 끝났는데도 접근 가능!
  };
}

const getSecret = outer();
getSecret(); // "비밀번호"

outer() 함수는 이미 실행이 끝났습니다. 일반적으로 함수가 끝나면 그 안의 변수들은 사라져야 합니다. 그런데 신기하게도 inner() 함수는 secret 변수에 여전히 접근할 수 있습니다. 이것이 바로 클로저의 핵심 동작입니다.

클로저는 JavaScript의 가장 강력한 기능 중 하나로, 데이터 캡슐화, 모듈 패턴, 함수형 프로그래밍의 핵심입니다. 하지만 잘못 사용하면 메모리 누수를 일으킬 수도 있습니다. 이 문서에서는 MDN 공식 문서를 기반으로 클로저의 원리부터 실전 활용까지 완벽하게 설명합니다.

먼저, 기초부터 이해하기

왜 클로저를 알아야 하나?

클로저를 이해하지 못하면 다음과 같은 코드의 동작을 설명할 수 없습니다:

// 예제 1: 왜 이런 결과가 나올까?
function createCounter() {
  let count = 0;

  return {
    increment() { count++; },
    get() { return count; }
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.get()); // 2
console.log(counter.count); // undefined - 왜 접근 불가?

// 예제 2: 루프와 클로저
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 출력: 3, 3, 3 - 왜 0, 1, 2가 아닐까?

// 예제 3: 독립적인 인스턴스
const counter1 = createCounter();
const counter2 = createCounter();

counter1.increment();
counter1.increment();
counter2.increment();

console.log(counter1.get()); // 2
console.log(counter2.get()); // 1 - 왜 독립적일까?

이 모든 것이 클로저 때문입니다!

클로저의 정의

MDN 공식 문서에 따르면:

“A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).”

번역하면: 클로저는 함수와 그 함수를 둘러싼 상태(렉시컬 환경)에 대한 참조가 함께 묶인 조합입니다.

더 쉽게 말하면:

  • 함수는 자신이 생성될 때의 환경을 기억합니다
  • 그 환경에 있던 변수들에 나중에도 접근할 수 있습니다
  • 이것이 클로저입니다
┌─────────────────────────────────────┐
│  클로저 = 함수 + 렉시컬 환경         │
│                                     │
│  ┌───────────┐   ┌──────────────┐  │
│  │  함수     │ + │ 렉시컬 환경   │  │
│  │  코드     │   │ (외부 변수)   │  │
│  └───────────┘   └──────────────┘  │
│                                     │
│  함수가 외부 변수를 "기억"합니다     │
└─────────────────────────────────────┘

중요: JavaScript에서는 함수가 생성될 때마다 클로저가 만들어집니다!

렉시컬 스코핑 (Lexical Scoping)

클로저를 이해하려면 먼저 렉시컬 스코핑을 이해해야 합니다.

렉시컬 스코핑이란?

“Lexical scoping means that the accessibility of variables is determined by their location in the source code.”

렉시컬 스코핑은 변수의 접근 가능성이 소스 코드에서 선언된 위치에 따라 결정되는 방식입니다.

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

  function inner() {
    console.log(outerVar); // "외부" - 접근 가능!
  }

  inner();
}

outer();

핵심 원리:

  1. 함수는 자신이 선언된 위치를 기준으로 스코프가 결정됩니다
  2. 내부 함수는 외부 함수의 변수에 접근할 수 있습니다
  3. 이것은 함수가 어디서 호출되는지와 무관합니다

스코프 체인

중첩된 함수는 여러 단계의 외부 스코프에 접근할 수 있습니다:

const global = "전역";

function level1() {
  const var1 = "레벨1";

  function level2() {
    const var2 = "레벨2";

    function level3() {
      const var3 = "레벨3";

      // 모든 상위 스코프에 접근 가능
      console.log(var3);   // "레벨3" (자신의 스코프)
      console.log(var2);   // "레벨2" (부모 스코프)
      console.log(var1);   // "레벨1" (조부모 스코프)
      console.log(global); // "전역" (전역 스코프)
    }

    level3();
  }

  level2();
}

level1();

스코프 체인 시각화:

┌─────────────────────────────────────┐
│ 전역 스코프                          │
│   global: "전역"                    │
│                                     │
│  ┌──────────────────────────────┐   │
│  │ level1 스코프                 │   │
│  │   var1: "레벨1"              │   │
│  │                              │   │
│  │  ┌───────────────────────┐   │   │
│  │  │ level2 스코프          │   │   │
│  │  │   var2: "레벨2"       │   │   │
│  │  │                       │   │   │
│  │  │  ┌────────────────┐   │   │   │
│  │  │  │ level3 스코프   │   │   │   │
│  │  │  │   var3: "레벨3"│   │   │   │
│  │  │  │                │   │   │   │
│  │  │  │ 안쪽에서 바깥쪽  │   │   │   │
│  │  │  │ 모두 접근 가능!  │   │   │   │
│  │  │  └────────────────┘   │   │   │
│  │  └───────────────────────┘   │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

클로저의 동작 원리

클로저가 정말 강력한 이유는 외부 함수가 끝난 후에도 외부 변수에 접근할 수 있다는 것입니다.

기본 예제

function makeFunc() {
  const name = "Mozilla";

  function displayName() {
    console.log(name);
  }

  return displayName;
}

const myFunc = makeFunc();
myFunc(); // "Mozilla"

무슨 일이 일어났을까?

  1. makeFunc() 호출 → 함수 실행 컨텍스트 생성
  2. name 변수 생성
  3. displayName 함수 생성 → 이때 클로저 형성
  4. displayName 반환
  5. makeFunc() 실행 종료 → 일반적으로는 name이 사라져야 함!
  6. 하지만 displayNamename을 참조하고 있어서 유지됨
  7. myFunc() 호출 → name에 여전히 접근 가능!

실행 컨텍스트 관점:

1. makeFunc() 호출
┌─────────────────────────────────────┐
│ makeFunc 실행 컨텍스트               │
│   name: "Mozilla"                   │
│   displayName: <function>           │
└─────────────────────────────────────┘

2. makeFunc() 종료, displayName 반환
┌─────────────────────────────────────┐
│ makeFunc 환경 (유지됨!)              │
│   name: "Mozilla" ← 클로저로 보존    │
│   ↑                                 │
│   └── displayName이 참조 유지        │
└─────────────────────────────────────┘

3. myFunc() (displayName) 호출
┌─────────────────────────────────────┐
│ displayName 실행 컨텍스트            │
│   (변수 없음)                        │
│   Outer Reference: makeFunc 환경 ───┐
└──────────────────────────────────────┤
                                      ↓
┌──────────────────────────────────────┤
│ makeFunc 환경 (여전히 살아있음!)      │
│   name: "Mozilla" ← 접근 가능!       │
└──────────────────────────────────────┘

핵심:

  • 일반적으로 함수 실행이 끝나면 그 환경은 사라집니다
  • 하지만 클로저가 있으면 환경이 메모리에 유지됩니다
  • 반환된 함수가 외부 변수를 계속 사용할 수 있습니다

실용적 클로저 패턴

클로저는 실제 개발에서 매우 유용하게 사용됩니다.

1. 함수 팩토리 (Function Factory)

같은 구조지만 다른 데이터를 가진 함수를 생성할 수 있습니다.

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(2));  // 7 (5 + 2)
console.log(add10(2)); // 12 (10 + 2)
console.log(add5(3));  // 8 (5 + 3)
console.log(add10(3)); // 13 (10 + 3)

각 함수는 독립적인 클로저를 가집니다:

add5 클로저:
┌─────────────────────────────────────┐
│ makeAdder 환경 #1                   │
│   x: 5                              │
│   ↑                                 │
│   └── add5가 참조                    │
└─────────────────────────────────────┘

add10 클로저:
┌─────────────────────────────────────┐
│ makeAdder 환경 #2                   │
│   x: 10                             │
│   ↑                                 │
│   └── add10이 참조                   │
└─────────────────────────────────────┘

서로 독립적! add5의 x와 add10의 x는 다른 변수

2. 데이터 캡슐화 (Private Variables)

클로저를 사용하면 private 변수를 만들 수 있습니다.

function createCounter() {
  let count = 0; // private 변수

  return {
    increment() {
      count++;
      return count;
    },

    decrement() {
      count--;
      return count;
    },

    getValue() {
      return count;
    }
  };
}

const counter = createCounter();

console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getValue());  // 2
console.log(counter.decrement()); // 1

// ❌ count에 직접 접근 불가
console.log(counter.count); // undefined
// count = 100; // 불가능! count는 외부에서 접근 불가

캡슐화 원리:

createCounter() 호출 후:
┌─────────────────────────────────────┐
│ createCounter 환경 (클로저로 유지)    │
│   count: 0 ← private 변수!          │
│   ↑    ↑    ↑                       │
│   │    │    │                       │
│   │    │    └─ getValue 참조         │
│   │    └────── decrement 참조        │
│   └─────────── increment 참조        │
└─────────────────────────────────────┘

외부에서는 count에 직접 접근 불가!
메서드를 통해서만 조작 가능 (캡슐화)

3. 모듈 패턴 (Module Pattern)

IIFE와 클로저를 결합하여 모듈을 만들 수 있습니다.

const ConfigManager = (function() {
  // Private 변수들
  let settings = {
    apiUrl: "https://api.example.com",
    timeout: 5000,
    debug: false
  };

  const defaultSettings = { ...settings };

  // Private 함수
  function validateKey(key) {
    return key in settings;
  }

  // Public API
  return {
    get(key) {
      if (validateKey(key)) {
        return settings[key];
      }
      throw new Error(`Invalid key: ${key}`);
    },

    set(key, value) {
      if (validateKey(key)) {
        settings[key] = value;
        return true;
      }
      return false;
    },

    reset() {
      settings = { ...defaultSettings };
    },

    getAll() {
      return { ...settings }; // 불변 복사본 반환
    }
  };
})();

// 사용
console.log(ConfigManager.get("apiUrl")); // "https://api.example.com"
ConfigManager.set("debug", true);
console.log(ConfigManager.get("debug")); // true

// ❌ Private 변수/함수 접근 불가
console.log(ConfigManager.settings); // undefined
console.log(ConfigManager.validateKey); // undefined

실제 프로젝트 예제 (config.js 패턴):

// 초기 설정 (불변)
const defaultSettings = Object.freeze({
  DEBUG: false,
  MOBILE_BREAKPOINT: 768,
  SCROLL_THRESHOLD: 300
});

// 현재 설정 상태 (클로저로 보호)
let currentSettings = { ...defaultSettings };

// Public API (ES6 모듈)
export const get = (key) => currentSettings[key];

export const set = (key, value) => {
  currentSettings = Object.freeze({
    ...currentSettings,
    [key]: value
  });
  return currentSettings;
};

export const reset = () => {
  currentSettings = Object.freeze({ ...defaultSettings });
  return currentSettings;
};

// 사용
import { get, set, reset } from './config.js';

console.log(get('DEBUG')); // false
set('DEBUG', true);
console.log(get('DEBUG')); // true
reset();
console.log(get('DEBUG')); // false

클로저의 역할:

모듈 스코프:
┌─────────────────────────────────────┐
│ defaultSettings (private)           │
│ currentSettings (private)           │
│   ↑         ↑           ↑           │
│   │         │           │           │
│ get()     set()     reset()         │
│   ↑         ↑           ↑           │
│   └─────────┴───────────┘           │
│        Public API                   │
└─────────────────────────────────────┘

외부에서는 currentSettings에 직접 접근 불가!
get(), set(), reset()을 통해서만 조작 가능
→ 캡슐화와 데이터 보호

4. 이벤트 핸들러와 콜백

클로저는 이벤트 핸들러에서 매우 유용합니다.

function setupButtons() {
  const buttons = document.querySelectorAll('.btn');

  buttons.forEach((button, index) => {
    button.addEventListener('click', function() {
      // index는 클로저로 보존됨
      console.log(`버튼 ${index} 클릭됨`);
      this.textContent = `클릭됨 (${index})`;
    });
  });
}

setupButtons();

실전 예제: 텍스트 크기 조절

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = `${size}px`;
  };
}

const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;

각 핸들러는 자신만의 size 값을 클로저로 기억합니다!

5. 커링 (Currying)

클로저를 활용한 함수형 프로그래밍 패턴입니다.

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }

    return function(...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}

// 예제 함수
function sum(a, b, c) {
  return a + b + c;
}

const curriedSum = curry(sum);

// 다양한 호출 방식
console.log(curriedSum(1)(2)(3));    // 6
console.log(curriedSum(1, 2)(3));    // 6
console.log(curriedSum(1)(2, 3));    // 6

// 부분 적용
const add5 = curriedSum(5);
console.log(add5(10, 15)); // 30
console.log(add5(20, 25)); // 50

클로저가 인자를 기억:

curriedSum(5) 호출:
┌─────────────────────────────────────┐
│ curried 환경                        │
│   args: [5] ← 클로저로 보존          │
│   ↑                                 │
│   └── 반환된 함수가 참조              │
└─────────────────────────────────────┘

add5(10, 15) 호출:
이전 args [5]와 새 nextArgs [10, 15]를 결합
→ curried([5, 10, 15])
→ fn(5, 10, 15) = 30

6. 메모이제이션 (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);
    }

    console.log('계산 중:', key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// 피보나치 수열
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const memoizedFib = memoize(fibonacci);

console.log(memoizedFib(40)); // 계산 중 (느림)
console.log(memoizedFib(40)); // 캐시에서 반환 (즉시!)

캐시가 클로저로 유지:

memoize(fibonacci) 호출:
┌─────────────────────────────────────┐
│ memoize 환경                        │
│   cache: Map { } ← 클로저로 보존     │
│   ↑                                 │
│   └── 반환된 함수가 참조              │
└─────────────────────────────────────┘

memoizedFib(40) 첫 호출:
cache에 결과 저장

memoizedFib(40) 두 번째 호출:
cache에서 즉시 반환 (같은 cache 객체!)

흔한 함정과 해결책

함정 1: 루프에서의 클로저

가장 흔한 클로저 실수입니다!

// ❌ 문제 코드: 모두 3 출력
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 3, 3, 3
  }, 100);
}

왜 이런 일이 발생할까?

루프 종료 후:
┌─────────────────────────────────────┐
│ 전역 스코프                          │
│   i: 3 ← 하나의 i만 존재             │
│   ↑  ↑  ↑                           │
│   │  │  │                           │
│   │  │  └── 세 번째 콜백             │
│   │  └───── 두 번째 콜백             │
│   └──────── 첫 번째 콜백             │
│                                     │
│ 모든 콜백이 같은 i를 참조!            │
└─────────────────────────────────────┘

해결책 1: let 사용 (가장 간단, 권장)

// ✅ 해결: let은 블록 스코프
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2
  }, 100);
}

let이 해결하는 이유:

각 반복마다 새로운 블록 스코프:
┌─────────────────────────────────────┐
│ 반복 1 블록                          │
│   i: 0 ← 첫 번째 콜백만 참조          │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 반복 2 블록                          │
│   i: 1 ← 두 번째 콜백만 참조          │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 반복 3 블록                          │
│   i: 2 ← 세 번째 콜백만 참조          │
└─────────────────────────────────────┘

각 콜백이 독립적인 i를 참조!

해결책 2: IIFE로 클로저 생성

// ✅ 해결: IIFE로 각 i를 포획
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2
    }, 100);
  })(i);
}

IIFE가 해결하는 이유:

각 반복마다 새로운 함수 실행:
┌─────────────────────────────────────┐
│ IIFE 환경 #1                        │
│   j: 0 ← 첫 번째 콜백만 참조          │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ IIFE 환경 #2                        │
│   j: 1 ← 두 번째 콜백만 참조          │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ IIFE 환경 #3                        │
│   j: 2 ← 세 번째 콜백만 참조          │
└─────────────────────────────────────┘

각 IIFE가 자신만의 j를 가짐!

해결책 3: 함수 팩토리 사용

// ✅ 해결: 함수 팩토리
function makeLogger(value) {
  return function() {
    console.log(value);
  };
}

for (var i = 0; i < 3; i++) {
  setTimeout(makeLogger(i), 100); // 0, 1, 2
}

함정 2: 메모리 누수

클로저는 외부 변수를 계속 참조하므로 메모리 누수가 발생할 수 있습니다.

// ❌ 메모리 누수 발생
function createHeavyClosure() {
  const hugeArray = new Array(1000000).fill('메모리 낭비');

  // 이 함수가 살아있는 한 hugeArray도 메모리에 유지됨
  return function() {
    console.log(hugeArray.length);
  };
}

const leak = createHeavyClosure();
// hugeArray는 계속 메모리를 차지함

해결책: 필요 없어진 참조 제거

// ✅ 명시적으로 참조 제거
let closure = createHeavyClosure();

// 사용 후
closure = null; // 이제 GC가 수거 가능

더 나은 해결책: 필요한 데이터만 보관

// ✅ 필요한 것만 클로저에 포함
function createOptimizedClosure() {
  const hugeArray = new Array(1000000).fill('데이터');
  const length = hugeArray.length; // 필요한 값만 추출

  // hugeArray는 이 함수가 끝나면 GC 대상
  return function() {
    console.log(length); // 작은 값만 유지
  };
}

함정 3: this 바인딩 문제

클로저와 this를 함께 사용할 때 주의가 필요합니다.

// ❌ 문제: this가 예상과 다름
const obj = {
  value: 42,

  createGetter() {
    return function() {
      return this.value; // this가 obj가 아님!
    };
  }
};

const getter = obj.createGetter();
console.log(getter()); // undefined (또는 에러)

해결책 1: 화살표 함수 사용

// ✅ 화살표 함수는 상위 this를 상속
const obj = {
  value: 42,

  createGetter() {
    return () => {
      return this.value; // obj의 this
    };
  }
};

const getter = obj.createGetter();
console.log(getter()); // 42

해결책 2: 변수에 this 저장

// ✅ 클로저로 this 포획
const obj = {
  value: 42,

  createGetter() {
    const self = this; // this를 변수에 저장

    return function() {
      return self.value; // 클로저로 self 접근
    };
  }
};

const getter = obj.createGetter();
console.log(getter()); // 42

성능과 메모리 고려사항

1. 불필요한 클로저 생성 방지

MDN에 따르면:

“Creating closures unnecessarily can negatively affect script performance and memory consumption.”

비효율적 방식:

// ❌ 인스턴스마다 메서드 생성 (비효율)
function MyObject(name) {
  this.name = name;

  // 매번 새로운 함수 생성!
  this.getName = function() {
    return this.name;
  };
}

const obj1 = new MyObject("Alice");
const obj2 = new MyObject("Bob");

// obj1.getName !== obj2.getName (각각 다른 함수)

효율적 방식: 프로토타입 사용

// ✅ 프로토타입 메서드 (효율적)
function MyObject(name) {
  this.name = name;
}

// 모든 인스턴스가 공유
MyObject.prototype.getName = function() {
  return this.name;
};

const obj1 = new MyObject("Alice");
const obj2 = new MyObject("Bob");

// obj1.getName === obj2.getName (같은 함수)

메모리 사용량 비교:

클로저 방식 (비효율):
┌─────────────────────────────────────┐
│ obj1                                │
│   name: "Alice"                     │
│   getName: function() { ... } ◄─┐   │
└──────────────────────────────────┼──┘
┌──────────────────────────────────┼──┐
│ obj2                             │  │
│   name: "Bob"                    │  │
│   getName: function() { ... } ◄─┼─┐│
└──────────────────────────────────┘ ││
                                     ││
두 개의 getName 함수가 메모리 차지! ─┴┘

프로토타입 방식 (효율):
┌─────────────────────────────────────┐
│ MyObject.prototype                  │
│   getName: function() { ... } ◄──┐  │
└──────────────────────────────────┼──┘
┌──────────────────────────────────┼──┐
│ obj1                             │  │
│   name: "Alice"                  │  │
│   __proto__: MyObject.prototype ─┘  │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ obj2                                │
│   name: "Bob"                       │
│   __proto__: MyObject.prototype ─┐  │
└──────────────────────────────────┼──┘
                                   │
하나의 getName만 메모리 차지! ◄────┘

2. 클로저 사용이 적절한 경우

다음 상황에서는 클로저가 최선의 선택입니다:

적절한 경우:

  • Private 데이터가 필요할 때
  • 함수 팩토리 패턴
  • 콜백과 이벤트 핸들러
  • 모듈 패턴

부적절한 경우:

  • 단순한 인스턴스 메서드 (프로토타입 사용)
  • 대량의 데이터를 보관해야 할 때
  • 성능이 중요한 반복 작업

클로저 디버깅

1. DevTools에서 클로저 확인하기

Chrome DevTools의 Scope 패널에서 클로저를 볼 수 있습니다:

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

  function inner() {
    debugger; // 여기서 중단점
    count++;
    console.log(outerVar, count);
  }

  return inner;
}

const fn = outer();
fn();

DevTools에서 보이는 것:

  • Local: 현재 함수의 변수
  • Closure (outer): 클로저로 접근 가능한 변수
    • outerVar: "외부"
    • count: 1

2. console.dir()로 클로저 내용 확인

function createCounter() {
  let count = 0;

  return {
    increment() { count++; },
    get() { return count; }
  };
}

const counter = createCounter();

// 브라우저 콘솔에서
console.dir(counter.increment);
// [[Scopes]] 항목에서 클로저 확인 가능

실전 프로젝트 예제

완전한 설정 관리 모듈

/**
 * Configuration Manager
 * 클로저를 활용한 설정 관리 시스템
 */

const ConfigManager = (function() {
  // Private: 기본 설정 (불변)
  const defaultSettings = Object.freeze({
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    debug: false,
    maxRetries: 3
  });

  // Private: 현재 설정 (클로저로 보호)
  let currentSettings = { ...defaultSettings };

  // Private: 변경 이력
  const changeHistory = [];

  // Private: 구독자들
  const subscribers = new Set();

  // Private: 유효성 검사
  function validateKey(key) {
    return key in defaultSettings;
  }

  function validateValue(key, value) {
    // 타입 검사
    const expectedType = typeof defaultSettings[key];
    const actualType = typeof value;

    if (expectedType !== actualType) {
      throw new TypeError(
        `Expected ${expectedType} for ${key}, got ${actualType}`
      );
    }

    // 도메인별 검증
    if (key === 'timeout' && value < 0) {
      throw new RangeError('Timeout must be positive');
    }

    if (key === 'maxRetries' && (value < 0 || value > 10)) {
      throw new RangeError('MaxRetries must be between 0 and 10');
    }

    return true;
  }

  // Private: 변경 기록
  function recordChange(key, oldValue, newValue) {
    changeHistory.push({
      key,
      oldValue,
      newValue,
      timestamp: Date.now()
    });

    // 이력은 최대 100개만 보관
    if (changeHistory.length > 100) {
      changeHistory.shift();
    }
  }

  // Private: 구독자 알림
  function notifySubscribers(key, value) {
    subscribers.forEach(callback => {
      try {
        callback(key, value);
      } catch (error) {
        console.error('Subscriber error:', error);
      }
    });
  }

  // Public API
  return {
    /**
     * 설정값 가져오기
     */
    get(key) {
      if (!validateKey(key)) {
        throw new Error(`Unknown setting: ${key}`);
      }
      return currentSettings[key];
    },

    /**
     * 설정값 변경
     */
    set(key, value) {
      if (!validateKey(key)) {
        throw new Error(`Unknown setting: ${key}`);
      }

      validateValue(key, value);

      const oldValue = currentSettings[key];

      // 불변성 유지
      currentSettings = Object.freeze({
        ...currentSettings,
        [key]: value
      });

      recordChange(key, oldValue, value);
      notifySubscribers(key, value);

      return currentSettings;
    },

    /**
     * 여러 설정 한번에 변경
     */
    setMany(updates) {
      // 먼저 모두 검증
      Object.entries(updates).forEach(([key, value]) => {
        if (!validateKey(key)) {
          throw new Error(`Unknown setting: ${key}`);
        }
        validateValue(key, value);
      });

      // 검증 통과하면 적용
      Object.entries(updates).forEach(([key, value]) => {
        this.set(key, value);
      });

      return currentSettings;
    },

    /**
     * 모든 설정 가져오기 (불변 복사본)
     */
    getAll() {
      return { ...currentSettings };
    },

    /**
     * 설정 초기화
     */
    reset() {
      const oldSettings = currentSettings;
      currentSettings = Object.freeze({ ...defaultSettings });

      Object.keys(oldSettings).forEach(key => {
        if (oldSettings[key] !== currentSettings[key]) {
          recordChange(key, oldSettings[key], currentSettings[key]);
        }
      });

      notifySubscribers('*', currentSettings);

      return currentSettings;
    },

    /**
     * 변경 이력 조회
     */
    getHistory(limit = 10) {
      return changeHistory.slice(-limit).map(item => ({ ...item }));
    },

    /**
     * 변경 구독
     */
    subscribe(callback) {
      if (typeof callback !== 'function') {
        throw new TypeError('Callback must be a function');
      }

      subscribers.add(callback);

      // 구독 해제 함수 반환
      return function unsubscribe() {
        subscribers.delete(callback);
      };
    },

    /**
     * 디버그 정보
     */
    debug() {
      if (!this.get('debug')) {
        console.warn('Debug mode is off');
        return;
      }

      console.group('ConfigManager Debug Info');
      console.log('Current Settings:', this.getAll());
      console.log('History:', this.getHistory());
      console.log('Subscribers:', subscribers.size);
      console.groupEnd();
    }
  };
})();

// 사용 예제
ConfigManager.set('debug', true);
ConfigManager.set('timeout', 10000);

// 구독
const unsubscribe = ConfigManager.subscribe((key, value) => {
  console.log(`Setting changed: ${key} = ${value}`);
});

ConfigManager.set('maxRetries', 5); // "Setting changed: maxRetries = 5"

// 디버그
ConfigManager.debug();

// 구독 해제
unsubscribe();

베스트 프랙티스

1. 명확한 의도로 클로저 사용

// ✅ 좋음: 의도가 명확 (캡슐화)
function createBankAccount(initialBalance) {
  let balance = initialBalance; // private

  return {
    deposit(amount) {
      if (amount > 0) {
        balance += amount;
        return balance;
      }
      throw new Error('Amount must be positive');
    },

    withdraw(amount) {
      if (amount > balance) {
        throw new Error('Insufficient funds');
      }
      balance -= amount;
      return balance;
    },

    getBalance() {
      return balance;
    }
  };
}

// ❌ 나쁨: 불필요한 클로저
function process(data) {
  return function() {
    return data.map(x => x * 2); // 단순 처리는 클로저 불필요
  };
}

// ✅ 더 나음: 직접 반환
function process(data) {
  return data.map(x => x * 2);
}

2. 클로저 대신 클래스 고려

// 클로저 방식
function createCounter() {
  let count = 0;

  return {
    increment() { count++; },
    get() { return count; }
  };
}

// 클래스 방식 (ES6+)
class Counter {
  #count = 0; // private 필드

  increment() { this.#count++; }
  get() { return this.#count; }
}

// 둘 다 괜찮지만, 클래스가 더 명시적이고 프로토타입 효율성도 있음

3. 명시적인 클린업

// ✅ 리소스 해제가 필요한 경우
function createTimer(callback, interval) {
  let timerId = setInterval(callback, interval);

  return {
    stop() {
      clearInterval(timerId);
      timerId = null; // 참조 제거
    }
  };
}

const timer = createTimer(() => console.log('tick'), 1000);

// 사용 후 정리
timer.stop();

4. 문서화

클로저를 사용할 때는 명확히 문서화하세요:

/**
 * Creates a rate limiter using closure
 * @param {Function} fn - Function to rate limit
 * @param {number} limit - Maximum calls per interval
 * @param {number} interval - Time window in ms
 * @returns {Function} Rate-limited function
 *
 * @example
 * const limited = rateLimit(api.call, 10, 1000);
 * limited(); // OK
 *
 * Note: Maintains internal state via closure.
 * Each call to rateLimit creates independent state.
 */
function rateLimit(fn, limit, interval) {
  const calls = []; // Closure: call timestamps

  return function(...args) {
    const now = Date.now();

    // Remove old calls
    while (calls.length && calls[0] < now - interval) {
      calls.shift();
    }

    if (calls.length < limit) {
      calls.push(now);
      return fn.apply(this, args);
    }

    throw new Error('Rate limit exceeded');
  };
}

참고 자료

공식 문서

관련 문서

이 저장소의 다른 JavaScript 문서들:

마치며

클로저는 JavaScript의 가장 강력하면서도 이해하기 어려운 개념 중 하나입니다. 하지만 제대로 이해하면:

데이터 캡슐화를 구현할 수 있습니다 ✅ 모듈 패턴으로 깔끔한 API를 만들 수 있습니다 ✅ 함수형 프로그래밍 패턴을 활용할 수 있습니다 ✅ 메모리 관리를 더 잘 이해하게 됩니다 ✅ 이벤트 핸들러를 효과적으로 작성할 수 있습니다

핵심을 기억하세요:

  • 클로저 = 함수 + 렉시컬 환경
  • 함수는 생성된 곳의 환경을 기억합니다
  • 외부 함수가 끝나도 외부 변수에 접근할 수 있습니다
  • 강력하지만 신중하게 사용해야 합니다 (메모리 고려)

클로저를 마스터하면 JavaScript의 핵심을 이해한 것입니다!

댓글