학습 자료

클로저 — 함수가 기억하는 것


Article

12강에서 함수 안의 변수는 함수가 끝나면 사라진다고 했다. 그런데 사라지지 않는 경우가 있다.

function makeCounter() {
  let count = 0;

  return function () {
    count++;
    return count;
  };
}

const counter = makeCounter();

console.log(counter());
console.log(counter());
console.log(counter());
콘솔
  • 1
  • 2
  • 3

makeCounter()는 첫 줄에서 이미 끝났다. 그런데 count는 살아서 값을 기억하고 있다.

이 현상이 클로저다.

왜 살아 있나

12강의 스코프 체인을 떠올려보자. 안쪽 함수는 바깥 변수를 볼 수 있었다.

function makeCounter() {
  let count = 0;
  return function () {
    count++;
    return count;
  };
}

안쪽의 익명 함수는 count를 쓴다. 즉 자기가 태어난 스코프를 참조하고 있다.

자바스크립트는 누군가 아직 참조하고 있는 변수를 지우지 않는다. makeCounter는 끝났지만, 반환된 함수가 count를 붙잡고 있으니 count는 메모리에 남는다.

반환된 함수가 붙잡고 있는 것
  • 전역 스코프counter 변수가 함수를 가리킨다
    • makeCounter 스코프count = 0 — 함수는 끝났지만 살아 있다
      • 반환된 익명 함수count 를 참조하므로 위 스코프가 유지된다

정의는 이렇다. 클로저란 함수와, 그 함수가 선언된 스코프를 함께 묶은 것이다. 함수는 자기가 태어난 환경을 들고 다닌다.

호출할 때마다 새로 만들어진다

makeCounter()를 두 번 부르면 어떻게 될까.

const a = makeCounter();
const b = makeCounter();

console.log(a());
console.log(a());
console.log(b());
콘솔
  • 1
  • 2
  • 1

b1부터 시작한다. 13강에서 배운 대로 함수를 호출할 때마다 새 실행 컨텍스트가 만들어지므로, count도 각자 하나씩 생긴다.

서로 다른 두 개의 count

a 와 b 는 각자의 환경을 들고 있다

a
0x1a2b
함수 + { count: 2 }
b
0x3c4d
함수 + { count: 1 }

서로 간섭하지 않는다. 이 성질이 클로저를 쓸모 있게 만든다.

값을 숨긴다

count를 밖에서 직접 건드릴 수 있을까.

const counter = makeCounter();

console.log(counter.count);
counter.count = 100;
console.log(counter());
콘솔
  • undefined
  • 1

건드릴 수 없다. count는 함수 안에 있고, 바깥에서 안은 보이지 않는다. 오직 반환된 함수를 통해서만 값이 바뀐다.

이걸 이용해 "밖에서 함부로 못 바꾸는 값"을 만든다.

function createAccount(initial) {
  let balance = initial;

  return {
    deposit(amount) {
      if (amount <= 0) return "금액이 올바르지 않다";
      balance += amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createAccount(1000);

console.log(account.deposit(500));
console.log(account.getBalance());
console.log(account.deposit(-9999));
console.log(account.balance);
콘솔
  • 1500
  • 1500
  • '금액이 올바르지 않다'
  • undefined

balance를 음수로 만들 방법이 없다. 반드시 deposit을 거쳐야 하고, 거기서 검사를 한다. 데이터를 지키는 이 방식을 은닉이라 부른다.

그냥 객체 vs 클로저

그냥 객체

{ balance: 1000 }

  • obj.balance = -9999 가 통한다
  • 누구나 아무 값이나 넣는다
  • 검사 로직을 우회할 수 있다

클로저

함수 안에 가둔 값

  • 바깥에서 접근 자체가 불가능
  • 정해진 함수로만 바꾼다
  • 규칙을 반드시 통과한다

유명한 반복문 함정

12강에서 예고한 그 버그다. var와 반복문과 클로저가 만나면 이렇게 된다.

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100);
}
콘솔
  • 3
  • 3
  • 3

0, 1, 2를 기대했는데 3이 세 번 나온다.

무슨 일이 벌어졌나
  1. 01

    var i 는 하나뿐이다

    블록 스코프가 없으므로 반복문 전체가 i 하나를 공유한다.

  2. 02

    setTimeout 은 나중에 실행된다

    100밀리초 뒤에 실행하라고 예약만 해두고 반복문은 계속 돈다.

  3. 03

    반복문이 먼저 끝난다

    i3 이 되어 조건이 거짓이 되고 반복이 종료된다.

  4. 04

    이제야 콜백들이 실행된다

    세 콜백 모두 같은 `i` 를 본다. 그 값은 이미 3 이다.

    console.log(i);  // 세 번 모두 3

세 개의 클로저가 같은 변수 하나를 붙잡고 있었던 게 문제다.

let으로 바꾸면 해결된다.

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

12강에서 말한 "let i는 반복할 때마다 그 회차만의 i를 새로 만든다"가 바로 이 뜻이다. 클로저 세 개가 **각자 다른 i**를 붙잡는다.

let 을 쓰면 회차마다 다른 변수

세 개의 클로저가 각자의 i 를 들고 있다

콜백 1
0x0a1
{ i: 0 }
콜백 2
0x0b2
{ i: 1 }
콜백 3
0x0c3
{ i: 2 }

실무에서 만나는 클로저

이름을 몰랐을 뿐 이미 쓰고 있었다.

function createGreeter(greeting) {
  return name => `${greeting}, ${name}님`;
}

const hello = createGreeter("안녕하세요");
const bye = createGreeter("안녕히 가세요");

console.log(hello("지원"));
console.log(bye("지원"));
콘솔
  • '안녕하세요, 지원님'
  • '안녕히 가세요, 지원님'

greeting을 기억하는 함수를 찍어내는 공장이다. 설정을 한 번 넣어두고 계속 쓰는 패턴이라, 라이브러리 설정 함수에서 자주 보인다.

정리하면

  • 클로저는 함수와 그 함수가 태어난 스코프를 함께 묶은 것이다. 함수는 자기 환경을 들고 다닌다.
  • 바깥 함수가 끝나도, 안쪽 함수가 참조하는 변수는 사라지지 않는다.
  • 호출할 때마다 새로운 환경이 만들어져 서로 간섭하지 않는다.
  • 값을 함수 안에 가둬 바깥에서 못 건드리게 하는 은닉에 쓴다.
  • 반복문 + var + 콜백은 같은 변수 하나를 공유해 버그를 만든다. let을 쓰면 회차마다 새 변수가 생겨 해결된다.
  • 클로저는 메모리를 붙잡는다. 필요 없어지면 참조를 끊는다.

Part 4가 끝났다. 다음 강의부터는 자료구조다. 값 하나가 아니라 여러 값을 한꺼번에 다루는 배열부터 시작한다.

클로저 — 함수가 기억하는 것 — 디코드랩(DCODELAB)