어제 오늘 내일

[JavaScript] 클로저(Closure) 완벽 가이드: 동작 원리부터 실전 패턴까지 본문

IT/Javascript

[JavaScript] 클로저(Closure) 완벽 가이드: 동작 원리부터 실전 패턴까지

hi.anna 2026. 8. 8. 22:14

면접 단골 질문이자, 설명은 들어봤지만 "그래서 이걸 어디에 쓰지?"가 남는 개념. 클로저입니다.
사실 클로저는 특별한 문법이 아닙니다. 자바스크립트 함수라면 거의 다 클로저이고, 우리는 이미 매일 클로저를 쓰고 있습니다. 다만 그 동작 방식을 정확히 모르면 반복문에서, 이벤트 핸들러에서, React 훅에서 이해할 수 없는 버그를 만나게 됩니다.
이 글은 원리 → 함정 → 실전 패턴 순서로 정리합니다.

클로저란 무엇인가

한 줄 정의는 이렇습니다.

클로저 = 함수 + 그 함수가 선언된 렉시컬 환경(lexical environment)의 조합

 
말이 어려우니 코드로 봅시다. 먼저 평범한 경우부터요.

function normal() {
  const message = "안녕하세요";  // normal의 지역 변수
}

normal();             // 함수 실행이 끝나면 message는 정리된다
console.log(message); // ReferenceError — 바깥에서는 접근할 방법이 없다

함수가 끝나면 그 안의 지역 변수도 함께 사라집니다. 여기까지는 예상대로입니다.
이번엔 안쪽 함수를 바깥으로 꺼내 보겠습니다.

function outer() {
  const message = "안녕하세요";  // outer의 지역 변수

  function inner() {
    console.log(message);  // 자기 스코프에 없는 변수를 바깥에서 찾아 씀
  }

  return inner;  // 함수를 "실행"하지 않고 "반환"
}

const fn = outer();
// ① outer()가 실행된다 → 그 안에서 message가 만들어지고, inner 함수가 정의된다
// ② outer()는 inner를 "반환"한다. 따라서 fn은 inner 함수 자체를 가리킨다
//    (아직 실행한 게 아니라, 함수를 변수에 담아둔 상태다)
// ③ 여기서 outer() 호출은 완전히 끝났다
//    → 방금 normal()에서 봤듯, 지역 변수 message도 함께 정리될 차례다

fn();
// ④ 이제야 inner를 실행한다. inner는 console.log(message)를 하려고 한다
//    outer는 이미 끝났으니 message는 없어져서 에러가 나야 할 것 같지만...
// ⑤ "안녕하세요"가 정상 출력된다 → message가 아직 메모리에 살아 있다는 뜻!

normal()에서는 사라졌던 지역 변수가, outer()에서는 왜 남아 있을까요? 반환된 innermessage를 참조하고 있고 그 innerfn이라는 이름으로 살아남았기 때문입니다. inner가 살아 있는 한 message도 버려지지 않습니다.

이렇게 함수가 자신이 태어난 환경을 기억하는 현상, 그게 클로저입니다.

전제: 렉시컬 스코프

클로저를 이해하려면 자바스크립트의 스코프 규칙을 먼저 알아야 합니다. 자바스크립트는 렉시컬 스코프(정적 스코프) 를 씁니다. 변수를 찾는 기준이 "어디서 호출됐는가"가 아니라 "어디에 작성됐는가" 라는 뜻입니다.

const name = "전역";

function printName() {
  console.log(name);  // 이 함수가 "작성된" 위치 기준으로 찾음
}

function run() {
  const name = "지역";  // 호출하는 쪽에 같은 이름이 있어도
  printName();          // 무시된다
}

run();  // "전역" — "지역"이 아님!

printName은 전역에 작성됐으므로, 어디서 호출되든 전역의 name을 봅니다. 이 규칙이 클로저의 토대입니다.

핵심: 값이 아니라 "변수"를 기억한다

가장 많이 오해하는 지점입니다. 클로저는 값을 복사해서 스냅샷으로 저장하지 않습니다. 변수 자체를 참조합니다.

function makeCounter() {
  let count = 0;  // 이 변수를 아래 함수들이 공유한다

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

const counter = makeCounter();

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

// 외부에서 count에 직접 접근할 방법이 없다
console.log(counter.count);  // undefined

여기서 두 가지가 확인됩니다.

  1. count선언 시점의 0으로 고정된 게 아니라 계속 변합니다 → 참조를 유지한다는 증거
  2. 세 함수가 같은 count를 공유합니다 → 하나의 렉시컬 환경을 함께 쓴다는 증거

그리고 count는 외부에서 접근할 수 없습니다. 자바스크립트에서 프라이빗 변수를 만드는 고전적인 방법이 바로 이겁니다.

인스턴스마다 환경은 분리된다

makeCounter를 두 번 호출하면 어떻게 될까요?

const counterA = makeCounter();
const counterB = makeCounter();  // 새로운 호출 = 새로운 환경

counterA.increment();
counterA.increment();
counterB.increment();

console.log(counterA.getValue());  // 2
console.log(counterB.getValue());  // 1 — 서로 영향 없음

함수를 호출할 때마다 새로운 렉시컬 환경이 만들어집니다. 그래서 클로저는 "함수로 만드는 인스턴스"처럼 동작합니다.

고전 함정: 반복문 속 클로저

클로저를 다룰 때 거의 모두가 한 번은 밟는 지뢰입니다.

// ❌ 의도: 0, 1, 2  /  실제: 3, 3, 3
for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100);
}

왜 전부 3일까요?

  • var함수 스코프라 반복문 전체에서 i딱 하나만 존재합니다
  • 세 개의 콜백이 모두 같은 i 변수를 참조합니다
  • 100ms 뒤 콜백이 실행될 때, 반복문은 이미 끝나서 i3입니다

값이 아니라 변수를 기억한다는 성질이 그대로 드러난 사례죠.

해결 1:  쓰기 (권장)

// ✅ 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100);
}

let을 쓰면 자바스크립트 엔진이 반복할 때마다 i를 새로 하나씩 만들어 줍니다. 이름은 똑같이 i지만, 실제로는 서로 다른 변수 세 개입니다.

그림으로 비교하면 이렇습니다.

var 사용 → 변수는 딱 1개, 모두가 그걸 공유
  [i] ←── 콜백1
       ←── 콜백2      반복문이 끝나면 i는 3
       ←── 콜백3      → 셋 다 3을 본다

let 사용 → 반복마다 변수가 새로 생성
  [i=0] ←── 콜백1
  [i=1] ←── 콜백2      각자 자기 변수를 본다
  [i=2] ←── 콜백3      → 0, 1, 2

엔진이 내부적으로 하는 일을 억지로 코드로 옮기면 이런 느낌입니다.

// for (let i = 0; ...) 는 대략 이렇게 동작한다고 상상하면 된다
{
  let i = 0;
  { let i_사본 = i; setTimeout(() => console.log(i_사본), 100); }  // i_사본 = 0
}
{
  let i = 1;
  { let i_사본 = i; setTimeout(() => console.log(i_사본), 100); }  // i_사본 = 1
}
{
  let i = 2;
  { let i_사본 = i; setTimeout(() => console.log(i_사본), 100); }  // i_사본 = 2
}

반복마다 변수가 따로 생기니, 콜백들이 참조하는 대상도 따로따로가 됩니다. 앞서 "클로저는 값이 아니라 변수를 기억한다"고 했죠. 기억하는 방식이 바뀐 게 아니라, 기억할 변수 자체가 세 개로 늘어난 것입니다.

이 특별 대우는 for 문의 let에만 적용됩니다. 아래처럼 바깥에 선언하면 var와 똑같이 하나뿐이라 다시 3, 3, 3이 나옵니다.

// ❌ let을 썼는데도 3, 3, 3
let i;
for (i = 0; i < 3; i++) {          // 초기화 부분에 let이 없다
  setTimeout(() => console.log(i), 100);
}

해결 2: IIFE로 스코프 만들기 (ES5 시절 방식)

// ✅ 0, 1, 2
for (var i = 0; i < 3; i++) {
  (function (captured) {          // 즉시 실행 함수로 새 스코프 생성
    setTimeout(function () {
      console.log(captured);      // 매개변수는 호출 시점 값으로 복사됨
    }, 100);
  })(i);                          // 현재 i 값을 인자로 넘겨 고정
}

let이 없던 시절의 관용구입니다. 요즘 새로 쓸 이유는 없지만, 오래된 코드를 읽을 때 알아둘 만합니다.

실전 패턴

1. 모듈 패턴 — 상태 은닉

const bankAccount = (function () {
  // ── private 영역 ──────────────────
  let balance = 0;
  const history = [];

  function record(type, amount) {
    history.push({ type, amount, at: new Date() });
  }

  // ── public 영역 ───────────────────
  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("금액은 0보다 커야 합니다");
      balance += amount;
      record("deposit", amount);
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("잔액 부족");
      balance -= amount;
      record("withdraw", amount);
      return balance;
    },
    getBalance() {
      return balance;  // 읽기 전용으로만 노출
    },
    getHistory() {
      return [...history];  // 복사본 반환 → 원본 배열 보호
    },
  };
})();

bankAccount.deposit(10000);
bankAccount.withdraw(3000);

console.log(bankAccount.getBalance());  // 7000
console.log(bankAccount.balance);       // undefined — 직접 접근 불가

bankAccount.balance = 999999;           // 이렇게 해도
console.log(bankAccount.getBalance());  // 7000 — 내부 balance와 무관

마지막 부분이 중요합니다. 외부에서 balance 속성을 만들어봐야 클로저 안의 balance와는 완전히 다른 변수입니다. 진짜 캡슐화죠.

요즘은 클래스의 # 프라이빗 필드로도 같은 목적을 달성할 수 있습니다. 다만 클로저 방식은 별도 문법 없이 어디서나 동작합니다.

2. 함수 팩토리 — 설정이 고정된 함수 만들기

값을 반환하는 함수는 익숙하죠. 그런데 함수가 함수를 반환하면, 매번 조금씩 다르게 동작하는 함수를 필요한 만큼 만들어낼 수 있습니다. 공장에서 같은 틀로 제품을 찍어내되 색만 바꾸는 것과 비슷해서 "팩토리(factory)"라고 부릅니다.
예를 들어 "2를 곱하는 함수"와 "3을 곱하는 함수"가 필요하다고 해봅시다. 따로따로 쓰면 이렇습니다.

// 곱하는 수만 다르고 나머지는 똑같은 코드의 반복
function double(number) { return number * 2; }
function triple(number) { return number * 3; }

다른 부분(곱할 수)만 매개변수로 빼면, 함수를 만들어 주는 함수가 됩니다.

// multiplier는 숫자가 아니라 "함수"를 반환한다
function multiplier(factor) {
  // 반환된 함수는 자기가 태어날 때의 factor를 계속 기억한다 ← 클로저
  return function (number) {
    return number * factor;
  };
}

// multiplier(2)를 호출하면 "2를 곱하는 함수"가 만들어져 double에 담긴다
const double = multiplier(2);  // 이 함수 안에서 factor는 영원히 2
const triple = multiplier(3);  // 이 함수 안에서 factor는 영원히 3

console.log(double(10));  // 20 — 10 * 2
console.log(triple(10));  // 30 — 10 * 3
console.log(double(7));   // 14 — factor는 계속 2로 유지된다

실무에서는 이런 식으로 씁니다. 매번 반복해서 넘겨야 하는 설정값을 한 번만 넣어두고, 그게 적용된 함수를 받아 쓰는 겁니다.

// 실전 예: API 클라이언트 생성기
function createApi(baseUrl, token) {
  // baseUrl과 token은 클로저에 갇혀 매 호출마다 재사용된다
  return {
    get(path) {
      return fetch(`${baseUrl}${path}`, {
        headers: { Authorization: `Bearer ${token}` },
      });
    },
    post(path, body) {
      return fetch(`${baseUrl}${path}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
    },
  };
}

const api = createApi("https://api.example.com", "abc123");
// 이후로는 baseUrl과 token을 다시 넘길 필요가 없다
api.get("/users");

3. 메모이제이션 — 계산 결과 캐싱

// memoize: 함수를 받아서, "결과를 기억하는 버전"의 함수로 바꿔 반환한다
function memoize(fn) {
  // 캐시 저장소. memoize를 호출할 때 딱 한 번 만들어지고,
  // 아래 반환된 함수가 참조하므로 계속 살아남는다 ← 클로저
  // 바깥에서는 이 Map에 손댈 방법이 없다
  const cache = new Map();

  // 실제로 우리가 사용할 함수. 원본 fn과 인자 형태가 같다
  return function (...args) {
    // 인자 조합을 문자열로 바꿔 캐시 키로 쓴다
    // 예: 인자가 (9) 이면 키는 "[9]", (2, 3) 이면 "[2,3]"
    const key = JSON.stringify(args);

    // ① 같은 인자로 이미 계산한 적이 있으면 → 계산 생략하고 저장된 값 반환
    if (cache.has(key)) {
      console.log(`캐시 히트: ${key}`);
      return cache.get(key);
    }

    // ② 처음 보는 인자면 → 원본 함수를 실제로 실행
    //    apply(this, args)는 this와 인자를 원본에 그대로 넘겨주기 위한 것
    const result = fn.apply(this, args);

    // ③ 다음번을 위해 결과를 저장해 둔다
    cache.set(key, result);
    return result;
  };
}

// 캐싱 효과를 눈으로 보려고 일부러 느리게 만든 함수
const slowSquare = (n) => {
  for (let i = 0; i < 1e7; i++) {}  // 아무 일도 안 하면서 시간만 끄는 반복문
  return n * n;
};

// slowSquare를 감싸서 "기억하는 버전"으로 만든다. 원본은 그대로 유지된다
const fastSquare = memoize(slowSquare);

console.log(fastSquare(9));   // 느림  — 캐시가 비어 있어 ②번 경로로 실제 계산 후 저장
console.log(fastSquare(9));   // 즉시  — 키 "[9]"가 있으므로 ①번 경로로 바로 반환
console.log(fastSquare(3));   // 느림  — 키 "[3]"은 처음이라 다시 계산
console.log(fastSquare(3));   // 즉시  — 이제 "[3]"도 캐시에 있다

cache가 전역에 노출되지 않으면서도 호출 사이에 유지된다는 점이 핵심입니다.

4. 디바운스 — 마지막 호출만 실행

function debounce(fn, delay) {
  let timerId = null;  // 호출 간에 유지되어야 하는 상태

  return function (...args) {
    // 이전에 예약된 실행이 있으면 취소
    clearTimeout(timerId);

    // 새로 예약. delay 동안 추가 호출이 없어야 실제로 실행된다
    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

const onSearch = debounce((keyword) => {
  console.log("검색 요청:", keyword);
}, 300);

// 사용자가 빠르게 타이핑하는 상황
onSearch("자");
onSearch("자바");
onSearch("자바스");
onSearch("자바스크립트");
// → 300ms 뒤 "검색 요청: 자바스크립트" 한 번만 출력

timerId를 전역 변수로 뺐다면 디바운스 함수를 두 개 만드는 순간 서로 간섭했을 겁니다. 클로저 덕분에 인스턴스마다 독립적인 타이머를 갖습니다.

5. 스로틀 — 일정 간격으로만 실행

function throttle(fn, interval) {
  let lastTime = 0;  // 마지막 실행 시각을 기억

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

    // 마지막 실행 후 interval이 지났을 때만 통과
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

const onScroll = throttle(() => {
  console.log("스크롤 위치:", window.scrollY);
}, 200);

window.addEventListener("scroll", onScroll);  // 최대 200ms에 한 번만 실행

6.  — 딱 한 번만 실행되는 함수

function once(fn) {
  let called = false;
  let result;  // 첫 호출의 결과를 보관

  return function (...args) {
    if (called) return result;  // 두 번째부터는 캐시된 결과 반환

    called = true;
    result = fn.apply(this, args);
    return result;
  };
}

const initialize = once(() => {
  console.log("초기화 실행!");
  return { ready: true };
});

initialize();  // "초기화 실행!" 출력
initialize();  // 아무것도 출력 안 됨
initialize();  // 아무것도 출력 안 됨

7. ID 생성기

function createIdGenerator(prefix = "id") {
  let seq = 0;

  return function () {
    seq += 1;                       // 호출될 때마다 증가하고 유지됨
    return `${prefix}-${seq}`;
  };
}

const userId = createIdGenerator("user");
const postId = createIdGenerator("post");

console.log(userId());  // "user-1"
console.log(userId());  // "user-2"
console.log(postId());  // "post-1" — 카운터가 독립적

클로저와 메모리

클로저가 변수를 붙잡고 있다는 건, 그 변수가 가비지 컬렉션되지 않는다는 뜻이기도 합니다.

function leaky() {
  const bigData = new Array(1_000_000).fill("무거운 데이터");

  return function () {
    // bigData를 실제로 쓰지 않아도, 참조하면 계속 메모리에 남는다
    console.log(bigData.length);
  };
}

const fn = leaky();  // bigData가 fn이 살아있는 동안 유지됨

대응 방법은 두 가지입니다.
필요한 것만 캡처하기

function better() {
  const bigData = new Array(1_000_000).fill("무거운 데이터");
  const size = bigData.length;  // 필요한 값만 미리 추출

  return function () {
    console.log(size);  // 이제 bigData 전체를 붙잡지 않는다
  };
}

다 쓴 클로저는 참조를 끊기

let handler = createHeavyHandler();
element.addEventListener("click", handler);

// 정리 시점
element.removeEventListener("click", handler);
handler = null;  // 참조 해제 → GC 대상이 됨

특히 SPA에서 컴포넌트가 사라질 때 이벤트 리스너나 setInterval을 정리하지 않으면, 클로저가 붙잡은 데이터가 계속 쌓여 누수가 됩니다.

React에서 만나는 "stale closure"

리액트를 쓴다면 이 버그를 반드시 만나게 됩니다.

// ❌ 카운터가 1에서 멈춘다
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);  // 이 count는 "첫 렌더의 count", 즉 항상 0
    }, 1000);
    return () => clearInterval(id);
  }, []);  // 의존성이 비어 있어 effect가 한 번만 실행됨

  return <p>{count}</p>;
}

useEffect가 처음 한 번만 실행되므로, 안에서 만들어진 콜백은 첫 렌더 시점의 count(=0) 를 영원히 기억합니다. 그래서 매초 setCount(0 + 1)을 반복해 1에서 멈춥니다. 이걸 stale closure(낡은 클로저) 라고 부릅니다.

// ✅ 함수형 업데이트로 해결
useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1);  // 클로저 대신 최신 상태를 인자로 받음
  }, 1000);
  return () => clearInterval(id);
}, []);

setCount에 함수를 넘기면 리액트가 항상 최신 값을 인자로 전달합니다. 클로저에 갇힌 옛날 값을 참조할 필요가 없어지죠.

흔한 오해 정리

"클로저는 함수를 반환할 때만 생긴다" → 아닙니다. 외부 변수를 참조하는 함수는 어떤 형태로든 클로저입니다.

function setup() {
  let clicks = 0;

  // 반환하지 않고 콜백으로 넘겨도 클로저
  document.addEventListener("click", () => {
    clicks += 1;
    console.log(`클릭 횟수: ${clicks}`);
  });
}

"클로저는 무거워서 피해야 한다" → 아닙니다. 자바스크립트 엔진은 클로저를 매우 흔한 패턴으로 최적화합니다. 문제는 클로저 자체가 아니라 불필요하게 큰 데이터를 오래 붙잡는 것입니다.
"클로저는 값을 복사한다" → 아닙니다. 참조를 유지합니다. 앞의 반복문 예제가 그 증거입니다.

정리

개념 설명
정의함수 + 선언된 렉시컬 환경의 조합
생성 시점함수가 정의될 때 (호출될 때가 아님)
캡처 방식값 복사가 아니라 변수 참조
환경 분리외부 함수를 호출할 때마다 새 환경 생성
주 용도상태 은닉, 팩토리, 캐싱, 디바운스/스로틀
주의점붙잡은 데이터의 메모리 누수, stale closure

클로저는 결국 "함수가 자기만의 기억을 갖는다" 는 이야기입니다. 전역 변수 없이 상태를 유지하고, 외부에서 건드릴 수 없게 숨기고, 설정이 미리 적용된 함수를 찍어낼 수 있게 해 주죠.
 

반응형
Comments