[JavaScript] Closure(클로저)

Mandy·2023년 1월 25일
1

✅배경지식 Check!

⚠ 간단한 js 코드를 읽고 이해할 수 있어야 함.
⚠ 실행 컨텍스트에 대해 알아보는것 권장함.
⚠ 스코프에 대해 알아보는것 권장함.
⚠ 가비지 컬렉션에 대해 알아보는것 권장함.
⚠ 일급 객체, 일급 함수, 고차 함수에 대해 알아보는것 권장함.





(클로저 하면 떠오르는 띵곡, 하지만 클로저는 Closure이다.)



클로저란 무엇일까? 한글로 직역하면 폐쇄 라는 뜻인데, 딱히 감이 오진 않는것 같다.


1. 클로저의 개념

우선, 클로저는 자바스크립트에서 만들어진 고유 개념이 아니다. 다른 프로그래밍 언어에서도 구현 가능한 개념인 것이다.

클로저의 정의는 아주 심플하고 명료하다.

“외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수”
-모던자바스크립트 튜토리얼-

“함수와 그 함수가 선언됐을 때의 렉시컬 환경과의 조합”
-MDN-

“반환된 내부 함수가 자신이 선언됐을 때의 환경인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수”
-poiemaweb-

위의 세가지 정의는 참고한 자료 사이트(MDN 등…)에서 그대로 인용한 것이다.

해당 정의만 읽고도 클로저에 대해 약간이라도 이해했다면 당신은 아마 실행 컨텍스트와 스코프에 대한 기본 지식이 있는 사람일 것이다.

하지만, 이 포스팅에서는 실행 컨텍스트와 스코프를 모르는 사람도 이해할 수 있도록 필요 개념을 얄팍하게나마 알아가며 클로저에 대해 알아가보자!



* 스코프(Scope)

1. 블록 레벨 스코프 vs 함수 레벨 스코프

스코프 라는 것은 유효 범위이다.
즉, 우리가 코드를 작성하면서 변수를 선언하고 사용함에 있어서 유효한 범위가 존재한다는 것이다.

이는 자바스크립트에만 해당되는 것이 아니라 다른 프로그래밍 언어에서도 존재하는 개념인데,

예를 들어 C 언어에서는

int main(void){

if(1){
	int count = 0;
}
printf(count); // use of undeclared identifier 'count'

return 0;
}

if문안에서 변수를 선언할 경우 해당 문의 밖에서는 count라는 변수 값을 알지 못한다.

C언어가 생소하다면 우리에게 익숙한 자바스크립트 코드로 예를 들어보겠다.

function Fruit() {
	var mango = 20;
}
console.log(mango); // ReferenceError: mango is not defined

위 코드를 보면 함수 블록 내에서 선언한 변수는 함수 외부에서 사용할 경우 참조에러가 발생한다.

이것이 스코프의 역할인 것이다.

다만, 이 코드들에서 C언어는 블록 레벨 스코프를 따르고 자바스크립트는 함수 레벨 스코프를 따른다는 점이 차이점이다.

블록 레벨 스코프란 { } 블록을 기준으로 스코프가 형성되어 유효 범위(참조 가능 범위)가 정해지므로 if문, for문, while문 등 { } 블록으로 감싸지는 경우가 모두 해당된다.

함수 레벨 스코프란 오직 함수 블록을 기준으로 스코프가 형성되어 유효 범위(참조 가능 범위)가 정해지는 것으로 if문, for문 등 { } 블록으로 감싸지는 블록은 스코프로 형성되지 않는다.

그러나, ES6부터는 let, const 를 사용한다면 블록 레벨 스코프를 사용할 수 있다.
우리는 자바스크립트를 처음 배운 이후로 var의 문제점을 알기에 let과 const를 사용하였을테니 대부분 블록 레벨 스코프를 경험해봤다.


2. 동적 스코프 vs 정적 스코프(렉시컬 스코프)

var time = 15;

function chicken() {
  var time = 10;
  pizza();
}

function pizza() {
  console.log(time);
}

chicken(); // ??
pizza(); // ??

이 코드를 보면서 아래의 chicken과 pizza를 호출할 경우 어떤 값이 나올지 잠시 생각해보자.



3

2

1

.
.
.



1) chicken 호출 시

먼저 전역에서 time = 15라는 변수를 선언하였다.
chicken을 호출한 부분에서는 time을 10으로 선언한 후 pizza를 호출하였다. (이 때, time은 var라서 중복된 식별자 값으로 선언하여도 문제가 없다.)

pizza는 time 값을 콘솔로그로 찍는 함수이다.
이때, time은 15가 출력된다.

2) pizza 호출 시

마찬가지로 전역에서 time = 15라는 변수를 선언하였다.
pizza 함수에서는 바로 time 값을 콘솔로그로 찍으므로
time은 15가 출력된다.

왤까? 그 이유는 바로 스코프 결정 방식에 있다.
프로그래밍 언어들은 동적 스코프와 정적 스코프 방식 중 하나를 채택하는데, 자바스크립트를 포함한 대부분의 프로그래밍 언어는 렉시컬 스코프(정적 스코프)를 따른다.

렉시컬 스코프는 함수의 호출이 아닌 선언시에 유효 범위가 결정되는 방식이므로
chicken 함수 내에서 pizza 함수를 호출하기 전에 time을 선언하여도 pizza 함수를 chicken 함수내에서 선언하지 않았으므로,
해당 값이 pizza 함수의 상위 스코프로 인식되는 것이 아니다.
그렇기에 pizza 함수는 선언 당시 자신의 상위 스코프인 전역 스코프에서 변수를 찾게된다.

이를 통해 우리는 자바스크립트가 함수 레벨 스코프, 렉시컬 스코프가 적용됨을 알 수 있었다.




스코프를 짤막하게 알아가면서 우리는 이제 처음에 언급했던 클로저의 정의 중 2개에 대해 이해할 수 있게 되었다.

“외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수”
-모던자바스크립트 튜토리얼-

“반환된 내부 함수가 자신이 선언됐을 때의 환경인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수”
-poiemaweb-

하지만 더 와닿으려면 코드를 직접 보는게 좋겠다.

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

outerFunc(); // 10

위 코드는 outerFunc 호출한다.
1. outerFunc 함수는 변수 x를 10으로 선언 및 할당한다.
2. 그리고 innerFunc 라는 변수 선언 후
3. 익명 함수 주소값을 할당하는데, 이 익명함수는 x를 콘솔로그로 출력하는 함수이다.
4. 그 이후 innerFunc를 호출한다.

이 로직에 의해 콘솔로그에 10이 출력된다.

이유인즉슨, innerFunc가 outerFunc에서 선언한 x에 접근할 수 있기 때문이다.

아까 스코프를 설명하며 말했다시피 자바스크립트에서는 렉시컬 스코프를 따르기 때문에 함수의 선언을 기준으로 스코프가 형성된다.

따라서 innerFunc가 outerFunc내에서 선언되었으므로 innerFunc의 상위 스코프는 outerFunc이며, outerFunc에서 선언된 변수에 접근이 가능한 것이다.

이것을 스코프가 아닌 실행 컨텍스트로 생각해보면 다음과 같다.

(회색: 일시 정지된 컨텍스트, 주황색: 실행중인 컨텍스트)

그림과 같이 실행 컨텍스트가 콜스택에 쌓이게 되고 마지막에 쌓인 innerFunc 컨텍스트부터 수행된다.

실행 컨텍스트 마다 Lexical Environment(렉시컬 환경)와 Variable Environment(변수 환경), Environment Record(환경 레코드), Outer Environment Reference(외부 환경 참조) 등이 포함되어 생성된다.

이중에서 우리는 Lexical Environment(렉시컬 환경)에 관심을 가져야 한다.

렉시컬 환경이란 현재 컨텍스트 내부의 식별자, 레코드, 외부 환경 정보, 외부 환경 참조를 모두 포함하는 객체이며 코드가 실행되면서 변경되는 사항들이 실시간으로 적용되는 것이다. 가장 최신의 상태, 정보들을 저장하고 있다.

렉시컬 환경에는 Environment Record(환경 레코드)와 Outer Environment Reference(외부 환경 참조)가 포함되어 있는데, 우리가 변수로 선언한 것들은 환경 레코드의 프로퍼티로 존재하며, 외부 환경 참조로 상위 컨텍스트의 변수를 참조할 수 있게 된다.(스코프 체이닝)

즉, innerFunc는 내부 렉시컬 환경에서 변수 x를 탐색하고 x가 존재하지 않을 경우 외부 환경 참조를 이용하여 외부의 렉시컬 환경에 존재하는 변수 x를 찾는 과정을 전역 컨텍스트에 이르기까지 반복한다.
다만, 위 코드는 outerFunc에 변수 x가 존재하므로 전역 컨텍스트 까지 탐색하지 않았다.


실행 컨텍스트와 스코프에 대해 처음 알게된 사람이라면 다소 생소해 보일 수 있으나 그래도 여기까지는 큰 어려움은 없을것으로 보인다.

이번에는 내부함수 innerFunc를 함수 outerFunc에서 return(반환) 하는 형태로 코드를 변경해보겠다.

function outerFunc() {
  var x = 10;
  var innerFunc = function () { console.log(x); };
  return innerFunc;
}

var inner = outerFunc();
inner(); // 10

위 코드에서는 inner 함수를 호출할 때,
1. inner 에 저장된 outerFunc 함수가 호출되고
2. outerFunc 함수는 변수 x = 10을 선언 후
3. innerFunc를 x 출력하는 함수로 선언 후 반환하게 되므로
4. innerFunc가 실행된다.

뭔가 복잡해진 느낌이지만, 하나하나 살펴보면 크게 어렵지 않다.
우선, outerFunc는 innerFunc를 return한 시점에 컨텍스트 소멸이 일어나게 된다.

모든 실행 컨텍스트들은 자신의 코드가 다 수행되고 호출이 끝난 경우 콜스택에서 삭제되는데 함수에서 리턴을 하는것은 함수의 끝을 의미하므로 해당 함수가 리턴과 동시에 수행이 완료되어 실행 컨텍스트도 콜스택에서 삭제되었다는 사실을 알 수 있다.

정확히 이 부분에서 가비지 컬렉션이 동작하여 루트로부터 도달할 수 없는, 더이상 루트로부터 연결된 어느 함수의 참조도 받지 않게된 함수의 메모리는 가비지 컬렉터가 수집하게 되는것이다.

우리는
1. innerFunc의 선언이 outerFunc내에서 이루어졌음
2. 콘솔로그에 출력될 변수 x는 innerFunc 컨텍스트 내부의 렉시컬 환경에 존재하지 않으므로 외부 렉시컬 환경을 참조하여 출력해야한다.

라는 사실을 알고있다.

그렇기 때문에 outerFunc 컨텍스트가 소멸한다면 우리가 참조해야할 변수 x도 사라진다고 생각할 수 있다.
그렇지만, 실제 위 코드의 출력 결과는 10으로 x의 값이 정상적으로 출력됨을 알 수 있다.

왜..?

왜 이런 현상이 나타날까? 나도 모르는 사이에 변수 x가 비밀장소로 도망친 후 살아남은 것일까? 그것도 아니면 죽은자의 소생을 쓴 것일까?

여기서 클로저의 정의를 다시한번 읽어보자.

외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수”
-모던자바스크립트 튜토리얼-

함수와 그 함수가 선언됐을 때의 렉시컬 환경과의 조합
-MDN-

반환된 내부 함수가 자신이 선언됐을 때의 환경인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수”
-poiemaweb-

내부 함수가 아직 실행중인 상태라면 외부 함수가 종료되어 외부 함수의 실행컨텍스트가 콜스택에서 삭제되어도 외부 함수의 실행컨텍스트 내부에 존재하는 Activation Object(변수, 함수 선언등의 정보를 포함하는 객체)는 내부 함수에 의해 참조되는 동안 유효하게 되어 스코프 체이닝이 일어날 수 있는 것이다.

즉, 외부 함수에서 선언된 변수를 참조하여 사용이 가능한 것이다. 이때 변수는 복사본이 아닌 실제 변수임을 주의하자.


클로저라는 이름의 의미를 우리는 이제 이해할 수 있게 되었다. 내부 함수에서는 외부 함수의 변수에 접근(참조)할 수 있고, 외부 함수에서는 내부 함수의 변수에 접근(참조)할 수 없다.

이러한 폐쇄적인 특징을 Closure 라고 부르는 것으로 이해할 수 있다.

또한, 함수의 호출이 아닌 함수의 선언으로 스코프가 형성되므로 클로저를 생성하기 위해서는 외부함수의 내부에 함수를 선언해야 한다.

한마디로 클로저는 외부 함수의 변수를 참조할 수 있는 내부함수 그 자체를 의미한다.



2. 클로저의 활용 및 예시


아래는 참조한 사이트에서 가져온 대표적인 클로저의 활용과 예시에 대한 코드이다.
많은 클로저 사용 예시가 있지만 아래의 두 예시를 보면 클로저를 활용할 수 있는 기초 아이디어가 생길 것이다.

1. 상태 유지

바닐라 자바스크립트에서 우리는 상태를 표현하기 위해 여러가지 방법으로 구현하는데 애써본 경험이 있을 것이다.

바닐라 자바스크립트 개발 시 리액트 처럼 상태를 유지하고 싶은 순간이 올테니 클로저를 이용해 상태를 유지하는 방법을 알면 좋을것이다.

자바스크립트 코드만을 보기 위해서 html 코드는 생략했다.

toggle 이라는 class를 가진 button 태그와
box 라는 class를 가진 div 태그가 존재한다고 하자.

  var box = document.querySelector('.box');
    var toggleBtn = document.querySelector('.toggle');

    var toggle = (function () {
      var isShow = false;

      // ① 클로저를 반환
      return function () {
        box.style.display = isShow ? 'block' : 'none';
        // ③ 상태 변경
        isShow = !isShow;
      };
    })(); // 즉시 실행 함수

    // ② 이벤트 프로퍼티에 클로저를 할당
    toggleBtn.onclick = toggle;

위 코드에서 toggle 이라는 변수에 익명 함수의 주소값이 할당되고 익명 함수를 외부 함수로 삼는 내부의 익명함수가 return 된다.

그러나, 특이한 점은 return 되는 익명 함수는 즉시 실행 함수의 형태를 띄고 있기 때문에 선언과 동시에 실행되어진다.

즉시 실행 함수는 함수를 반환하고 즉시 실행 컨텍스트에서 소멸한다.
return 하는 즉시 실행 함수(익명의 내부 함수)가 곧 클로저 함수이며, 자신이 생성됐을 때의 렉시컬 환경에 속한 변수인 isShow를 기억하는 클로저이다.

클로저를 이벤트 핸들러에 할당했으므로 해당 이벤트 프로퍼티에서 클로저를 null 로 제거하지 않는 이상 클로저가 기억하는 isShow는 소멸하지 않는다. 현재의 isShow값을 기억한다.

가비지 컬렉터에서 루트로부터 도달할 수 없는 함수들의 메모리를 해제한다고 하였는데, 이 순간에도 그것이 적용되는 것이다. 이벤트 프로퍼티에 의해 클로저 함수가 참조되고 있으므로 메모리 해제가 일어나지 않는다.

버튼을 클릭할때마다 클로저가 호출되며, 그때마다 반환된 클로저에 의해 상태 값 isShow가 변경되며 isShow는 클로저에 의해 참조되는 변수이므로 소멸되지 않고 자신의 변경된 최신 상태를 계속 유지할 수 있다.

이처럼 클로저를 사용하면 우리는 불필요한 전역변수를 사용해서 상태를 유지할 필요가 없다.



2. 클로저를 사용한 Counting

inclease 라는 id를 가진 button 태그와
count 라는 id를 가진 p 태그가 존재한다고 하자.

    var incleaseBtn = document.getElementById('inclease');
    var count = document.getElementById('count');
	
    var increase = (function () {
       // 카운트 상태를 유지하기 위한 자유 변수
      var counter = 0;
       // 클로저를 반환
      return function () {
        return ++counter;
      };
    }()); //즉시 실행 함수

    incleaseBtn.onclick = function () {
      count.innerHTML = increase();
    };

상태 유지 예제와 거의 비슷하게 보이는 예시 코드이다.
여기서도 마찬가지로 즉시 실행 함수를 반환하여 클로저를 생성했다.
동작 원리는 상태 유지 코드와 유사하므로 설명을 생략한다.
다만, 이 예제는 버튼을 클릭할 때마다 카운트 숫자가 1씩 증가하는 코드이므로 이 점에 유의해야한다.

즉시 실행 함수는 한번만 실행되므로 increase가 호출될 때마다 계속해서 counter가 초기화되는 일은 벌어지지 않는다. 그렇기에 계속해서 클릭할 경우 이전 숫자에 비해 1씩 증가된 숫자를 표시하게 된다.

또, counter는 외부에서 접근 할 수없는 변수이므로 전역 변수 사용에서의 부작용을 걱정하지 않아도 된다.




(이 대사를 치면 절대 해치운게 아니다...)

클로저에 대해 개념부터 활용까지 알아보는 시간을 가졌다.
정말 길고긴 여정처럼 느껴진다. 클로저 하나를 이해하기 위해 필요한 개념들이 생각보다 많았기 때문일 수도 있다.

그렇지만 클로저를 이해하는데에 필요한 지식들을 습득하는 과정들이 모두 유익했다.
이상으로 클로저에 대한 포스팅을 마치겠다.

-끝

참고한 자료
https://poiemaweb.com/js-scope
https://ko.javascript.info/closure
https://poiemaweb.com/js-function#3-first-class-object-%EC%9D%BC%EA%B8%89-%EA%B0%9D%EC%B2%B4
https://youtu.be/PVYjfrgZhtU
https://poiemaweb.com/js-closure
http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/
https://developer.mozilla.org/ko/docs/Web/JavaScript/Closures

profile
즐코 행코 하세용

0개의 댓글