본문으로 건너뛰기

호이스팅이 코드를 위로 올리는게 아니라고? (ft. V8 Engine)

· 약 6분
김현재
개발자

들어가며

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

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

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

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

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

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

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

var의 호이스팅

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

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

let, const의 호이스팅

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

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

let myValue = 'Hello, World!';

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

TDZ

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

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

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

함수 선언문

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

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

함수 표현식

함수 표현식은 변수 호이스팅 규칙을 따릅니다. 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 엔진의 내부 동작까지 살펴보았습니다.