ES6가 나오기 전까지 JS에서 변수를 선언하기 위해서는 var를 사용했습니다. var는 다음과 같은 특징을 갖고 있는데, 이 특징들로 예상치 못한 issue가 생기기도 합니다.
- 함수 레벨 scope(Function-level scope)
- var 키워드 생략 가능
- 전역 객체 property 생성
- 중복 선언 가능
- hoisting
ES6에서 let과 const가 도입되면서 var을 사용하면서 생기는 문제점들을 보완했습니다.
[목차]
1. var
2. let과 const
3. Hoisting
1. var
1 - 1. 함수 레벨 scope
함수 안에서 var을 사용해 변수를 선언하면 그 변수는 선언된 함수 안에서만 사용가능합니다. 함수 밖에서 var을 사용한다면, 선언된 변수는 global scope를 갖습니다.
함수 블록 안에서 선언된 변수를 제외한 모든 변수가 전역변수가 되기 때문에, for 문 initialization에서 선언한 변수에 외부에서 접근할 수도 있습니다.
for (var i = 0; i < 10; i++) {
console.log(i);
}
console.log(i) // 10
1 - 2. var 키워드 생략 가능
var 키워드를 생략한 변수들은 항상 전역변수가 되고, 실행 컨텍스트에 저장되지 않기 때문에 코드가 실행되기 전에는 접근할 수 없습니다. strict mode에서 var 키워드를 생략하면 RefereneceError가 발생합니다.
[JS] 실행 컨텍스트(Execution Context) 더 알아보기
var 키워드를 생략한 x 는 전역변수입니다.
function foo() {
x = 10;
var y = 20;
}
foo();
console.log(x); // 10
var 키워드를 생략한 b는 b = 20; 코드가 실행되기 전까지는 존재하지 않습니다.
console.log(a); // undefined
var a = 10;
console.log(b); // ReferenceError
b = 20;
1 - 3. 전역 객체 property 생성
전역 scope에서 var을 사용한 경우 변수는 전역 객체(브라우저의 경우 window)의 non-configurable property로 저장됩니다. 반면에 var을 생략한 경우 변수는 전역 객체의 property로 저장됩니다. 따라서 var을 생략한 경우에는 delete 연산자를 이용해서 삭제될 수 있는 반면, var을 사용한 경우 삭제되지 않습니다.
var a = 10;
b = 20;
console.log(window.a, window.b); // 10 20
delete window.a; // strict mode에서는 TypeError
delete window.b;
console.log(window.a); // 10
console.log(window.b); // undefined
1 - 4. 중복 선언 가능
중복 선언으로 의도하지 않은 변수값의 변경이 일어날 수 있습니다.
var x = 10;
var x = 20;
1 - 5. Hoisting
var을 사용한 변수는 선언문 전에 접근 가능합니다.
console.log(x); // undefined
var x = 10;
console.log(x); // 10
위에서 설명한 var의 특징들이 개발자가 의도하지 않은 문제들을 야기했습니다. ES6에서 var의 문제를 해결하기 위해서 새로운 키워드인 let과 const를 도입했습니다.
2. let과 const
2 - 1. 블록 레벨 scope(Block-level scope)
var는 if문, for문, while문, try/catch문 등의 블록 안에서 선언한 변수도 전역 변수로 간주하는 반면에 let과 const는 지역 변수로 간주합니다. let과 const로 선언한 이 지역 변수는 선언된 코드 블록{ } 내에서만 접근가능하며 블록 외부에서는 참조할 수 없습니다. 이를 통해서 전역 변수를 남발할 가능성을 낮춰, 예상치 못한 오류 발생 가능성을 줄여줍니다.
for (let i = 0; i < 10; i++) {
console.log(i);
}
console.log(i) // ReferenceError
var에서 사용한 예시를 그대로 사용해 보면 이번에는 error가 나오게 됩니다.
참고) ECMA-262 6th에서 "let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment."라고 설명하고 있습니다. LexicalEnvironment는 { } 블록문이 실행될 때 생기고, { } 블록문 안에서 선언된 변수와 함수에 대한 정보를 포함합니다. { } 블록문이 실행될 때 생긴 LexicalEnvironment의 scope가 그 블록이므로, let과 const가 블록 레벨 scope를 갖게 됩니다.
[JS] 실행 컨텍스트(Execution Context) 더 알아보기
2 - 2. 전역 객체의 property 생성 X
var와 달리 let과 const로 선언한 변수는 전역 객체의 property로 할당되지 않습니다.
2 - 3. 중복 선언 불가능
var과 같이 let으로 선언된 변수는 재할당(reassignment) 할 수 있지만, const로 선언된 변수는 재할당 할 수 없습니다. 하지만 같은 scope 안에서 let과 const 모두 변수를 중복 선언할 수는 없습니다. 이를 통해 var에서 발생하던 의도치 않은 변수값 변경을 막을 수 있습니다.
const로 변수를 선언할 때는 선언과 할당을 동시에 해야 합니다. const 변수를 나중에 업데이트 할 수 없기 때문입니다. 하지만 이것이 const가 참조하는 값이 immutable하다는 것을 의미하지는 않습니다. 재할당을 통해 변수가 참조하는 메모리 주소를 변경할 수 없다는 의미입니다. 예를 들어, const로 객체를 선언한 경우 변수에 다시 할당하는 것은 불가능 하지만, 객체의 property를 바꿀 수는 있습니다.
const obj = {
a: 10,
b: 20,
}
obj = {
a: 10,
b: 30,
} // TypeError
obj.b = 30;
console.log(obj) // {a: 10, b: 30}
2 - 4. Hoisting
var과 같이 let과 const로 선언한 변수도 hoisting됩니다. 하지만, var에서 사용한 예시를 그대로 사용해보면 아래 코드에서처럼 ReferenceError가 발생합니다.
console.log(x); // ReferenceError
let x = 10;
console.log(x);
에러가 발생하는 이유는 var에서와 let, const에서 hoisting이 발생하는 방식에 차이가 있기 때문입니다. 이에 대해서는 3. Hoisting 에서 자세히 다루겠습니다.
3. Hoisting
위에서 본 것처럼 let과 const에서는 hoisting이 일어나지 않는 것처럼 보입니다. 하지만 let과 const에서도 hoisting은 일어납니다.
let a = 10;
let b = 20;
let c = 30;
{
console.log(a); // 10
}
{
console.log(b); // ReferenceError: Cannot access 'b' before initialization
console.log(c); // ReferenceError: Cannot access 'c' before initialization
let b = 50;
const c = 100;
}
위 코드에서 두 번째 코드 블럭을 보면 let 변수 b와 const 변수 c가 블록 레벨 scope에서 hoisting이 되어, console.log() 함수에서 ReferenceError가 발생하는 것을 볼 수 있습니다. Hoisting이 일어나지 않았다면, console.log에서 20과 30이 출력되었을 겁니다.
let과 const에서 hoisting이 일어남에도 불구하고 var에서와 달리 선언 전에 변수에 접근할 수 없는 이유는 let과 const로 선언한 변수가 블록의 시작(스코프의 맨 위)에서부터 변수의 선언부까지 Temporal Dead Zone(TDZ)에 빠지기 때문입니다.
3 - 1. Temporal Dead Zone(TDZ)
TDZ은 scope의 시작 지점부터 변수가 초기화될 때까지의 구간을 의미하고, TDZ에서는 변수에 접근해 사용할 수 없습니다. 변수가 초기화되면 식별자와 변수 값의 메모리 주소가 매핑되어 그 변수에 정상적으로 접근할 수 있습니다. 변수 초기화와 동시에 변수는 TDZ 밖으로 나오게 됩니다.
TDZ은 코드의 작성 순서가 아니라 코드의 실행 순서와 연관이 있습니다. 아래 코드에서 bar 변수 선언문 전에 bar 변수를 사용하는 코드가 있는데, 코드 실행 순서 상 TDZ 밖에서 bar 변수를 사용하기 때문에 문제 없이 잘 실행 됩니다.
{
// 스코프의 맨 위에서 TDZ 시작
function foo () {
console.log(bar);
}
let bar = 10; // bar 변수 초기화로 TDZ 종료
foo(); // TDZ 종료 후에 foo 함수를 호출 => TDZ 종료 후 bar 변수에 접근
}
let과 const는 변수의 선언문을 지나면서 초기화되고 TDZ 밖으로 나옵니다. var의 경우 변수 선언문 이전에도 정상적으로 접근할 수 있었습니다. 이를 통해 유추할 수 있는 사실은 var 변수는 선언문 이전에 이미 초기화가 된다는 것입니다. 정말로 그럴지는 variable lifecycle을 통해서 알아보겠습니다.
3 - 2. Variable Lifecycle
JS 엔진은 변수를 다음과 같은 단계로 처리합니다.
- Declaration phase(선언 단계): scope에 식별자를 등록합니다. 선언 단계 이후에 변수는 uninitialized 상태입니다.
- Initialization phase(초기화 단계): 메모리를 할당하고 식별자에 메모리 주소를 연결합니다. 이 과정에서 변수는 자동적으로 undefined로 초기화됩니다.
- Assignment phase(할당 단계): 초기화된 변수에 값을 할당합니다.
앞에서 TDZ은 scope의 시작 지점부터 변수가 초기화될 때까지의 구간이라고 했습니다. 더 자세하게 설명하자면 TDZ는 Declaration phase와 Initiation phase 사이의 구간입니다.
※ 참고) Variable Lifecylcle에서 declaration phase는 변수 선언(variable declaration)과는 다른 용어입니다. JS 엔진이 variable cycle의 세 단계를 지나면서 변수를 선언한다고 할 수 있습니다.
var variable lifecycle
Step 1) var는 scope가 시작할 때 선언 단계와 초기화 단계가 한번에 이루어집니다. 따라서 scope 상에서 var 변수 선언이 어디 있는지와 상관없이 var 변수는 undefined 값으로 초기화 됩니다. 이것이 변수 선언문 전에 var 변수에 접근하고 사용할 수 있는 이유입니다.
Step 2) 코드가 진행되면서 변수 선언문을 만나게 되면, 할당 단계가 진행되면서 undefined로 초기화된 var 변수에 값을 할당하게 됩니다. 이 단계를 지나면서 initialized 상태의 변수가 assiged 상태로 변합니다.
function foo() {
// Declaration and Initialization phases
console.log(variable); // undefined
var variable = 'value'; // Assignment phase
console.log(variable); // value
}
foo();
var에서는 선언 단계와 초기화 단계가 동시에 일어나기 때문에 변수는 uninitialized 상태를 갖지 않습니다.
노란색 박스는 실행 컨텍스트 생성의 어떤 단계에서 variable lifecycle 단계가 실행되는지를 보여줍니다.
var 변수는 JS 엔진이 코드를 실행하기 전인 creation phase에서 식별자 정보를 모을 때부터 undefined 값으로 초기화됩니다. 그렇기 때문에 실제로 코드를 실행하는 execution phase에서 변수 선언문 전에 사용할 수 있게 됩니다. 코드를 진행하며 변수 선언문을 만나게 되면 JS 엔진은 변수에 새로운 값을 할당합니다.
let and const variable lifecycle
let과 const는 선언 단계과 초기화 단계가 분리되어 진행됩니다. 초기화 단계는 변수 선언문에 도달했을 때 이루어집니다. var과 달리 초기화가 바로 이뤄지지 않아 let과 const는 TDZ에 빠지게 되는 것입니다. 아직 메모리 할당이 이루어지지 않았기 때문에 초기화 전에 let과 const에 사용하려고 하면 ReferenceError가 발생하게 됩니다.
▶ let
Step 1) Scope가 시작할 때, let 변수는 할당 단계를 지나면서, scope에 식별자를 등록합니다. 이 때, 변수는 TDZ에 위치하고, 변수의 상태는 uninitialize입니다.
Step 2) Interpreter가 let 변수 선언문에 도달하면 초기화 단계가 시작되고, 변수가 undefined로 초기화됩니다. 동시에 변수는 TDZ에서 벗어납니다. 이제 변수의 상태는 initialized 상태입니다.
Step 3) 코드를 계속 실행하다가 interpreter가 let 변수에 할당문을 발견하면, 할당 단계를 거처서 새로운 값을 변수에 할당합니다. 변수의 상태는 assigned 가 됩니다.
변수 선언과 할당을 동시에 하는 경우(e.g. let variable = 'value'), 초기화 단계와 할당 단계가 동시에 일어납니다.
{
// Declaration phase
console.log(variable1); // ReferenceError
let variable1; // Initialization phase
console.log(variable1); // undefined
variable1 = 'value'; // Assignment phase
console.log(variable1); // value
}
{
// Declaration phase
console.log(variable2); // ReferenceError
let variable2 = 'value'; // Initialization and Assignment phases
console.log(variable2); // value
}
let의 경우 변수에 할당이 이뤄지지 않는 경우 undefined 값을 갖게 됩니다.
▶ const
Step 1) Scope가 시작할 때, const 변수는 할당 단계를 지나면서, scope에 식별자를 등록합니다. 이 때, 변수는 TDZ에 위치하고, 변수의 상태는 uninitialize입니다.
Step 2) Interpreter가 const 변수 선언문에 도달하면 초기화 단계와 할당 단계가 동시에 진행됩니다. 동시에 변수는 TDZ에서 벗어납니다. 초기화와 할당이 동시에 일어나므로 변수의 상태는 assigned 상태가 됩니다.
{
// Declaration phase
console.log(variable); // ReferenceError
const variable = 'value'; // Initialization and Assigned phase
console.log(variable); // value
}
const는 초기화 단계와 할당 단계가 동시에 이뤄집니다. 이는 const는 재할당이 불가능하기 때문입니다.
노란색 박스는 실행 컨텍스트 생성의 어떤 단계에서 variable lifecycle 단계가 실행되는지를 보여줍니다.
let과 const는 hoisting 되면 TDZ에 빠지고, 변수는 uninitialized state가 됩니다. 이 시점에서 LexicalEnvironment가 식별자 정보를 수집해서 식별자가 존재한다는 것은 알고 있지만, 아직 메모리 상에서 식별자가 참조하는 정보가 없기 때문에 ReferenceError가 발생하게 됩니다.
3 - 3. TDZ와 typeof
선언하지 않은 변수에 typeof를 사용하면 undefined 값을 반환합니다. 반면에, TDZ 안에 있는 변수에 typeof를 사용하면 ReferenceError가 발생합니다.
console.log(undeclaredVariable); // undefined
console.log(TDZVariable); // ReferenceError
let TDZVariable = 'TDZ';
Hoisting은 var, let, const 변수에서 뿐만 아니라 class, module import, function declaration, default function parameter 등에서도 일어나는 현상입니다. 자세한 내용은 이 글과 이 글에서 확인할 수 있습니다.
※ 참고) 함수 선언의 경우 scope의 처음 부분에서 선언 단계, 초기화 단계, 할당 단계가 동시에 일어납니다. 따라서 scope 내에서 함수 선언부의 위치와 상관없이 어디서는 함수를 호출할 수 있습니다.
TDZ를 통해서 변수가 선언되기 전에 접근하는 것을 제한할 수 있습니다. 따라서 좋은 코드를 만들기 위해서 TDZ에 영향을 받지 않는 var보다는 let과 const를 사용하는 것이 좋을 것 같습니다. 또한 TDZ로 인한 issue가 생기지 않도록 let과 const 변수를 scope의 최상위에서 선언하는 것이 안전합니다.
References
https://262.ecma-international.org/6.0/#sec-declarations-and-the-variable-statement
https://www.freecodecamp.org/news/var-let-and-const-whats-the-difference/
https://dmitripavlutin.com/variables-lifecycle-and-why-let-is-not-hoisted/
https://excellencetechnologies.in/blog/javascript-variable-scope-and-lifecycle/
https://www.freecodecamp.org/news/what-is-the-temporal-dead-zone/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const
'개발' 카테고리의 다른 글
[JS] 데이터 타입(Data type) - 복사, 불변성과 가변성 (0) | 2022.01.12 |
---|---|
[JS] 메모리와 데이터 (0) | 2022.01.10 |
[JS] Event Delegation(이벤트 위임) (0) | 2022.01.06 |
[JS] Event Bubbling과 Capturing (0) | 2022.01.06 |
[JS] 실행 컨텍스트(Execution Context) (0) | 2021.10.23 |
댓글