본문으로 건너뛰기

클로저: 만들어진 칸을 기억하는 함수

자바스크립트 목차클로저와 클래스레슨 50 / 76

목차

블록과 함수 안의 이름에서 함수가 끝나는 순간 그 함수의 호출 칸도 함께 사라진다고 배웠습니다. 그런데 함수 안에서 안쪽 함수를 만들어 밖으로 돌려주면, 바깥 함수가 끝난 뒤에도 안쪽 함수가 자신이 만들어진 칸을 잡고 있어 그 칸의 이름을 계속 읽고 바꿀 수 있습니다. 이 성질을 **클로저(closure)**라고 부릅니다. 나중에 타이머나 화면의 단추 리스너가 바깥의 개수나 할 일 배열을 기억해 바꾸는 코드가 모두 이 원리로 돌아갑니다.

이 단원(클로저와 클래스)을 이미 안다면 단원 연습 문제부터 풀고 틀린 레슨만 보세요. 건너뛸지 볼 때는 힌트를 펴지 않고 풉니다.

파이썬을 배웠다면 — 이 단원에서 다른 곳
  • 바깥 함수의 변수를 안쪽 함수에서 고칠 때 파이썬은 nonlocal count를 적어야 하지만, 자바스크립트는 지금 칸에 이름이 없으면 만들어진 바깥 칸의 이름을 찾아 그대로 바꿉니다(블록과 함수 안의 이름 · 이 레슨).
  • 파이썬은 Card('민지')처럼 클래스 이름을 바로 부르고 __init__(self, ...)의 첫 매개변수로 self를 받지만, 자바스크립트는 꼭 new Card('민지')처럼 new를 붙이고 constructor(...) 안에서 매개변수 없이 this를 씁니다(클래스와 new · 메서드와 this).
  • 파이썬에서는 fn = card.add처럼 메서드를 꺼내 fn()으로 불러도 self가 유지되지만, 자바스크립트에서는 점(.) 없이 부르면 this가 undefined가 되어 오류가 납니다(this를 잃을 때).

14단계 중 1단계. `function makeCounter` 선언은 실행 전에 전역 칸의 `makeCounter` 이름표로 올라갑니다. 8번 줄부터 실행합니다.

1함수가 끝나도 남는 칸 — 규칙 7 뒤 절반

블록과 함수 안의 이름에서 세운 규칙 7의 앞 절반은 "이름은 지금 칸에서 찾고, 없으면 그 함수가 만들어진 칸으로 나가며 찾는다"였습니다. 여기에 뒤 절반 한 줄이 더해집니다.

규칙 7(뒤 절반) — 함수는 만들어진 칸을 기억해, 나중에 불려도 그 칸의 이름을 읽고 바꾼다(클로저).

위 그림의 코드를 다시 보겠습니다.

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

const tick = makeCounter();
console.log(tick());
console.log(tick());
실행 결과
1
2
  1. const tick = makeCounter()⁠를 실행하면 makeCounter 호출 칸이 열리고 count 이름표가 0을 가리킵니다.
  2. return () => ...로 돌려준 안쪽 화살표 함수(tick)가 자신이 만들어진 makeCounter 호출 칸을 잡고 있어, makeCounter()가 끝난 뒤에도 그 칸과 count가 사라지지 않고 남습니다.
  3. 첫 번째 tick()을 부르면 새 tick 호출 칸이 열리고, 기억해 둔 makeCounter 칸의 count를 찾아 0에서 1로 바꿉니다.
  4. 두 번째 tick()을 부르면 다시 남아 있는 makeCounter 칸의 count(1)를 찾아 2로 바꿉니다.

보통 함수는 끝나면 호출 칸이 사라지지만, 그 칸 안에서 만들어진 함수가 밖으로 나와 살아 있는 동안에는 그 칸도 함께 남습니다.

2값을 복사해 두는 것이 아니라 그 칸의 이름을 기억한다

함수가 만들어진 칸을 기억한다고 할 때, 함수를 만들던 순간의 값을 사진 찍듯 복사해 둔다고 생각하기 쉽습니다. 다음 코드가 무엇을 찍을지 먼저 맞혀 보세요.

let price = 4000;
const show = () => {
  console.log(price);
};
price = 4500;
show();

show 함수를 만들던 2번 줄에서는 price가 4000이었습니다. 하지만 5번 줄에서 price = 4500으로 바꾼 뒤 show()를 부르면 무엇이 찍힐까요?

실행 결과 보기
실행 결과
4500

4000이 아니라 4500이 찍힙니다. show는 4000이라는 값을 복사해 둔 것이 아니라, 자신이 만들어진 칸의 price라는 이름표 자리를 기억합니다. 나중에 show()가 불릴 때 그 칸의 price 이름표가 가리키는 값을 읽으므로, 그사이에 바뀐 4500이 나옵니다.

3바깥 함수를 두 번 부르면 칸도 두 개 생긴다

함수 만들고 부르기의 규칙 5대로 함수를 부를 때마다 새 호출 칸이 열립니다. 따라서 makeCounter()를 두 번 부르면 기억되는 makeCounter 칸도 두 개가 따로 생깁니다.

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

이어서 makeCounter()를 두 번 불러 a와 b에 붙입니다.

const a = makeCounter();
const b = makeCounter();
console.log(a());
console.log(a());
console.log(b());
실행 결과
1
2
1
  • const a = makeCounter()⁠가 만든 첫 번째 makeCounter 칸에는 a만의 count가 들어 있습니다. a()를 두 번 부르면 이 칸의 count가 1, 2로 오릅니다.
  • const b = makeCounter()⁠는 두 번째 makeCounter 칸을 새로 엽니다. 그 칸의 count는 따로 0에서 시작하므로 b()를 부르면 3이 아니라 1이 나옵니다.

이처럼 부를 때마다 이어지는 상태(count)를 두는 길은 두 가지입니다. 하나는 이 레슨처럼 **만들어진 칸을 기억하는 함수(클로저)**를 쓰는 길이고, 다른 하나는 바로 다음 레슨(클래스와 new)에서 배울 **속성 칸을 가진 객체(class)**를 만드는 길입니다.

예제

처음 넣어 준 시작 금액(start)을 기억해 두고, 부를 때마다 넣은 금액만큼 더해 누적 합계를 돌려주는 makeWallet 함수를 만들어 보겠습니다.

function makeWallet(start) {
  let total = start;
  return (amount) => {
    total += amount;
    return total;
  };
}

이어서 지갑을 만들고 돈을 두 번 넣어 봅니다.

const add = makeWallet(1000);
console.log(add(500));
console.log(add(2000));
실행 결과
1500
3500

makeWallet(1000)이 끝난 뒤에도 그 호출 칸의 total(1000)이 사라지지 않고 남아, add(500)에서 1500, add(2000)에서 3500으로 이어집니다.

자주 틀리는 곳

1. 함수를 만들 때의 값을 복사해 둔다고 생각함

클로저는 값을 복사해 두지 않고 만들어진 칸을 기억합니다. 함수가 만들어진 뒤라도 부르기 전에 그 칸의 이름표가 다른 값으로 바뀌면, 함수를 불렀을 때는 바뀐 값을 읽습니다.

let rate = 100;
const calc = (n) => n * rate;
rate = 200;
console.log(calc(3));
실행 결과
600

2. 바깥 함수를 다시 부르면 같은 칸을 나눠 쓴다고 생각함

makeCounter()를 부를 때마다 새 호출 칸이 열립니다. const a = makeCounter()⁠와 const b = makeCounter()⁠는 서로 다른 칸을 기억하므로 한쪽을 아무리 불러도 다른 쪽 숫자는 바뀌지 않습니다.

3. 바깥 함수를 부르지 않고 안쪽이 바로 실행된다고 생각함

const tick = makeCounter⁠처럼 괄호 없이 쓰면 makeCounter 자체만 가리킬 뿐 안쪽 화살표 함수가 아직 만들어지지 않습니다. 먼저 makeCounter()를 불러 안쪽 함수를 돌려받아야 합니다.

확인 문제

문제 1

다음 코드를 실행했을 때 출력되는 세 줄을 순서대로 쓰세요.

function makeStep(start) {
  let num = start;
  return () => {
    num += 2;
    return num;
  };
}

이어서 makeStep을 두 번 부릅니다.

const first = makeStep(10);
const second = makeStep(0);
console.log(first());
console.log(first());
console.log(second());
힌트

first는 num이 10에서 시작한 첫 번째 makeStep 칸을 기억하고, second는 num이 0에서 시작한 두 번째 makeStep 칸을 따로 기억합니다.

정답
실행 결과
12
14
2
  • 첫 번째 first()는 첫 칸의 num을 10에서 12로 바꾸고 12를 돌려줍니다.
  • 두 번째 first()는 같은 첫 칸의 num을 12에서 14로 바꾸고 14를 돌려줍니다.
  • second()는 두 번째 칸의 num을 0에서 2로 바꾸고 2를 돌려줍니다.

문제 2

이름 name을 받아 도장 개수를 0부터 세는 makeCard 함수입니다. 돌려받은 함수를 부를 때마다 도장이 1씩 늘어나고 '민지: 1'처럼 찍히도록 빈칸 ____ 두 곳을 채우세요.

function makeCard(name) {
  let n = ____;
  return () => {
    ____ += 1;
    return `${name}: ${n}`;
  };
}

이어서 카드를 만들고 두 번 부릅니다.

const card = makeCard('민지');
console.log(card());
console.log(card());
힌트

도장 개수는 처음에 0에서 시작하고, 안쪽 화살표 함수가 바깥 makeCard 칸의 n을 1씩 올립니다.

정답
  • 첫째 빈칸: 0
  • 둘째 빈칸: n
function makeCard(name) {
  let n = 0;
  return () => {
    n += 1;
    return `${name}: ${n}`;
  };
}
const card = makeCard('민지');
console.log(card());
console.log(card());
실행 결과
민지: 1
민지: 2

안쪽 화살표 함수는 makeCard 칸의 매개변수 name과 지역 변수 n을 모두 기억합니다.

문제 3

기본 점수 base를 받아 안쪽 함수를 돌려주는 makeAdder(base) 함수를 직접 쓰세요. 돌려받은 함수에 점수 n을 넣어 부르면, 그때마다 base에 n을 더한 값을 돌려주어야 합니다(base 자체는 바뀌지 않고 그대로 기억됩니다).

기대 출력과 시험 값은 다음과 같습니다.

15
30
// 여기에 makeAdder 함수를 쓰세요

const addTen = makeAdder(10);
console.log(addTen(5));
console.log(addTen(20));
힌트

makeAdder(base) 안에서 (n) => base + n 화살표 함수를 만들어 return으로 돌려주면, 안쪽 함수가 makeAdder 칸의 base를 기억합니다.

정답
function makeAdder(base) {
  return (n) => base + n;
}

const addTen = makeAdder(10);
console.log(addTen(5));
console.log(addTen(20));
실행 결과
15
30
  • 다르게 써도 되는 곳: 안쪽 함수를 중괄호와 return base + n;으로 감싸거나 function(n)으로 써도 같습니다.

컴퓨터에서 해 보기(선택) — 1절의 makeCounter 코드 아래에 const other = makeCounter();⁠와 console.log(other());⁠를 더 적어 돌려 보세요. tick()이 2까지 올라간 뒤에도 other()는 새로 열린 칸의 count를 써서 1이 찍히는 것을 확인할 수 있습니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

자바스크립트 목차로