실행 문맥, 클로저, 함수

실행 문맥, 호이스팅, this, 함수, 그리고 클로저

실행 문맥, 클로저, 함수
 자바스크립트를 실행할 때 코드를 어떻게 관리하는지 알아보고 this와 클로저를 이해합니다.

실행 문맥

 “실행 문맥은 코드의 실행, 관리를 위해 만드는 구조적인 정보입니다. 실행 문맥에는 this, 변수, 함수에 대한 정보 등이 담겨있습니다.”

실행 문맥에는 세 가지 종류가 있습니다.

  1. 전역 실행 문맥 : 전체 코드의 문맥. 처음에 생성.
  2. 함수 실행 문맥 : 함수 코드의 문맥. 함수 실행 시 생성.
  3. Eval 실행 문맥 : Eval 함수 코드의 문맥. Eval 함수를 실행하면 생성. 보안 문제가 있어 거의 쓸 일이 없습니다.

 모든 실행 문맥은 환경 기록을 가집니다. 환경 기록은 세부적으로 여러 구조, 기능을 가지지만 핵심 요소는 아래와 같습니다.

  • 식별자 바인딩 : 환경 기록 범위 안에 있는 식별자 참조. 바인딩은 식별자와 값을 올바르게 연결하는 작업입니다. 같은 이름의 변수가 있어도 올바르게 작동하는 이유는 바인딩이 정확하게 연결하기 때문입니다.
  • outerEnv : 외부 환경 참조. 스코프 체인이라고도 부릅니다.
  • this : this에 대한 정보.

생성과 실행

 자바스크립트 엔진은 코드를 평가해서 실행 문맥을 만든 다음에, 코드를 실행합니다. 아래 코드를 봅시다.

let firstName = 'Zelda';

function nameMaker(firstName){
  let lastName = 'Link';
  let fullName = firstName + lastName;
  return fullname;
}  
  
nameMaker(firstName);  

 위 코드는 실행 문맥을 통해 순서대로 작업이 진행됩니다.

// 생성 단계 GEC
전역환경기록 = {
    객체기록: global Object
    outerEnv: null,
    선언형환경기록: {
        firstName: <uninitialized>,
     	nameMaker: funcion,
    },
    this: window
}

// 생성 단계 FEC
nameMaker환경기록 = {
    outerEnv: [전역환경기록],
    선언형환경기록: {
        arguments: [0:"Zelda", length:1, ...],
        lastName: <uninitialized>,
        fullName: <uninitialized>
    },
    // 'use strict'에선 undefined, 안 쓰면 Global.
    this: undefined
}

 문맥으로 실행 전 코드 개요를 만듭니다. 어떤 식별자가 있고 상위 스코프는 어떤 것인지, this는 뭔지 등을 파악합니다. 식별자를 바인딩해서 미리 파악하는 걸 호이스팅이라고도 부릅니다.

// 실행 단계 GEC
전역환경기록 = {
    객체기록: global Object
    outerEnv: null,
    선언형환경기록: {
        firstName: 'Zelda',
     	nameMaker: pointer to function nameMaker
    },
    this: window
}

// 실행 단계 FEC
nameMaker환경기록 = {
    outerEnv: [전역환경기록],
    선언형환경기록: {
        arguments: [firstName],
        firstName: 'Zelda',
        lastName: 'Link',
        fullName: 'ZeldaLink',
    },
    // 'use strict' 모드로 하면 this가 undefined로, 안 쓰면 Global로 나온다.
    this: Global or undefined
}
console.log(x); // ReferenceError
console.log(y); // undefined
const x = "hello world"; 
var y = "hello";

 코드를 실행하기 전에도 실행 문맥은 x, y 변수의 존재를 압니다. 하지만 var는 let, const와 다르게 호이스팅됩니다.

호이스팅과 let, const, var

 자바스크립트에서 변수는 선언, 초기화, 할당 3단계에 걸쳐서 값이 평가됩니다.
  1. 선언: 식별자 이름을 실행 문맥에 등록. 바인딩이 없습니다.
  2. 초기화: 식별자와 초기값(보통 undefined)을 바인딩.
  3. 할당: 식별자에 실제 값을 바인딩.

 선언, 초기화, 할당은 실행 문맥이 생성될 때, 실행할 때에 나눠서 이뤄집니다.

생성 단계 실행 단계
let, const 선언 초기화, 할당
var 선언, 초기화 할당

 var는 선언과 초기화가 동시에 일어나기 때문에 호이스팅 시 undefined로 참조가 됩니다. 따라서 선언보다 실행을 먼저 해도 에러가 안 납니다. let, const는 선언과 초기화 사이에 물리, 시간적으로 빈 구간이 존재하는데 이러한 구간을 Temporal Dead Zone(TDZ)라고 부릅니다.

다른 초기화 예시

 초기값은 흔히 함수에서 많이 쓰입니다. 아래 코드를 봅시다.

// 매개 변수 num을 출력합니다.
function myFunc(num) {
  console.log(num)
}

function myFunc2(num = 1) {
  console.log(num)
}

myFunc(); // undefined
myFunc(1); // 인자 1을 전달합니다.
myFunc2(); // 1

 자바스크립트에서 함수를 실행할 때 인자(arguments)의 갯수는 매개 변수(parameters)와 일치할 의무가 없습니다. myFunc는 num 매개 변수를 필요로 하지만, 인자를 전달하지 않더라도 함수는 실행됩니다.
 이럴 때 자바스크립트는 매개 변수의 초기값을 사용합니다. 보통 초기값은 undefined지만 myFunc2처럼 매개 변수의 초기값을 직접 지정할 수도 있습니다.

var는 왜 나쁠까?

  1. 변수 선언과 초기화를 동시에 하기에 에러가 안 나고 어디서 값이 할당되는지 혼란을 주기 쉽습니다.
  2. var는 이름을 중복으로 선언해도 문제가 안 일어납니다.
var x = 1;
var x = 2;
var x = 3;

3. var의 scope는 함수 단위이고, let과 const의 스코프는 블록 단위입니다.

for (var i = 0; i < 4; i++) {
  console.log(i); // 0,1,2,3
}

console.log(i); // 4

for (let i = 0; i < 4; i++) {
  console.log(i); // 0,1,2,3
}

console.log(i); // ReferenceError

let big = 2;
let small = 1;
if (big > small) {
  var x = 3;
}

console.log(x); // 3

let big = 2;
let small = 1;
if (big > small) {
  let x = 3;
}

console.log(x); // ReferenceError
var는 for, if 등의 block 문 밖에서도 살아있다.

 또한 var로 전역 변수를 선언하면 전역 객체의 프로퍼티로 등록되므로 성능과 상태 변화 추적 등에 안 좋습니다.

outerEnv

 outerEnv는 외부 스코프를 참조합니다.

function tester() {
  let testing = "outer";
  function innerFunc() {
    return testing; // "outer"
  }
  return innerFunc;
}

const inner = tester();

 innerFunc에는 testing 변수가 없습니다. 이럴 경우, 외부 스코프에 식별자가 있는지 탐색합니다. innerFunc의 외부 스코프는 tester이므로 tester 스코프에 식별자가 있는지 탐색합니다. tester 스코프에도 없다면 tester의 바깥 스코프를 탐색합니다. 해당 식별자를 찾을 때까지 연쇄되기에 이를 스코프 체인이라고도 부릅니다.
 함수 바깥에 있는 변수는 자유 변수(free variable)라고 부릅니다. 반대는 묶인 변수(bound variable)라고 부릅니다. 자유 변수를 쓰는 함수는 열린 함수라 부르고 매개 변수와 자체 변수만 사용하는 함수를 닫힌 함수라 부릅니다.

함수와 화살표 함수, this

 함수는 객체입니다. 함수 선언문은 실행 문맥 생성 때 선언, 초기화, 할당 세 단계를 동시에 처리합니다. 그래서 호이스팅에 아무 제한을 안 받습니다.

const x = func(); // 2

function func(){
    const num = 2;
    return num;
}

 그러나 함수 실행 문맥 자체는 함수를 실행할 때 생성, 실행되므로 함수 내부에 있는 코드까지 전부 미리 평가된다는 의미는 아닙니다. 또한 이렇게 함수 정의가 나중에 보이는 건 좋지 않으므로 상단에서 미리 선언하는 작법을 권합니다.
 이와 같은 호이스팅은 함수 선언문에 해당하는 얘기이며 함수 표현식과 화살표 함수는 해당이 안 됩니다.

const x = func(); // Error
const y = func2(); // Error

// 화살표 함수
const func = () => 2 
// 함수 표현식
const func2 = function() {
    return 2
}

this

 자바스크립트에서 this란 자신이 속한 객체를 가리킵니다. 아래 예제 코드를 봅시다.

const person = {
    name: "Lee",
    greet() { 
        console.log(`안녕하세요. 제 이름은 ${person.name}입니다.`)
    }
}

 greet처럼 person의 메소드가 person을 조회하는 건 이상합니다. 어떻게 하면 좋을까요?
 이처럼, 다른 프로퍼티를 조회하려면 자신이 속한 객체를 참조할 방법이 필요합니다. 그것이 this입니다.

const person = {
    name: "Lee",
    greet() { 
        console.log(`안녕하세요. 제 이름은 ${this.name}입니다.`)
    }
}

 메소드는 함수이고 함수는 객체입니다. 객체는 원시 타입이 아니기 때문에 직접적인 값이 아니라 주소가 참조됩니다. 즉, 메소드는 다른 객체에서도 활용이 가능합니다.

const person2 = {
    name: "Kim"
}
person.greet.call(person2); // 안녕하세요. 제 이름은 Kim입니다.

 call 메소드는 첫 번째 매개 변수로 this를 받습니다. greet은 this를 person2 객체로 받아들여서 실행하기 때문에 name을 Kim으로 평가합니다.

화살표 함수와 this

class Person {
  constructor() {
    this.name = "Lee"
    this.likes = ["coffee", "pasta"];
  }

  print() {
    this.likes.reduce(function (acc, cur) {
      console.log(this.name) // undefined
      }
    );
  }
}

 일반 함수에서 this를 호출하면 대부분 전역 객체 (strict 모드일 경우 undefined)를 참조합니다. this는 자신을 호출하는 객체를 가리키는데, 일반 함수는 호출하는 별도의 객체가 없을 때가 많습니다.
 그래서 내부 콜백 함수에서 this를 사용하고 싶으면 별도의 바인딩 처리가 필요했습니다.

class Person {
  constructor() {
    this.name = "Lee"
    this.likes = ["coffee", "pasta"];
  }

  print() {
    this.likes.reduce(function (acc, cur) {
      console.log(this.name) // Lee
      }.bind(this), // <------ this bind
    );
  }
}

매 작성마다 주의를 요구하는 작업은 개발자의 실수가 필연으로 따라옵니다. 화살표 함수는 이 문제를 해결하기 위해 나온 개념입니다.
 화살표 함수는 일반 함수처럼 동적으로 this가 결정되지 않고 자신이 정의된 스코프에서 참조하는 this를 따라갑니다. 즉, 아래에서 reduce의 콜백 함수는 print 메소드의 this를 참조합니다.

class Person {
  constructor() {
    this.name = "Lee"
    this.likes = ["coffee", "pasta"];
  }

  print() {
    this.likes.reduce((acc, cur) => {
      console.log(this.name) // Lee
      }
    );
  }
}

 this에 대한 처리도, 코드도 더 간결합니다. 그래서 this를 동적으로 참조하는 등의 케이스가 아니면 보통은 화살표 함수가 많이 작성됩니다.

일반 함수에서 this를 사용하는 케이스

 일반 함수의 this는 호출 객체가 없으면 undefined지만, 반대로 보면 호출하는 객체가 명확하면 this를 사용할 수 있습니다.

  function activeStyle(event) {
    let rect1 = event.target.getBoundingClientRect();
    let rect2 = this.getBoundingClientRect();
  }

item.addEventListener("focus", activeStyle);

 이러한 경우 item에 이벤트 함수를 등록했으므로 this는 event.target과 동일하게 item을 가리킵니다.

클로저

 “클로저는 함수와 함수가 참조하는 어휘적 환경을 묶은 것입니다. 클로저를 통해 내부 함수는 외부 함수 스코프에 접근합니다.”

 자바스크립트는 누구도 참조하지 않는 코드는 메모리를 회수합니다. 즉, 함수는 실행이 끝나면 메모리가 회수됩니다. 그러나 함수는 변수에 할당되고 재사용되는 경우도 많습니다. 이럴 경우는 어떻게 할까요?

1급 객체

“자바스크립트에서 함수는 1급 객체다.”
인자로 사용 가능 함수의 return값 가능 변수에 할당 가능
1급 객체 O O O
2급 객체 O X X
3급 객체 X X X
function counter() {
  let count = 0;
  function increase() {
    return ++count;
  }
  return increase;
}

const increaser = counter();
increaser();

 increaser를 계속 사용하려면 counter의 렉시컬 환경도 계속 기억해야만 합니다. 그래서 함수는 기본적으론 외부의 렉시컬 환경을 기억합니다.
 increase는 열린 함수입니다. 하지만 실질적으로 count 변수에 접근할 수 있는 건 increaser뿐이고, increaser는 마치 닫힌 함수처럼 보입니다. 이러한 것을 클로저라고 합니다.
 이는 코드를 외부로부터 숨기고 데이터 무결성(정확성, 일관성, 유효성)에 일조합니다. 객체 지향에서 말하는 캡슐화와 연관이 깊습니다.
 모든 함수는 렉시컬 환경을 기억하기 때문에 모든 함수가 클로저의 자격을 갖추지만, 일반적인 함수는 실행 후 바로 메모리가 회수되기 때문에 열린 함수가 변수에 할당되는 걸 클로저라고 부르곤 합니다.

클로저와 메모리

 클로저도 일종의 객체입니다. 자바스크립트는 원시 타입(number, string, boolean, null, undefined, symbol, bigint)을 스택 메모리에 할당하고 객체 타입은 힙 메모리에 할당합니다. 자바스크립트는 마크 앤 스윕 알고리즘으로 가비지 컬렉션을 자동 실행합니다. 마크 앤 스윕은 전역 객체를 시점으로 참조 관계를 살핀 다음, 참조하는 곳이 어디에도 없는 식별자는 메모리를 회수하는 방식입니다. 클로저는 보통 재사용을 목적으로 만들기 때문에 회수되는 일이 적습니다.

 lexicalEnvironment 함수는 btn1을 제거하고 클로저를 생성합니다. 이후 클로저를 사용하는 일은 없지만 btn1과 클로저는 메모리에서 회수되지 않습니다.

모든 코드를 실행한 상태. button은 화면에선 삭제됐지만 실제로는 남아있다. 클로저도 남아있다.

 전역 변수 btn1은 전역 실행 문맥에 있기 때문에 메모리에서 회수하고 싶다면 btn1을 함수 내부에서 선언하거나 즉시 실행 함수 등으로 선언해야 메모리에서 회수됩니다.
 클로저는 사용이 완전히 끝났어도 변수에서 참조하고 있기 때문에 메모리 회수 대상이 아닙니다. 만약 클로저의 메모리도 회수하고 싶다면 참조하는 식별자를 직접 비우면 되지만 추천하지는 않습니다.
 현대 브라우저는 클로저 전체를 기억하기보단 실제 참조하는 것들만 기억합니다. 아래 코드에서 foo 변수는 어디에서도 재사용되지 않습니다.

function outer() {
  const freeVariable = "자유 변수";
  const foo = "foo"
  function inner() {
    const str = freeVariable + "입니다.";
    console.log(str);
  }
  return inner;
}

let closure = outer();
console.log(closure)

 클로저가 foo 변수를 배제하고 기억하는 모습을 볼 수 있습니다. 클로저가 성능에 악영향을 미치는 주요 원인이 될 일은 많지 않을 뿐더러, 만약 문제가 있다면 다른 원인일 확률이 더 높을 것입니다.

이벤트 핸들러와 클로저

// html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="main.js" defer></script>
    <title>Document</title>
  </head>
  <body>
    <button>button</button>
  </body>
</html>

// main.js
(() => {
  const button = document.querySelector("button");
  let count = 0;
  button.addEventListener("click", () => {
    count = count + 1;
    console.log(count);
  });
})();

 즉시 실행 함수를 써서 button과 count 변수는 외부에서 접근할 수 없지만 버튼 클릭 시 count가 1씩 올라가며 계속 기억됩니다.

리액트와 클로저

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <>
      <p>카운트: {count}</p>
      <button onClick={handleClick}>클릭</button>
    </>
  );
}
함수 컴포넌트

 useState는 클로저를 활용해서 count에 접근, 관리합니다.

const React = (() => {
  let hooks = [];
  let idx = 0;

  function useState(initialValue) {
    const state = hooks[idx] || initialValue;
    const _idx = idx++;

    const setState = (newValue) => {
      hooks[_idx] = newValue;
    };

    return [state, setState];
  }

  function render(Component) {
    idx = 0;
    const C = Component();
    C.render();
    return C;
  }

  return { useState, render };
})();

const Counter = () => {
  const [count, setCount] = React.useState(1);
    ...
간단한 구현 예시

디바운스와 스로틀링과 클로저

const debounce = (callback, timeout) => {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      callback(...args);
    }, timeout);
  };
};

const throttle = (callback, timeout) => {
  let isWait = false;
  const finishWaiting = () => { 
      isWait = false; 
  };
  return (...args) => {
    if (isWait) return;
    isWait = true;
    callback(...args);
    setTimeout(finishWaiting, timeout);
  };
};

const log = (i) => {
  console.log(i);
};

const debouncedLog = debounce(log, 300);
const throttledLog = throttle(log, 150);

for (let i = 0; i < 10; i++) {
  debouncedLog(i); // 가장 최근 실행인 9만 출력
}

let timer;

timer = setInterval(() => {
  throttledLog(2); // 2를 2번 출력
}, 30);

setTimeout(() => {
  clearInterval(timer);
}, 300);

 디바운스와 스로틀링은 함수가 함수를 return합니다. 제한 시간 내에 중복 실행됐는지 판단하기 위해서는 계속 기억해야 할 상태 변수가 필요하기 때문에 클로저를 활용합니다.

참조