자바스크립트 - Scope, Hoisting

kdeun1·2021년 7월 3일
0
post-thumbnail

스코프 (Scope)

스코프는 위에서 설명한 실행 컨텍스트와 콜 스택에 연관된 개념이다. 스코프는 식별자와 값의 바인딩을 유지하는 데이터 구조이며, 자바스크립트 엔진에 의해 관리된다. 스코프는 자바스크립트 엔진이 참조할 변수를 검색하는데 사용하는 규칙이다.
자바스크립트는 스코프를 사용해서 복잡한 구조의 실행 컨텍스트와 콜 스택의 환경에서 자바스크립트의 단일 실행스레드가 어떠한 데이터를 접근할 수 있는지 결정할 수 있다.

스코프의 가시성은 내부에서 외부로만 작동한다. 즉, 콜 스택에 쌓여있는 실행 컨텍스트 중에서 제일 위에 위치한 실행 컨텍스트가 제일 아래의 전역 컨텍스트에 도달하여 환경을 볼 수 있지만, 반대로 아래의 실행 컨텍스트는 스택 위에 위치한 실행 컨텍스트를 볼 수 없다.
자바스크립트가 이처럼 데이터를 조회할 때 실행 컨텍스트를 콜 스택 구조 상 찾아 내려가는 것을 소위 스코프 체인이라고 말한다. Scope Chain이라는 용어는 공식적으로 ES3에서까지만 사용되었다.

function a() {
  function b() {
    var ccc = 'testC';
    console.log(aaa); // testA
    console.log(bbb); // testB
  }
  var aaa = 'testA';
  b();
}

var bbb = 'testB';
a();
console.log(ccc); // Uncaught ReferenceError: ccc is not defined

위의 예제를 확인해보자. aaa와 bbb 변수의 값을 찾기 위해 함수 내부에서 함수 외부로 올라가는 모습이다.

a() 함수 내부에 b() 함수를 선언하였기 때문에, b() 함수의 outer Environment Reference는 a() 함수의 Lexical Environment를 참조한다. 마찬가지로 a() 함수는 전역에서 선언되었으며, a() 함수의 outer Environment Reference는 Global Lexical Environment를 참조한다.

콜 스택 아래의 실행 컨텍스트를 뒤져가며 outerReference를 타고 내려가며 외부의 함수의 스코프에서 식별자를 해결하려고 한다. 콜스택 가장 아래의 글로벌 실행 컨텍스트 안에서도 식별하다가, 글로벌 실행 컨텍스트에서도 찾지 못하게 되면, outer의 null값을 만나 ReferenceError를 발생한다.
이 현상은 위에서 설명한 실행 컨텍스트 (execution context) 내 외부 렉시컬 환경 참조 (Outer Lexical Environment Reference)로 인해 발생된다.

위 예제에서 aaa 변수의 결과는 testA가 나오며, bbb 변수는 testB가 나온다. ccc 변수는 b() 함수 내부에 선언되어있어, 전역에서 ccc 변수에 접근하려고 하면 ReferenceError가 발생한다.

스코프는 이처럼 범위를 제한하여 식별자를 해결하기 위해 사용된다.

식별자 해결 (Identifier Resolution)
스코프 내 사용할 변수, 함수를 결정하는 것을 식별자 해결이라고 부른다.

함수 function 키워드를 만나면 자바스크립트는 function 오브젝트를 생성하고, function 오브젝트의 [[Scope]]에 스코프를 설정한다. 이 때 스코프가 결정된다.
일반적으로 function 오브젝트를 만드는 시점에 스코프를 결정하는 것을 정적 스코프(lexical scope)라고 한다.

ES5의 식별자 생명 주기는 함수 레벨 스코프를 지원하며, ES6+부터는 식별자의 생명 주기의 제어를 더 잘 관리하기 위해 블록 레벨 스코프의 개념이 추가되었다.

자바스크립트 ES6+는 함수 레벨 스코프(ES5)와 블록 레벨 스코프(ES6+)의 렉시컬 스코프(정적 스코프) 규칙을 가진다.

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

자바스크립트에서 var 변수나 함수 선언문으로 만들어진 함수는 함수 레벨 스코프를 갖는다. 즉, 함수 내부 전체에서 유효한 식별자가 된다. 하지만, 함수 레벨 스코프는 전벽 변수를 남발한 가능성이 높아서 의도하지 않은 변수 중복 선언 및 값 할당이 존재할 수 있다.

function foo() {
  if (true) {
    var aaa = 'testA';
    console.log(aaa); // testA
  }
  console.log(aaa); // testA
}

foo();

ES6의 let, const 키워드는 블록 레벨 스코프 변수를 만들어준다. aaa 변수가 if문 블록에서 선언되었으므로, 해당 블록 밖에서는 잘못된 참조 에러가 발생한다. const, let 키워드로 선언한 변수는 모든 코드 블록(함수, if문, for문, while문, try~catch문 등)을 지역 스코프로 인정한다.

function foo() {
  if (true) {
    let aaa = 'testA';
    console.log(aaa); // testA
  }
  console.log(aaa); // ReferenceError: aaa is not defined.
}

foo();

ES6가 표준화되면서, 블록 레벨 스코프와 함수 레벨 스코프를 모두 지원하는 자바스크립트에서는 var, let, const의 혼용된 방식을 사용하게되면, 개발자가 스코프에 대해 헷갈릴 수 있으므로 ES6의 let, const 사용으로 충분히 대체할 수 있기 때문에 블록 레벨 스코프로 개발하는 것이 좋은 것 같다.

ES5과 ES6+의 실행컨텍스트 내부 ThisBinding의 위치가 다름.

ES6+가 되면서 let, const가 추가되었으며, 실행 컨텍스트의 내부구조도 변경되었다.
ES5의 Execution Context 구조는 Variable Environment와 Lexical Environment, ThisBinding이었다. 하지만 ES6+부터는 Variable Environment와 Lexical Environment 컴포넌트만 존재하고, VE, LE 각각 내부에 ThisBinding이 존재한다. VE는 var statements를 LE는 let, const를 담당하는 이유로 인해 위에서 설명한 함수 레벨 스코프와 블록 레벨 스코프 둘다 존재하는 이유가 되는 것 같다.

렉시컬 스코프, 정적 스코프 (Lexical Scope)

자바스크립트는 with, eval 외의 함수에서는 정적으로 스코프가 지정되며, 함수의 스코프는 선언 시에 결정된다.
렉시컬(Lexical)이라는 명칭을 붙은 이유는 자바스크립트 컴파일러가 소스코드를 토큰으로 쪼개서 의미를 부여하는 렉싱(Lexing) 단계에서 해당 스코프가 확정되기 때문이다.

function a() {
  console.log(aaa);
}
function b() {
  aaa = 'testB';
  console.log(aaa);
  a();
}

var aaa = 'testA';
b();

해당 예제는 변수 aaa가 전역에서 선언되고 'testA'라는 값이 할당되었고, b() 함수 내부에서 'testB'값이 재할당되었다. 그러므로 로그에는 'testB', 'testB' 값이 출력된다.

예제를 아래와 같이 변경해보자.

function a() {
  console.log(aaa);
}
function b() {
  var aaa = 'testB';
  console.log(aaa);
  a();
}

var aaa = 'testA';
b();

아까 스코프가 함수 선언 시 결정된다고 하였는데, 첫번째 예제는 aaa 라는 변수에 재할당하였고, 이번 예제는 aaa를 선언하였다. 결과 값은 'testB', 'testA'가 나온다.

이처럼 함수를 선언할 때 함수 내부의 변수들은 함수가 선언된 스코프에서 상위의 실행 컨텍스트 방향으로 가장 가까운 곳의 변수를 계속 참조한다는 뜻이다.

동적 스코프 vs. 렉시컬 스코프

동적 스코프
The name resolution depends upon the program state when the name is encountered which is determined by the execution context or calling context.
렉시컬 스코프
The name resolution depends on the location in the source code and the lexical context, which is defined by where the named variable or function is defined.

동적 스코프는 함수가 호출할 때마다 스코프가 생성되는 방식이며, 정적 스코프는 함수 선언 시(function 오브젝트를 생성할 때)에 스코프가 결정되는 방식이다. 이처럼 바인딩 시점에 따라 구분되는데, 자바스크립트에서 eval()과 with문은 실행할 때 바인딩이 되는 동적 바인딩이며, 그 외 대부분은 정적 바인딩을 사용한다. 일반적으로 자바스크립트는 대부분의 코드에서 정적 바인딩(렉시컬 스코프)를 따른다고 생각하면 될까?

동적 스코프는 런타임 도중의 실행 컨텍스트에 의해 결정되고, 렉시컬 스코프에서는 변수나 함수가 선언된 위치에 따라 정의되는 실행 컨텍스트에 의해 결정된다.

스코프의 목적은 식별자 해결(Identifier Resolution)을 위한 것이다.

식별자 해결은 식별자(변수명, 함수명)를 통해 값을 찾는 것이다. 스코프를 이용해서 식별자 해결을 할 수 있다. 실행 컨텍스트에 LexicalEnvironment나 Variable Environment의 Environment Record에 프로퍼티로 인식하게 되는 것이며, 함수의 function 오브젝트를 만나는 순간(함수를 선언하는 순간) 스코프를 결정한다. 자바스크립트는 함수가 어디서 선언되었는지에 따라 상위 스코프를 결정하는 렉시컬 스코프(Lexical Scope) 방식을 사용한다. 이처럼 자바스크립트는 렉시컬 스코프 방식으로 상위 스코프를 계속 찾는 방식으로 유효 범위를 제한하며, 식별자 해결을 하게 된다.


호이스팅 (Hoisting)

호이스팅은 코드가 실행되기 전에 변수, 함수 선언을 모두 끌어올려서 해당 유효 스코프의 최상단에 선언하는 메커니즘을 말한다. 물리적으로 변수, 함수가 코드 상단으로 올라가지는 것으로 생각할 수 있는데, 이전 글의 실행 컨텍스트의 구조를 생각해보면 실제로는 그렇지 않다.
자바스크립트 엔진이 코드를 실행(Execution Phase)하기 전에 코드의 실행 환경 정보를 구축(Creation Phase)한다. 코드가 실행되기 전에 자바스크립트 엔진은 이미 해당 환경에 속한 코드의 변수명을 모두 알게된다. 변수, 함수 내용을 위한 메모리 준비하는 것을 호이스팅(Hoisting)이라고 한다.
구글링을 하다보면 var는 호이스팅이 되지만, const, let은 호이스팅이 안된다라는 식의 글이 존재한다. var, const, let 키워드를 사용하는 변수는 실행 컨텍스트 내 구조가 같은 Lexical Environment Component와 Variable Environment Component의 Environment Record에 모두 사용되므로 당연히 모두 호이스팅이 되게 된다.

변수의 선언과 초기화 단계

실행 컨텍스트의 Creation Phase에서 변수에 대한 선언초기화 단계가 일어난다.

var

var 키워드로 선언한 변수는 런타임 이전에 자바스크립트 엔진에 의해 암묵적으로 선언과 초기화 단계가 한번에 진행된다. 즉, 선언 단계에서 ER에 변수 식별자를 등록해 EC가 변수의 존재를 알게 된다. 그 즉시 초기화 단계가 일어나 변수의 값은 아무것도 할당받지 않은 undefined value가 된다. 사용자가 명시적으로 undefined를 할당하는 것과 다른 것 같다. 자바스크립트 엔진이 해당 변수의 값이 존재하지 않아 하는 수 없이 undefined를 반환하는 것같다. 그러므로 스크립트 코드에서 변수가 선언되기 이전에 변수에 접근해도 초기화된 undefined가 나오고 에러는 발생하지 않게 된다.

let

ES6 이후에 나온 let 키워드로 선언된 변수는 선언과 초기화 단계가 분리된다.
즉, 런타임 이전에 자바스크립트 엔진에 의해 암묵적으로 선언 단계가 먼저 실행되지만, 초기화 단계는 변수 선언문에 도달했을 때 실행된다.
만약, 초기화 단계인 변수 선언 코드와 만나기 이전에 해당 변수에 접근하려고 하면 참조 에러(Reference Error)가 발생한다.
해당 실행 컨텍스트 내 스코프가 시작되는 지점에서 변수 초기화 단계인 선언부까지는 Temporal Dead Zone(TDZ, 일시적 사각지대)라 말하며, 해당 구간에서는 변수를 참조할 수 없다.
let 키워드로 선언한 변수는 호이스팅이 발생하기 때문에 변수에 접근 시 참조 에러가 발생한다.

const

ES6 이후로 나온 const 키워드는 주로 상수를 선언하기 위해 사용한다. const 키워드로 선언한 변수는 재할당이 금지되어 있다. 여담으로 참조 값에 대한 변경 불가능이므로 객체의 경우 값은 바꿀 수 있다.
호이스팅이 발생하기 때문에 const의 경우에도 스코프의 시작되는 지점에서 변수의 선언과 초기화 이전까지 해당 변수에 접근하려고 하면 참조 에러가 발생한다.

console.log(aaa);
var aaa = 'bbb';
console.log(aaa);
console.log(bbb);
function bbb(m) {
  return m;
}
console.log(bbb);
console.log(ccc);
var ccc = 'ccc';
var ccc = function(n) {
  return n;
}
console.log(ccc);

실행 컨텍스트의 ER에는 실행 전 코드의 실행 컨텍스트를 위해 함수와 변수의 환경 정보를 구축한다. 선언형 함수는 함수 전체에 대한 참조를 저장하고, var statements는 undefined로, let과 const 선언문은 uninitialized로 저장된다.
위 코드에서 var aaa와 var ccc는 변수 선언과 할당이 이루어졌기에 위에서 콘솔로그의 결과 값은 undefined가 나온다.
bbb 함수는 일반적인 익명 함수 표현식이며, 변수명 bbb가 함수명이다. function 키워드와 만나 함수의 전체 텍스트는 함수가 호출되기 전에 Variable Environment의 Environment Record에 환경이 설정되며, 함수 선언문 코드 전체가 호이스팅된다.
함수 선언문(bbb)은 전체가 호이스팅되지만, 함수 표현식(ccc)는 변수 선언부만 호이스팅된다.
또한, ccc 변수에 'ccc'값이 할당되고, 함수가 재할당되었다. 이러한 오버라이딩때문에 함수 선언문의 호이스팅 케이스를 지양해야한다.


참조

profile
프론트엔드 개발자입니다.

0개의 댓글