Published on

클로저는 함수가 종료된 후에도 어떻게 변수를 기억할까?

Authors
  • avatar
    Name
    GW

개요

JavaScript를 공부하다보면 한 번쯤 이런 의문이 듭니다.

함수는 이미 끝났는데, 그 안에 있던 변수는 왜 아직도 사용할 수 있을까?

이 현상을 이해하기 위해 알아야 하는 개념이 클로저입니다.

클로저는 JavaScript에서 자주 사용되는 개념이지만, 단순히 “외부 변수를 기억하는 함수”라고만 외우면 실제 코드에서 어떻게 동작하는지 이해하기 어렵습니다.

이번 글에서는 먼저 클로저가 무엇인지 살펴본 뒤,

  • 클로저가 만들어지는 원리
  • 함수가 종료된 후에도 변수가 유지되는 이유
  • 클로저가 값을 저장하는 방식
  • 클로저의 활용 방법과 메모리 관리

순서로 조금씩 깊게 들어가 보겠습니다.

클로저란 무엇일까?

클로저는 함수가 자신이 선언된 외부 스코프의 변수에 계속 접근할 수 있는 특성입니다.

다음 코드를 살펴보겠습니다.

const createMessage = () => {
  const message = '안녕하세요';

  return () => {
    console.log(message);
  };
};

const printMessage = createMessage();

printMessage();

실행 결과는 다음과 같습니다.

안녕하세요

createMessage는 내부 함수를 반환한 뒤 실행을 마쳤습니다. 그런데 반환된 함수는 createMessage 안에서 선언한 message에 여전히 접근할 수 있습니다.

이처럼 내부 함수가 외부 함수의 실행이 끝난 뒤에도 외부 변수에 접근할 수 있는 이유가 바로 클로저입니다.

여기서 중요한 점은 클로저가 별도의 문법이 아니라는 것입니다.

JavaScript에 closure라는 키워드가 있는 것도 아니고, 개발자가 클로저를 만들겠다고 선언하는 것도 아닙니다.

함수가 외부 스코프의 변수를 참조하면 JavaScript의 동작 방식에 따라 자연스럽게 클로저가 만들어집니다.

클로저는 어떻게 동작할까?

클로저의 동작을 이해하려면 먼저 렉시컬 스코프를 알아야 합니다.

렉시컬 스코프란 함수가 접근할 수 있는 변수의 범위가 함수가 호출된 위치가 아니라 선언된 위치를 기준으로 결정되는 방식입니다.

const name = '전역';

const printName = () => {
  console.log(name);
};

const run = () => {
  const name = '지역';

  printName();
};

run();

printName은 run 안에서 호출되었으므로 지역이 출력될 것처럼 보입니다.

하지만 실제 결과는 전역입니다.

전역

printName은 run 안이 아니라 전역 스코프에서 선언되었습니다.

따라서 호출된 위치와 관계없이 자신이 선언된 위치를 기준으로 name을 찾습니다.

클로저도 이 원리에서 시작합니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;

    return count;
  };
};

const counter = createCounter();

console.log(counter());
console.log(counter());
console.log(counter());

실행 결과는 다음과 같습니다.

1
2
3

반환된 함수는 createCounter 안에서 선언되었습니다.

따라서 이 함수는 자신이 선언된 위치의 외부 변수인 count에 접근할 수 있습니다.

함수가 createCounter 바깥으로 반환되더라도 변수를 찾는 기준은 바뀌지 않습니다.

반환된 함수는 여전히 자신이 처음 선언된 환경을 기준으로 count를 찾습니다.

함수가 종료되어도 변수가 남는 이유

일반적으로 함수의 실행 과정을 다음과 같이 생각하기 쉽습니다.

함수 호출
↓
지역 변수 생성
↓
코드 실행
↓
함수 종료
↓
지역 변수 제거

대부분의 경우에는 이렇게 이해해도 문제가 없습니다.

const calculateTotal = () => {
  const price = 10_000;
  const quantity = 2;

  return price * quantity;
};

calculateTotal();

calculateTotal의 실행이 끝난 뒤에는 price와 quantity에 다시 접근할 방법이 없습니다.

더 이상 사용하는 곳이 없으므로 관련 데이터는 이후 가비지 컬렉션의 대상이 될 수 있습니다.

하지만 createCounter의 경우는 다릅니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;

    return count;
  };
};

const counter = createCounter();

createCounter가 반환한 함수는 counter에 저장되어 있습니다. 이 함수는 앞으로 다시 호출될 수 있고, 내부에서 count를 사용합니다.

따라서 JavaScript 엔진은 createCounter의 실행이 끝났다는 이유만으로 count를 정리할 수 없습니다. 아직 접근 가능한 함수가 count를 참조하고 있기 때문입니다.

이를 이해하기 위해 사용하는 개념이 렉시컬 환경입니다.

렉시컬 환경은 함수의 실행에 필요한 변수와 외부 스코프로 이어지는 연결을 관리하는 구조입니다.

createCounter가 실행될 때 만들어지는 환경을 단순하게 표현하면 다음과 같습니다.

createCounter의 렉시컬 환경
└─ count: 0

그리고 반환된 함수는 자신이 선언될 당시의 외부 렉시컬 환경을 참조합니다.

반환된 함수
└─ 외부 렉시컬 환경
   └─ count: 0

createCounter의 실행은 끝났지만 반환된 함수는 여전히 살아 있습니다. 그 함수가 count를 참조하고 있으므로 count가 포함된 렉시컬 환경도 함께 유지됩니다.

이후 counter()를 호출하면 JavaScript는 현재 함수 내부에서 count를 찾습니다. 현재 함수 안에는 count가 없으므로 외부 렉시컬 환경으로 이동하고, 그곳에 있는 count를 찾아 값을 변경합니다.

첫 번째 호출: count 0 → 1
두 번째 호출: count 1 → 2
세 번째 호출: count 2 → 3

즉, 함수가 종료되어도 변수가 남는 이유는 JavaScript가 모든 지역 변수를 무조건 보관하기 때문이 아닙니다.

외부 변수를 참조하는 함수가 여전히 접근 가능한 상태이기 때문에, 그 함수에 필요한 환경도 함께 유지되는 것입니다.

각각의 클로저는 독립된 상태를 가진다

같은 외부 함수를 여러 번 호출하면 각각의 클로저가 같은 상태를 공유하는지도 살펴보겠습니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;

    return count;
  };
};

const firstCounter = createCounter();
const secondCounter = createCounter();

console.log(firstCounter());
console.log(firstCounter());
console.log(secondCounter());

실행 결과는 다음과 같습니다.

1
2
1

두 카운터는 모두 같은 createCounter에서 만들어졌지만 서로 다른 count를 사용합니다.

createCounter를 호출할 때마다 새로운 렉시컬 환경이 만들어지기 때문입니다.

firstCounter
└─ 첫 번째 렉시컬 환경
   └─ count: 2

secondCounter
└─ 두 번째 렉시컬 환경
   └─ count: 1

firstCounter는 첫 번째 호출에서 만들어진 환경을 참조하고, secondCounter는 두 번째 호출에서 만들어진 환경을 참조합니다.

이 특성을 이용하면 전역 변수를 사용하지 않고도 서로 독립적인 상태를 가진 함수를 만들 수 있습니다.

클로저는 어디에서 사용될까?

클로저는 내부 함수를 반환하는 예제에서 가장 쉽게 확인할 수 있지만, 실제로는 다양한 JavaScript 코드에서 사용됩니다.

상태를 외부에서 직접 수정하지 못하게 만들기

클로저를 사용하면 상태를 함수 내부에 숨기고, 제공된 함수를 통해서만 변경하도록 만들 수 있습니다.

const createCounter = () => {
  let count = 0;

  const getCount = () => {
    return count;
  };

  const increase = () => {
    count += 1;
  };

  const decrease = () => {
    count -= 1;
  };

  return { getCount, increase, decrease };
};

const counter = createCounter();

counter.increase();
counter.increase();
counter.decrease();

console.log(counter.count); // undefined
console.log(counter.getCount()); // 1

외부에서는 count에 직접 접근할 수 없습니다.

대신 increase, decrease, getCount처럼 미리 제공된 함수로만 상태를 다룰 수 있습니다.

설정을 기억하는 함수 만들기

일부 설정을 미리 전달한 뒤 반복해서 사용하는 함수를 만들 때도 클로저가 유용합니다.

const createLogger = (level) => {
  return (message) => {
    console.log(`[${level}] ${message}`);
  };
};

const logInfo = createLogger('INFO');
const logError = createLogger('ERROR');

logInfo('애플리케이션을 시작합니다.');
logError('요청 처리에 실패했습니다.');

각 함수는 생성될 때 전달받은 level을 계속 참조합니다.

[INFO] 애플리케이션을 시작합니다.
[ERROR] 요청 처리에 실패했습니다.

매번 로그 레벨을 다시 전달하지 않아도 같은 설정을 가진 동작을 재사용할 수 있습니다.

비동기 콜백과 이벤트 핸들러

클로저는 비동기 콜백이나 이벤트 핸들러에서도 자연스럽게 사용됩니다.

const scheduleMessage = (message) => {
  setTimeout(() => {
    console.log(message);
  }, 1000);
};

scheduleMessage('1초 후 출력됩니다.');

scheduleMessage의 실행은 콜백보다 먼저 끝나지만, 나중에 실행되는 콜백은 여전히 message에 접근할 수 있습니다.

함수 반환, 비동기 콜백, 이벤트 핸들러처럼 함수가 선언된 스코프 밖에서 나중에 실행되는 상황에서는 클로저의 동작이 특히 눈에 잘 드러납니다.

클로저와 메모리 관리

클로저가 외부 변수를 유지한다고 하면 관련 데이터가 메모리에 영원히 남는 것처럼 느껴질 수 있습니다.

하지만 클로저도 더 이상 접근할 수 없게 되면 관련 환경이 가비지 컬렉션의 대상이 될 수 있습니다.

let counter = createCounter();

counter();
counter();

counter = null;

counter가 반환된 함수를 참조하고 있을 때는 해당 함수와 렉시컬 환경이 필요합니다.

하지만 counter = null로 참조를 제거하고, 해당 함수에 접근할 다른 방법도 없다면 함수가 참조하던 환경도 더 이상 사용할 수 없습니다. 이때 관련 데이터는 가비지 컬렉션의 대상이 될 수 있습니다.

정확한 메모리 해제 시점은 JavaScript 엔진이 결정하므로 참조를 제거한 즉시 메모리가 해제된다고 단정할 순 없습니다.

또한 클로저 자체가 메모리 누수를 만드는 것은 아닙니다.

문제는 더 이상 필요하지 않은 함수가 큰 데이터나 이벤트 핸들러를 계속 참조하면서 불필요하게 오래 유지되는 경우입니다.

따라서 오래 살아 있는 클로저가 많은 데이터를 참조하고 있다면 해당 참조가 정말 필요한지 확인해야 합니다.

클로저는 상태를 유지하고 데이터를 숨기는 데 유용하지만, 그만큼 어떤 데이터를 얼마나 오래 참조하는지도 함께 고려해야 합니다.

정리

클로저는 함수가 자신이 선언된 외부 스코프의 변수에 계속 접근할 수 있는 특성입니다.

핵심을 정리하면 다음과 같습니다.

  • JavaScript는 함수가 호출된 위치가 아니라 선언된 위치를 기준으로 스코프를 결정합니다.
  • 함수는 자신이 선언될 당시의 외부 렉시컬 환경을 참조할 수 있습니다.
  • 외부 함수가 종료되어도 내부 함수가 변수를 참조하면 필요한 환경은 유지됩니다.
  • 클로저는 값을 복사하는 것이 아니라 변수가 존재하는 환경을 참조합니다.
  • 같은 외부 함수를 여러 번 호출하면 각각 독립된 렉시컬 환경이 만들어집니다.
  • 더 이상 접근할 수 없는 함수와 렉시컬 환경은 가비지 컬렉션의 대상이 될 수 있습니다.

결국 클로저는 함수가 자신이 선언된 외부 환경을 참조하면서 자연스럽게 만들어지는 동작입니다.

참고 자료