들어가며
최근 자바스크립트를 공부한 사람이라면 변수를 선언할 때 let
과 const
키워드 익숙할 것이다. 하지만 2015년 Javascript ES6(이하 ES6)
가 발표되기 전까지 많은 자바스크립트 프로그램은 var
키워드를 사용하여 변수를 선언했다.
그리고 시간이 지나 ES6
가 등장하면서 우리가 사용하는 let
과 const
가 새롭게 추가되었다. 자바스크립트는 이미 변수 선언 키워드가 있음에도 불구하고 왜 새로운 키워드를 추가했을까?
이번 포스팅에서는 각 키워드의 특징과 호이스팅, TDZ에 대해 알아보려고 한다.
var 키워드
var
키워드는 ES6
가 등장하기 전까지 자바스크립트에서 변수를 선언하기 위해 사용된 키워드다. var
키워드는 몇가지 특이한 특징을 가지고 있으며, 이러한 특징들 때문에 개발자들이 의도치 않은 코드 동작을 자주 경험했다.
아니 이게 왜 동작하는데..
키워드 생략
변수를 선언할 때 var
키워드는 생략할 수 있다. 아래 코드를 보면 var
키워드를 사용한 a
변수와 사용하지 않은 b
변수가 모두 정상적으로 동작하는 것을 확인할 수 있다.
var a = 10;
b = 20;
console.log(a); // 10
console.log(b); // 20
중복 선언
var
키워드를 사용해 선언한 변수는 재선언이 가능하다. 일반적으로 동일한 변수명으로 변수를 다시 선언하는 경우는 드물겠지만, 전역에서 사용하는 변수라면 상황이 달라질 수 있다. 만약 우리가 전역에서 선언해둔 변수명을 잊어버리고 동일한 변수명으로 새로운 변수를 선언한다면, 의도치 않은 동작으로 이어질 수 있다.
전역 변수 사용은 지양하자
특히 윈도우 객체에 동일한 이름의 프로퍼티가 들어간다고 생각해보자.(생각만 해도 어지럽다)
var value = 10;
console.log(value); // 10
var value = 20;
console.log(value); // 20
함수 레벨 스코프
혹시 스코프라는 용어를 들어본적이 있는가? 스코프를 우리말로 번역하면 범위
라는 뜻을 가진다.
var
키워드는 함수 레벨 스코프를 사용한다. 아래 코드에는 main()
함수 내부에서 선언된 변수 value
가 있으며, 전역에 선언된 value
도 존재한다. 함수 바깥에서 value
에 접근하니 10
이 출력되는 모습을 확인할 수 있다.
즉, var
키워드로 선언된 변수는 함수 레벨 스코프에서만 사용 가능하다.
var value = 1;
function main() {
var value = 2;
}
console.log(value); // 1
함수 레벨 스코프를 사용하기 때문에 아래와 같이 블록 내부에서 변수를 선언했다면, 나중에 할당된 20
의 값이 출력된다.
var value = 10;
if (true) {
var value = 20;
}
console.log(value); // 20
if문과 for문은 함수가 아닌 블록이다.
let, const 키워드
2015년 ES6
가 등장하면서 우리가 자주 사용하게 된 let
과 const
키워드가 새롭게 추가되었다. 이 두 키워드는 var
키워드에서 발생했던 문제들을 해결할 수 있는 기능들을 갖추고 있다.
참고로 2015년 인터뷰에서 자바스크립트 창시자는 var
키워드가 잘못되었다고 인정한 바 있다.
"If I could go back in time, I would have designed JavaScript differently. var was a quick decision for dynamic scoping, but over time, we've seen the issues it can cause. That's why we now have let and const."
(번역: "시간을 되돌릴 수 있다면 JavaScript를 다르게 설계했을 것입니다. var는 동적 스코핑을 위해 빠르게 결정된 것이었지만, 시간이 지나면서 그로 인해 발생하는 문제들을 알게 되었습니다. 그래서 이제는 let과 const를 사용하게 된 것입니다.")
재선언 불가능
let
과 const
로 선언된 변수는 동일한 변수명으로 재선언이 불가능하다.
let value = 10;
let value = 20;
// SyntaxError: Identifier 'value' has already been declared
블록 레벨 스코프
let
과 const
키워드는 블록 레벨 스코프를 사용한다. 따라서 블록 내부에서 선언된 변수는 블록 외부에서 접근할 수 없다.
let value = 10;
if (true) {
let value = 20;
console.log(value); // 20
}
console.log(value); // 10
let
과 const
키워드는 블록 레벨 스코프를 사용한다고 했다. 즉 코드 평가 단계에서 변수 선언이 블록 레벨 내 최상단으로 일어난다. 예시 코드를 작성해보면 아래와 같다.
let value;
value = 10;
if (true) {
let value;
value = 20;
console.log(value); // 20
}
console.log(value); // 10
선언과 할당
let
키워드는 선언과 초기화를 동시에 하지 않아도 된다. 즉 아래와 같은 코드는 유효한 코드이다.
let value;
value = 20;
하지만 const
키워드는 반드시 선언과 동시에 초기화를 함께 해야한다.
const value1; // SyntaxError: Missing initializer in const declaration
const value2 = 20;
상수 선언
const
키워드를 사용하면 상수를 선언할 수 있다. 상수란 무엇인가?
위대한 위키백과에 아래와 같이 설명되어 있다.
수학에서 상수란 그 값이 변하지 않는 불변량으로, 변수의 반대말이다.
즉 변하지 않는 값인 상수는 const
키워드를 이용해 선언할 수 있다. 그렇기에 상수로 선언된 값을 재할당하려고 하면 에러가 발생한다.
const a = 10;
a = 20; // Uncaught TypeError: Assignment to constant variable.
하지만 Array
, Object
와 같이 Call by Reference
호출 방식을 사용하는 타입은 내부 프로퍼티 변경이 가능하다.
주의할 점은 참조를 변경하려고 한다면 IDE가 (거품을 물고) 안된다고 에러 문구를 보여줄 것이다.
// 참조 변경
const obj1 = { value: 10 };
obj1 = { value: 20 }; // Uncaught TypeError: Assignment to constant variable.
// 객체 프로퍼티 변경
const obj2 = { value: 10 };
obj2.value = 30;
console.log(obj); // { value: 30 }
호이스팅
자바스크립트는 인터프리터 언어로 알려져 있다. 인터프리터 언어라면 우리가 작성한 코드는 위에서 아래로 실행되어야 한다. 하지만 자바스크립트는 선언문을 스코프 내에 최상단으로 올리는 호이스팅
이라는 개념이 존재한다. 자세한 내용은 아래 각 키워드별 호이스팅
작동과 함께 알아보자.
var의 호이스팅
var a = 10;
console.log(a);
console.log(b);
var b = 20;
먼저 a
변수를 선언과 동시에 10
으로 초기화한 후 출력했다. 그 다음 b
변수를 출력한 후에 b
변수를 선언했다.
우리가 일반적으로 알고 있는 인터프리터 언어라면 b
변수를 선언하기 전에 참조하려고 했으니 에러가 발생할 것이라고 생각할 수 있다. 하지만 우리의 자바스크립트는 console.log(b)
의 값으로 undefined
출력된다.(아니 인터프리터 언어라면서) 이렇게 동작하는 이유는 다음과 같다.
자바스크립트의 모든 선언문은 런타임 이전에 코드 평가 과정에서 실행된다.
즉, 자바스크립트는 코드 평가 단계에서 선언문을 스코프 내 최상단으로 끌어올린다. 이 끌어 올리는 것을 호이스팅
이라고 한다.
호이스팅
이후 선언문을 제외한 코드를 한 줄씩 순차적으로 실행하게 된다.
// 변수 선언이 끌어올려짐
var a;
var b;
// 변수 선언 이후 초기화와 실행
a = 10;
console.log(a); // 10
console.log(b); // undefined (초기화는 아직 이루어지지 않음)
b = 20;
그런데 변수 선언이 끌어올려진다면 아직 메모리에는 아무 값도 들어있지 않다고 예상할 수 있다. 하지만 var
키워드로 선언된 변수는 선언과 동시에 undefined
로 초기화 되기 때문에 참조 에러가 발생하지 않는다.
let, const의 호이스팅
let
과 const
로 선언한 변수는 var
키워드로 선언한 변수와 다르게 동작한다.
console.log(value); // ReferenceError: Cannot access 'value' before initialization
let value = 10;
위 코드는 value
를 선언 및 할당하기 전에 참조하는 코드를 작성한 것이다. var
키워드로 선언한 변수는 undefined
를 출력했지만, let
키워드로 선언된 변수에 접근하려고 하면 초기화 전에는 접근할 수 없다는 참조 에러가 발생한다.
이렇게 참조 에러가 발생하는 구역, 즉 참조가 불가능한 영역을 TDZ(Temporal Dead Zone)
라고 한다.
여기까지 잘 읽었다면 누군가에게 호이스팅
이 무엇인지, TDZ
가 무엇인지에 대해 간략하게 설명할 수 있는 수준이 되었을 것이다.
여기서 필자는 한 단계 더 나아가 보려고 한다. 자바스크립트를 해석하고 실행하는 v8
엔진에서는 var
, let
, const
키워드를 어떤 방식으로 선언하고 초기화 하는지 알아보자.
v8
v8
엔진은 c++
로 작성되어 있다. 하지만 필자는 c++
을 사용할 줄 모르기 때문에 gpt와 함께 v8
엔진 코드를 분석해 보았다.
변수 선언
먼저 변수 선언의 시작은 Parser::DeclareVariable
함수에서 처리된다.
// /src/parsing/parser.cc
Variable* Parser::DeclareVariable(const AstRawString* name, VariableKind kind,
VariableMode mode, InitializationFlag init,
Scope* scope, bool* was_added, int begin,
int end) {
Declaration* declaration;
if (mode == VariableMode::kVar && !scope->is_declaration_scope()) {
DCHECK(scope->is_block_scope() || scope->is_with_scope());
declaration = factory()->NewNestedVariableDeclaration(scope, begin);
} else {
declaration = factory()->NewVariableDeclaration(begin);
}
Declare(declaration, name, kind, mode, init, scope, was_added, begin, end);
return declaration->var();
}
DeclareVariable
함수는 VariableMode
타입의 mode
라는 매개변수를 받고 있다. 필자가 확인한 바로는 mode
의 값으로 kVar
, kLet
, kConst
를 사용할 수 있다. 즉, 모드에 따라 변수를 선언하는 키워드를 분기하는 로직이 있을 것이라고 예상된다.
추가로, 필자는 왜 접두사로 k
를 붙였는지 궁금해서 gpt에게 물어봤는데, c++ 코드에서 enum
또는 상수
값의 식별자로 k
를 붙이는 것이 관례라고 한다. (지피티 피셜이니 맹신하지는 말자)
다음 흐름으로 넘어가보자. 조건문에서는 변수를 선언할 때 kVar
모드이며 선언 스코프
가 아닌지 확인한다. 선언 스코프
란 함수 스코프
를 의미한다. 이후 mode
값에 따라 NewNestedVariableDeclaration
함수를 사용할 것인지, NewVariableDeclaration
함수를 사용할 것인지 결정하게 된다.
// /src/ast/ast.h
NestedVariableDeclaration* NewNestedVariableDeclaration(Scope* scope,
int pos) {
return zone_->New<NestedVariableDeclaration>(scope, pos);
}
VariableDeclaration* NewVariableDeclaration(int pos) {
return zone_->New<VariableDeclaration>(pos);
}
분기처리 후 사용하는 함수가 정의되어 있는 헤더 파일을 살펴보면, 두 함수의 공통 부분에서 zone_
이라는 키워드가 보인다.
gpt에 따르면, 이는 효율적인 메모리 할당을 위해 사용되는 키워드라고 한다.
두 함수의 차이점으로는 매개변수 scope
의 유무가 있다. kVar
모드의 변수는 상위 스코프와 연결되어야 하기 때문에 scope
값을 사용한다. 반면, kLet
과 kConst
모드의 변수는 AST
및 스코프 체인
을 통해 관리되므로 따로 명시하지 않아도 선언된 블록의 Scope
객체에 자동으로 추가된다.
특징 | var | let / const |
---|---|---|
스코프 | 함수 스코프 또는 전역 스코프 | 블록 스코프 |
스코프 정보 필요 여부 | 필요 (상위 스코프 연결) | 불필요 (선언된 블록에서 관리) |
호출 함수 | NewNestedVariableDeclaration | NewVariableDeclaration |
메모리 할당 및 초기화
var
와 let
, const
는 선언과 동시에 초기화 여부가 서로 달랐다. 그렇다면 v8
에서는 이 둘을 분기하는 코드를 보자.
// /src/ast/variables.h
static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
DCHECK(IsDeclaredVariableMode(mode));
return mode == VariableMode::kVar ? kCreatedInitialized
: kNeedsInitialization;
}
InitializationFlag
라는 이름만 봐도 초기화 여부를 결정하는 함수임을 알 수 있다. 이 함수의 동작은 간단하다. kVar
모드라면 kCreatedInitialized
값을 반환하고, 그 외의 값이라면 kNeedsInitialization
을 반환한다.
let
과 const
를 사용해서 변수를 선언하면 TDZ
영역에서 접근을 시도할 때 ReferenceError: Cannot access '' before initialization
에러가 발생했었다. 즉, kNeedsInitialization
값을 받으면 초기화 전에 접근을 하지 못하도록 하는 키워드임을 알 수 있다.
다음으로 kVar
모드라면 선언과 동시에 undefined
로 초기화를 해주어야 하므로 메모리를 할당해야 한다. 그래서 kVar
의 경우 메모리를 할당하는 코드를 찾아보았지만, 클론받은 패키지에서는 찾지 못했다..
그래서 구글링을 통해 구글 깃이라는 곳에서 확인했다. 아래 주소의 532번째 줄을 참고하면 된다.
auto var = scope->DeclareParameter(name, VariableMode::kVar, is_optional,
is_rest, &is_duplicate,
ast_value_factory(), beg_pos);
DCHECK(!is_duplicate);
var->AllocateTo(VariableLocation::PARAMETER, 0);
kVar
모드라면 변수 객체를 생성한 후 AllocateTo
함수를 통해 메모리를 할당한다. 하지만 kLet
과 kConst
모드는 다르다.
// /src/parsing/parser.cc
VariableProxy* tdz_proxy = DeclareBoundVariable(
bound_name, VariableMode::kLet, kNoSourcePosition);
tdz_proxy->var()->set_initializer_position(position());
VariableProxy* proxy =
DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());
kLet
과 kConst
모드로 생성한 변수 객체는 메모리를 할당하는 AllocateTo
함수 대신 set_initializer_position
함수의 매개변수로 position
값을 넘겨주고 있다.
kVar
모드와 다르게 kLet
과 kConst
모드는 메모리를 할당 받지 못했고 이 시점에 변수에 접근하려고 한다면 ReferenceError: Cannot access '' before initialization
가 발생하는 것이다.
마무리
이번 포스팅을 통해 var
, let
, const
키워드의 차이점과 각 키워드가 어떻게 동작하는지를 살펴보았다. 특히, 호이스팅과 TDZ에 대한 이해는 자바스크립트에서 발생할 수 있는 다양한 오류를 예방하는 데 큰 도움이 될 것이다.
사실 필자는 자바스크립트를 사용하면서 var
키워드를 사용해 변수를 선언한 기억이 거의 없다. 현재 개발하는 프로젝트에서도 직접 작성한 변수는 모두 let
과 const
를 사용하고 있다. 하지만 레거시 코드에서는 여전히 var
로 작성된 코드가 많으며, 언젠가 레거시 코드를 봐야 한다면 오늘의 학습이 도움이 될 것이라고 생각한다.
오늘의 한줄 : 웬만하면
const
를 사용하자