JavaScript의 Closure란 무엇이고 왜 그렇게 동작할까

Frontend

JavaScript를 공부하다 보면 언젠가 이런 코드를 만나게 됩니다.

function createCounter() {
  let count = 0;
 
  return function increment() {
    count += 1;
    return count;
  };
}
 
const counter = createCounter();
 
console.log(counter()); // 1
console.log(counter()); // 2

처음 보면 자연스럽게 이런 생각이 듭니다.

  • createCounter()는 이미 끝났는데 왜 count가 살아 있지?
  • 함수가 바깥 변수 값을 복사해둔 건가?
  • 이게 메모리 누수랑도 관련이 있나?
  • 왜 어떤 상황에서는 유용하고, 어떤 상황에서는 버그를 만들까?

이 질문들의 중심에는 closure가 있습니다.

겉보기에는 "함수가 바깥 변수를 기억한다" 정도로 설명되곤 합니다. 물론 빠르게 이해하는 데는 도움이 됩니다. 하지만 기본기 관점에서는 그것만으로 부족합니다. 중요한 것은 함수가 왜 바깥 스코프의 바인딩에 계속 접근할 수 있는지, 그리고 그 결과로 어떤 상태 유지가 가능한지를 이해하는 것입니다.

이 글에서는 JavaScriptclosure를 아래 흐름으로 정리해보겠습니다.

  1. closure는 무엇인지
  2. lexical scope와 어떤 관계가 있는지
  3. 함수가 왜 바깥 값을 기억하는 것처럼 보이는지
  4. 실무에서 어디에 유용한지
  5. 언제 조심해야 하는지

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • closure는 함수가 자신이 선언된 바깥 스코프에 계속 접근할 수 있는 현상입니다.
  • 이 현상은 lexical scope 위에서 동작합니다.
  • 함수가 값을 통째로 복사하는 것이 아니라, 바깥 바인딩에 접근 가능한 상태가 유지된다고 보는 편이 더 정확합니다.
  • closure는 상태 은닉, 팩토리 함수, 이벤트 핸들러, 훅과 콜백에서 매우 자주 등장합니다.
  • 반대로 의도치 않은 참조 유지나 오래된 값 캡처 문제를 만들 수도 있습니다.

즉, closure는 특별한 트릭이 아니라 스코프가 시간축 위에서 계속 살아 보이는 방식을 설명하는 핵심 개념입니다.

closure는 정확히 무엇일까?

가장 단순하게 말하면 closure함수가 자신이 선언된 바깥 스코프의 식별자에 계속 접근할 수 있는 상태입니다.

다시 처음 예제를 보면:

function createCounter() {
  let count = 0;
 
  return function increment() {
    count += 1;
    return count;
  };
}

increment()createCounter() 안에서 선언됐습니다. 그래서 increment()는:

  • 자기 내부 스코프를 보고
  • 없으면 createCounter()의 스코프를 보고
  • 필요하면 더 바깥으로 올라갑니다

즉, countincrement()의 지역 변수는 아니지만, 선언 위치 기준으로 접근 가능한 외부 바인딩입니다.

왜 "기억한다"고 표현할까?

클로저를 설명할 때 흔히 "함수가 바깥 변수를 기억한다"고 합니다.

이 표현은 직관적으로는 좋지만, 아주 정확한 표현은 아닙니다.

더 정확히는:

  • 함수가 바깥 값 자체를 복사해서 저장한다기보다
  • 바깥 스코프의 바인딩에 접근 가능한 상태가 유지된다

고 이해하는 편이 맞습니다.

즉, count 값이 어딘가에 복제돼 따로 쌓여 있는 것이 아니라, increment()가 계속 그 바인딩을 볼 수 있기 때문에 값 변화가 이어져 보이는 것입니다.

이 차이는 기본기에서 중요합니다. 왜냐하면 "값 복사"로 이해하면 나중에:

  • 참조형 값이 왜 계속 바뀌는지
  • 여러 클로저가 왜 같은 상태를 공유할 수 있는지
  • 왜 의도치 않은 참조 유지가 생기는지

를 설명하기 어려워지기 때문입니다.

closure는 왜 생길까?

이 질문의 핵심은 사실 closure 자체보다 lexical scope에 있습니다.

JavaScript 함수는 자신이 어디서 선언됐는지를 기준으로 바깥 스코프를 봅니다.

예를 들어:

const name = 'global';
 
function outer() {
  const name = 'outer';
 
  return function inner() {
    return name;
  };
}

inner()는 선언될 때 이미 outer() 스코프를 바깥 환경으로 갖게 됩니다.

즉, closure는 갑자기 별도로 붙는 기능이라기보다, lexical scope가 실제 런타임에서 끝까지 유지되는 결과에 가깝습니다.

그래서 scope를 이해한 뒤 closure를 보면 훨씬 자연스럽습니다.

함수가 끝났는데도 왜 바깥 변수가 살아 있을까?

이 부분이 closure에서 가장 많이 궁금한 지점입니다.

보통은 이런 감각이 들기 쉽습니다.

"createCounter() 실행이 끝났으면 그 안 변수도 사라져야 하는 것 아닌가?"

직관적으로는 맞는 질문입니다. 하지만 자바스크립트 엔진은 어떤 함수가 여전히 그 바깥 바인딩을 참조하고 있다면, 그 스코프를 바로 버리지 않습니다.

즉:

  • 바깥 함수 실행이 끝났더라도
  • 안쪽 함수가 그 스코프를 계속 참조할 수 있으면
  • 그 환경은 계속 살아남을 수 있습니다

이게 closure가 "시간이 지난 뒤에도 스코프가 살아 있는 것처럼 보이는 이유"입니다.

즉, 클로저는 함수가 끝났는데도 메모리가 이상하게 남는 현상이 아니라, 참조가 아직 필요해서 유지되는 정상 동작입니다.

closure는 언제 만들어질까?

많은 경우 감각적으로는 함수가 선언될 때부터 가능성이 생긴다고 보는 편이 좋습니다.

예를 들어:

function outer() {
  const value = 1;
 
  return function inner() {
    return value;
  };
}

여기서 inner는 선언되는 순간:

  • 자기 스코프뿐 아니라
  • 바깥 스코프를 어떻게 볼지

가 같이 결정됩니다.

즉, closure는 나중에 호출 시점에 우연히 생기는 것이 아니라, 함수가 선언된 구조 위에서 이미 가능성이 정해져 있는 동작입니다.

이 점은 scope, hoisting, 실행 컨텍스트와도 자연스럽게 이어집니다.

모든 함수가 클로저일까?

엄밀하게 말하면 많은 함수는 클로저로 볼 수 있는 성질을 가집니다. 왜냐하면 함수는 대체로 자신이 선언된 바깥 환경과 연결되기 때문입니다.

다만 실무에서 "클로저를 쓴다"고 말할 때는 보통:

  • 바깥 스코프의 값을 실제로 활용하고
  • 그 값이 함수 실행 이후에도 의미 있게 유지되는 경우

를 더 자주 가리킵니다.

즉, 이론적으로는 넓게 볼 수 있지만, 실무에서는 보통 상태 유지나 캡슐화가 실제로 드러나는 패턴에서 클로저를 의식하게 됩니다.

여러 함수가 같은 클로저 상태를 공유할 수도 있을까?

가능합니다. 이 부분이 꽤 중요합니다.

예를 들어:

function createCounter() {
  let count = 0;
 
  return {
    increment() {
      count += 1;
      return count;
    },
    decrement() {
      count -= 1;
      return count;
    },
    getCount() {
      return count;
    },
  };
}

여기서 increment, decrement, getCount는 모두 같은 count를 봅니다.

즉, 클로저는 "함수 하나가 값 하나를 기억"하는 정도가 아니라, 여러 함수가 하나의 비공개 상태를 공유하는 방식으로도 자주 쓰입니다.

이 패턴이 중요한 이유는, 외부에서는 직접 count를 건드릴 수 없고 오직 공개된 함수들만 그 상태를 조작할 수 있기 때문입니다.

즉, 클로저는 상태 은닉과 인터페이스 분리에 꽤 강한 도구입니다.

실무에서는 어디에 자주 쓰일까?

1. 팩토리 함수

클로저는 팩토리 함수에서 자주 보입니다.

function createFormatter(locale: string) {
  return function formatPrice(price: number) {
    return new Intl.NumberFormat(locale).format(price);
  };
}

여기서 반환된 함수는 locale을 계속 참조합니다.

즉, 설정값을 외부에서 한 번 받아 내부 함수가 계속 활용하는 구조에 잘 맞습니다.

2. 상태 은닉

클래스를 쓰지 않더라도 클로저만으로 비공개 상태처럼 다룰 수 있습니다.

function createToggle() {
  let open = false;
 
  return {
    toggle() {
      open = !open;
      return open;
    },
    isOpen() {
      return open;
    },
  };
}

이 구조는 외부에서 open을 직접 바꿀 수 없게 만듭니다.

3. 이벤트 핸들러와 콜백

이벤트 핸들러는 선언 시점의 바깥 값들을 그대로 잡고 들어가는 경우가 많습니다.

function bindButton(label: string) {
  button.addEventListener('click', () => {
    console.log(label);
  });
}

즉, UI 코드에서도 클로저는 아주 흔합니다.

4. React 훅과 렌더 함수

React를 쓰면 클로저를 매일 만나게 됩니다.

  • 이벤트 핸들러
  • effect 콜백
  • memoized 함수

같은 것들이 전부 바깥 값을 캡처합니다.

그래서 클로저를 모르면 React의 "오래된 값(stale value)" 문제를 이해하기 어렵습니다.

클로저는 왜 강력할까?

핵심은 두 가지입니다.

1. 상태를 함수에 묶을 수 있다

전역 변수 없이도 특정 로직에만 연결된 상태를 만들 수 있습니다.

2. 외부 노출을 줄일 수 있다

필요한 인터페이스만 밖으로 내보내고, 나머지 값은 감출 수 있습니다.

즉, 클로저는 단순 트릭이 아니라 작은 모듈을 만드는 도구처럼도 볼 수 있습니다.

하지만 언제 조심해야 할까?

클로저는 강력하지만, 기본기가 부족한 상태에서 쓰면 꽤 헷갈리는 버그도 만듭니다.

1. 오래된 값 캡처

대표적으로 이런 문제입니다.

function createLogger(value: number) {
  return function log() {
    console.log(value);
  };
}

이 함수는 선언 시점의 value를 봅니다.

즉, 나중에 바깥 상태가 바뀌더라도 내가 기대한 최신 값을 보는 것이 아닐 수 있습니다.

React에서 흔히 말하는 stale closure 문제도 이 감각과 연결됩니다.

2. 불필요한 참조 유지

클로저가 큰 객체나 DOM 참조를 계속 잡고 있으면, 생각보다 오래 메모리가 유지될 수 있습니다.

즉, 클로저 자체가 메모리 누수라는 뜻은 아니지만, 필요 없는 참조를 오래 붙잡고 있을 위험은 있습니다.

3. 디버깅 난이도 상승

값이 함수 안에 숨겨져 있고 여러 함수가 같은 상태를 공유하면:

  • 지금 어느 함수가 상태를 바꿨는지
  • 왜 값이 이렇게 되었는지

추적이 어려워질 수 있습니다.

즉, 클로저는 강력하지만 상태 흐름이 감춰질수록 코드 이해 비용도 같이 올라갑니다.

반복문과 클로저는 왜 자주 같이 나오나?

이 주제도 기본기에서 자주 묶입니다.

예를 들어 var를 쓴 반복문에서:

for (var i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

이 코드는 많은 사람이 기대한 대로 동작하지 않습니다.

이유는:

  • 각 콜백이 반복마다 별도 값을 복사한 것이 아니라
  • 같은 바인딩을 바라보고 있기 때문입니다

반면 let은 반복마다 더 분리된 바인딩처럼 보이기 때문에 결과가 달라집니다.

즉, 반복문 문제도 결국 클로저가 무엇을 캡처하고 있는가로 설명할 수 있습니다.

클로저와 메모리 누수는 같은 말일까?

아닙니다. 이건 아주 흔한 오해입니다.

클로저는 정상 기능입니다.

메모리 누수는:

  • 더 이상 필요 없는 참조가
  • 해제되지 않고
  • 계속 남아 있는 문제

에 가깝습니다.

즉, 클로저가 있다고 해서 곧 메모리 누수는 아닙니다. 다만 클로저가 큰 객체나 오래된 DOM 참조를 붙잡고 있으면, 누수의 원인 구조가 되기 쉬울 수는 있습니다.

따라서 중요한 것은 클로저를 피하는 것이 아니라, 무엇을 얼마나 오래 참조하고 있는지 의식하는 것입니다.

실무 체크리스트

실제로 코드를 볼 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.

  1. 이 함수는 어떤 바깥 값을 캡처하고 있는가?
  2. 그 값은 내가 의도한 최신 값인가, 선언 시점 값인가?
  3. 여러 함수가 같은 상태를 공유하고 있는가?
  4. 클로저가 불필요하게 큰 객체나 DOM 참조를 오래 붙잡고 있지는 않은가?
  5. 이 로직은 클로저로 숨기는 것이 더 읽기 쉬운가, 아니면 상태 흐름을 너무 감추는가?

이 기준으로 보면 클로저는 신기한 현상이 아니라, 상태를 함수와 함께 다루는 설계 도구로 보이기 시작합니다.

자주 하는 실수

정리하면 아래 실수가 정말 자주 나옵니다.

  • 클로저를 "값 복사"로 이해한다
  • 함수가 어디서 호출됐는지가 클로저를 결정한다고 생각한다
  • 여러 함수가 같은 바인딩을 공유한다는 점을 놓친다
  • 오래된 값 캡처 문제를 이해하지 못한다
  • 클로저 자체를 메모리 누수라고 오해한다

즉, 클로저는 어려운 개념이라기보다 스코프가 시간축까지 확장되어 보이는 현상으로 이해하는 편이 가장 안정적입니다.

같이 보면 좋은 글

결론

JavaScriptclosure는 함수가 특별한 마법을 부리는 기능이라기보다, 자신이 선언된 바깥 스코프에 계속 접근할 수 있는 결과를 설명하는 개념입니다.

짧게 정리하면:

  • 클로저는 lexical scope 위에서 동작하고
  • 함수가 바깥 값을 복사하는 것이 아니라 바인딩에 접근 가능한 상태가 유지되며
  • 상태 은닉, 팩토리 함수, 이벤트 핸들러, React 코드에서 매우 자주 등장하고
  • 오래된 값 캡처나 불필요한 참조 유지 같은 문제도 만들 수 있습니다

결국 클로저를 이해하면 "왜 함수가 값을 기억하는 것처럼 보이는가"를 넘어서, 상태를 함수와 함께 어떻게 설계하고 통제할 것인가까지 더 자연스럽게 볼 수 있게 됩니다.