1주차

클로저 closure

hwiy 2025. 4. 11. 18:53
안녕하세요! AT SOPT WEB 36기 YB 황인영입니다.

 

javascript를 공부하다보면 클로저라는 개념을 한 번쯤 들어봤을 것입니다.

클로저는 함수를 일급 객체로 취급하는, javascript를 포함한 프로그래밍 언어에서 사용되는 중요한 특성입니다.

오늘 그 클로저에 대해 자세히 소개해보도록 하겠습니다.

 

MDN에서 정의하는 클로저의 개념은 다음과 같습니다.

"클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다."

 

정의가 난해해보여서,,,, 별로 알고 싶지 않게 생겼지만 하나씩 뜯어 알아보도록 하겠습니다.

 

 

🔎 렉시컬 스코프

javascript 엔진은 함수가 어디서 호출했는지가 아닌, 함수를 어디에 정의했는지에 따라 상의 스코프를 결정합니다.

이를 렉시컬 스코프(=정적 스코프)라고 합니다. 함수의 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정되고, 변하지 않습니다.

 

예를 들어 다음의 코드를 보면

const x = 1;

function outerFunc() {
	const x = 10;
    
    function innerFunc() {
    	console.log(x); // 10
	}
    
    innerFunc();
}

outerFunc();

 

outerFunc 함수 내부에서 중첩 함수 innerFunc가 정의되고 호출된 것을 확인할 수 있습니다. 

이때 중첩 함수 innerFunc이 정의된 상위 스코프는 outerFunc의 스코프입니다. 즉, 렉시컬 스코프가 outerFunc 이 됩니다.
따라서 중첩 함수 innerFunc 내부에서 자신을 포함하고 있는 외부함수 outerFunc 의 x 변수에 접근할 수 있습니다.

 

이를 통해 렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결되게 됩니다. 이를 스코프 체인이라고 합니다.

const a = 1;

function outer() {

  function inner() {
    console.log(a); // 1
  }

  return inner;
}

const fn = outer(); 
fn(); // 여기서 inner() 호출

 

위의 코드에서 inner()가 a를 접근할 수 있는 이유는 무엇일까요?

inner함수는 자신이 정의된 위치인 스코프 (= outer)를 기억합니다. outer는 a를 포함한 전역 스코프를 기억합니다.

이렇게 스코프가 계층적으로 연결된 것을 스코프 체인이라고 합니다.

즉.. 상자로 비유하면, 함수 정의한다는 것은 함수를 상자에 넣고, 이 상자는 해당 함수를 둘러싼 환경을 기억합니다. 함수를 호출할 시 기억해둔 위치에서 사용할 수 있는 변수를 체인처럼 따라가서 사용할 수 있게 됩니다.

 

따라서, 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 저장할 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정되며, 이것이 렉시컬 스코프입니다.

 

 

 

🔎 함수 객체의 내부 슬롯 [[ Environment ]]

함수가 호출되는 위치와 정의된 위치가 다를 수 있습니다. 따라서 위와 같은 렉시컬 스코프 개념이 가능하려면, 함수는 자신이 정의된 환경인 상위 스코프를 기억해야합니다. 이를 위해 함수는 자신의 내부 슬롯 [[ Environment ]] 에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장합니다. 이때 자신의 내부 슬롯 [[ Environment ]] 에 저장된 상위 스코프의 참조는, 현재 실행 중인 실행 컨텍스트의 렉시컬 환경을 가르킵니다. javascript는 코드가 실행되는 순간 마다 어떤 스코프에서 실행 중인지를 추적하는데, 이 실행 중인 공간에 대해 제공할 환경 정보들을 모아놓은 객체를 실행 컨텍스트라고 합니다. 이때 실행 컨텍스트의 렉시컬 환경이 해당 함수의 렉시컬 환경이 되기 때문에 이 정보를 [[ Environment ]] 에 저장하게 되는 것입니다.

 

위의 코드를 예시로 들면, inner()가 정의될 때 이 함수의 내부 슬롯 [[ Enviroment ]]에 변수 a에 대한 정보를 담아 놓게 됩니다.

 

 

🔎 클로저와 렉시컬 환경

클로저의 배경이 되는 지식들을 알아보았으니, 클로저의 개념에 대해 알아보도록 하겠습니다.

const x = 1;

// ①
function outer() {
  const x = 10;
  const inner = function () { console.log(x); }; // ②
  return inner;
}

// outer 함수를 호출하면 중첩 함수 inner를 반환한다.
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 pop 되어 제거된다.
const innerFunc = outer(); // ③
innerFunc(); // ④ 10

 

outer 함수를 호출하면, outer 함수는 inner 함수를 반환하고 생명 주기를 마감합니다. 

이때, outer 함수의 지역 변수 x 또한 생명 주기를 마감합니다. 이에 따라 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만, outer 함수의 렉시컬 환경까지 소멸하지는 않습니다. outer 함수의 렉시컬 환경은 inner 함수의 [[ Environment ]] 내부 슬롯에 의해 참조되고 있고, inner 함수는 innerFunc 에 의해 참조되고 있으므로, 가비지 컬렉션의 대상이 되지 않기 때문입니다. 가비지 컬렉터는 누군가가 참조하고 있는 메모리 공간을 함부로 해제하지 않습니다. 따라서 이미 생명주기를 다 해 종료되어 실행 컨텍스트에서 제거된 outer 함수의 지역변수 x 값이 10이 동작할 수 있게 됩니다.

 

이와 같이 외부 함수보다 중첩 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있습니다. 이러한 중첩 함수를 클로저라고 부릅니다. 즉, 정리하면 함수는 자신이 정의될 때의 렉시컬 환경을 기억하여, 외부 함수의 실행이 끝난 이후에도 외부 함수 변수에 접근할 수 있도록 하는 기능이 클로저입니다.

 

javascript의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저이지만, 일반적으로 모든 함수를 클로저라고 하지 않습니다. 일반적으로 클로저는 그보다 더 좁은 범위로, 중첩 함수가 상위 스코프의 식별자를 참조하고 있고, 중첩 함수가 외부 함수가 더 오래 유지되는 경우를 의미합니다. 

 

 

🔎 클로저의 활용

클로저는 상태를 안전하게 변경하고 유지하기 위해 사용합니다. 즉, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고, 특정 함수에게만 상태 변경을 허용하기 위해 사용합니다.

// 카운트 상태 변수
let num = 0;

// 카운트 상태 변경 함수
const increase = function () {
  // 카운트 상태를 1만큼 증가 시킨다.
  return ++num;
};

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

 

위의 코드는 다음과 같은 이유로 오류를 발생시킬 가능성을 내포하고 있는 좋지 않는 코드입니다.

 

먼저 num의 카운트 상태는 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 합니다.

이를 위해 num의 카운트 상태는 increase 함수만이 변경할 수 있어야 합니다.

하지만, 카운트 상태는 전역 변수를 통해 관리되고 있기 때문에 의도치 않게 상태가 변경될 수 있습니다.

 

따라서 increase 함수만이 num 변수를 참조하고 변경할 수 있도록, 전역변수 num을 increase 함수의 지역변수로 바꾸고 클로저를 사용해 주는 것이 바람직합니다.

const increase = function () => {
	// 카운트 상태 변수
    let num = 0;
    
    // 카운트 상태를 1만큼 증가시킨다.
    return ++num;
};

console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1

 

하지만 위와 같이 num을 지역 변수로 만들면 increase 함수가 호출될 때마다 다시 선언되고 0으로 초기화 되는 문제가 발생합니다. 다시 말해, 상태가 변경되기 이전 상태를 유지하지 못하게 됩니다. 이전 상태를 유지할 수 있도록 클로저를 사용해봅시다.

const increase = (function () { // 즉시 실행 함수
  // 카운트 상태 변수
  let num = 0;

  // 클로저 
  return function () {
    // 카운트 상태를 1만큼 증가 시킨다.
    return ++num;
  };
}());

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

 

위 코드가 실행되면 즉시 실행 함수가 호출되고 즉시 실행 함수가 반환한 함수가 increase 변수에 할당됩니다.

increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저입니다.

즉시 실행 함수는 호출된 이후 즉시 소멸되지만 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출됩니다. 이때 즉시 실행 함수가 반환한 클로저는 상위 스코프인 즉시 실행 함수의 렉시컬 환경( let num = 0; 이 있던 환경 )을 기억하기 때문에, 클로저는 num을 언제 어디서 호출하든지 참조하고 변경할 수 있게 됩니다. 

즉시 실행 함수는 한 번만 실행되므로 increase가 호출될 때마다 num 변수가 더 초기화될 일은 없습니다. 또 num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경에 의한 부수 효과를 억제할 수 있습니다.

 

다음은 함수형 프로그래밍에서 클로저를 활용하는 간단한 예제입니다.

// 함수를 반환하는 고차 함수
// 이 함수는 카운트 상태를 유지하기 위한 자유 변수 counter를 기억하는 클로저를 반환한다.
const counter = (function () {
  // 카운트 상태를 유지하기 위한 자유 변수
  let counter = 0;

  // 함수를 인수로 전달받는 클로저를 반환
  return function (aux) {
    // 인수로 전달 받은 보조 함수에 상태 변경을 위임한다.
    counter = aux(counter);
    return counter;
  };
}());

// 보조 함수
function increase(n) {
  return ++n;
}

// 보조 함수
function decrease(n) {
  return --n;
}

// 보조 함수를 전달하여 호출
console.log(counter(increase)); // 1
console.log(counter(increase)); // 2

// 자유 변수를 공유한다.
console.log(counter(decrease)); // 1
console.log(counter(decrease)); // 0

 

 

모던 자바스크립트 Deep Dive 24 장 클로저

클로저는 변수 은닉과 상태 유지를 가능하게 해주기 때문에, 실제 프로젝트에 적절히 활용하면 더 안정적인 코드로 만들 수 있을 것 같습니다. 제 글이 도움이 되셨으면 좋겠습니다 읽어주셔서 감사합니다! 🐕🐕