본문 바로가기

[자바스크립트] var, let, const의 차이를 알아보자 (feat. TDZ)

 

 

 

들어가며

자바스크립트로 개발을 하면서, 호이스팅 이라는 개념을 알고서는 개발은 참 심오하고 어렵구나 생각했던 기억이 있습니다. 변수는 분명 아래에 있는데, 왜 위에서 그 변수를 부르면 코드가 어떻게 이해할 수 있을까 신기해하곤 했습니다. 그러던 중 누군가 저에게 호이스팅에 대해 이야기하면서 TDZ에 대해 이야기한 적이 있습니다. TDZ에 대해 처음 들어본 개념이었기에, 아직 자바스크립트에 대해 제대로 모르고 있구나 라는 생각을 했습니다. 이번 기회에 이 부분을 제대로 정리해봐야겠다고 생각했습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

이상한 알 수 없는 에러는 무엇인가?

가끔 개발하다 보면 Cannot access 'X' before initialization과 같은 에러를 받아보곤 했습니다. 초기화 전에는 접근할 수 없다는 뜻인데, 여기서 초기화는 무엇일까, 왜 이런 에러가 뜨는 것일까 궁금했습니다. 

 

console.log(test); // Cannot access 'test' before initialization
let test;

 

분명 var, let, const 모두 호이스팅이 대상이 될 텐데, 그렇다면 undefined라는 결과가 나와야 하는 것이 아닌가 생각했습니다. 하지만 let과 const는 호이스팅 대상체는 맞지만, TDZ라는 특수한 영역을 사용하여 참조를 방어하는 것임을 알게 됐습니다.

 

그렇다면 TDZ는 무엇인지, var, let, const의 차이는 무엇인지 제대로 알아야겠다고 생각했습니다. 먼저 var, let, const의 차이를 살펴보면서 이에 대해 알아보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

var 키워드

var 키워드는 Javascript ES5까지 변수를 선언할 수 있는 키워드로 사용되었는데, 특징은 다음과 같습니다.

 

변수의 중복 선언이 가능하다.

var 키워드가 붙은 변수는 중복 선언이 가능합니다. 이 기능은 큰 문제가 될 수 있습니다. 

var name = 'Const';
var name = 'Const2';
console.log(name) // Const2

 

만약 name이라는 변수가 두 번 선언되었는데, 만약 첫 번째 변수와 두 번째 변수 사이에 500줄의 코드가 있다면 어디서 name이라는 변수를 사용했는지 파악하기도 힘들고, name이라는 변수를 사용했었는지조차 잊을 수 있습니다. 이런 변수의 중복 선언 허용은 의도하지 않은 변수의 변경이 일어날 가능성이 충분합니다. 

 

 

호이스팅 당한다.

호이스팅은 실행 컨텍스트 객체가 구성될 때 스코프 안에 있는 변수들의 정보를 기억하는 과정에서 스코프 안에 있는 선언들을 모두 스코프의 최상단으로 끌어올리는 것을 의미합니다. 호이스팅은 Javascript 인터프리터가 코드를 해석할 때 변수 및 함수의 선언 처리, 실제 코드 실행의 두 단계로 나눠서 처리하기 때문에 발생하는 현상인데 이게 또 굉장히 사람 헷갈리게 만듭니다.

 

console.log(name); // undefined
var name = 'Const';

 

위와 같은 코드가 호이스팅이 발생한다면 아래와 같은 방식으로 동작합니다.

 

var name; // 선언부를 제일 위로 끌어올린다.

console.log(name);
name = 'Const';

 

호이스팅이 발생하면 코드를 읽는 순서와 코드가 실행되는 순서가 달라지게 된다는 특징이 있습니다.

 

 

 

함수 레벨 스코프

var 키워드로 선언된 변수는 함수 레벨 스코프 내에서만 인정됩니다. 

 

(function () {
	var local = 1;
})();
console.log(local); // Uncaught ReferenceError: local is not defined

for (var i = 0; i < 10; i++) {}
console.log(i); // 10

 

함수 스코프만 인정되기 때문에 심지어 for 문 내부에서 선언한 변수 i 도 외부에서 참조 가능합니다.

 

 

 

var 키워드 생략 가능

변수를 선언할 때 var 키워드를 붙여도 되고 안 붙여도 됩니다. 

 

var globalVariable = 'global!';

if (globalVariable === 'global') {
	globlVariable = 'global?' // 오타 냄
}

console.log(globalVariable); // global!
console.log(globlVariable);  // global?

 

실수로 globalVariable 변수를 globlVariable 변수로 오타를 냈습니다. globalVariable 변수의 값이 global?로 변경되었으리라 기대를 하겠지만 아쉽게도 그 값은 오타 낸 변수 명인 globlVariable이 가져갔습니다. 위의 경우에서는 그나마 쉽게 문제를 찾을 수 있지만, 이 경우도 코드가 복잡해지면 문제를 찾기 쉽지 않을 수 있습니다. 

 

이런 var의 문제를 해결하기 위해서는 let과 const를 사용하는 것이 좋습니다. 그렇다면, let과 const는 어떤 특징이 있는지 알아보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

let과 const 키워드의 등장 

let 키워드는 var와 마찬가지로 변수를 선언할 때 사용하는 키워드이고 const 키워드는 상수를 선언할 때 사용하는 키워드입니다. 즉 const 키워드는 리터럴 값의 재할당이 불가능하다는 특징이 있습니다. 

 

const callConst = 'Hello, Const!';
callConst = 'Bye, Const!'; // Uncaught TypeError: Assignment to constant variable.

 

만약 const 변수에 값을 재할당하려고 한다면 위와 같은 에러가 나올 수 있습니다. 그렇다면, let과 const의 차이는 무엇일까요? 먼저 let과 const의 특징부터 살펴보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

let과 const 키워드의 특징

var 키워드의 특징과 대조되는 점부터 살펴보겠습니다.

 

1. 변수의 중복 선언이 불가능하다.

let과 const는 var 키워드와는 다르게 한번 키워드를 사용해서 선언한 변수는 재선언이 불가능하다는 특징이 있습니다.

 

var name = 'Const';
var name = 'Const2'; // 아무 일도 일어나지 않았다.

let name = 'Const';
let name = 'Const2'; // Uncaught SyntaxError: Indentifier 'name' has already been declared

const name = 'Const';
const name = 'Const2'; // Uncaught SyntaxError: Identifier 'name' has already been declared

 

이로써 var를 활용해서 변수를 두 번 선언해서 값이 변경되어 어디가 어떻게 변경됐는지 살펴보기 어려웠던 문제를 해결할 수 있습니다. 

 

 

 

2. 호이스팅 당한다?

처음 궁금증이 생겼던 부분입니다. let과 const의 경우 Javascript 인터프리터가 코드를 해석하는 과정에서 호이스팅이 발생하는데, 어떻게 호이스팅이 되지 않게 설정했을까요? 이에 대해 알아보겠습니다.

 

2-1. 변수 선언 키워드에 따라 다른 에러가 발생한다.

아래의 예시를 통해 살펴보겠습니다.

 

console.log(name); // undefined
var name = 'Const';

 

첫 번째 줄에서 name을 콘솔에 출력했습니다. var의 특징에 따라 name이 호이스팅 당하기 때문에 깔끔하게 undefined가 출력됩니다. 

 

console.log(aaa) // Uncaught ReferenceError: aaa is not defined

 

두 번째의 경우엔 aaa를 출력하려고 하는데, aaa는 처음부터 선언한 적이 없는 변수입니다. 선언하지 않은 변수를 출력하려고 한다면, Uncaught ReferenceError가 발생하고 메시지는 aaa is not defined라고 나옵니다.

 

console.log(name); // Uncaught ReferenceError: Cannot access 'name' before initialization
let name = 'Const';

 

세 번째의 경우, 콘솔 출력에서는 let 키워드를 사용하여 선언한 변수를 선언부 이전에 호출한 모습입니다. 두 번째와 마찬가지로 Uncaught ReferenceError가 발생했지만 에러 메시지는 Cannot access 'name' before initialization이라고 나옵니다. let, const도 호이스팅이 된다면, undefined가 나와야 할 텐데 왜 이런 에러가 출력되는 것일까요? 이 부분을 이해하기 위해 V8 엔진에 대해 알아보겠습니다.

 

 

2-2. V8 엔진을 뜯어보자

앞에서 두 개의 에러( Uncaught ReferenceError, Cannot access 'name' before initialization )를 살펴봤습니다. 이 두 에러는 전혀 다른 에러로, V8 엔진 내부에서 사용하는 MESSAGE_TEMPLATE 에도 엄밀히 구분되어있고 실제 호출되는 케이스도 다릅니다.

 

T(NotDefined, "% is not defined")
T(AccessedUninitializedVariable, "Cannot access '%' before initialization")

 

그렇다면, var, let, const 모두 호이스팅은 이뤄지는데, var 키워드와 let, const 키워드의 차이는 어디서 오는 것일까요? 이는 변수를 선언할 때, 즉 V8이 변수 객체를 생성할 때는 전부 동일하게 처리하지만 변수를 위해 메모리에 공간을 확보하는 초기화 단계에서 차이가 생깁니다.

 

static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
  DCHECK(IsDeclaredVariableMode(mode));
  return mode == VariableMode::kVar ? kCreatedInitialized
                                    : kNeedsInitialization;
}

 

DefaultInitializationFlag라는 함수를 통해 V8 엔진 내부에서 사용되는 VariableKind라는 타입을 반환하는데, 이때 var 키워드를 사용하여 선언한 변수는 kCreatedInitialized 값을, 그 외의 키워드인 let과 const로 선언한 변수는 kNeedsInitialization 키워드를 반환합니다. 

 

위의 경우를 통해 알 수 있는 것은, let, const 키워드로 선언한 리터럴 값은 호이스팅은 되나 특별한 이유로 인해 '초기화가 필요한 상태'로 관리되고 있다는 것입니다. 그렇다면 초기화가 필요한 상태는 무엇일까요?

 

 

2-3. 초기화가 필요한 상태는 무엇을 의미할까?

Javascript 인터프리터 내부에서 변수는 총 3단계에 걸쳐 생성됩니다.

 

선언 (Declaration) : 스코프와 변수 객체가 생성되고 스코프가 변수 객체를 참조한다.
초기화 (Initialization): 변수 객체가 가질 값을 위해 메모리에 공간을 할당한다. 이때 초기화되는 값은 undefined이다.
할당 (Assignment): 변수 객체에 값을 할당한다.

 

var 키워드를 사용하여 선언한 객체의 경우 선언과 초기화가 동시에 이뤄집니다. 즉 선언이 되지 마자 undefined로 값이 초기화된다는 것입니다. 

 

// v8/src/parsing/parser.cc
// Var 모드로 변수 선언 시
auto var = scope->DeclareParameter(name, VariableMode::kVar, is_optional,
                                         is_rest, ast_value_factory(), beg_pos);
var->AllocateTo(VariableLocation::PARAMETER, 0);

 

V8 엔진의 코드를 보면 kVar 모드로 변수 객체를 생성한 후 바로 AllocateTo 메서드를 통해 메모리에 공간을 할당하는 모습을 볼 수 있습니다. 하지만 let이나 const 키워드로 생성한 변수 객체는 다릅니다.

 

// v8/src/parsing/parser.cc
// kLet 모드로 변수 선언 시
VariableProxy* proxy =
      DeclareBoundVariable(variable_name, VariableMode::kLet, class_token_pos);
proxy->var()->set_initializer_position(end_pos);

// Const 모드로 변수 선언 시
VariableProxy* proxy =
          DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());

 

kLet 모드나 kConst 모드로 생성한 변수 객체들은 AllocateTo 메서드가 바로 호출되지 않았고 대신 소스 코드 상에서 해당 코드의 위치를 의미하는 position값만 정해주는 것을 볼 수 있습니다.

 

바로 이 타이밍에 let 키워드나 const 키워드로 생성된 변수들이 TDZ(Temporal Dead Zone) 구간에 들어가는 것입니다. 즉, TDZ 구간에 있는 변수 객체는 선언은 되어있지만 아직 초기화가 되지 않아 변수에 담길 값을 위한 공간이 메모리에 할당되지 않은 상태라고 할 수 있습니다. 이때 해당 변수에 접근을 시도하면 얄짤없이 Cannot access '%' before initialization 에러 메시지를 만나게 되는 것입니다. 

 

 

 

 

 

3. 블록 레벨 스코프를 사용한다

함수 레벨 스코프를 사용하는 var 키워드와 다르게 let과 const는 블록 레벨 스코프를 사용합니다. var의 경우에는 블록 레벨 스코프를 사용하지 않기 때문에 블록 내부에서 선언한 변수와 전역 변수를 둘 다 접근할 수 있습니다. 

 

var globalVariable = 'I am global';

if (globalVariable === 'I am global') {
  var globalVariable = 'am I local?';
}

console.log(globalVariable); // am I local?

 

그러나 let과 const 키워드의 경우에는 블록 내부에서 선언한 변수는 지역 변수로 취급되기 때문에 외부 스코프에서 내부 스코프의 변수에 접근할 수 없습니다. 

 

let globalVariable = 'I am global';

if (globalVariable === 'I am global') {
  let globalVariable = 'am I local?';
  let localVariable = 'I am local';
}

console.log(globalVariable); // I am global
console.log(localVariable); // Uncaught ReferenceError: localVariable is not defined

 

이 경우 블록 내부에서 선언된 localVariable은 지역 변수로 취급되어 전역 스코프에서는 참조가 불가능합니다. 참고로 let const는 호이스팅도 블록 단위로 발생합니다.

 

 

 

 

 

 

 

4. let, const 키워드는 생략이 불가능하다

name = 'Const'
// 상기 코드는
var name = 'Const'
// 과 같다

 

만약 변수 선언 키워드를 사용하지 않으면 var 키워드를 사용한 것으로 취급되기 때문에  let과 const를 사용하려면 반드시 키워드를 변수 앞에 작성해야 합니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

const 키워드의 특징

지금까지 var와 let, const에 대해 큰 차이에 대해 알아봤다면, 이번에는 const에 대해 알아보겠습니다.

 

 

 

1. 상수를 선언할 때 사용한다

const는 상수를 선언할 때 사용하는 키워드입니다. 상수는 말 그대로 어떠한 불변 값을 의미합니다. 만약 const 키워드를 사용하여 값을 할당하면 두 번 다시 변경할 수 없습니다.

const maxCount = 30;
maxCount = 40; // Uncaught TypeError: Assignment to constant variable.

 

만약 const 키워드로 선언한 값을 재할당하려고 시도하면 친절한 에러 메시지와 함께 불가능하다고 알려줍니다. 하지만 여기에 중요한 점이 있습니다. 바로 Call by reference 호출 방식을 사용하는 타입을 const 키워드로 선언했을 때입니다.

 

const obj = { name: 'Const' }
obj = { name: 'Park' } // Uncaught TypeError: Assignment to constant variable.

 

이 경우 당연히 obj 변수가 바라보는 값 자체의 참조를 변경하려고 했기 때문에 에러가 발생합니다. 그러나 객체 내부의 프로퍼티들은 const 키워드의 영향을 받지 않습니다.

 

const obj = { name: 'Const' }
obj.name = 'Park'
console.log(obj) // { name: 'Park' }

 

이건 Call by reference 호출 방식을 사용하는 다른 타입인 Array도 마찬가지입니다. const 키워드를 사용하여 선언했더라도 push나 splice 등으로 배열 내부의 원소를 변경하는 행위에는 아무런 제약이 없습니다.

 

 

 

2. 반드시 선언과 동시에 초기화해줘야 한다

let 키워드의 경우 명시적으로 선언만 했더라도 인터프리터가 해당 코드 라인을 해석함과 동시에 묵시적으로는 undefined가 할당되며 초기화됩니다.

 

let hi;
console.log(hi); // undefined

 

그러나 const 키워드를 사용하는 경우 반드시 선언과 동시에 값을 할당해줘야 한다는 특징이 있습니다.

 

const hi; // Uncaught SyntaxError: Missing initializer in const declaration

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

class에서의 TDZ

위에서 var, let, const에 대한 특징들을 살펴봤고, TDZ에 대해 살펴봤습니다. 그렇다면, class 구문을 사용할 때 TDZ는 어떻게 동작하는지에 대해 알아보겠습니다. 먼저 아래와 같은 코드가 있다면, 어떻게 동작할까요?

 

const myNissan = new Car('red');

class Car {
  constructor(color) {
    this.color = color;
  }
}

 

정답은 에러가 뜹니다. Car라는 클래스를 선언하지 않은 상태로 클래스의 인스턴스를 생성하려고 하면 에러가 출력됩니다. 만약 인스턴스를 생성하려면, 클래스를 선언한 후에 사용하도록 수정해야 합니다. 

 

 

class Car {
  constructor(color) {
    this.color = color;
  }
}

const myNissan = new Car('red');
myNissan.color;

 

그렇다면, 부모 클래스로부터 상속받은 자식 클래스의 생성자 함수 안에서 super()를 호출하기 전에 this를 사용한다면 어떤 결과가 나올까요?

 

class MuscleCar extends Car {
  constructor(color, power) {
    this.power = power;
    super(color);
  }
}

const myCar = new MuscleCar(‘blue’, ‘300HP’);

 

여기서는 클래스를 선언한 후에, 인스턴스를 생성했으니 정상적으로 작동될 것 같습니다. 하지만 여기서도 에러가 출력됩니다. 왜냐하면 생성자 안에서 super()를 호출하기 전까지 this 바인딩은 TDZ에 있습니다. 그러므로 생성자 함수 안에서 super()가 호출되기 전까지 this를 사용할 수 없게 됩니다.

 

class MuscleCar extends Car {
  constructor(color, power) {
    super(color);
    this.power = power;
  }
}

// Works!
const myCar = new MuscleCar('blue', '300HP');
myCar.power; // => '300HP'

 

TDZ는 인스턴스를 초기화하기 위해 보무 클래스의 생성자를 호출할 것을 제안합니다. 즉 부모 클래스의 생성자를 호출하고 인스턴스가 준비되면 자식 클래스에서 this 값을 사용할 수 있습니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

마치며

이번 글을 정리하면서 TDZ에 대해, 그리고 var, let, const의 차이에 대해 보다 제대로 이해할 수 있었습니다. 아무리 어려운 문제라도 시간을 갖고 공부하다 보면, 언젠가는 이해하고 넘어갈 수 있는 날이 올 것이라 믿습니다. 앞으로도 꾸준히, 지속해서 공부할 수 있는 개발자가 되고 싶습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

출처

 

JavaScript의 let과 const, 그리고 TDZ

이번 포스팅에서는 JavaScript ES6에서 추가되었던 과 키워드에 대해서 자세히 포스팅하려고 한다. 부끄럽지만 지금까지 필자는 과 는 호이스팅이 되지 않는다고 생각하고 있었다. 하지만 얼마 전

evan-moon.github.io

 

TDZ을 모른 채 자바스크립트 변수를 사용하지 말라

간단한 질문을 하나 하겠다. 아래 코드 스니펫에서 에러가 발생할까? 첫 번째 코드는 인스턴스를 생성한 다음 클래스를 선언한다.

ui.toast.com