자바스크립트의 비밀스러운 삶: 클로저 이해하기

발행: (2025년 12월 12일 오후 12:15 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

함수 기억하기

Timothy는 비에 흠뻑 젖은 채 도서관에 도착했다. 비가 그의 재킷에서 뚝뚝 떨어졌다. 그는 런던 거리를 뛰어다니며 Margaret에게 함수가 어떻게 동작하는지에 대한 자신의 이해를 깨뜨린 무언가에 대해 이야기하고 싶어 했다.

“불가능해요,” 그는 머리카락에 맺힌 물을 털어내며 말했다. “제가 메모리와 스코프에 대해 배운 모든 것을 위배하는 코드를 작성했어요.”

Margaret는 차를 마시며 고개를 들었다.

“보여 주세요.”

Timothy는 노트북을 꺼내 다음과 같이 입력했다:

function createCounter() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();

counter(); // 1
counter(); // 2
counter(); // 3

“이해가 안돼요,” Timothy가 말했다. “createCounter가 실행을 마치면 그 안의 지역 변수들은 가비지 컬렉션 되어야 합니다. count 변수도 사라져야 하는데, 반환된 함수가 아직도 그 변수를 알고 있어요. 존재하지 않아야 할 변수를 접근하고 있는 거잖아요.”

Margaret는 미소 지었다.

“당신은 클로저를 발견한 겁니다. 그리고 혼란스러워하는 게 당연해요—프로그래밍 언어가 동작하는 방식을 위배하는 것처럼 보이니까요. 하지만 JavaScript에서는 버그가 아니라 기능입니다.”

렉시컬 스코프

Margaret는 노트에 펜을 들었다.

“무슨 일이 일어나고 있는지 설명하기 전에, 렉시컬 스코프를 이해해야 해요. 1장에 나왔던 스코프, 즉 변수들이 사는 곳을 기억하나요? 하지만 그보다 더 깊은 것이 있어요.”

그녀는 적었다:

function outer() {
  let outerVar = "I'm in outer";

  function inner() {
    console.log(outerVar); // Can I see this?
  }

  inner();
}

outer(); // "I'm in outer"

innerouterVar를 찾을 때, 어디를 찾아요?” Margaret가 물었다.

“변수가 선언된 곳을 찾아요,” Timothy가 대답했다. “외부 함수 안이죠.”

“맞아요. inner가 어디서 호출되든 상관없어요. 중요한 건 inner작성된 위치예요. 이것이 렉시컬 스코프입니다: 함수는 실행되는 위치가 아니라 정의된 스코프에서 변수를 찾습니다.”

내부 함수 반환하기

function outer() {
  let outerVar = "I'm in outer";

  function inner() {
    console.log(outerVar);
  }

  return inner; // Return the function itself
}

const myFunction = outer(); // `outer()` finishes here
myFunction(); // "I'm in outer" - but how?!

outer가 실행을 마치면 지역 변수들은 사라져야 하는데,” Timothy가 천천히 말했다. “그런데 inner는 여전히 outerVar에 접근하고 있어요. 그 변수가 어떻게 살아 있나요?”

“JavaScript가 그 변수가 죽지 않게 해 주기 때문이에요,” Margaret가 답했다. “inner를 반환하면 JavaScript는 ‘이 함수가 outerVar를 참조하고 있다. 나중에 함수가 호출될 때 그 변수가 필요하니 아직 가비지 컬렉션하지 않겠다’고 판단합니다. 그래서 outerVar를 메모리에 남겨 두고 inner가 사용할 수 있게 합니다.”

“그럼 변수가 지속되는 거군요?”

“네, 변수가 지속됩니다. 함수는 자신의 ‘출생지’를 기억하죠. 함수와 그 스코프에서 필요로 하는 변수들의 조합을 클로저라고 부릅니다.”

배낭 비유

Margaret가 몸을 뒤로 젖혔다.

“함수가 생성될 때마다 배낭을 하나 받는다고 상상해 보세요. 함수가 정의되는 순간, JavaScript는 주변을 살피며 ‘이 함수가 필요로 할 변수는 뭐지?’라고 생각하고 그 변수를 배낭에 넣습니다.”

function createGreeter(greeting) {
  // createGreeter's scope
  return function(name) {
    // This inner function needs `greeting` from createGreeter's scope
    console.log(greeting + ", " + name);
  };
}

const sayHello = createGreeter("Hello");
const sayHi    = createGreeter("Hi");

sayHello("Alice"); // "Hello, Alice"
sayHi("Bob");      // "Hi, Bob"

“내부 함수를 정의할 때, 주변을 살펴요. greeting을 발견하고 배낭에 넣죠. 이후 그 함수가 어디서 호출되든 greeting을 함께 가지고 다닙니다.”

Timothy가 코드를 가리켰다.

“그러니까 sayHellosayHi는 각각 다른 배낭을 가진 거죠? 서로 다른 greeting 값이요?”

“정확해요. createGreeter가 실행될 때마다 새로운 스코프와 새로운 greeting 변수가 만들어지고, 반환된 함수는 그 특정 greeting을 배낭에 담게 됩니다. 완전히 독립적인 것이죠.”

데이터 프라이버시를 위한 클로저

function createAccount() {
  let balance = 100;

  return {
    deposit: function(amount) {
      balance += amount;
      console.log("Balance: " + balance);
    },

    withdraw: function(amount) {
      balance -= amount;
      console.log("Balance: " + balance);
    },

    getBalance: function() {
      return balance;
    }
  };
}

const account = createAccount();
account.deposit(50);    // Balance: 150
account.withdraw(20); // Balance: 130
account.getBalance(); // 130

“세 메서드 모두 balance에 대한 클로저예요. 같은 balance 값을 공유하죠, 맞나요?”

“맞아요. 그리고 중요한 점은 실시간 참조라는 거예요, 복사본이 아니라.”

추가 시연:

account.deposit(50);    // Balance: 150
account.withdraw(20);  // Balance: 130
account.deposit(100);  // Balance: 230

// 세 메서드가 **같은** 변수를 접근합니다.
// 하나가 값을 바꾸면 다른 모든 메서드가 그 변화를 바로 봅니다.

프라이빗 변수를 직접 접근하려 하면 실패한다:

console.log(account.balance); // undefined
account.balance = 9999;       // 새로운 프로퍼티가 생성될 뿐, 클로저 변수에는 영향을 주지 않음
console.log(account.getBalance()); // Still 230

balance 변수는 완전히 숨겨져 있어요. 외부에서 접근할 수 있는 유일한 방법은 공개된 세 메서드를 통해서만 가능합니다. 이것이 진정한 데이터 프라이버시죠.”

모듈 패턴

const calculator = (function() {
  let lastResult = 0;

  return {
    add: function(a, b) {
      lastResult = a + b;
      return lastResult;
    },

    subtract: function(a, b) {
      lastResult = a - b;
      return lastResult;
    },

    getLastResult: function() {
      return lastResult;
    }
  };
})();

calculator.add(10, 5);        // 15
calculator.subtract(20, 8); // 12
calculator.getLastResult(); // 12

// `lastResult`는 프라이빗—외부에서 접근하거나 수정할 수 없고 오직 이 메서드들을 통해서만 가능함.

“이게 바로 모듈이에요. 즉시 실행 함수 표현식(IIFE)을 사용해 프라이빗 스코프를 만들고, 그 스코프에 있는 프라이빗 변수에 접근하는 공개 메서드들을 객체 형태로 반환합니다. ES6 클래스가 등장하기 전까지는 모듈 패턴이 JavaScript에서 프라이빗 상태를 가진 객체를 만들 수 있는 주요 방법이었죠.”

고전적인 클로저 함정

function setupButtons() {
  for (var i = 1; i <= 3; i++) {
    setTimeout(function() {
      console.log("Button " + i + " clicked");
    }, 1000);
  }
}

“이건 1장에서 다룬 var 문제와 클로저가 뒤섞인 예시예요. var를 쓰면 변수 isetupButtons 전체 함수에 속하게 되고, 루프 블록마다 새로 선언되지 않아요. 그래서 세 개의 콜백이 모두 같은 i를 공유하게 되고, 루프가 끝난 뒤 i는 4가 됩니다(루프는 i가 4가 될 때 종료됨).”

해결 방법: 블록 스코프를 위해 let을 사용하거나 각 반복마다 새로운 스코프를 만들면 된다.

// Using let (ES6)
function setupButtons() {
  for (let i = 1; i <= 3; i++) {
    setTimeout(function() {
      console.log("Button " + i + " clicked");
    }, 1000);
  }
}
// Using an IIFE
function setupButtons() {
  for (var i = 1; i <= 3; i++) {
    (function(index) {
      setTimeout(function() {
        console.log("Button " + index + " clicked");
      }, 1000);
    })(i);
  }
}
Back to Blog

관련 글

더 보기 »

Swift #12: 함수

함수는 중괄호 { 로 구분된 코드 블록이며 이름으로 식별됩니다. 반복문 및 조건문에 사용되는 코드 블록과 달리...