본문으로 건너뛰기

Hoisting

들어가며

자바스크립트로 코드를 작성하다 보면 가끔 의아한 현상을 목격하게 돼요. 변수를 선언하기 전에 호출했는데 에러가 나지 않는다거나, 의도하지 않은 값(undefined)이 나오는 경우요. 마치 선언부가 코드 최상단으로 끌어올려진(hoisted) 것처럼요.

console.log(myVar); // undefined
var myVar = 'Hello, World!';

이런 현상을 바로 호이스팅(Hoisting) 이라고 불러요. 면접 질문으로도 자주 출제되어 "선언문을 맨 위로 올리는 현상"이라고 외우곤 하지만, 이 글에서는 좀 더 깊게 호이스팅에 대해 파헤쳐보려고 해요.

모든 선언이 똑같이 끌어올려질까? - var vs let, const

호이스팅을 이해하기 전, 자바스크립트가 변수를 처리하는 두 단계인 선언초기화를 구분해야 해요.

  • 선언(Declaration): "나 myVar라는 변수 쓸 거야"라고 자바스크립트 엔진에 알리는 단계
  • 초기화(Initialization): 변수에 메모리를 할당하고, 처음에는 undefined 값을 넣어두는 단계

자바스크립트에서 변수를 선언하기 위해 사용하는 var, let, const는 이 두 단계의 진행 방식에서 차이를 가지고 있어요.

1. var의 호이스팅

var로 선언된 변수는 스코프 최상단에서 선언과 초기화가 동시에 이루어져요. 그래서 변수를 선언하기 전에 호출해도 에러가 나지 않고 undefined를 반환해요. 아래 코드는 var로 선언된 변수가 V8 엔진을 통해 해석된 예시 코드에요.

// V8 엔진이 코드를 이렇게 해석해요
var myVar; // 선언 + 초기화 (undefined)
console.log(myVar); // -> undefined
myVar = 'Hello, World!'; // 할당

2. let, const의 호이스팅

letconst는 선언 단계만 이루어져요. 초기화는 실제 코드 라인에 도달해야만 이루어져요. 그래서 실제 코드에서 초기화 전에 호출하게 되면 자바스크립트 엔진은 ReferenceError를 발생시켜요.

console.log(myValue); // ReferenceError: Cannot access 'myValue' before initialization

let myValue = "Hello, World!"

위 코드에서 발생한 ReferenceError는 참조 에러라고도 말하며, 초기화 전에는 해당 변수에 접근할 수 없다는 것을 의미해요.

3. TDZ

letconst의 호이스팅에서는 중요한 개념이 하나 존재해요. 앞서 정리한 내용을 보면 letconst로 선언된 변수는 실제 코드 라인에 도달해서 초기화가 되어야 접근할 수 있었어요. 이 때 선언은 되었지만 초기화가 되지 않은 영역TDZ(Temporal Dead Zone, 일시적 사각지대)라고 해요.

함수 호이스팅 - 선언문 vs 표현식

자바스크립트에서는 함수도 호이스팅이 일어나요. 단, 함수를 선언한 방식에 따라 호이스팅의 동작이 달라져요.

1. 함수 선언문

함수 선언문은 선언과 동시에 사용 가능한 상태가 돼요. 따라서 선언문 이전에 함수를 호출해도 정상적으로 작동해요.

sayHello(); // "Hello!"
function sayHello() {
console.log('Hello!');
}

2. 함수 표현식

함수 표현식은 변수 호이스팅 규칙을 따라요. var로 선언하면 undefined가 반환되며, let또는 const로 선언하면 TDZ영역이 생성되며 ReferenceError가 발생해요.

// var의 경우
sayHi(); // TypeError: sayHi is not a function
var sayHi = function() {
console.log('Hi!');
};

호이스팅은 왜 발생하는걸까?

지금까지 호이스팅의 정의와 변수, 함수별 동작 방식을 살펴보았어요. 그렇다면 호이스팅은 왜 발생하는 걸까요?

이 질문의 해답을 찾기 위해서는 실행 컨텍스트라는 개념을 이해해야해요. 이번 글에서는 호이스팅을 이해하는 데 꼭 필요한 핵심 원리 위주로 실행 컨텍스트를 가볍게 짚어보려 해요.

실행 컨텍스트

실행 컨텍스트는 자바스크립트 코드를 실행하기 위해 필요한 정보를 모아놓은 객체에요. 실행 컨텍스트에는 변수 및 함수 선언 정보, 스코프, this 바인딩 정보 같은 것들이 저장돼요.

실행 컨텍스트는 크게 전역 실행 컨텍스트와 함수 실행 컨텍스트로 나눌 수 있어요. 함수가 호출되면 새로운 실행 컨텍스트가 만들어지고 콜 스택에 쌓이게 되며, 함수 실행이 종료되면 해당 실행 컨텍스트는 콜 스택에서 제거돼요.

함수 호출

함수 호출문을 만나면 자바스크립트 엔진은 하던 일을 멈추고 해당 함수의 실행 컨텍스트를 생성해요. 실행 컨텍스트가 생성될 때 함수 내부의 변수와 함수 정보를 환경 레코드라고 하는 실행 컨텍스트 내부 공간에 미리 기록해요. 이 기록하는 행위 때문에 호이스팅이 발생하는거에요.

V8 엔진과 호이스팅

자바스크립트 코드는 자바스크립트 엔진이라고 불리는 해석기를 통해 실행돼요. 이 글에서는 가장 대표적인 구글의 V8 엔진을 기준으로 호이스팅이 내부적으로 어떻게 처리되는지 설명할 예정이에요.

자바스크립트 코드는 자바스크립트 해석기를 통해 코드가 실행돼요. 대표적으로 V8이 있으며, 이 글에서는 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를 사용할 수 있어요. 즉, mode에 따라 변수를 선언하는 키워드를 분기하는 로직이 있을 것이라고 예상할 수 있어요.

var, let, const의 접두사로 k를 붙인 이유는 c++ 코드에서 enum 또는 상수값의 식별자로 k를 붙이는 것이 관례라고 해요. 이 내용은 Gemini가 알려준 내용이니 궁금한 분들은 교차 검증을 추천드려요.

다음 흐름으로 넘어가볼게요. 조건문에서는 변수를 선언할 때 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);
}

위 헤더 파일에 정의되어 있는 두 함수의 차이점으로는 매개변수 scope의 유무가 있어요. kVar 모드의 변수는 상위 스코프와 연결되어야 하기 때문에 scope 값을 사용해요. 반면 kLetkConst 모드의 변수는 AST스코프 체인을 통해 관리되므로 따로 명시하지 않아도 선언된 블록의 Scope 객체에 자동으로 추가돼요.

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

메모리 할당 및 초기화

varlet, const는 선언과 동시에 초기화 여부가 서로 달랐어요.

// /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가 발생했어요. 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가 발생해요.

마무리

이번 글에서는 단순히 면접용 답변으로 외웠던 호이스팅을 넘어, 실행 컨텍스트와 V8 엔진의 내부 동작까지 살펴보았어요.