프론트엔드 개발/JavaScript

자바스크립트) 클로저 끝장 정리

Ella Seon 2023. 6. 5. 07:54

클로저에 관한 게시글을 읽기 전에 렉시컬 환경, 스코프체인에 관해 알고오시면 도움됩니다!

0. 클로저

-  외부 변수를 기억하고, 이 외부 변수에 접근할 수 있는 함수를 의미한다.

-  내부 함수는 외부함수의 지역변수에 접근할 수 있는데, 외부함수의 실행이 끝나서 외부함수가 소멸된 이후에도 내부함수가 외부함수의 변수에 접근할 수 있는 것

- 함수와 그 함수가 선언되었을 때의 렉시컬환경과의 조합이라고 부르며, 내부 함수가 외부 함수 변수에 접근할 수 있는 자바스크립트 기능을 말한다.

// 외부 함수
function closuer() {
    // 변수 정의
    var count = 0;
    // 내부 함수(클로저) 선언
    function inner() {
        return ++count;
    }
    // 내부 함수 반환
    return inner();
}

// 익명 함수를 이용한 방법
function closure() {
    var count = 0;
    // 익명 함수(클로저) 반환
    return function() {
        return ++count;
    }
}

 

const outer = () => {
  const outerVariable = 'outer!'; // 1. 바깥 함수 outer의 스코프에 변수선언

  const inner = () => {
    console.log(outerVariable); // 2. 내부 함수 inner의 스코프에서 스코프체인을 타고 바깥 함수 스코프의 변수 참조
  };

  return inner; // 3. 1급 시민인 함수 inner를 바깥으로 반환
};

const fano = outer(); // 4.  fano에 inner함수의 주소값이 저장됨. 함수호출문이 보이면 return 값을 생각하라. 따라서,  inner 함수 저장

fano(); // 5. inner 함수 호출, outer함수 호출은 종료가 되어서 스코프가 사라져야 하지만 outerVariable은 여전히 잘 참조된다.

- 위 클로저 예제에서 왜 outer() 를 fano라는 변수에 담아야하는 걸까? outer()만 실행하면 안되는건가?

함수를 호출하면 함수의 반환값이 담긴다. 이 반환값을 나중에 다시 사용하려면 그 값을 어딘가에 저장해두어야한다. 따라서, 변수를 사용해야한다.

- 'outer' 라는 함수가 있고 반환값으로 'inner' 라는 함수를 반환한다고 할때, inner 함수를 나중에 다시 호출하려면 'inner'를 어딘가에 저장해두어야한다. 이를 위해 'fano'라는 변수를 만들어 'outer' 함수의 반환값을 'fano'에 저장해두는 것이다. 

- 이제 'fano'라는 변수에 'inner' 함수가 저장되어있으므로, 'fano()'를 통해 'inner' 함수를 언제든지 호출할 수 있다. 

- outer() 만 호출했을 때는 이 함수의 반환값인 'inner' 함수가 바로 실행되지 않고, 'inner' 함수의 정의(함수의 본체)만 반환된다. 이렇게 반환된 'inner'를 실행하려면 변수에 저장해두어야한다.

- fano라는 변수 없이 outer()를 바로 호출하면, outer의 반환값인 inner 함수는 어디에도 저장되지 않기 때문에, outer 함수가 호출된 이후에는 이 inner 함수를 다시 호출할 수 없게 된다.

 

 

- 위 코드에서 myFunc라는 변수에 반환값인 새로운 함수를 담았다. 그 후 호출했더니 x값이 없음에도 외부 변수 였던 x를 기억하고 10을 보여준다. 

 

다른 비슷한 사례를 더 보자

 

- 위 사례에서 왜 ella()는 10이 나왔을까? outer 함수는 중첩함수 inner를 ella에게 반환하면서 생명주기를 잃게됨. outer 함수의 호출이 종료가 되면, outer 함수의 실행컨텍스트는 콜스택에서 제거가 됨 . outer 함수의 x의 생명주기도 마감이됨. 더이상 지역변수 x 10 에 접근할수가 없는데 어째서 10이 나왔을까?

- 중첩함수 inner가 이미 생명주기를 마감한 outer 함수(외부 함수)에 지역변수 x를 참조할 수 있다면이때 inner를 클로저라고 한다. 즉, 원래는 함수 내부에 선언한 변수는 함수가 끝나면 사라지지만, 클로저가 스코프 체인을 계속 들고 있으므로 outer 함수 내부의 변수를 참조할 수 있게 된다

 

🔸콜스택에 쌓이는 과정 구체적으로 보려면 더보기 누르기

더보기

🔸콜스택에 쌓이는 과정 구체적으로 보기 (챗지피티 참고)

  1. 초기 상태: 콜스택은 비어 있습니다.
  2. const x = 1;을 실행: 전역 실행 컨텍스트가 콜스택에 푸시되며, 전역 변수 x가 생성됩니다.
  3. function outer(){...}를 실행: outer 함수가 전역 실행 컨텍스트에 추가됩니다. 이 시점에서는 함수가 호출되지 않으므로 콜스택에는 아무런 변화도 없습니다.
  4. const ella = outer();를 실행: outer 함수가 호출되고, outer 함수의 실행 컨텍스트가 콜스택에 푸시됩니다. 이 실행 컨텍스트는 outer 함수의 지역 변수 x와 내부 함수 inner를 포함합니다. outer 함수가 inner 함수를 반환하면서 종료되므로, outer의 실행 컨텍스트는 콜스택에서 제거됩니다. 그러나 inner 함수는 클로저이므로 outer의 지역 변수 x에 여전히 접근할 수 있습니다.
  5. ella();를 실행: ella 변수는 inner 함수를 가리키고 있으므로, inner 함수가 호출됩니다. inner 함수의 실행 컨텍스트가 콜스택에 푸시되며, 이 컨텍스트는 inner 함수의 코드를 실행하는 데 필요한 모든 정보를 포함합니다.
  6. console.log(x)를 실행: 이 시점에서 x는 inner 함수의 스코프 체인을 통해 접근됩니다. inner 함수의 자신의 스코프에 x가 없으므로, 바로 바깥 스코프인 outer 함수의 스코프에서 x를 찾게 됩니다. x의 값은 10이므로, "10"이 콘솔에 출력됩니다. 이후 inner 함수의 실행이 완료되므로, inner의 실행 컨텍스트는 콜스택에서 제거됩니다.
  7. 마지막 상태: 전역 실행 컨텍스트만이 콜스택에 남아 있습니다. 전역 실행 컨텍스트는 x, outer, ella를 포함하고 있습니다.

 

🔸어떻게 외부함수 생명주기가 끝났는데 접근이 가능하지?
외부함수가 실행을 마치고 반환되면, 일반적으로 해당 함수의 실행컨텍스트는 스택에서 제거되고 메모리에서 해제된다. 이로 인해 외부 함수의 변수들도 일반적으로 메모리에서 해제된다. 하지만, 내부 함수가 외부함수의 변수에 접근하는 경우, 이러한 변수들은 여전히 사용되어야 하므로 메모리에서 해제되지 않는다.

클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다. 
자바스크립트의 모든 함수는 각각의 렉시컬 환경을 갖는다.
1) 함수가 호출되면 새로운 환경 레코드가 생성되고, 이 환경 레코드는 호출에 필요한 모든 변수를 저장한다. (호이스팅과 관련된 정보)
2) 또한, 함수 내부함수는 자신의 외부 환경으로 해당 함수의 렉시컬 환경을 참조한다. (스코프 체인을 가능케함)

클로저는 내부 함수와 그 함수가 선언된 렉시컬 환경의 조합으로 구성되는데, 내부 함수는 자신의 스코프와 더불어 클로저에 대한 참조를 유지한다. 이 클로저에는 외부 함수의 렉시컬 환경이 저장되어있다. 내부함수에서 외부 함수의 변수에 접근하려고 할 때, 해당 변수는 클로저에 의해 참조되어 값을 가져올 수 있다. 

클로저는 외부 함수의 렉시컬 환경을 참조하고 있기 때문에 외부 함수의 변수들은 클로저가 유효한 한 메모리에 남게 된다. 이는 외부 함수가 반환된 이후에도 내부함수가 외부 함수의 변수에 접근할 수 있는 이유이다. 클로저는 자신이 형성된 시점의 외부 함수의 변수들의 상태를 기억하고 유지하는 역할을 한다.

1. 클로저를 사용하는 이유

1) 전역 변수 사용억제 (상태 은닉)

전역변수로는 로직이 복잡해질 시, 어디서 변경이 되는지 추적이 어려워 가독성과 유지보수에 어려움을 겪을 수 있다. 따라서 클로저를 통해서 전역 변수 사용을 억제할 수 있다. 

 

클로저는 상태(state)를 안전하게 변경하고 유지하기 위해 사용한다.

다시 말해, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉(information hiding)하고 특정 함수에게만 상태변경을 허용하기 위해 사용한다.  +) 전역변수의 사용을 억제하기위해

 

상태 은닉: 클로저는 함수와 그 함수가 만들어진 환경을 함께 기억합니다. 이를 통해 함수 내부에서 선언된 변수와 함수들은 외부에서 직접 접근할 수 없습니다. 데이터를 보호하고 캡슐화하여 코드의 안정성과 가독성을 높일 수 있습니다.

 

ex) 상태은닉 예제코드(private 변수와 함수 구현)

cnt 라는 전역변수가 있는데 cntPlus 라는 함수를 통해서만 값을 바꿀 수 있다. 하지만 이 코드는 상태가 의도치 않게 변경될 가능성이 있다. 

만약에 중간에 1억개의 코드가 있다가 cnt = 100; 이라고 해벌면 다시 cnt가 101이 되어버림

이 문제점을 해결하려면 중간에 cnt 변수에 접근을 못하도록 하면 된다. 그럴때 클로저를 쓰면된다.

cnt로 접근할 수 없게 하기 위해서 cnt를 전역변수에서 지역변수로 바꾼다. 즉, 함수로 감싸준다. 

아래 예제에서, cntClosure 는 'closure' 함수의 클로저이다. cntClosure는 cnt 변수를 직접적으로 접근할 수 없지만, closure 함수의 내부 함수를 통해 cnt 값을 조작할 수 있다. 

function closure(){
    let cnt = 0;  //private 변수
    function cntPlus(){
        cnt = cnt +1; 
    }
    return{
        cntPlus 
    }
}

const cntClosure = closure();
console.log(cntClosure) //{cntPlus: ƒ}
console.log(cnt) //Uncaught ReferenceError: cnt is not defined
cntClosure.cntPlus() // cnt 값이 1 증가됩니다. 직접적으로 cnt에 접근하는 것은 불가능함
// 클로저를 사용하면 cnt 변수가 함수 내에서 유지되지만 외부에서는 접근할 수 없는 private 변수가 됨

함수로 감싸주면 cnt값을 참조하려고 해도 cnt는 클로저 함수의 지역변수이기 때문에, 밖에서 참조를 할 수 없다. 하지만, cntPlus() 함수를 실행시킬수도 없다. cntPlus는 closure함수의 지역범위에 있는 함수이기 때문에 참조할 수 가 없다. cntPlus()를 사용하기 위해서는 함수를 return 해준다. 사용은 cntClosure라는 변수에 담아서 실행해보자. cntClosure를 콘솔창에 살펴보면 {cntPlus : [Function : cntPlus]} 객체 형태로 나온다. cnt 를 실행시킬수 있다. 

 

2) 함수를 여러번 호출하면 상태가 연속적으로 유지되어야 할 때

클로저는 함수가 생성될 때의 스코프를 기억하므로, 상태를 유지하고 일관된 동작을 보장할 수 있다. (사이드 이펙트 초래하지 않음 => 코드의 안정성과 예측가능성을 높인다.) 

 

🔸함수를 여러번 호출할 때 일반함수

 

- 일반함수 f1 과 outer 는 매번 함수를 호출할 때마다 그 안의 'a'라는 변수가 새롭게 생성된다. 함수가 실행될때마다 자신의 실행컨텍스트가 생성되고 그 안에 변수 'a' 가 있는데 이 'a'는 함수 실행이 끝나면 사라진다. 그래서 'f1' 과 'outer' 함수를 여러번 호출하더라도 'a'는 매번 새로 생성되고, 초기값1에 1을 더해 2가 출력이 되는 것이다. 

//일반함수
function f1(){
	var a = 1;
    a++;
    console.log(a);
}
f1(); //2
f1(); //2


//클로저랑 비슷하게 생긴 외부함수 안에서 내부함수를 호출 할 때, 하지만 클로저는 아님
function outer(){
	var a = 1;
    function inner(){
		a++;
        console.log(a);
    }
    inner()
}

outer() //2
outer() //2
outer() //2

 

🔸함수를 여러번 호출할 때 클로저

- f2() 에서 내부함수를 반환하고 있다. 내부함수는 외부함수인 f2() 의 변수인 a에 접근할 수 있고, 호출될때마다 a를 1 증가시키고 출력하도록 되어있다. 

- 클로저 f2는 f2 함수 내부에서 반환된 익명 함수를 closureFunc에 저장했을 때, 이 익명 함수는 자신이 생성된 f2 함수의 실행 컨텍스트와 변수 a를 계속 참조하게 된다. 이렇게 함수가 종료되어도 자신의 실행 컨텍스트와 변수를 계속 참조하는 현상을 클로저라고 한다. 따라서 closureFunc를 여러 번 호출하면 a는 계속 증가하게 된다. 

function f2(){
	var a = 1;
    return function(){
		a++;
        console.log(a);
    }
}

const closureFunc = f2(); // f2 반환값 저장  a++, console.log(a) 값 저장

closureFunc()//2
closureFunc()//3
closureFunc()//4
closureFunc()//5

 

- 일반적으로는 함수가 실행을 완료하고 종료되면 해당함수의 실행컨텍스트는 콜스택에서 사라지고, 그에 따라 함수 내의 지역 변수들도 메모리에서 해제된다. 그러나, 클로저는 자신이 생성된 렉시컬 환경을 '기억' 한다. 클로저로 반환된 함수가 외부 함수의 지역 변수를 참조하고 있을 경우, 그 변수는 클로저가 존재하는 한 메모리에서 해제되지 않는다. 즉, 클로저는 해당 변수를 계속 참조하고 있기 때문에 메모리에서 해제되지 않는다. 따라서, 클로저는 메모리를 계속 차지하므로, 필요없어진 클로저는 적절히 해제해주는게 좋다. 

 

※메모리 해제 접은글 펼쳐보기

더보기

메모리가 해제되었다는 것은 해당 메모리 공간에 저장되어 있던 값이 삭제되었으며, 그 메모리 공간이 다른 용도로 사용될 수 있게 된 것입니다. 그러나 식별자는 코드의 일부분으로, 메모리가 해제되어도 해당 식별자는 여전히 존재합니다. 그러나 그 식별자가 참조하고 있던 값은 더 이상 존재하지 않는 상태가 됩니다.

예를 들어, 자바스크립트에서 let x = 10; 이라는 코드가 있다면 x라는 식별자가 메모리 공간에 10이라는 값을 가지도록 할당됩니다. 만약 이후에 x라는 식별자가 참조하고 있던 메모리 공간이 해제된다면, x는 더 이상 10이라는 값을 가지지 않습니다. 하지만 x라는 식별자 자체는 코드 상에서 여전히 존재합니다.

 

리액트 훅과도 연결되어있다. 3번 챕터 참고

 


2. 외부함수 안에 내부함수가 있다고 다 클로저는 아니다.

🔸스코프체인 VS 클로저

이 둘 모두 내부함수가 외부함수의 변수에 접근할 수 있게 된다. 

둘 사이의 핵심 차이점은 외부 함수 생명주기가 끝나는지 안끝나는지에 달려있다.

 

클로저의 정확한 정의는 아래와 같다. 

내부함수가 외부 함수보다 생명주기가 길고, 외부함수의 생명주기가 끝난 이후에도 외부함수의 식별자를 참조하고 변경할 수 있다면 이러한 내부함수를 클로저 라고 칭한다. (외부함수의 실행컨텍스트가 종료되고, 외부함수의 반환값으로 내부함수가 반환되어야함.반환된 내부 함수는 외부 함수의 렉시컬 환경을 기억하고 유지하게 됨)

더불어, 클로저가 형성되려면 외부 함수의 반환값으로 내부함수가 반환되어야 한다. 

이 함수는 스코프 체인에 의해 console.log(b)는 outer 함수 내부에 있는 지역변수를 참조하고 있고, console.log(c) 는 전역변수를 참조하고 있다.

내부함수와 외부함수가 있지만 클로저는 아니다. 

클로저는 외부함수의 생명주기가 내부함수보다 짧아야한다. 즉, 외부함수의 생명주기가 끝나고, 내부에서 외부함수의 식별자를 참조해야한다.

이 함수는 외부함수의 생명주기가 내부함수보다 더 길다.
 
outer 함수의 실행컨텍스트가 종료되기 전에 inner함수가 outer 함수 내부에서 직접 호출되고 있으며, outer 함수의 실행컨텍스트가 종료되기 전에 bar가 실행되어버리기 때문에 클로저가 형성되지 않는다. 

(= 외부함수의 반환값으로 내부함수가 반환되고 있지 않다.
해당 코드에서는 outer() 함수가 반환값을 반환하지 않고, 내부함수인 inner()를 직접 호출하고 있기 때문)
외부 함수 안에 return 이 있어서 외부함수의 생명주기가 내부함수보다 짧다.  더불어, console.log(b) 는 외부함수의 지역변수를 참조하고 있기에 클로저가 된다!


추가설명)
왼쪽에 있는 코드에서 'someFunc' 변수에 outer()를 할당하는 부분은 함수를 반환하는 것이다. outer() 함수를 호출하면 그 결과로 내부에 있는 inner()함수가 반환된다.

따라서, someFunc 는 inner 함수를 참조하게 된다.

outer() 함수가 호출되면서 아래의 코드가 실행된다.

function inner(){
   var a = 2;
console.log(b);
}

이때, outer() 함수 내부에서 선언된 b변수가 3으로 초기화된다. 그리고 inner() 함수가 outer()의 반환값으로 반환된다. 

따라서, someFunc 변수에 할당되는 것은 inner() 함수 자체이며, 이후에 someFunc()를 호출하면 inner() 함수가 실행된다. 

 


3. 실제 활용 사례(리액트)

 

React 함수형 컴포넌트에서 쓰이는 hook API 가 클로져를 통해서 구현되었다. hook 은 함수를 여러번 호출하는 상황에서 데이터를 연속적으로 유지하는 기능이다. 

 

대표적으로 useState 훅을 봐보자

useState는 초기값을 받아서 [상태,상태를 변경하는 함수] 형태의 배열을 반환한다. 

비구조화 할당을 통해 아래 형태로 사용한다.

const [state, setState] = useState(initialValue)

함수형 컴포넌트에서 이전 상태와 현 상태의 변경이 있는지를 감지하기 위해서는 함수가 실행되었을 때 이전 상태에 대한 정보를 가지고 있어야 한다. React는 이 과정에서 클로저를 사용한다.

 

🔸useState Hook 예제

const Counter = () => {
  const [value, setValue] = useState(0); // 이 hook함수가 클로져를 통해 구현되었습니다.

  return (
    <div>
      <p>{value}</p>
      <button onClick={() => setValue(value + 1)}>+</button>
      <button onClick={() => setValue(value - 1)}>-</button>
    </div>
  );
};

상태 value가 바뀌어 렌더링이 계속 일어남에 따라 Counter 함수가 여러 번 호출된다.  하지만 useState는 초기값 0이 아니라 이전 상태 value의 값을 유지하고 있다. 이는 useState 선언 시점의 바깥 변수에 0을 초기화한 다음, setValue로 해당 바깥 변수를 변경하는 것이다. 다음 Counter가 호출되고 그 안의 useState가 다시 호출되면 변경된 바깥 변수를 value로 반환한다.

 

즉, useState는 외부에 선언된 상태값에 접근해서 이전 상태를 가져오고, 변경된 상태값을 관리하고 있다. 함수형 컴포넌트도 결국 함수이기 때문에, 클로저를 통해 선언되는 시점에 접근 가능했던 외부 상태값에 계속 접근할 수 있는 것이다.

함수형 컴포넌트에서 상태값을 변경하면 외부의 값이 변경되고, 리렌더링(=함수 재호출)을 통해 새로운 값을 받아오게 된다. 

 

🔸useState 와 비슷한 역할을 하는 코드

let _value;

export useState(initialValue){
  if (_value === 'undefined') {
    _value = initialValue;
  }
  const setValue = newValue => {
    _value = newValue;
  }
  
  return [_value, setValue];
}

useState 밖에 선언된 변수 _value가 있다. useState() 함수에서는 초기값(initialValue)를 받아 만약 기존 _value 값이 없으면 초기값으로 세팅한다. setValue 함수는 받아오는 값으로 전역 _value를 업데이트한다. 그리고 _value와 setValue 함수를 배열 형태로 반환한다. useState() 함수가 어디에서 실행되었건, 클로저를 통해 _value 값에 접근할 수 있다.

이렇게 React hook에서는 useState를 통해 생성한 상태를 접근하고 유지하기 위해서 useState 바깥쪽에 state를 저장한다. 이 state들은 선언된 컴포넌트를 유일하게 구별할 수 있는 키로 접근할 수 있으며 배열 형식으로 저장된다. useState 안에서 선언되는 상태들은 이 배열에 순서대로 저장된다.

 

✅한줄 정리 : React에서 함수형 컴포넌트의 상태관리를 위해서는 컴포넌트 외부에 저장된 값을 사용하며, 클로저를 통해 해당 값에 접근해 상태를 비교하고 변경한다. useState는 컴포넌트 내부에서 값을 변경시키는 것이 아니라, 외부에 있는 값을 변경시키기 때문에 상태가 변경된 직후 컴포넌트가 가진 값은 이전의 값을 그대로 참조한다. 

 


참고자료

https://github.com/junh0328/prepare_frontend_interview/blob/main/js.md#%ED%81%B4%EB%A1%9C%EC%A0%80

 

GitHub - junh0328/prepare_frontend_interview: 📚 프론트엔드 기술 면접을 위한 핸드북 만들기

📚 프론트엔드 기술 면접을 위한 핸드북 만들기. Contribute to junh0328/prepare_frontend_interview development by creating an account on GitHub.

github.com

https://tislwlstnf.tistory.com/8

 

클로저의 의미와 사용하는 이유

클로저란? 클로저는 내부 함수가 정의될 떄 외부 함수의 환경을 기억하고 있는 내부 함수를 말합니다. 외부 함수 안에서 선언된 내부 함수는 그 외부 함수의 지역 변수나 함수에 접근하여 사용

tislwlstnf.tistory.com

https://velog.io/@mygomi/TIL-75-JS-Closure-%ED%81%B4%EB%A1%9C%EC%A0%80%EB%8A%94-%EC%99%9C-%ED%81%B4%EB%A1%9C%EC%A0%80%EC%9D%B8%EA%B0%80

https://tecoble.techcourse.co.kr/post/2021-07-16-closure/

 

클로져와 가까워지기

0. 인트로 클로져를 MDN…

tecoble.techcourse.co.kr

https://velog.io/@ggong/useState-Hook%EA%B3%BC-%ED%81%B4%EB%A1%9C%EC%A0%80

 

useState Hook과 클로저

전에 기술 면접을 보면서 "React hook에서 클로저가 어떻게 쓰이는지 설명해보세요" 라는 질문을 받은 적이 있었다. 나름 리액트를 오래 썼다고 생각했는데, 클로저에 대해서도 방금 설명했는데..

velog.io