ReferenceError in JavaScript

ReferenceError in JavaScript

·

5 min read

TL;DR

  • letconst는 LexicalEnvironment에 바인딩 되며, LexicalEnvironment는 블록이 실행되기 전에 생성된다. 블록 내에서 선언된 변수는 블록이 실행되기 전에 변수가 생성되지만, LexicalBinding이 평가되기 전까지는 접근할 수 없다.

  • var로 선언된 변수는 VariableEnvironment에 바인딩 되며, VariableEnvironment는 함수가 새로 생성될 때마다 생성된다. 함수 내에서 선언된 변수는 함수가 실행되기 전에 변수가 생성되며, AssignmentExpression(=)을 마주할 때 값이 할당된다. 함수 내에서 선언된 변수는 함수가 실행되기 전에 접근할 수 있다.

  • JavaScript의 이러한 특징으로 인해 동일해 보이는 코드라도 결과가 다를 수 있다. 이런 특징을 모르고 사용하다가는 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.

서론

경험이 아닌 이론적인 지식을 바탕으로 글을 쓰는 것은 종종 내게 두려움을 준다. 이 글도 그렇다. 가능한 오류를 범하지 않고자 많은 자료를 찾아보고 이해한 바를 정리하려고 노력했으나, 그럼에도 불구하고 틀린 부분이 있을 수 있다. 만약 그러하다면 언제든지 지적을 바란다.

TDZ, Temporary Dead Zone 이라는 개념은 익히 들어본 적이 있을 것이다. let이나 const로 선언된 변수에 대해, 선언되기 이전에 접근하려고 하면 ReferenceError가 발생하는 것인데, 가령 아래와 같다.

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는 코드에 대한 평가 및 실행 환경을 제공하며, 실행 중인 코드의 범위에 대한 정보, 변수, 객체, 함수 등의 참조를 포함하고 있다. 이 구조를 그래프로 표현하면 아래와 같다.

💡
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), letconst는 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 특징으로 인해 마치 동일해 보이는 코드라도 결과가 다를 수 있다. 어떻게 보면 당연한 이야기라 생각할 수도 있겠지만, 이런 특징을 모르고 사용하다가는 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.