실행 컨텍스트(렉시컬 환경과 this)

WooBuntu·2020년 8월 17일
6

  • 지난 포스팅에서는 ES3를 기준으로 렉시컬 스코프에 관해 알아보았다.

  • ES3의 '중첩 스코프'개념은 ES5로 넘어오면서 객체 형태의 렉시컬 환경을 참조하는 개념으로 바뀌었는데, 이번 포스팅에서는 이에 대해 알아볼 것이다.

  • 지금까지의 포스팅이 인용한 책들의 내용을 비교적 충실하게 반영한 반면, 이번 포스팅의 내용은 개인적인 추측이 많이 들어가 있다.
  • YDKJS에서는 ES3기준으로 스코프 개념을 설명했고, '몰입! 자바스크립트'에서는 ES5기준으로 스코프 개념을 설명했는데 이 두 개념을 연결해서 설명할 만한 자료를 못찾았기 때문이다.
  • 이런 부분이 찝찝하다면, 지난 포스팅은 YDKJS의 내용을 충실히 반영했으므로 이번 포스팅은 그냥 넘어가도 좋다.
  • 다만, this바인딩에 관해서는 지난 포스팅에서 다루지 않았으므로 이번 포스팅을 참고하는 것이 좋다.

1. 예제

실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        선언적 환경 레코드: { // 글로벌 스코프에서는 오브젝트 환경 레코드
          // 현재 스코프에 선언된 변수와 함수들이 식별되어 있음
        }
      },
      외부 렉시컬 환경 참조 : 상위 스코프 참조
      // 글로벌 스코프에서는 상위 스코프가 없으므로 null 참조
    },
  },
  변수 환경 컴포넌트: {
    // 렉시컬 환경 컴포넌트에 설정된 '렉시컬 환경'을 복사한 것으로 렉시컬 환경 컴포넌트와 초기값이 같다.
    // 실행 단계에서 참조하는 것은 렉시컬 환경 컴포넌트이고, 변수 환경 컴포넌트는 이후 초기값으로의 환원을 위한 컴포넌트이다.
    // 함수가 호출될 때마다 매번 새로운 실행 컨텍스트를 생성하는 걸로 알고 있는데 캐시 개념이라도 적용되는 걸까... x같은 자스...
  },
  this바인딩 컴포넌트
  // 글로벌 스코프에서는 Global object참조
}
  • 결론부터 먼저 제시하자면, 실행 컨텍스트는 위와 같은 형태를 갖는다.
    (더럽게 복잡하네)

    • 이후의 예제에서는 변수 환경 컴포넌트에 대한 내용은 생략한다.

    • this바인딩에 대해서는 맨 아래 챕터에서 한꺼번에 묶어 설명한다.

  • 기본적으로 변수의 탐색(LHS,RHS)은 ES3의 중첩 스코프와 크게 다르지 않다.

  • ES3에서는 찾는 변수가 현재 스코프에 없으면 상위 스코프로 중첩 스코프를 타고 올라간다고 개념적으로 설명했다면,

  • -ES5에서는 찾는 변수가 '환경 레코드'에 없으면 외부 렉시컬 환경 참조를 통해 중첩 스코프를 타고 올라간다는 것을, 객체의 property참조 형태로 보다 가시화해서 보여준다.
    (물론, 가시화 여부만이 ES3와 ES5의 차이점은 아니다.
    개념적으로도 분명히 구분되는 부분이 있는데, 이는 후술한다)

  • 사실 위에서 표현한 실행 컨텍스트 구조에서 '컴포넌트'는 원래 자바스크립트 스펙상 용어가 아니다.
  • 하지만, 컴포넌트 용어를 뺄 경우 렉시컬 환경은 똑같은 이름으로 계층을 가지므로 혼란을 줄 수 있다고 판단하여, 김영보 선생님의 표현법을 빌린 것이다.
  • 다음의 예제를 살펴보기 이전에 세 가지 전제를 깔고 가자.
  1. 실행 컨텍스트는 함수의 호출로 생성된다.
    (즉, 실행 컨텍스트의 단위는 함수이다)

  2. 컴파일의 단위도 함수 코드이다.
    (JIT방식)

  3. 호출된 함수들의 관계는 콜스택이라는 자료구조로 표현된다.

  • debugger는 작성된 위치에서 프로그램의 실행을 멈추는 기능을 한다.

  • 즉, 코드의 첫번째 줄에서 자바스크립트 프로그램이 멈춰있는 상태이다.

  • 우측의 콜스택을 확인해보면 anonymous라는 익명 함수가 호출된 상태임을 알 수 있다.

  • 자바스크립트 프로그램이 실행되자마자 debugger로 멈췄는데 난데없이 익명함수가 어디에서 호출된 것일까?

  • 이는 글로벌 스코프의 코드 역시 함수의 코드이기 때문이다.

  • 자바스크립트 프로그램은 렌더링이 끝나고 나면, 글로벌 스코프에 있는 코드를 함수의 코드로 간주하여 실행한다.

  • 굳이 비유하자면 다음과 같은 형태라고 볼 수 있겠다.

(function () {
  debugger;

  var a0 = "woobuntu";

  function a1() {
    debugger;
    var a2 = 3;
    function a3() {
      debugger;
      console.log(a0, a2);
    }
    a3();
    debugger;
  }

  a1();
  debugger;
})();
// 글로벌 스코프에 있는 코드는 IIFE로 감싸 있다고 이해하자
  • 어쨌거나 글로벌 스코프의 코드가 함수로써 호출되었으니 글로벌 스코프에 해당하는 실행 컨텍스트가 다음과 같이 생성된다.
// 글로벌 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        오브젝트 환경 레코드: {
          a0: undefined,
          a1: f a1(),
          // 기타 글로벌 스코프에 존재하는 모든 변수 및 함수들
        }
      },
      외부 렉시컬 환경 참조 : null
    },
  },
  this바인딩 컴포넌트: Global object참조
}
  • 내부 엔진 처리에 관한 내용이라 정확하지 않지만, 컴파일이 이루어지고 난 후에 실행 컨텍스트가 생성된다고 생각하면 되겠다.

  • 실행 컨텍스트 개념이 들어왔다고 당황하지 말고 ES3의 렉시컬 스코프 형성 과정을 그대로 떠올려보자.

  • 글로벌 스코프에는 a0이라는 변수와 a1이라는 함수가 선언되어 있다.

  • 따라서 컴파일러는 컴파일하면서 a0과 a1을 스코프에 엮는다.

    • 예제 우측의 Scope부분을 보면 보이듯이 a0과 a1이 글로벌 스코프에 잡혀 있다.
      (Scope의 Script는 무시하자... 뭔지 모르겠다)

    • 데이터의 할당은 코드 실행 시점에서 이루어지기 때문에 debugger로 멈춰진 지금은 a0이 undefined의 초기값을 갖는 것이 당연하다.

  • a1의 내부 property중 [[Scopes]]가 보인다.

    • 이는 선언된 함수가 자신이 선언된 렉시컬 스코프를 내부 property로 참조하는 것이다.

    • a1함수는 글로벌 스코프에서 선언되었기 때문에 [[Scopes]]에 Global 스코프가 존재한다.

    • 이전 포스팅에서 중첩 스코프를 타고 올라간다고 표현한 것은, 이 [[Scope]]를 참조한다는 의미였다.

  • ES3의 스코프 개념과 다른 부분은 다음 두 부분이다.

    • 현재 스코프(글로벌 스코프)에 있는 식별자들을 오브젝트 환경 레코드가 참조하도록 설정한다.
      (글로벌 스코프의 경우 선언적 환경 레코드가 아니라 오브젝트 환경 레코드이다)

    • 외부 렉시컬 환경 참조가 null을 참조하도록 한다.
      (글로버 스코프가 최상위 스코프이므로 변수의 탐색은 여기서 끝난다)

  • 이제 다음 debugger로 넘어가보자

  • 가장 먼저 확인할 것은 콜스택이다.

    • anonymous함수 위에 a1함수가 쌓였다.

    • 앞서 콜스택이 호출된 함수들의 관계라고 설명했다.

    • anonymous함수는 글로벌 스코프 상의 모든 코드의 실행이 완료되어야 종료된다.

    • 따라서 anonymous함수는 콜스택에서 빠질 수 없다.

    • anonymous함수가 종료되기 전에 a1함수가 호출되었으므로 콜스택에는 현재 2개의 함수가 존재하는 것이다.

  • a1함수가 콜스택의 최상위에 있으니, 이제 a1함수의 실행 컨텍스트를 생성할 차례다.

// a1함수 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        선언적 환경 레코드: {
          a2: undefined,
          a3: f a3(),
        }
      },
      외부 렉시컬 환경 참조 : a1함수의 [[Scope]] 참조
    },
  },
  this바인딩 컴포넌트: Global object
}
  • 컴파일러가 a1함수의 코드를 컴파일한 결과, 우측 Local Scope에서 확인할 수 있듯이 a2변수와 a3함수가 스코프에 엮인다.

  • 이렇게 스코프에 엮인 변수와 함수는 선언적 환경 레코드가 참조한다
    (글로벌 스코프의 실행 컨텍스트가 아닌 실행 컨텍스트는 모두 선언적 환경 레코드이다)

  • a3함수는 a1함수 스코프에서 선언되었으므로 [[Scopes]]에 a1함수의 렉시컬 스코프를 가지게 되는데, 위의 개발자 도구에서는 Closure (a1)으로 표기되었다.
    (지난 포스팅에서 말했던 것처럼 렉시컬 스코프와 클로저는 사실상 동일한 개념이라는 것을 알 수 있다)

    • a3함수에서 a2변수에 대한 RHS탐색을 수행할 때, a3함수 스코프에 a2가 없으니 상위 렉시컬 스코프인 a1 함수 스코프로 넘어간다
  • 외부 렉시컬 환경 참조는 현재 함수인 a1함수의 [[Scope]]를 참조한다.

    • 즉, Global 스코프의 오브젝트 환경 레코드를 참조한다

    • 주의해야할 것은 Global 스코프의 '렉시컬 환경'을 참조하는 것이 아니라 '오브젝트 환경 레코드'를 참조한다는 점이다.

    • 즉, 외부 렉시컬 환경 참조로 들어가서 또 다시 외부 렉시컬 환경 참조로 들어갈 수는 없다는 얘기다.

  • 다음 debugger시점으로 넘어가보자.

  • 역시 가장 먼저 파악해야할 것은 콜스택이다.

    • anonymous함수와 a1함수 모두 아직 종료되지 않았으므로, 콜스택에서 아무것도 빠지지 않고 위에 a3함수가 올라온다.
  • a3함수가 호출되었으니 a3함수의 실행 컨텍스트가 생성된다.

// a3함수 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        선언적 환경 레코드: {
          // 선언된 변수나 함수가 없다
        }
      },
      외부 렉시컬 환경 참조 : a3함수의 [[Scope]] 참조
    },
  },
  this바인딩 컴포넌트: Global object
}
  • a3함수의 내부 코드를 컴파일한 결과, 선언된 변수나 함수가 없기 때문에 선언적 환경 레코드는 아무것도 참조하지 않는다.

  • a0과 a2에 대한 RHS 탐색을 수행한다.

    • a3함수는 a0도 없고, a2도 없기에 '외부 렉시컬 참조'를 참조한다.

    • 외부 렉시컬 참조는 앞서 봤던 a3함수의 [[Scope]], 즉 Closure (a1)을 참조한다.
      (개발자 도구 우측의 Scope에도 Closure가 추가되어 있는 것을 확인할 수 있다)

    • 이 Closure (a1)은 결국 a1 함수의 렉시컬 스코프이다.

    • 마침 Closure (a1)에 a2가 있기 때문에 a2까지는 별도 처리 없이 빠르게 찾을 수 있다.

    • 그런데, a0은 a1함수 스코프보다도 한 단계 밖에 있기 때문에 '외부 렉시컬 환경 참조'로 참조할 수 없다.

    • 따라서 ES3에서의 중첩 스코프 개념처럼 근접 스코프로 넘어가는 별도의 처리를 따로 해주어야 한다.(느리다)

  • 다음 debugger시점으로 넘어가보자.

  • 언제나 가장 먼저 살펴볼 것은 콜스택이다.

    • 현재 a3함수가 종료된 시점이므로 콜스택에서 a3함수가 빠졌다.

    • a3함수가 콜스택에서 빠지면서 a3함수의 실행 컨텍스트도 사라진다.

    • 즉, 지금 콜스택의 최상단 함수는 a1함수이다.

  • 현재 콜스택의 최상단 함수가 a1함수이기 때문에 앞서 생성했던 a1함수의 실행 컨텍스트를 바라본다.

    • 실행 컨텍스트는 함수가 종료되기 전까지 사라지지 않으며, 일단 사라진 다음에는 같은 함수를 호출하더라도 새로 실행 컨텍스트를 생성한다.

  • 바로 위 debugger의 반복이다.

  • anonymous가 콜스택에서 사라지려면 프로그램이 종료된 시점인데 이는 브라우저나 Node.js 프로그램을 종료한 시점이므로 관찰할 수 없다.

2. 렉시컬 환경

렉시컬 환경: {
  환경 레코드: {
    선언적 환경 레코드: { // 글로벌 스코프에서는 오브젝트 환경 레코드
      // 현재 스코프에 선언된 변수와 함수들이 식별되어 있음
    }
  },
  외부 렉시컬 환경 참조 : 상위 스코프 참조
  // 글로벌 스코프에서는 상위 스코프가 없으므로 null 참조
}, 

환경 레코드

선언적 환경 레코드

  • 선언적 환경 레코드는 function문, try-catch의 catch문에서 생성된다.

  • 콜스택을 보면 알 수 있듯이 catch문은 함수를 호출한 것이 아니다.

  • 따라서 catch문은 실행 컨텍스트를 생성하지 않는다.

  • 대신 현재 실행 컨텍스트의 선언적 환경 레코드에 catch문의 Error객체가 추가될 뿐이다.

  • catch문의 종료 이후 해당 Error객체는 사라지므로, catch문은 일종의 블록 스코프로 작동하는 것이나 다름없다.

오브젝트 환경 레코드

  • 오브젝트 환경 레코드는 글로벌 스코프의 실행 컨텍스트와 with문에 해당한다.
    (그놈의 with이랑 eval은 진짜)

  • 환경 레코드는 컴파일 결과 선언된 변수와 함수를 참조한다.

  • 굳이 '선언적 환경 레코드'와 '오브젝트 환경 레코드'를 구분하는 것은 글로벌 스코프의 '오브젝트 환경 레코드'가 동적으로 함수와 변수를 바인딩하기 때문이다.

  • 이 첫번째 debugger로 프로그램이 멈춘 시점에서 글로벌 스코프에 대한 컴파일은 끝난 상태다.
// 글로벌 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        오브젝트 환경 레코드: {
          a0: f a0(),
          // 기타 글로벌 스코프에 존재하는 모든 변수 및 함수들
        }
      },
      외부 렉시컬 환경 참조 : null
    },
  },
  this바인딩 컴포넌트: Global object참조
}
  • 이제 다음 debugger로 넘어가보자

  • 우측에서 Global Scope를 확인해보면, 분명 전에 없던 a1이 추가된 것을 확인할 수 있다.

  • 지난 포스팅에서도 설명했듯이, 값을 할당받을 변수의 탐색은 LHS탐색에 해당한다.

  • a0함수를 컴파일할 때 var, const, let등의 키워드가 없었으니 a1은 a0함수 스코프의 변수로 선언되지 않았다.

  • 따라서 중첩 스코프를 타고 올라가 a1을 찾게 되는데, a1이 없으니 글로벌 스코프에 a1을 변수로 선언한 것이다.

// 글로벌 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        오브젝트 환경 레코드: {
          a0: f a0(),
          a1: 3
          // 기타 글로벌 스코프에 존재하는 모든 변수 및 함수들
        }
      },
      외부 렉시컬 환경 참조 : null
    },
  },
  this바인딩 컴포넌트: Global object참조
}
  • 이렇듯 글로벌 스코프의 '오브젝트 환경 레코드'는 함수의 선언된 시점뿐만 아니라 함수가 호출된 시점에도 영향을 받으므로 동적 스코프에 해당한다.
    (그런데 이건 ES3의 중첩 스코프 개념도 마찬가지 아닌가...)

  • 반면 일반 함수 스코프는 함수가 선언된 위치에 의해 결정되고, 함수의 호출과는 하등 관계가 없기 때문에 '선언적 환경 레코드'라 불러 '오브젝트 환경 레코드'와 구분하는 것이다.
    (비록 함수 스코프 자체가 함수의 호출로 실행 컨텍스트가 생성되는 시점에서 결정되지만, 이것은 시점일 뿐 함수 스코프에 어떤 변수와 함수가 속하는지는 전적으로 함수가 선언된 위치에 의해 결정된다)

외부 렉시컬 환경 참조

  • 외부 렉시컬 환경 참조는 바로 상위의 렉시컬 스코프를 참조한다.

  • 외부 렉시컬 환경 참조가 있기 때문에 '렉시컬 환경'이라는 단일 객체 안에서 바로 상위의 렉시컬 스코프까지 변수 탐색이 빠르게 이루어질 수 있다.
    (=현재의 실행 컨텍스트를 벗어나지 않아도 된다)

  • 반면, ES3의 중첩 스코프에서는 상위의 스코프로 넘어가기 위해서는 별도의 처리가 더 필요하다고 한다
    (별도의 처리가 정확히 뭔지 알려주세요 김영보쌤 ㅠㅠ)
    (실행 컨텍스트에서는 객체의 property접근법으로 식별자를 찾고 ES3는 그렇지 않다는 의미인가... 뭘까 대체)

  • ES5에서 스코프 개념이 바뀐 것은 '외부 렉시컬 환경 참조'를 통해 변수 탐색을 더욱 빠르게 하기 위함인 것

  • 그러나 스코프를 두 단계 이상 넘어가게 되면 ES5도 ES3처럼 별도의 처리가 필요하다.

  • 따라서 프로그램의 성능을 생각하면 함수에서 접근할 변수는 멀어도 1단계 밖의 렉시컬 스코프 안에는 있어야 한다는 뜻이다.
    (당연히 함수 안에 있는 것이 최선)

3. 실행 컨텍스트

실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        선언적 환경 레코드: { // 글로벌 스코프에서는 오브젝트 환경 레코드
          // 현재 스코프에 선언된 변수와 함수들이 식별되어 있음
        }
      },
      외부 렉시컬 환경 참조 : 상위 스코프 참조
      // 글로벌 스코프에서는 상위 스코프가 없으므로 null 참조
    },
  },
  변수 환경 컴포넌트: {
    // 렉시컬 환경 컴포넌트에 설정된 '렉시컬 환경'을 복사한 것으로 렉시컬 환경 컴포넌트와 초기값이 같다.
    // 실행 단계에서 참조하는 것은 렉시컬 환경 컴포넌트이고, 변수 환경 컴포넌트는 이후 초기값으로의 환원을 위한 컴포넌트이다.
    // 함수가 호출될 때마다 매번 새로운 실행 컨텍스트를 생성하는 걸로 알고 있는데 캐시 개념이라도 적용되는 걸까... x같은 자스...
  },
  this바인딩 컴포넌트
  // 글로벌 스코프에서는 Global object참조
}
  • 실행 컨텍스트는 함수의 호출로 생성된다.

  • 현재의 실행 컨텍스트가 종료되지 않은 시점에서 함수의 호출로 콜스택의 최상단 함수가 변경되면, 기존 실행 컨텍스트를 살려둔 채로 최상단 함수에 해당하는 실행 컨텍스트를 생성한다.

  • 함수 코드의 실행이 완료되면, 콜스택에서 해당 함수가 빠지고 대응되는 실행 컨텍스트 역시 사라진다.
    (즉, 같은 함수를 다시 호출하더라도 이전에 있던 실행 컨텍스트를 사용하는 것이 아니라 새로운 실행 컨텍스트를 생성하는 것)

  • 변수 환경 컴포넌트는 초기값으로 환원할 때 사용된다고 한다.
    (함수 종료되면 실행 컨텍스트는 사라진다면서요... 대체 초기값으로 환원하는 게 언제임)

4. this 바인딩

  • 앞서 살펴본 렉시컬 환경이 비록 함수 '호출된 시점'에 형성되기는 하지만, 환경 레코드에 바인딩되는 변수와 함수는 전적으로 함수가 '선언된 위치'에 따라 결정된다.

  • 즉, 어디서 호출하느냐에 관계없이 항상 스코프가 일정하기 때문에 동적 바인딩이 아니다.

  • 이와는 반대로 함수가 선언된 위치와는 관계없이 함수가 호출된 위치에 따라 바인딩이 결정되는 것을 동적 바인딩이라고 한다.

  • 이번 챕터에서 다루는 this바인딩이 바로 그러한 동적 바인딩의 예이다.

  • 같은 실행 컨텍스트의 property이지만, 렉시컬 환경은 정적 바인딩이고 this바인딩은 동적 바인딩인 것이다.

바인딩 우선순위

  • this 바인딩은 다음과 같은 우선순위를 따라 결정된다.
  1. new 바인딩

  2. 명시적 바인딩

  3. 암시적 바인딩

  4. 기본 바인딩

  • 이 순서대로 살펴보자.

new 바인딩

  • [[Prototype]] 포스팅에서 강조했듯이, 자바스크립트에는 클래스나 인스턴스가 없다.

  • 다만, 이러한 '클래스 지향'을 흉내낸 흔적들은 많이 남아있다.

  • 대표적인 것이 new 함수()의 형태로 함수.prototype의 위임객체를 생성하는 것이다.

  • 이렇게 new 키워드를 활용하여 수임객체를 만들 때에도 this바인딩은 적용되는데, 이때 this바인딩 컴포넌트는 새로 만들어진 수임객체를 참조한다.

명시적 바인딩

  • 명시적 바인딩은 this로 바인딩할 객체를 직접 지정할 수 있다.

  • 명시적 바인딩은 Function.prototype에 정의되어 있는 다음의 세 가지 메소드를 통해 가능하다.
    (모든 함수는 Function.prototype의 수임객체이므로)

    • Function.prototype.call

    • Function.prototype.apply

    • Function.prototype.bind

Function.prototype.call

  • call메소드의 첫번째 인자로는 this로 지정할 객체를,

  • 두번째 인자부터는 함수에 전달할 인자값을 전달한다.

  • 두번째 인자부터는 옵션이다.

Function.prototype.apply

  • Function.prototype.call과 다른 점은 함수에 전달할 인자값들은 배열로 묶어서 전달한다는 것 뿐이다.

Function.prototype.bind

function foo() {
  debugger;
  console.log(this.a);
}

var obj = { a: 2 };
  • 예를 들어, foo함수를 setTimeout함수의 콜백으로 넘겨주면서도 obj를 this로 넘겨주고 싶다고 가정해보자.

  • call이나 apply메소드는 함수를 반환하는 것이 아니라 호출하기 때문에 아래와 같이 콜백을 넘겨줄 수가 없다.

setTimeout(foo.call(obj), 1000); // TypeError
  • 따라서 원본 함수에 명시적으로 this바인딩을 적용한 새로운 함수가 필요하다.

  • 이런 경우에 사용하는 것이 Function.prototype.bind이다.

  • bind를 흉내내보자면 다음과 같은 코드일 것이다
    (물론 실제 코드를 상당히 추상화한 것에 불과하다)
function bind(fn, obj) {
  return function () {
    return fn.apply(obj, arguments);
  };
}
  • 명시적 바인딩(call,apply)을 클로저와 연계해서 사용하는 방식이다.

  • bind함수는 Currying에도 유용하게 쓰이는데, 이는 나중에 '함수형 프로그래밍'시리즈에서 다룰 것이다.

  • arguments와 관련해서는 나중에 spread연산자와 엮어서 다른 포스팅에서 다루도록 하겠다.

API 호출 컨텍스트

  • 라이브러리 함수와 자바스크립트 내장 함수, 호스트 환경 내장 함수에는 보통 '컨텍스트'라는 인자를 지원한다.

  • 인자의 위치는 함수에 정의되어 있기 나름이지만, 보통 아래와 같은 형태를 갖는다.

암시적 바인딩

  • 위의 new 바인딩과 명시적 바인딩에 해당하지 않는 경우에는 함수가 객체의 property로서 호출되었는지 여부를 파악해야 한다.

  • 함수가 객체의 property로서 호출되었다면 암시적 바인딩에 해당하며, 이 경우 this는 foo를 해당 객체를 참조한다.

  • 객체의 property참조 깊이가 2이상이라면, 함수의 바로 상위 객체를 this로 참조한다.

암시적 소실

  • 암시적으로 바인딩된 함수를 콜백 함수로 넘겨주는 경우 this바인딩이 끊기는 현상이 나타난다.

  • 여기서 undefined가 출력되는 이유는 기본 바인딩이 적용되었기 때문이다.
    바로 밑의 기본 바인딩과 연결해서 보자.

기본 바인딩

  • 앞서 this바인딩이 동적 바인딩임을 미리 밝혔다.

  • 즉, this바인딩은 함수의 호출과 관련되어 있다.

  • new바인딩은 함수가 'new 함수()'의 형태로 호출된 경우이고,

  • 명시적 바인딩은 함수가 '함수.call/apply/bind()'의 형태로 호출된 경우이며,

  • 암시적 바인딩은 함수가 '객체.함수()'의 형태로 호출된 경우이다.

  • 그리고 앞선 경우를 모두 제외한, 함수가 오로지 '함수()'의 형태로 호출되는 경우가 기본 바인딩에 해당한다.

  • 이 기본 바인딩에서는 this가 Global Object, 즉 글로벌 스코프를 가리킨다.

    • 다만 strict모드에서는 Global object가 기본 바인딩 대상에서 제외되기 때문에, 이 경우 this는 undefined가 된다.
  • 앞서 '암시적 소실'의 예제를 다시 살펴보자.

  • 이전 포스팅에서 변수 탐색은 '일차 식별자'에만 해당한다고 언급했다.

  • 그러니 obj.foo()의 형태로 호출할 때 obj는 변수 탐색으로 찾지만, foo는 객체의 내부 property [[Get]]으로 접근한다.

  • 즉, obj.foo()에서 foo는 obj의 property로써 호출되는 것이다.

  • 그런데, 함수에 인자로 obj.foo를 넘겨주는 것은 다르다.

  • 지난 포스팅의 LHS 탐색에서 언급했듯이, 함수에 인자를 넘기는 것은 일종의 할당이다.
    (doFoo함수의 fn에 obj.foo를 할당하는 것이다)

  • doFoo함수의 fn 역시 변수이고, 변수에는 참조형 데이터인 메모리 주소값만이 담길 수 있다.

  • 즉, fn에 담긴 값은 foo함수의 메모리 주소값이다.

  • 따라서 doFoo안에서 호출되는 fn은 obj의 [[Get]]으로 호출되는 property가 아닌 foo함수 그 자체이다.

  • 이러한 설명이 너무 장황하다면 그저 호출의 형태를 살펴보는 것만으로도 충분하다.

예외

this 무시

  • 명시적 바인딩에 사용되는 함수들의 첫번째 인자로 null 혹은 undefined를 넘기면 this바인딩이 무시되고 기본 바인딩 규칙이 적용된다.

  • null같은 값으로 this바인딩을 하는 것은 bind함수를 Currying함수로 쓰기 위한 목적이다.

더 안전한 this

  • 앞서 명시적 바인딩 메소드에 null값을 의도적으로 전달하는 경우를 살펴봤는데, 이는 주의해서 행해야 한다.

  • 예를 들어 내가 내부 코드를 완전히 알지 못하는, 특정 라이브러리의 함수에 이렇게 null을 적용한 명시적 바인딩 메소드를 사용했다고 치자.

  • 그 라이브러리 함수가 내부적으로 this를 참조하고 있다면, 기본 바인딩 규칙에 따라 Global object를 참조하면서 어떤 예상치 못한 부작용이 발생할 지 모르는 일이다.

  • 따라서, [[Get]]으로 그 어떤 property도 참조할 수 없는 비어있는 객체를 명시적 바인딩으로 묶어주는 것이 훨씬 안전한 방법이다.

  • Object.create을 이용하여 [[Prototype]]이 아무것도 참조하지 않는 그야말로 텅빈 객체를 만들어냈다.

  • 여기서는 성의없게 객체명을 a라고 지었지만, 텅비었다는 의미를 담아 변수명을 지으면 될 일이다.

암시적 소실(암시적 바인딩 + 할당문)

  • 앞서 암시적 소실에서 장황하게 설명한 내용이다.

  • = 연산자로 변수에 값을 할당한다는 것은 메모리 주소값의 할당을 의미한다.

  • 따라서 = 연산자의 우측에 object.property와 같은 할당을 하면 property가 가리키는 메모리 주소값이 할당이 된다.

  • 그러니 이렇게 할당된 함수를 참조하면, [[Get]]을 통한 property접근이 아니라 메모리 주소를 직접 참조하기 때문이다.

Lexical this

function foo() {
  debugger;
  console.log(this.a);
}

var obj = {
  a: "woobuntu",
  foo: foo,
};

setTimeout(obj.foo, 1000); // undefined
// setTimeout함수 내에서 foo함수의 메모리 주소값이 할당되는 연산이 일어나기 때문에 foo는 기본 바인딩 규칙이 적용된다.
  • 앞서 암시적 소실은 콜백 함수를 인자로 넘겨주면서 = 연산이 일어나 생기는 문제였다.

  • 이에 대한 해결책은 두 가지 방법이 있는데, 첫번째 방법은 렉시컬 스코프를 이용한 방법이다.

  • call함수를 이용한 명시적 바인딩으로 foo함수가 호출되는 시점에서 this는 obj를 가리킨다.

  • 이 foo함수가 setTimeout의 인자로 전달되면서 = 연산이 일어나 this바인딩이 소실되는 것이었으므로, 클로저를 통해 this에 접근할 수 있도록 한다.

  • foo함수 안에서 self 변수를 선언하여 콜백 함수 안에서 참조시키면 클로저가 생기므로 이것이 가능하다.

  • 딱봐도 번거로운 이 코드를 ES6에서는 좀 더 간단하게 표현할 수 있게 되었다.

  • 바로 화살표 함수를 활용하는 방법인데, 화살표 함수 내부의 this는 화살표 함수가 호출될 당시의 렉시컬 스코프에서 this값을 받아온다.

  • 즉, 방금 전 self를 이용한 예제를 문법상 정형화시킨 것이다.

  • 하지만 self를 이용했건, 화살표 함수를 이용했건 이는 지양해야 할 방법이다.

  • this는 동적으로 이루어지는 바인딩인데, 이를 정적 바인딩인 렉시컬 스코프를 통해 우회하려는 것이 정상적인 방법은 아니기 때문이다.

  • 일관성 있게 프로그램을 작성하려면 this체계를 올바르게 사용하거나 this를 포기하고 명시적으로 렉시컬 스코프를 이용하는 것이 타당하다.
    (따라서 화살표 함수를 사용할 거라면 this를 사용하지 않는 게 맞다)

  • 그럼 올바르게 this체계를 이용하는 두번째 방법에 대해 알아보자.
    (사실 이미 알아봤다)

  • 이렇게 명시적 바인딩을 이용하는 것이 this체계를 올바르게 이용하는 방법이다.

5. 기타

함수 선언문과 함수 표현식

함수 선언문

  • 위와 같이 함수 선언문의 경우 함수는 자신이 선언된 스코프의 변수가 된다.
    (여기서는 글로벌 스코프)

  • 따라서 a함수 안에서 자기 자신을 참조하려면 글로벌 스코프의 a함수를 참조해야 한다.

기명 함수 표현식

  • 이전 포스팅에서 함수 표현식은 자신이 함수 자신의 스코프의 변수가 된다고 했다.

  • 우측 Local Scope에서 b함수 자신을 확인할 수 있다.

  • 실행 컨텍스트의 내용은 다음과 같다.

// b함수 스코프에 해당하는 실행 컨텍스트
실행 컨텍스트: {
  렉시컬 환경 컴포넌트: {
    렉시컬 환경: {
      환경 레코드: {
        선언적 환경 레코드: {
          b: f b() // b함수는 자신의 스코프의 변수가 된다!
        }
      },
      외부 렉시컬 환경 참조 : b함수의 [[Scope]] 참조(=글로벌 스코프)
    },
  },
  this바인딩 컴포넌트 : Global object참조
}
  • 따라서 b함수 내부에서 참조하는 b는 글로벌 스코프가 아니라 b함수 스코프 내부에 있는 b함수이다.
    (설계의 목적이 뭘까...)

익명 함수 표현식

  • 익명 함수 표현식은 자기 자신을 지칭할 식별자가 없다.

  • 디버깅 등의 여러 목적을 생각하면 최대한 익명 함수 표현식은 안 쓰는 것이 좋다.

브라우저 개발자 도구

  • 브라우저 개발자 도구로 스코프와 this를 이렇게 명확하게 확인할 수 있는데 왜 지난 포스팅에서는 개발자 도구를 사용하지 않았냐고 할 수도 있겠다.

  • 이는 브라우저가 '모든' 정보를 보여주지 않기 때문이다
    (아마도 브라우저 차원에서 최적화를 위해 '꼭 필요한' 정보만을 보여주는 것으로 추정된다)

  • 분명 a0함수 스코프 상에서 a1과 a2가 선언되어 있는데, Local Scope에 이들은 보이지 않는다.

  • 이는 선언된 a1과 a2가 어디에서도 참조되지 않아서인데 다음 경우를 살펴보자.

  • a1에 값을 할당하고, a2를 호출하니 Local Scope에 a1과 a2가 보인다.

  • 오해하지 말자, a1과 a2가 없었는데 할당과 참조로 생긴 것이 아니다.

  • 컴파일 시점에서 a1과 a2가 스코프 상에 이미 존재하지만, 브라우저 개발자 도구가 실제로 쓰이는 변수와 함수들만을 보여줄 뿐이다.

  • a2함수가 호출된 시점에서 a2함수는 a0함수 스코프를 상위 렉시컬 스코프(Closure (a0))로 가진다.

  • 그런데 앞선 예제들에서는 Scope에서 이 Closure를 보여줬는데 지금은 보여주지 않는다.

  • 아래처럼 예제를 바꿔보자.

  • a2함수에서 a0함수 스코프의 a1을 참조하니 드디어 Closure (a0)가 생겼다.

  • 하지만, a0함수 스코프의 a3변수는 참조되지 않아서인지 Closure (a0)에서 제외되어 있다.

  • 역시 오해해서는 안 된다.
    a3변수가 없는 것이 아니라 표시되지 않은 것일 뿐이다.

  • 앞서 var로 선언한 변수는 할당되거나 참조되지 않으면 스코프에 표시되지 않았는데 let으로 선언한 변수는 undefined로 보인다
    (뭔데...)

  • 또, 글로벌 스코프에서 선언된 함수와 변수는 따로 참조되지 않아도 멀쩡히 스코프에 잡힌다
    (누가 설계했냐고)

  • 이렇듯 개발자 도구가 모든 정보를 보여주는 것이 아니기 때문에 지금까지 살펴본 개념들을 잘 숙지한 뒤에 보조 도구로 쓰는 것이 좋다.

2개의 댓글

comment-user-thumbnail
2021년 5월 13일

굉장히 복잡한 일들이 일어나는군요.. 잘읽었습니다 감사합니다.

답글 달기
comment-user-thumbnail
2022년 3월 13일

this 바인딩은 왜 그런가 했었는데 이해가 되네요. 감사합니다. ^^

답글 달기