Vanilla JS로 VDOM을 직접 구현하면서, 이해되지 않는 에러를 반복해서 마주했습니다.
ReferenceError: Cannot access 'x' before initialization
ReferenceError: vnode is not defined
우연치 않게 var을 선언하기 전에 console.log를 찍게 되는 일이 있었습니다.
그런데, undefined만 뜨고 에러가 발생하지 않아서 "아 JS는 변수자체가 선언되기 전에 변수를 저장해두는건가"라고 생각했는데, const에서는 레퍼런스에러가 발생했습니다.
왜 var은 되고 const는 안되지? 대체 JS에는 어떤 특징이 있어서 이런 일이 발생하는거지 하고 의문이 생겼습니다. 이 질문을 시작으로 따라가다보니 자연스럽게 실행 컨텍스트(Execution Context)라는 개념을 마주쳤고, 그 안에서 호이스팅(Hoisting)과 TDZ(Temporal Dead Zone)를 만나게 되었습니다. 호이스팅은 들어본 적 있었지만, "내 코드에서 크게 문제가 될 일 없겠지" 하고 넘겼던 개념이었는데, 이번 기회에 호이스팅과 TDZ, 그리고 var,let/const가 다른 이유에 대해 알아보고자 합니다.
본격적으로 알아보기 전에, 아래 코드의 실행 결과를 예측해볼까요?
let x = 'global';
function test() {
console.log(x); // 뭐가 출력될까요?
let x = 'local';
}
test();
저는 당연히global이 출력될 거라고 생각했습니다. 하지만 실제로는ReferenceError가 발생합니다.
단순히 선언 시점의 문제가 아니라, 자바스크립트가 변수를 어떤 범위 안에서 찾고, 어떻게 접근하는지와 연결되어 있습니다.
그 이유를 이해하기 위해, 먼저 변수의 범위를 뜻하는 <스코프> 에 대해 먼저 살펴보고자 합니다.
스코프
💡스코프란? 변수나 함수의 유효 범위 → 렉시컬 스코프 규칙에 따른다
💡렉시컬 스코프(=정적 범위)란? - 변수가선언된 위치에 따라 접근 가능 범위가 결정되는것
전역 스코프(global scope ) :전역 범위
로컬 스코프
function scope(var): 함수 범위
var이 따르는 스코프
함수 블록({ })만 경계로 인식, if/for 블록은 무시
code block Scope (let, const) :블록 범위 - 블록 스코프
let/const가 따르는 스코프
모든 블록({ })을 경계로 인식 (함수 포함)
var은 함수 전체를 스코프로 가지며,호이스팅으로 인해 블록 안에 선언해도 함수 최상단에 선언한 것처럼 동작합니다. 때문에 블록 밖에서도 접근이 가능합니다. 반면 let/const는 블록 {}을 스코프 경계로 인식하기 때문에, 블록이 닫히면 해당 변수는 블록 밖에서 접근할 수 없습니다.
function foo() {
if (true) {
var a = 1; // 함수 스코프 → foo 전체에서 접근 가능
let b = 2; // 블록 스코프 → if 블록 안에서만 접근 가능
}
console.log(a); // 1
console.log(b); // ReferenceError
}
실행 컨텍스트
JS는 코드를 실행하기 전에 먼저"이 코드를 실행하려면 뭐가 필요하지?"를 정리합니다.
어떤 변수가 있는지, this는 누구인지, 바깥 스코프는 무엇인지. 그 정보를 담아두는 공간이 바로실행 컨텍스트입니다.
💡실행 컨텍스트란? JS 엔진이 코드를 실행하기 위해 세팅해두는 환경
ECMAScript에서 소스 코드를 4가지 타입으로 구분합니다. 각각의 4가지 타입의 소스코드는 실행 컨텍스트를 생성합니다. 각 코드는 코드평가가 수행한 후에 실행 컨텍스트가 생성됩니다.
전역 코드 -> 전역 실행 컨텍스트Global Execution Context
JS파일을 처음 로드할 때 최초 1개 생성
프로그램이 종료될 때까지 콜 스택 가장 아래에 항상 깔려있습니다.
전역에서 선언한 var 변수와 함수가 등록됩니다.
this 값은 실행 환경에 따라 다릅니다:
브라우저 일반 <script> : this === window
브라우저 <script type="module"> : this === undefined
Node.js CommonJS 모듈 최상단 : this === module.exports (빈 객체)
globalThis는 모든 환경에서 전역 객체를 가리킴
함수 코드 -> 함수 실행 컨텍스트Function Execution Context
함수가호출될 때마다 새로 생성 (함수 스코프를 갖는 실행 컨텍스트)
매개변수, 지역변수, 내부 함수, this 등이 여기에 포함됩니다.
같은 함수라도 호출할 때마다새로운 컨텍스트가 생성되기 때문에 호출마다 독립적인 실행 환경을 갖습니다.
eval 코드 -> eval() 실행 컨텍스트
문자열을 코드로 실행하는 eval() 함수가 호출될 때 생성 -eval("var x = 1");
보안 취약점(XSS 등) 때문에실무에서는 사용을 금지하는 경우가 대부분
모듈 코드 -> 모듈 실행 컨텍스트Module Execution Context
import/export를 사용하는 ES 모듈이 로드될 때 생성됩니다.
<script type="module"> 로 선언된 스크립트나 .mjs 파일이 여기에 해당해요.
각 모듈은 자체적인 모듈 스코프를 가지며, 최상위 코드에서 선언한 변수도 전역을 오염시키지 않습니다.
function foo() {
if (true) {
var a = 1; // 함수 스코프 → foo 전체에서 접근 가능
let b = 2; // 블록 스코프 → if 블록 안에서만 접근 가능
}
console.log(a); // 1
console.log(b); // ReferenceError
}
💡 그럼 블록({})은요? if, for, while 같은 블록은 실행 컨텍스트를 새로 만들지 않습니다. 대신 블록 진입 시 현재 실행 컨텍스트의 렉시컬 환경(Lexical Environment) 이 새로운 블록용 환경으로 교체됩니다. 블록을 빠져나오면 이전 환경으로 다시 돌아갑니다.
함수/전역 진입 시점에는 둘이 같은 환경을 가리키지만, 블록에 진입할 때는 LexicalEnvironment만 새 블록 환경으로 바뀌고, VariableEnvironment는 그대로 유지됩니다.
- let, const, class → LexicalEnvironment에 등록, 블록 진입 시 교체되는 쪽에 등록되기 때문에, 블록이 닫히면 사라집니다. → 블록 스코프 - var, 함수 선언 → VariableEnvironment에 등록, 블록 진입 시에도 교체되지 않는 쪽에 등록되기 때문에, 블록 안에서 선언해도 함수 전체에서 살아있습니다. → 함수 스코프
let/const가 블록 스코프를 갖는 건 바로 이 차이 때문
실행 컨텍스트(=코드 실행 환경) 객체 내부
Lexical Environment (렉시컬 환경)
let, const, 함수 선언, class 등블록/함수 스코프 변수를 관리하는 공간
“이 이름이 어떤 값을 가리키지?” 할 때 찾아보는 일종의 환경 지도
렉시컬 환경 : 정보 꾸러미 메타데이터
Environment Record (환경 레코드)
식별자 바인딩 : 식별자를 키 값 상태로 저장됨. (호이스팅)
this 바인딩
그 컨텍스트에서 this가 무엇인지 (window, 어떤 객체 인스턴스 등)
Outer LexicalEnvironment Reference(외부 렉시컬 환경 참조)
바깥 스코프의 렉시컬 환경에 대한 참조
함수 실행 컨텍스트가 콜 스택에서 pop되면 보통 그 환경 레코드는 GC 대상이 됩니다.
하지만 내부 함수가 Outer Reference를 통해 바깥 환경 레코드를 여전히 참조하고 있다면, GC가 해당 환경 레코드를 수거하지 않습니다. 이것이 클로저의 동작 원리입니다.
Variable Environment (변수 환경)
var 선언과 함수 선언을 관리하는 환경
Lexical Environment와 동일하게 Environment Record와 Outer Environment Reference를 가짐
함수/전역 진입 시점에는 LexicalEnvironment와 같은 환경을 가리키지만, 블록 진입 시에는 교체되지 않고 원래 환경을 유지함
이 "교체되지 않는다"는 특성이 var의 함수 스코프 동작을 만들어냄
실행 컨텍스트 내부 구조
실행 컨텍스트 (Execution Context)
├── LexicalEnvironment
│ └── Environment Record
│ ├── 식별자 바인딩 (let, const, class, 함수 매개변수 등)
│ └── [[ThisValue]] ← FunctionER/GlobalER의 경우
│ └── [[OuterEnv]] ← 바깥 렉시컬 환경 참조
├── VariableEnvironment
│ └── Environment Record (var, 함수 선언)
│ └── [[OuterEnv]]
└── (Realm, ScriptOrModule 등 기타 필드)
소스 코드가 실행 컨텍스트를 생성하기 까지
자바스크립트 엔진은 소스코드를 2개의 과정으로 나누어 처리합니다.
Creation Phase (생성/평가 단계) : 소스 코드의 평가
실행 컨텍스트를 만들고
변수/함수 선언을 먼저 스캔해서 환경 레코드에 등록 → 이때 호이스팅이 발생
전역/함수 실행 컨텍스트 모두 이 단계를 거침
Execution Phase (실행 단계): 소스 코드를 한 줄씩 실행한다.(런타임 실행)
선언 할당, 함수 호출, 연산 등 로직 수행
[ 변수에 대한 정보를 찾는 구조적 단계 ]
현재 Lexical Environment(렉시컬 환경) 안의 Environment Record(환경 레코드)에서 찾음 → 없으면
Outer Lexical Environment (렉시컬 환경의 외부 참조)으로 이동해서 다시 찾음
이 과정을 반복하게 되면 스코프 체인이 된다.
실행 컨텍스트(단위)들은콜 스택(Call Stack)(자바스크립트의 메인/싱글 스레드 담당)에 쌓였다가, 맨 위의 컨텍스트가 끝나면 스택에서 빠지고, 그 아래 컨텍스트가 돌아가는 구조
실행 컨텍스트가 콜 스택에 쌓이는 순서 💡 핵심 : 호출될 때마다 생성 단계부터 다시 시작
1. 전역 실행 컨텍스트 생성 단계 (전역 변수/함수 스캔, 환경 레코드 등록) → 실행 단계 (한 줄씩 실행) → func2() 호출 발견
2. func2() 실행 컨텍스트 생성 단계(func2 스코프 스캔, 환경 레코드 등록) → 실행 단계 (한 줄씩 실행) →func1() 호출 발견
3. func1() 실행 컨텍스트 생성 단계 (func1 스코프 스캔, 환경 레코드 등록) → 실행 단계 (한 줄씩 실행) → 완료 시 pop → func2로 복귀
4. func2 완료 → pop → 전역으로 복귀 5. 전역 완료 → pop
호이스팅
생성 단계에서 미리 스캔해서 등록하기 때문에, 실행 단계에서 코드를 위에서부터 읽어 내려올 때 이미 변수가 올라가 있는 것처럼 동작하는 것을 호이스팅이라고 말합니다.
💡 호이스팅이란? 실행컨텍스트의 생성 단계에서 렉시컬 환경의 환경 레코드에 변수가 저장되는 현상
function, var, let/const 호이스팅 차이
◻️ function
function 키워드 함수는 선언과 초기화, 할당 단계가 내부적으로 동시에 진행
foo(); // 여기서도 호출 가능 -> foo : 선언 및 초기화 동시에 진행
function foo() {
console.log("hi");
}
생성단계(Creation Phase):
foo 라는 식별자를 환경 레코드에 등록(호이스팅)
foo 에 함수 객체 자체를 바로 할당(초기값 : 객체 할당)
→ 그래서 실행단계(Execution Phase) 들어가기 전부터 foo는 완전히 초기화된 함수
function는선언과 초기화 모두 호이스팅이 된다.
때문에, 함수는 초기화 전에 호출해도 정상적으로 호출이 가능하다.
주의 foo(); // TypeError 또는 ReferenceError const foo = function() { ... };
이 경우 foo는 const 변수의 호이스팅 규칙을 따르기 때문에 TDZ에 걸립니다.
◻️ var
var은 변수 선언은 하지만, undefined를 초기값으로 갖는다.
console.log(num); //undefined
var num = 10;
console.log(num); //10
생성단계(Creation Phase):
num 라는 식별자를 환경 레코드에 등록(호이스팅)
num의 초기값을 undefined를 넣어둠.(초기값 : undefined)
실행단계(Execution Phase)
console.log(num) 은 이미 num이 존재하지만 undefined인 상태
이후 라인 2에서 num 값 할당
내부적으로 아래와 같은 경우로 진행됨.
var num; //<- 환경 레코드에 식별자 저장 및 undefined
console.log(num)
num=10;
◻️ let/const
let/const는 변수 선언은 하지만 초기화되지 않은 상태를 갖는다.
console.log(num); //ReferenceError
let num = 10;
생성단계(Creation Phase):
num 라는 식별자를 환경 레코드에 등록(호이스팅)
num의 초기값을 uninitialized(초기화되지 않은 상태) -> 이렇게 초기화되지 않은 상태를 갖는 구간을 TDZ라고 합니다.
TDZ(Temporal Dead Zone) : 일시적 사각지대
일시적 사각지대에 포함되어있는 변수를 사용하지 못하도록 막았다고 함.
→ 코드의 예측 가능성을 높이기 위해 TDZ라는 개념을 도입 : var 때문
실행단계(Execution Phase)
let num = 10; 도달 시 값 할당 이 된다. num : 10
→ 값이 할당되기이전에 접근 시 referenceError : TDZ 발생
테스트
console.log(num);
let num = 10;
console.log(num);
위와 같은 코드가 있을 경우 첫번째 console.log 라인은 TDZ 구간
처음, 인트로에서의 코드입니다.
console.log는 어떻게 출력될까요?
let x = 'global';
function test() {
console.log(x); // 뭐가 출력될까요?
let x = 'local';
}
test();
test() 함수 실행 컨텍스트의 생성 단계에서 내부의 let x가 먼저 호이스팅되어 uninitialized 상태로 환경 레코드에 등록됩니다. 때문에 console.log(x) 시점에 x는 TDZ 구간에 있고, ReferenceError가 발생합니다.
이번 퀴즈의 인사이트는 하나입니다.
호이스팅은 무조건 일어난다.
전역에 같은 이름의 변수가 있어도, 현재 스코프 안에 선언이 있다면 그게 먼저 호이스팅되어 TDZ에 들어갑니다.
바깥 변수를 참조할 것 같지만, 실제로는 내부 선언이 먼저 잡히는 것입니다.
그렇다면 ,var은 선언 전에도 undefined로 접근이 가능하니, 코드가 예측 불가능해지는 것 아닐까요?
맞습니다. 이것은 이상한 게 맞습니다.
실제로 var의 호이스팅 동작(undefined 초기화)은 JS 설계 과정에서 의도한 기능이 아니라, 함수 호이스팅을 구현하는 과정에서 부수적으로 딸려온 결과물이라고 합니다.
JS를 만든 브랜든 아이크(Brendan Eich)의 언급 : " var hoisting was thus unintended consequence of function hoisting, no block scope, JS as a 1995 rush job. ES6 'let' may help."
var 호이스팅은 함수 호이스팅의 의도치 않은 결과(unintended consequence)였습니다. 블록 스코프가 없던 1995년 급하게 만들어진(JS as a 1995 rush job) 자바스크립트에서는 불가피한 부작용이었던 셈이고, ES6의 let이 이 문제를 해결해줄 수 있을 거라고 말하고 있습니다.
결론
호이스팅과 TDZ 관계
호이스팅은 JS의 엔진이 실행 컨텍스트의 환경 레코드에 필수적으로 변수를 저장하는 특징 때문에 자연스럽게 발생하는 현상
하지만 var은 호이스팅 즉시 undefined로 초기화 되기 때문에 선언전에 접근해도 에러가 나지 않는 비직관적인 동작이 존재한다.
console.log(x); // undefined //이딴게 에러가 안뜬다고?
var x = 10;
ES6에서 let과 const가 등장할때, 호이스팅은 JS언어 스펙상 없앨수없기때문에 버그성 동작을 막자. 라는 철학으로 -> TDZ(Temporal Dead Zone)가 등장했다.