클로져와 가까워지기

31318 단어 JavaScriptJavaScript

0. 인트로

클로져를 MDN에서 검색해보면

클로저는 함수와 함수가 선언된 어휘적 환경의 조합이다.

라는 문장이 나옵니다. 무슨 뜻인지 단번에 받아들이기 어렵지 않나요?

클로져를 이해하는 것이 중요하다고 생각하는 이유는 클로져가 자바스크립트 엔진의 동작과 연관이 있기 때문입니다.

오늘은 클로져에 대해서 알아보도록하겠습니다.

1. 클로져란?

코드를 보면 어떤 상황을 클로져라 부르는지 쉽게 이해할 수 있습니다.

const outer = () => {
    const outerVariable = 'outer!'; // 1. outer 함수 안에 지역변수 outerVariable 선언
    
    const inner = () => { 
        console.log(outerVariable); // 2. 바깥의 outerVariable을 참조해 console.log 출력
    }
    
    return inner; // 3. 바깥의 outerVariable을 참조해 console.log를 출력하는 함수를 반환
}

const fano = outer(); // 4. outer함수 호출 -> 변수 fano에 inner함수의 주소값이 저장됨

fano(); // 5. 'outer!'

이게 클로져의 전부입니다.

클로져는 어떤 함수(outer) 내부에 선언된 함수(inner)가 바깥 함수(outer)의 지역변수(outerVariable)를 참조하는 것이 함수(outer)가 종료된 이후에도 계속 유지되는 현상을 말합니다.

2. 클로져가 발생하는 이유

클로져가 무엇인지 말하기는 간단하지만, 이 현상이 왜 발생하는지 알기 위해서는 자바스크립트에 대해 좀 더 알 필요가 있습니다.

1. 스코프

스코프는 우리말로 기회, 범위입니다. 자바스크립트의 스코프는 범위를 뜻합니다.

      function scopeA() {
          const dolharubang = '슈-욱';
          
          function scopeB() {
          	const dolharubang = '슈슉';
              
              console.log(dolharubang);
          }
          scopeB(); // 슈슉
          console.log(dolharubang);
      }
      
      scopeA(); // 슈-욱
      console.log(dolharubang); // reference error

scopeA를 호출 했을 때 슈슉이 아닌 슈-욱이 출력됐습니다. 이것은 scopeB 함수의 fano 변수 선언 및 할당 과정이 scopeA의 동작에 영향을 주지 않았다는 것입니다. 즉, scopeA와 scopeB가 고유한 스코프를 가지고 있다고 말할 수 있습니다.

모든 프로그래밍 언어는 코드를 한 줄씩 읽습니다. 그리고 이를 계산(앞으로는 평가한다고 하겠습니다.)해 메모리에 계산된 값을 저장(const dolharubang = '슈-욱')하거나, 특정한 동작을(console.log(dolharubang)) 합니다.

자바스크립트는 프로그램을 평가하기 전과 함수를 평가하기 전에 변수 선언함수 선언 정보를 미리 한 번 쭉 훑어서 수집합니다. (실행컨텍스트의 Environment Record를 수집하는 과정)

프로그램을 시작하기 전에 scopeA함수 선언 정보를 수집하고 한 줄씩 평가를 하다가, scopeA가 호출되는 시점에 scopeA 내부의 (문자열 슈-욱이 할당되는)dolharubang 과 scopeB의 정보를 수집하는 것입니다. 이렇게 정보를 수집하고 다시 처음으로 돌아와 한 줄씩 평가를 시작하는데, 평가하는 코드줄에서 dolharubang에 값이 할당되거나, console.log(dolharubang)과 같이 사용될 때, 미리 수집해둔 정보를 가져와서 값을 새로 저장하고, 사용하는 것입니다.

그렇기 때문에 함수 호출 전에 담긴 정보로는 다른 함수 내부의 변수를 알 수 있는 방법이 없습니다. 그래서 각 함수는 고유한 스코프를 가지게 됩니다. (es6에서는 while, if, for문 같은 함수가 아닌 블록문에서도 새로 스코프를 만듭니다.) 이렇게 만들어진 스코프는 함수가 종료되면서 사라지게 됩니다. (참고: 호출 스택)

2. 스코프체인

스코프를 설명하면서 다른 함수 내부의 변수를 알 수 있는 방법이 없다고 말했는데 사실 이것은 반만 맞는 말입니다.

function outer() {
    const outerVariable = 'outer!'; 

    function inner() { 
        console.log(outerVariable); 
    }

      inner(); // outer!
}

맨 처음 봤던 예제를 살짝 바꿨습니다. inner에서는 outerVariable이라는 변수가 선언되지 않았는데도 값을 잘 불러왔고, 그 값은 다른 함수인 outer의 변수입니다.

감이 오시나요? 자바스크립트는 스코프 내에 참조할 수 있는 변수나 함수가 존재하지 않으면 바깥의 스코프에서 식별자 정보를 찾습니다.

이것이 가능한 이유는, 앞서 말했던 함수 평가 이전에 쭉 훑는 과정(변수 선언, 함수선언 수집) 이외에 바깥 스코프에 대한 정보를 수집하는 과정도 있기 때문입니다 (실행컨텍스트의 outerEnvironmentReference에 정보가 담깁니다.)

  function furtherOuter() {
      const furtherOuterVariable = 'further outer!'; 

      function outer() {    
          function inner() { 
              console.log(furtherOuterVariable); 
          } 

            inner(); // further outer!
    }
  }

자바스크립트 엔진은 스코프 안에 참조하는 식별자 정보가 없다면 함수 평가 전에 수집했던 바로 바깥 스코프로 가서 식별자를 찾습니다. 바로 바깥 스코프에도 찾는 식별자가 없다면, 그 다음 스코프로 가서 찾고, 마지막엔 전역 스코프까지 가서 찾는데 이때도 존재하지 않는다면 참조에러 를 발생시킵니다. 이렇게 스코프가 체인처럼 연결 되어있는 것을 스코프체인이라고 합니다.

이때 주의해야할 것은 바로 바깥 스코프는 함수를 실행하는 시점의 바깥영역이 아닌 선언되는 시점의 바깥 스코프를 가리킨다는 것입니다. (렉시컬 스코프)

      function scopeA() {
          const fano = '전환오'; // 선언 시점의 상위 스코프
          
          function scopeB() {
              console.log(fano);
          }
          
          return scopeB;
      }
      
      const global = scopeA();
      
      const fano = 'hwano jeon'; // 실행 시점의 상위 스코프(?)
      
      global(); // '전환오';
      

3. 자바스크립트의 함수는 1급 시민

이제 스코프체인으로 어떤 스코프가 갖고있지 않은 변수, 함수는 그 바깥 스코프를 참조한다는 것을 알았습니다. 클로져에 가까워지기 위한 마지막 개념이 하나 남았습니다. 그것은 1급 시민이라는 개념입니다.

이것은 자바스크립트만의 개념이 아닌 프로그래밍 언어 전반에 사용되는 개념입니다.

위키피디아에 따르면

  > 로빈 포플스톤은 일급 시민을 구성하는 요소는 4개의 요구조건이 있다는 정의를 내렸다.
  >
  > 1. 모든 요소는 함수의 실제 매개변수가 될 수 있다.
  > 2. 모든 요소는 함수의 반환 값이 될 수 있다.
  > 3. 모든 요소는 할당 명령문의 대상이 될 수 있다.
  > 4. 모든 요소는 동일 비교의 대상이 될 수 있다.

자바스크립트의 객체는 1급시민입니다.

      
      const func = (obj) => {
          return obj; // 모든 요소는 함수의 반환 값이 될 수 있다.
      }
      
      const obj1 = func({a: 1}) // 모든 요소는 함수의 실제 매개변수가 될 수 있다.
      
      const obj2 = {b: 1} // 모든 요소는 할당 명령문의 대상이 될 수 있다.
      
      console.log(obj1 === obj2) // 모든 요소는 동일 비교의 대상이 될 수 있다.

자바스크립트의 함수는 곧 객체이므로, 함수 역시 1급시민입니다.

4. 돌고돌아 클로져

         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함수의 주소값이 저장됨
         
         fano(); // 5. outer함수 호출은 종료가 되어서 스코프가 사라져야 하지만 outerVariable은 여전히 잘 참조된다. 

예제 코드의 5번째 주석에서 나타나는 현상이 스코프입니다. 맨 처음의 정의를 다시 가져와보겠습니다.

클로져는 어떤 함수(outer) 내부에 선언된 함수(inner)가 바깥 함수(outer)의 지역변수(outerVariable)를 참조하는 것이 함수(outer)가 종료된 이후에도 계속 유지되는 현상을 말합니다.

outer 함수 바깥으로 반환된 inner함수가 outer 함수의 outerVariable 변수를 참조하기에 메모리에 outer의 스코프가 여전히 남아있는 것입니다.

3. 클로져를 응용할 수 있는 영역

이런 클로저를 어떻게 활용할 수 있을까요? 예제를 통해서 알아보도록 하겠습니다.

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

      const counterCreator = () => {
          let value = 0;
          
          return {
              increase() {
                  console.log(++value);
              },
              decrease() {
                  console.log(--value);
              }
          }
      }
      
      const counter = counterCreator();
      
      counter.increase(); // 1
      counter.increase(); // 2
      counter.decrease(); // 1

위와 같이 함수를 호출하면 이전 함수 호출 상태가 기억되길 바랄 때 사용할 수 있을 것입니다.

실제 사례로 프론트엔드 프레임워크인 React 의 hook이라는 기능이 클로져를 통해서 구현되었습니다.

hook은 함수를 여러 번 호출하는 상황에서 데이터를 연속적으로 유지하는 기능입니다.

(다음 예제는 React가 생소하신 분은 넘어가셔도 좋습니다.)

      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로 반환합니다.

2. 변수를 숨겨야할 때

let timerId = null;
    
function throttle(callback) {
        if (timerId) return;
        
        timerId = setTimeOut(() => {
            callback();
            timerId = null;
        }, 300)
    }
}

document.addEventListener('scroll', () => {
    throttle(() => console.log('슈-슉'))
})

document.addEventListener('mousemove', () => {
    throttle(() => console.log('슉'))
})

위와 같이 throttle이라는 함수를 만들 때, timerIdlet이어서 변경이 가능합니다. 하지만 이렇게 throttle을 같이 쓴다면 쓰로틀링에 대한 동작이 신뢰할만할까요? 아예 한 곳에서만 수정을 하게 해주면 어떨까요? 이때 클로져가 역할을 할 수 있습니다.

const throttleCreator = (term) => {
    let timerId = null;
    const timeout = term;
    
    return (callback) => {
        if (timerId) return;
        
        timerId = setTimeOut(() => {
            callback();
            timerId = null;
        }, timeout)
    }
}

const scrollThrottle = throttleCreator(300);

document.addEventListener('scroll', () => {
    scrollThrottle(() => console.log('슈-슉'))
})

const mousemoveThrottle = throttleCreator(300);

document.addEventListener('mousemove', () => {
    mousemoveThrottle(() => console.log('슉'))
})

throttleCreator라는 바깥함수를 하나 만들어 timerId를 은닉하고 각각 만들어주었습니다. 이제 scroll 이벤트와 mousemove이벤트는 독립적으로 쓰로틀링이 작동할 수 있게되었습니다.

좋은 웹페이지 즐겨찾기