ReferenceError in JavaScript

TDZ와 ReferenceError

Table of Contents

TL;DR

  • letconst는 LexicalEnvironment에 바인딩 되며, 블록이 실행되기 전에 생성된다. 이는 LexicalBinding이 평가되기 전까지는 접근할 수 없다.
  • var로 선언된 변수는 VariableEnvironment에 바인딩 되며, 함수가 새로 생성될 때마다 생성된다. 이는 함수가 실행되기 전에 접근할 수 있다. AssignmentExpression(=)을 마주할 때 값이 할당된다.
  • 동일해 보이는 코드라도 결과가 다를 수 있다. 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.

서론

TDZ(Temporary Dead Zone)는 들어본 적이 있을 것이다. let이나 const로 선언된 변수를 선언 이전에 접근하려 하면 ReferenceError가 발생한다. 이를 TDZ라고 한다.

const foo = 1;
{
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  const foo = 2;
}

이제 아래 코드를 보자. 두 코드는 서로 다른 에러를 출력한다. 차이점은 무엇일까? 잠시 생각해 보자.

console.log(foo);
let foo;
{
  console.log(foo);
  let foo;
}
정답

첫 번째 코드는 ReferenceError: foo is not defined가, 두 번째 코드는 ReferenceError: Cannot access 'foo' before initialization이 발생한다.

console.log(foo); // ReferenceError: foo is not defined
let foo;
{
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  let foo;
}

왜 이런 차이가 존재할까?

Execution Context

이를 설명하기 전에, 먼저 Execution Context에 대한 이해가 필요하다. Execution Context는 코드에 대한 평가 및 실행 환경을 제공하며, 실행 중인 코드에 대한 스코프 정보, 변수, 객체, 함수 등을 포함하고 있다. 이 구조를 그래프로 표현하면 아래와 같다.

mermaid diagram

💡 Environment Record: 식별자와 그에 해당하는 값을 추적하고 관리하며, 코드가 실행되는 동안 식별자에 접근하거나 값을 변경할 수 있게 함

여기서 VariableEnvironment와 LexicalEnvironment는 변수나 함수와 같은 식별자를 관리하는 역할을 한다. 이 차이를 Ecma TC39 멤버 답변을 빌려 설명하자면 이렇다.

A LexicalEnvironment is a local lexical scope, e.g., for let-defined variables. If you define a variable with let in a catch block, it is only visible within the catch block, and to implement that in the spec, we use a LexicalEnvironment.

VariableEnvironment is the scope for things like var-defined variables. vars can be thought of as “hoisting” to the top of the function.

VariableEnvironment는 var로 정의된 변수에 대한 스코프를, LexicalEnvironment는 letconst로 정의된 변수에 대한 스코프를 의미한다. 즉, letconst는 LexicalEnvironment에, var는 VariableEnvironment에 바인딩 된다.

바인딩 되는 위치뿐 아니라, 초기화 과정에서도 차이가 존재한다.

var (스펙):

Var variables are created when their containing Environment Record is instantiated and are initialized to undefined when created.

A variable defined by a VariableDeclaration with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the VariableDeclaration is executed, not when the variable is created.

var 키워드로 정의된 변수는 자신이 속한 Environment Record가 초기화될 때 undefined 값을 갖고 생성되며, 이후 실제로 값이 할당되는 구문인 AssignmentExpression(=)을 마주할 때 값이 변수에 할당된다.

letconst (스펙):

The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated.

A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created.

💡 LexicalBinding: letconst 키워드를 사용하여 변수를 선언

letconst 키워드로 정의된 변수 역시 자신이 속한 Environment Record가 초기화 될 때 생성됨은 동일하다. 다만 LexicalBinding이 평가되기 전까지는 접근할 수 없으며(TDZ), AssignmentExpression을 마주할 때가 아닌 LexicalBinding이 평가될 때 값이 할당된다는 차이가 있다.

그렇다면 이제 Environment가 초기화되는 시점을 살펴보자. 이 역시 Ecma TC39 멤버 답변에 잘 설명되어 있다.

To implement this in the spec, we give functions a new VariableEnvironment, but say that blocks inherit the enclosing VariableEnvironment.

함수는 새로운 VariableEnvironment를 생성하고, 블록은 상위 VariableEnvironment를 상속받는다는 말.

LexicalEnvironment는 스펙에서 찾을 수 있다.

{ StatementList }

  1. Let oldEnv be the running execution context’s LexicalEnvironment.
  2. Let blockEnv be NewDeclarativeEnvironment(oldEnv).
  3. Perform BlockDeclarationInstantiation(StatementList, blockEnv).
  4. Set the running execution context’s LexicalEnvironment to blockEnv.
  5. Let blockValue be the result of evaluating StatementList.
  6. Set the running execution context’s LexicalEnvironment to oldEnv.
  7. Return blockValue.

블록이 평가되기 전에 블록에 대한 LexicalEnvironment를 생성하고, 블록 내 선언문을 평가한 뒤, LexicalEnvironment를 제거한다.

Reasons for ReferenceError

이제 ReferenceError가 발생하는 이유를 설명할 수 있다. 먼저 첫 번째 코드를 다시 살펴보자면,

console.log(foo); // ReferenceError: foo is not defined
let foo;

let으로 정의된 변수는 Environment Record가 초기화될 때 생성된다고 했다. 그런데 왜 정의가 되지 않았을까? 이는 JavaScript가 위에서부터 한 줄씩 읽어 내려오는 인터프리터 언어이기 때문이다. 따라서 코드는 아래와 같이 해석된다.

console.log(foo);
let foo;

foo 라는 변수가 생성조차 되지 않았었다. 따라서 ReferenceError: foo is not defined가 발생.

이제 두 번째 코드를 살펴보자.

{
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  let foo;
}

foo는 블록 내에서 선언된 변수이다. 블록 내에서 선언된 변수는 블록이 실행되기 전에 LexicalEnvironment와 함께 생성되지만, LexicalBinding이 평가되기 전까지는 접근할 수 없다. 따라서 ReferenceError: Cannot access 'foo' before initialization가 발생한다.

다시 한 번 정리

서론에서 언급한 첫 번째 예시 코드도 이제 설명이 가능하다.

const foo = 1;
{
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  const foo = 2;
}

블록 외부에서 foo가 선언되었지만, 블록 내부에서도 foo가 선언되었다. 이로 인해 블록 내부에서 foo는 LexicalBinding이 평가되기 전까지 접근할 수 없다. 따라서 ReferenceError: Cannot access 'foo' before initialization가 발생한다.

마치며

letconst는 LexicalEnvironment에 바인딩 되며, LexicalEnvironment는 블록이 실행되기 전에 생성된다. 따라서 블록 내에서 선언된 변수는 블록이 실행되기 전에 생성되지만, LexicalBinding이 평가되기 전까지는 접근할 수 없다. 이를 Temporal Dead Zone, 줄여서 TDZ라고 한다.

이처럼 JavaScript 특징으로 인해 마치 동일해 보이는 코드라도 결과가 다를 수 있다. 당연한 이야기라 생각할 수도 있겠지만, 이런 특징을 모르고 사용하다가는 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.