호이스팅? TDZ? 그게 뭔데

Deep Dive
2025년 01월 16일

들어가며

최근 자바스크립트를 공부한 사람이라면 변수를 선언할 때 letconst 키워드 익숙할 것이다. 하지만 2015년 Javascript ES6(이하 ES6) 가 발표되기 전까지 많은 자바스크립트 프로그램은 var 키워드를 사용하여 변수를 선언했다.

그리고 시간이 지나 ES6 가 등장하면서 우리가 사용하는 letconst 가 새롭게 추가되었다. 자바스크립트는 이미 변수 선언 키워드가 있음에도 불구하고 왜 새로운 키워드를 추가했을까?

이번 포스팅에서는 각 키워드의 특징과 호이스팅, 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 가 등장하면서 우리가 자주 사용하게 된 letconst 키워드가 새롭게 추가되었다. 이 두 키워드는 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를 사용하게 된 것입니다.")

재선언 불가능

letconst 로 선언된 변수는 동일한 변수명으로 재선언이 불가능하다.

let value = 10;
let value = 20;
 
// SyntaxError: Identifier 'value' has already been declared

블록 레벨 스코프

letconst 키워드는 블록 레벨 스코프를 사용한다. 따라서 블록 내부에서 선언된 변수는 블록 외부에서 접근할 수 없다.

let value = 10;
 
if (true) {
  let value = 20;
  console.log(value); // 20
}
 
console.log(value); // 10

letconst 키워드는 블록 레벨 스코프를 사용한다고 했다. 즉 코드 평가 단계에서 변수 선언이 블록 레벨 내 최상단으로 일어난다. 예시 코드를 작성해보면 아래와 같다.

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의 호이스팅

letconst 로 선언한 변수는 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 값을 사용한다. 반면, kLetkConst 모드의 변수는 AST스코프 체인 을 통해 관리되므로 따로 명시하지 않아도 선언된 블록의 Scope 객체에 자동으로 추가된다.

특징varlet / const
스코프함수 스코프 또는 전역 스코프블록 스코프
스코프 정보 필요 여부필요 (상위 스코프 연결)불필요 (선언된 블록에서 관리)
호출 함수NewNestedVariableDeclarationNewVariableDeclaration

메모리 할당 및 초기화

varlet , const 는 선언과 동시에 초기화 여부가 서로 달랐다. 그렇다면 v8 에서는 이 둘을 분기하는 코드를 보자.

// /src/ast/variables.h
 
  static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
    DCHECK(IsDeclaredVariableMode(mode));
    return mode == VariableMode::kVar ? kCreatedInitialized
                                      : kNeedsInitialization;
  }

InitializationFlag 라는 이름만 봐도 초기화 여부를 결정하는 함수임을 알 수 있다. 이 함수의 동작은 간단하다. kVar 모드라면 kCreatedInitialized 값을 반환하고, 그 외의 값이라면 kNeedsInitialization 을 반환한다.

letconst 를 사용해서 변수를 선언하면 TDZ 영역에서 접근을 시도할 때 ReferenceError: Cannot access '' before initialization 에러가 발생했었다. 즉, kNeedsInitialization 값을 받으면 초기화 전에 접근을 하지 못하도록 하는 키워드임을 알 수 있다.

다음으로 kVar 모드라면 선언과 동시에 undefined 로 초기화를 해주어야 하므로 메모리를 할당해야 한다. 그래서 kVar 의 경우 메모리를 할당하는 코드를 찾아보았지만, 클론받은 패키지에서는 찾지 못했다..

그래서 구글링을 통해 구글 깃이라는 곳에서 확인했다. 아래 주소의 532번째 줄을 참고하면 된다.

kVar 모드 메모리 할당

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 함수를 통해 메모리를 할당한다. 하지만 kLetkConst 모드는 다르다.

// /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());

kLetkConst 모드로 생성한 변수 객체는 메모리를 할당하는 AllocateTo 함수 대신 set_initializer_position 함수의 매개변수로 position 값을 넘겨주고 있다.

kVar 모드와 다르게 kLetkConst 모드는 메모리를 할당 받지 못했고 이 시점에 변수에 접근하려고 한다면 ReferenceError: Cannot access '' before initialization 가 발생하는 것이다.

마무리

이번 포스팅을 통해 var, let, const 키워드의 차이점과 각 키워드가 어떻게 동작하는지를 살펴보았다. 특히, 호이스팅과 TDZ에 대한 이해는 자바스크립트에서 발생할 수 있는 다양한 오류를 예방하는 데 큰 도움이 될 것이다.

사실 필자는 자바스크립트를 사용하면서 var 키워드를 사용해 변수를 선언한 기억이 거의 없다. 현재 개발하는 프로젝트에서도 직접 작성한 변수는 모두 letconst를 사용하고 있다. 하지만 레거시 코드에서는 여전히 var로 작성된 코드가 많으며, 언젠가 레거시 코드를 봐야 한다면 오늘의 학습이 도움이 될 것이라고 생각한다.

오늘의 한줄 : 웬만하면 const 를 사용하자