ES5까지 변수를 선언할 수 있는 유일한 방법은 var
키워드를 사용하는 것이었다. 그리고 ES6부터 let
, const
키워드가 도입됐다.
if(true) {
var test = "test"; // 전역 변수
}
console.log(test); // "test", 블록 외부에서 참조 가능
test ="changed";
console.log(test); // "changed", 블록 외부에서 재할당도 가능
test = "test";
console.log(test); // "test"
var test = "test";
console.log(test); // "test"
var test = "changed";
console.log(test); // "changed"
console.log(test); // undefined
var test = "test";
위와 같은 특징들로, 유의하지 않으면 var 키워드로 인해 심각한 오류가 발생할 수 있다. 특히, 함수 레벨 스코프를 따르기 때문에 전역 변수가 의도치 않게 변경돼 문제를 일으킬 위험이 있다. ES6는 이러한 var 키워드의 단점을 보완하기 위해 let과 const 키워드를 도입했다.
모든 코드 블록을 스코프로 인정하기 때문에 if 문, for 문 등에서 선언한 변수를 코드 블록 외부에서 참조할 수 없다.
if(true) {
let test = "test"; // 지역 변수
}
console.log(test);
// Uncaught ReferenceError: test is not defined (에러 발생)
- 함수 레벨 스코프 : 함수 내에서 선언된 변수는 함수 내에서만 유효하며 함수 외부에서는 참조할 수 없다. 즉, 함수 내부에서 선언한 변수는 지역 변수이며 함수 외부에서 선언한 변수는 모두 전역 변수이다.
- 블록 레벨 스코프 : 모든 코드 블록(함수, if 문, for 문, while 문, try/catch 문 등) 내에서 선언된 변수는 코드 블록 내에서만 유효하며 코드 블록 외부에서는 참조할 수 없다. 즉, 코드 블록 내부에서 선언한 변수는 지역 변수이다.
let test = "test";
let test = "changed";
// Uncaught SyntaxError: Identifier 'test' has already been declared (에러 발생)
모든 선언(var, let, const, function, function*, class)을 호이스팅한다. 호이스팅은 선언문을 해당 스코프의 선두로 옮긴 것처럼 동작하는 특성을 말한다.
하지만 var 키워드로 선언된 변수와는 달리 let 키워드로 선언된 변수를 선언문 이전에 참조하면 참조 에러(ReferenceError)가 발생한다. 이런 차이가 발생하는 이유는 변수 생성의 과정에서 차이가 있기 때문이다.
console.log(test);
let test = "test";
// Uncaught ReferenceError: test is not defined (에러 발생)
변수 생성 3단계
- 선언 단계 : 변수를 실행 컨텍스트의 변수 객체에 등록한다. 이 변수 객체는 스코프가 참조하는 대상이 된다.
- 초기화 단계 : 변수 객체에 등록된 변수를 위한 공간을 메모리에 확보한다. 이 단계에서 변수는 undefined로 초기화된다.
- 할당 단계 : undefined로 초기화된 변수에 실제 값을 할당한다.
var 키워드로 선언된 변수는 선언과 초기화 단계가 한번에 진행된다. 따라서 변수 선언문 이전에 변수에 접근해도 스코프에 변수가 존재하기 때문에 에러가 발생하지 않고 undefined를 반환하는 것이다. 이후 변수 할당문에 도달하면 비로소 값이 할당된다. 이러한 현상을 변수 호이스팅
이라 한다.
반면, let 키워드로 선언된 변수는 선언 단계와 초기화 단계가 분리돼 진행된다. 스코프에 변수를 등록(선언 단계)은 하지만 초기화 단계는 변수 선언문에 도달했을 때 이뤄진다. 따라서 변수 선언문(초기화) 이전에 변수에 접근하려고 하면 참조 에러가 발생한다. 변수를 위한 메모리 공간이 아직 확보되지 않았기 때문이다. 스코프의 시작 지점부터 초기화 시작(변수 선언문) 지점까지의 구간을 일시적 사각지대(Temporal Dead Zone; TDZ)라고 한다.
이렇게 보면 let, const 키워드로 선언된 변수는 호이스팅이 발생하지 않는 것과 차이가 없어보인다. 하지만 분명히 호이스팅이 이뤄진다. 아래의 예제를 보면 호이스팅이 발생하기 때문에 전역 변수가 콘솔에 출력이 되는 것이 아니라 참조 에러가 발생하는 것이다.
let test = "global test"; // 전역 변수
{
console.log(test); // Uncaught ReferenceError: Cannot access 'test' before initialization
let test = "local test"; // 지역 변수
}
블록 레벨 스코프를 지원하는 let은 var보다 직관적이다. 함수 레벨 스코프만 인정하는 var는 전역 변수로 인해 의도치 않은 실행 결과를 야기할 수 있기 때문에 클로저를 활용해야 할 때가 있기 때문이다.
var 키워드
var funcs = [];
// for 루프의 i는 전역 변수
for (var i = 0; i < 3; i++) {
funcs.push(function () { console.log(i); });
}
for (var j = 0; j < 3; j++) {
funcs[j]();
}
위 코드의 결과로 차례로 0, 1, 2를 호출하는 것이 아니라 3이 3번 호출된다. for 문의 var i는 함수 레벨 스코프를 갖기 때문에 여기서는 전역 변수로 취급되기 때문이다. 따라서 0, 1, 2를 출력하게 하려면 클로저를 활용해야 한다.
var 키워드 with 클로저
var funcs = [];
for (var i = 0; i < 3; i++) {
function outer(index) { // index는 자유 변수
funcs.push(function () {
console.log(index);
});
}
outer(i);
}
for (var j = 0; j < 3; j++) {
funcs[j]();
}
반면 for 문의 초기화 식에 let 키워드를 사용하면 클로저를 사용하지 않아도 위 코드와 동일한 동작을 한다.
let 키워드
var funcs = [];
// for 루프의 i는 지역 변수이면서 자유 변수
for (let i = 0; i < 3; i++) {
funcs.push(function () { console.log(i); });
}
for (var j = 0; j < 3; j++) {
console.dir(funcs[j]);
funcs[j]();
}
for 문의 let i는 for 루프에서만 유효한 지역 변수이다. 또한, i는 자유 변수로서 for 루프의 생명주기가 종료되어도 변수 i를 참조하는 함수가 존재하는 한 계속 유지된다.
클로저 개념 짚고 넘어가기
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.
function outerFunc() {
var x = 10; // 자유 변수
var innerFunc = function () { console.log(x); };
return innerFunc;
}
var inner = outerFunc();
inner(); // 10
위 정의에서 말하는 “함수”란 반환된 내부함수를 의미하고 “그 함수가 선언될 때의 렉시컬 환경(Lexical environment)”란 내부 함수가 선언됐을 때의 스코프를 의미한다. 즉, 클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다. 이를 조금 더 간단히 말하면 클로저는 자신이 생성될 때의 환경(Lexical environment)을 기억하는 함수다라고 말할 수 있다.
클로저에 의해 참조되는 외부함수의 변수 즉 outerFunc 함수의 변수 x를 자유변수(Free variable)라고 부른다. 클로저라는 이름은 자유변수에 함수가 닫혀있다(closed)라는 의미로 의역하면 자유변수에 엮여있는 함수라는 뜻이다.
렉시컬 환경(Lexical environment) : 현재 컨텍스트 내의 식별자들에 대한 정보와 외부 환경 정보이며, 변경 사항이 실시간으로 반영 된다.
전역 객체는 모든 객체의 유일한 최상위 객체를 의미하며, 일반적으로 브라우저에서는 window 객체, node.js에서는 global 객체를 뜻한다.
var 키워드로 선언된 변수를 전역 변수로 사용하면 전역 객체의 프로퍼티가 된다. 반면 let 키워드로 선언된 변수를 전역 변수로 사용하면 보이지 않는 개념적인 블록 내에 존재하게 되어 전역 객체의 프로퍼티가 아니게 된다.
var varTest = "var";
let letTest = "let";
console.log(window.varTest); // "var"
console.log(window.letTest); // undefined
const는 상수(변하지 않는 값)를 위해 사용한다. 하지만 반드시 상수만을 위해 사용하지는 않는다. const의 특징은 let과 대부분 동일하나 일부 차이가 있다.
let은 재할당이 자유로우나 const는 재할당이 금지된다.
const test = "test";
test = "changed";
// Uncaught TypeError: Assignment to constant variable.
또한 const는 반드시 선언과 동시에 할당이 이루어져야 한다.
const test;
// caught SyntaxError: Missing initializer in const declaration
const는 재할당이 불가하고 let은 가능하기 때문에 사용 의도에 따라 구별하여 사용함으로써 가독성과 유지보수성을 높일 수 있다.
const independenceMovementDay = "19190301";
let today = "20230428";
- var 키워드는 예기치 못한 오류를 야기하기 쉽기 때문에 사용하지 않는 것이 좋다.
- 변수를 선언할 때에는 일단 const 키워드를 사용하고 추후 반드시 재할당이 필요하다면, let 키워드로 변경하는 것이 좋다.
const 변수의 타입이 객체인 경우, 참조는 변경할 수 없으나 객체의 프로퍼티는 변경할 수 있다.
const user = { name: 'Lee' };
// 재할당 금지 (에러 발생)
user = {}; // Uncaught TypeError: Assignment to constant variable.
// 객체의 프로퍼티는 변경 가능
user.name = 'Kim';
console.log(user); // { name: 'Kim' }
참고