🐕 호이스팅으로 딥다이브를 해보자
호이스팅은 자바스크립트를 배우다보면 거의 기본문법을 배우자마자 배우게되는 개념이기도 합니다.
그만큼 자바스크립트에서 중요한 개념으로 작용하고 있고
모르면 의도와는 다르게 동작하는 자바스크립트를 만날 수 있는 개념이기도 하고
언어 자체의 결함이라고 말씀하시는 분들도 있습니다.
다만 호이스팅은 그만큼 학습을 처음 시작하시는 분들이 많이 공부하는 내용이다보니
여러가지 유언비어 / 잘못된 정보들이 많은 것 또한 사실입니다.
저도 공부를 한 기간이 오래된 것도 아닐 뿐더러
제가 작성한 내용이 틀릴 수 있다는 생각이 들기도합니다만
그래도 옳다는 생각으로 작성을 하도록 하겠습니다.
혹시 글에 오류가 있는 것을 발견하신 분은 댓글로 알려주시면 정말 감사드리겠습니다.
호이스팅은 제가 자바스크립트를 처음 접했을 때의 감정을 떠올려보면
매우 이해하기 어려운 개념이었고 지금 와서 돌이켜보면
제대로 이해하기 위해서는 실행컨텍스트와 같은 자바스크립트의 배경지식들이 필요하기에
많은 분들이 자신들이 이해한 개념을 바탕으로 글을 풀어써 나가는 과정에서
잘못된 정보가 양산되는 경향이 있는 듯 합니다.
대표적인 잘못된 정보로는 다음과 같은 주장들을 찾을 수 있습니다.
👻let , const는 호이스팅 되지 않는다??
👻호이스팅은 선언문이 코드의 최상단에 끌어올려지는 것을 말한다??
👻호이스팅은 var에서만 존재하는 개념이다?
위 문장들은 모두 잘못된 문장 입니다.
호이스팅의 개념을 실습을 통해 이해하는 것에는
에러의 종류를 파악하는 것이 매우 중요합니다.
단순히 빨강색 에러메시지가 떴다고 해서 호이스팅이 되지 않는다고 말할 수는 없습니다.
따라서 호이스팅을 학습하면서 마주치게 되는 다양한 에러메시지를 먼저
기억해두고 앞으로 나아가겠습니다.
🥶 알아두어야하는 에러메시지
👩🦰ReferenceError : Cannot access --- before initialization
console.log(zinsol); // ReferenceError: Cannot access 'zinsol' before initialization
let zinsol = 0;
한국어로 간단하게 옮기면 참조오류입니다.
zinsol이라는 식별자에 접근할 수 없다는 참조오류인데
before initialization이라고 명시되어있습니다.
intialization 즉 초기화가 이루어지기 이전에는 zinsol 식별자에 access를 할 수 없다는 오류입니다.
zinsol 변수의 선언문을 만나기도 전에 console.log(zinsol)을 통해 zinsol에 access를 하려고하는 과정에서
발생하는 오류입니다.
👩🦰 ReferenceError: --- is not defined
console.log(ryujisoo); // ReferenceError: ryujisoo is not defined
위 오류는 선언되지 않은 변수에 접근하고자 시도할 때 발생하는 오류입니다.
위와 같이 ReferenceError가 발생하지만 이건 식별자 자체를 찾을 수 없어 발생하는 오류입니다.
식별자는 찾았지만 access 할 수 없는 상태인 위 에러와는 전혀 다른 에러라고 할 수 있습니다.
후술할 환경레코드의 관점에서 보면 환경레코드에 기록되지 않은 식별자를 참조하려고하고 있기 때문에
발생하는 ReferenceError 입니다.
👩🦰TypeError: --- is not a function
jinhwa(); // TypeError: jinhwa is not a function
var jinhwa = function () {
console.log('evolution');
};
위 예제 코드는 표현식의 형태로 선언한 함수를 선언 전에 호출하는 코드입니다.
실제로 코드를 작성한 뒤 실행을 시켜보면
TypeError : jinhwa is not a function이라는 에러메시지를 만날 수 있습니다.
이는 참조오류와는 전혀 다른 오류입니다.
호출을 하는 행위는 자바스크립트에서 함수에만 할 수 있는 행위임에도 불구하고
함수가 아닌 것을 호출하려고하여 발생하는 에러입니다.
그렇다면 왜 호이스팅이 되는 var를 이용해 선언한 함수표현식이
호출이 되지 않는 것일까요?
최상단부로 끌어올려지면 호출도 가능해야하지않을까요?
그 이유는 var 키워드로 선언한 선언문은 호이스팅되며
후술하겠지만 var문은 코드평가단계에서 선언과 초기화를 동시에 해두기 때문입니다.
또한 초기화는 간단하게 생각하면 암묵적으로 undefined를 값에 할당해두는 것을 말합니다.
따라서 함수가 아닌 undefined를 마치 함수처럼 호출하려고 하는 과정에서 생기는 타입에러입니다.
👩🦰SyntaxError: Identifier __has already been declared
console.log(hero); // SyntaxError: Identifier 'hero' has already been declared
const hero = 'youngyoong';
const hero = 'hero';
다음은 SyntaxError 문법오류입니다.
변수 hero를 이미 선언했는데 다시 선언하는 코드를 만나게되어 문법오류를 반환합니다.
🌞호이스팅의 과정
호이스팅은 크게 세개의 과정으로 나누어 줄 수 있습니다.
다만 선언단계와 초기화단계를 뭉뚱그려 선언/초기화단계 - 할당단계와 같이 2단계로 보는 시각도 존재하고
초기화 단계에 대하여 서로 다른 정의를 내리고 혼용되고 있습니다.
어떻게 인식을 하든 이해만 제대로 되면 상관없을 수 있겠지만
후술한 let ,const와 TDZ의 개념을 받아들일 때 선언/초기화를 동일하게 생각하면
이해가 어려워지는 경향이 있으므로 전 편의상 세단계로 나누어 생각을 하는 편입니다.
따라서 지금부터 각 단계는 이 글에서는 이렇게 정의를 하도록 하겠습니다.
선언단계 : 식별자들이 메모리에 등록되는 단계
초기화단계 : 식별자들의 값을 undefined로 초기화하는 단계
할당단계 : 식별자에 값을 할당해주는 단계
var로 선언된 변수는 런타임 이전에 자바스크립트 엔진에 의해 암묵적으로
"선언 단계"와 "초기화 단계"가 한번에 진행됩니다.
반면 let, const로 선언된 변수는 선언 단계와 초기화 단계가 분리되어 진행됩니다.
런타임 이전 자바스크립트 엔진에 의해 암묵적으로 선언 단계가 먼저 실행되지만
초기화 단계는 변수 선언문에 도달하는 시점에 실행됩니다.
이렇듯 let , const 키워드로 선언된 변수는 스코프의 시작 지점부터 초기화 시작 지점까지
변수를 참조할 수 없는 구간이 존재하게 됩니다.
이러한 구간을 일시적 사각지대 (Temporal Dead Zone)이라고 부릅니다.
🌞일시적 사각지대 (TDZ)
일시적 사각지대 (Temporal Dead Zone)
let, const 키워드로 선언된 변수는 스코프의 시작 지점부터 초기화 시작 지점까지
변수를 참조할 수 없는 구간이 존재하며 이를 일시적 사각지대라고 부른다.
다시 한번 기억해야할 것은 let, const 키워드 역시 런타임 이전에
선언문이 실행된다는 것입니다.
즉 let, const는 호이스팅이 발생하지 않는 것이 아닌
발생한 호이스팅을 선언문을 만나기전까지 참조할 수 없게 하는 것이며
그렇기에 RefferenceError또한 not defined이 아닌 not Acces가 되는 것입니다.
😋함수 호이스팅
foo(); // foo
function foo() {
console.log('foo');
}
함수 선언문으로 작성한 함수는
호이스팅 될 뿐만 아니라 호출가능한 함수의 형태로 호이스팅됩니다.
이를 저는 편의상 함수 호이스팅이라고 부르겠습니다.
함수 호이스팅이라는 명칭이 공식적인지는 잘 모르겠습니다만
함수 선언문에서 이루어지는 특이한 형태의 호이스팅이기 때문에 저는 함수호이스팅이라고 부르곤 합니다.
이러한 함수 호이스팅으로 인하여
위 예제에서 함수 foo는 선언 이전에도 호출할 수 있습니다.
foo(); // TypeError: foo is not a function
var foo = function () {
console.log('foo');
};
반면 다음은 var를 통해 선언한 함수표현식입니다.
TypeError가 발생하는것을 확인할 수 있습니다.
이는 var로 선언한 식별자는 호이스팅되며 그 값은 할당문을 만나기전까지
undefined로 초기화되기때문입니다.
즉 foo()를 호출하는 시점에서 foo는 undefined가 할당되어 있기 때문에
foo() 를 하는 부분은 undefined()와 같다고 볼 수 있습니다.
사실인지 한번 foo를 console.log()해보면
console.log(foo); // undefined
var foo = function () {
console.log('foo');
};
undefined가 출력되는것을 확인할 수 있습니다.
그렇다면 let 혹은 const로 선언을 하면 어떻게 동작할까요?
console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
const foo = function () {
console.log('foo');
};
함수 표현식으로 작성된 경우
어떤 변수 선언 키워드를 이용해 선언했는지에 따라 호이스팅이 어떻게 될지가 결정된다
라는 것을 알 수 있습니다.
🤢var와 let,const의 차이점
이는 호이스팅을 이해하는데에 큰 영향을 미치는 것은 아니지만 알아두면 좋으니
간략히 서술하고 넘어가도록 하겠습니다.
const 키워드로 선언한 변수는 반드시 선언과 동시에 초기화해야합니다.
const foo // SyntaxError: Missing initializer in const declaration
foo = 100
그렇지 않은 경우 위와 같은 문법에러가 발생하게 됩니다.
또한
const,let 키워드로 선언한 변수는 블록 레벨 스코프를 가진다는 것과
재선언이 불가하다는 점
정도만 간략하게 정리를 하고 넘어가겠습니다.
🤮실행 컨텍스트를 통해 이해하기
실행컨텍스트는 자바스크립트의 동작 원리를 담고 있는 핵심적인 개념입니다.
호이스팅 역시도 실행컨텍스트와 밀접한 관련이 있습니다.
아래부터는 실행 컨텍스트에 대해 기본적인 이해가 있다는 가정을 바탕으로
글을 전개해 나갑니다.
실행 컨텍스트란 소스코드를 실행하는 데 필요한 환경을 제공하고 코드의 실행결과를 관리하는 영역이다.
이 실행컨텍스트에는 Lexical Environment라는 개념이 존재합니다.
렉시컬 환경 Lexical Environment는 식별자와 식별자에 바인딩된 값
그리고 상위 스코프에 대한 참조를 기록하는 자료구조의 일종이라고 볼 수 있습니다.
이러한 렉시컬 환경은 실행컨텍스트를 구성하는 컴포넌트라고 할 수 있습니다.
실행 컨텍스트 스택이 코드의 실행 순서를 관리한다면
렉시컬 환경은 스코프와 식별자를 관리하는 영역입니다.
우리는 호이스팅을 이야기하고 있고
호이스팅은 식별자가 위로 끌어올려지는 것처럼 보이는 현상입니다.
렉시컬 환경은 스코프와 식별자를 관리하는 영역이라고 정의했습니다.
따라서 우리는
호이스팅이 렉시컬 환경과 연관이 있지 않을까?
라는 합리적 추론을 도출할 수 있습니다.
자바스크립트의 코드 실행은 크게 두단계로 나뉘어서 실행됩니다.
코드 평가 단계와 코드 실행 단계로 간단히 나눌 수 있으며
코드 평가 단계에서 자바스크립트 코드는 코드를 평가하며 선언문을 먼저 실행해둡니다.
이 내용은 매우 중요합니다.
자바스크립트는 코드를 먼저 평가하고 그 다음에 코드를 실행한다.
따라서 코드 평가 시점에서 선언문은 이미 Lexical Environment에 등록되게됩니다.
그리고 코드 평가 과정이 끝나면 선언문을 제외한 나머지 코드의 실행이 시작됩니다.
그리고 Lexical Environment에는 또 다시
Environment Record와 Outer Lexical Environment Reference라는 영역이 존재합니다.
아우터는 외부 환경에 대한 이야기일테니
우리가 알고 싶은 내용은 Environment Record에 담겨있을 것을 추측할 수 있습니다.
The LexicalEnvironment and VariableEnvironment components of an execution context are always Environment record
그 이전에 그냥 참고사항 정도로 알고 넘어가는 내용으로
실제로 실행컨텍스트에는 LexicalEnvironment와 VariableEnvironment 두가지의 컴포넌트가 있으며
생성 초기에는 lexical ,variable 모두 같은 lexical Environment를 가리키지만
몇가지 상황에서 variableEnvironment를 위한 새로운 렉시컬 환경이 생성되면서
같은 실행컨텍스트의 lexical, variable이 서로 다른 lexical Environment를 가리키게되는 경우도 있습니다.
다만 VariableEnvironment까지 고려하면서 개념을 정리해나가면
너무 복잡해지니 그냥 Lexical Environment로 퉁쳐서 개념을 그려나가도록 하겠습니다.
다음은 ECMAScript 명세서의 Environment Records에 대한 항목입니다.
ECMAScript 명세서는 누구나 무료로 열람할 수 있으니 참고하시고 Envrionment Records를 보겠습니다.
https://tc39.es/ecma262/
위 링크로 접근하여 볼 수 있습니다.
개인적인 생각으로는 웹으로 보는 경우 한 페이지에 모든 내용이 다 담겨있어서
너무 페이지가 무겁고 PDF를 다운로드 받아 보는 방법을 추천드립니다.
https://www.ecma-international.org/publications-and-standards/standards/ecma-262/
PDF 다운로드는 위 링크에서 받을 수 있습니다.
환경 레코드는 특정 변수에 대한 식별자의 연결을 정의하는 데 사용되는 사양 유형이며
ECMAScript 코드의 Lexical nesting structure를 기반으로 합니다.
일반적으로 환경 기록 FunctionDeclaration, BlockStatement 또는 TryStatement의 Catch 절.
이러한 코드가 평가될 때마다 새로운 환경 해당 코드에 의해
생성된 식별자 바인딩을 기록하기 위해 레코드가 생성됩니다.
대충 환경레코드는 함수레벨스코프 , 블록레벨스코프 ,tryCatch절 같은 코드를 평가할 때 생성되며
그 스코프의 식별자 바인딩을 기록하는 일을 해준다. 라는 소리입니다.
중요한 부분은 맨 첫줄에서 찾아볼 수 있습니다
Environment Record는 특정 변수에 대한 식별자의 연결을 정의하는데 사용된다.
실제로는 환경레코드도 전역 환경 레코드 , 함수 환경 레코드와 같이 나누어지고
함수가 호출되면 새로 함수실행컨텍스트가 생성되지만
구조 자체는 함수환경레코드도 전역환경레코드와 꽤 비슷하게 생겼으니
굳이 더 그림을 복잡하게 만들지는 않겠습니다.
지금까지 나온 모든 자료를 종합해 시각화 하면 다음과 같은 그림을 그릴 수 있습니다.
렉시컬 환경에는 아우터렉시컬참조와 환경레코드가 있고
환경레코드안에는 또 객체환경레코드와 선언적환경레코드가 존재하는 중첩구조로 이루어집니다.
실제로는 렉시컬환경에 더욱 많은 정보가 담겨있지만
그렇게 되면 그림이 복잡해지고 더 어렵게 느껴질 수 있으니 우선은 이정도로만 간략하게 정리하겠습니다.
또한 렉시컬환경은 실행컨텍스트의 일부라고 했습니다. 그러니 실제로는 다음과 같은 느낌이 됩니다.
실제로는 실행컨텍스트엔 Lexical Environment 뿐만아니라 아래와 같이 아주 많은 다른것들이 들어있습니다만..
우리가 궁금한건 호이스팅이니까
Lexical Environment를 중점적으로 보기위해 다른건 생각하지 않겠습니다.
var zansol = 1;
const znew = 2;
이런 코드를 실행한다고 생각 했을 때 실행컨텍스트 스택은 어떻게 동작할까요?
var zansol은 var키워드로 선언한 변수이며 선언위치가 전역이기에
평가 시점에서 Global(전역 환경이기때문) Lexical Environment에 연결되어 있는
Object Environment Record(객체 환경 레코드)에 바인딩되어있는
BindingObject를 통하여 전역객체(window, global 등)에 변수식별자 zansol을 등록합니다.
BindingObject
BindingObject는 전역 객체의 프로퍼티입니다.
이 BIndingObject로 인하여 전역객체에 var, 함수선언문으로 정의된 변수,함수들은
전역객체의 프로퍼티가 됩니다.
이때 전역 객체에 zansol을 key로 value는 undefined를 암묵적으로 할당해 바인딩합니다.
바로 이 과정때문에 호이스팅이 발생하게 됩니다.
var 키워드로 선언한 변수는 코드를 평가하는 단계에서
Object Environment Record의 BindingObject에 의해 전역 객체에는 zansol이 등록되고
undefined로 값의 초기화까지 완료한 상태가 되니까
따라서 var 키워드로 선언한 변수는 호이스팅이 일어나며 참조를 시도하면 undefined가 반환되는것입니다.
함수 선언문의 경우엔 정의한 함수가 평가되면 함수 이름과 동일한 이름의 식별자를
객체 환경 레코드에 바인딩된 Binding Object를 통해 전역 객체에 키를 등록하고
바로 생성된 함수 객체를 즉시 할당해줍니다.
따라서 함수선언문은 함수를 선언하기도 전에 호출하는 것이 가능한 것입니다.
그렇다면 let, const는 왜 호이스팅이 일어나지 않는 것처럼 보이는 것일까요?
그것은 Declarative Environment Record와 밀접한 관련이 있습니다.
let, const 키워드로 선언한 변수는 Declarative Environment Record
즉 선언적 환경 레코드에 등록되어 관리됩니다.
var zansol = 1;
const znew = 2;
따라서 const znew는 const 키워드로 선언된 변수이기 때문에
선언적 환경 레코드에서 관리하게 됩니다.
그렇기때문에 BindingObject를 통해 전역객체에 등록되지도 않으며
이는 window.znew 와 같이 전역객체의 프로퍼티형태로 접근하는 것이 불가하다는 것을 나타냅니다.
let으로 선언한 변수는 전역객체에 등록되지않는다.
var로 선언한 변수는 전역 객체에 등록된다.
하지만 const, let 으로 선언한 변수 역시도 런타임에 추가되는것이 아닌
코드 평가 단계에서 환경 레코드에 등록된다는 것이 호이스팅의 본질입니다.
따라서 let, const 키워드로 선언한 변수는 호이스팅이 되지 않는다는 문장은 틀렸음을 알 수 있습니다.
즉 선언적 환경 레코드에 등록은 되어있는 상태이지만 값의 초기화(undefined 할당)은
이루어지지 않은 상태이기 때문에 런타임에서 선언문보다 먼저 참조를 시도하면
초기화가 되지 않았다는 에러를 만나게되는 것입니다.
let zansol = 'zansol';
{
console.log(zansol);
let zansol = 'zinsol';
}
따라서 위 예제코드는 let, const 키워드로 선언한 경우 호이스팅이 일어나지 않는다고 가정하면
전역에 선언되어있는 zansol을 참조한 console.log가 찍혀야할 것 같지만
ReferenceError: Cannot access 'zansol' before initialization
실제로는 초기화 이전에 접근할 수 없다는 에러가 발생하게 됩니다.
만약 호이스팅이 일어나지않고 코드 실행 단계에서 선언문이 평가되고 환경레코드에 등록된다면
let zansol = 'zinsol'보다 console.log가 더 빠르니 전역에 있는 zansol을 참조해야 옳을 것입니다.
let zansol = 'zansol';
{
console.log(zansol);
}
이렇게 작성한 코드는 문제없이 zansol이 출력됩니다.
즉 정리를 해보면 다음과 같습니다.
이제 여기까지 잘 따라오셨다면
for (i = 0; i < 2; i++) {}
console.log(i);
console.log(i);
for (i = 0; i < 2; i++) {}
console.log(i);
for (var i = 0; i < 2; i++) {}
위 세가지 예제의 실행결과를 예측해보세요!
🤮V8 엔진은 어떻게..?
// var
var->AllocateTo(VariableLocation::PARAMETER, index);
// const
VariableProxy* proxy =
DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());
// let
VariableProxy* proxy =
DeclareBoundVariable(variable_name, VariableMode::kLet, class_token_pos);
proxy->var()->set_initializer_position(end_pos);
V8엔진의 코드를 분석해보면 var 키워드와 const, let 키워드는 분기로 갈라놓습니다.
그리고 v8엔진의 호이스팅 키워드인 should_hoist는 키워드를 구분하지 않고 모두 true를 할당합니다.
즉 should_hoist는 변수가 어떤 키워드로 선언되었는지 구분하지않고 모두 호이스팅합니다.
이후 DefaultInitializationFlag라는 함수를 통해 분기를 갈라주고
그렇게 분기가 갈라진 var와 let const는 호이스팅 이후로는 다르게 처리됩니다.
var로 선언된 변수의 경우에는 AllocateTo라는 메서드를 호출하여
바로 메모리에 공간을 할당해주는 것을 확인할 수 있지만
let, const 키워드로 선언한 변수의 경우에는 그 대신 set_initializer_position 메서드를 통하여
해당 코드의 위치를 지정해주는 행위만 할뿐 메모리에 공간을 할당하는 초기화는 진행하지 않는것을 확인할 수 있습니다.
따라서 이 분기에서의 차이때문에 var는 호이스팅되어 선언보다 먼저 참조할 경우 undefined가
let, const 키워드로 선언한 경우엔 TDZ에 빠지게되는 차이가 발생하는 것입니다.
여기까지 잘 따라오셨다면 이제 다양한 방법으로 나만의 예제를 만들어보면서
자신의 예상과 같이 동작하는지 테스트해보세요!
🐶마치며
자신있게 시작했지만 정작 점점 깊은 내용으로 들어갈수록 힘들어지는 것을 느꼈습니다.
많은 내용들을 다른 레퍼런스들에서 참고해왔기에 온전한 제 지식이라고 하기에는 무리가 있을 것 같아요
제가 참고한 레퍼런스는 다음과 같습니다.
https://evan-moon.github.io/2019/06/18/javascript-let-const/
https://poiemaweb.com/js-data-type-variable
https://www.youtube.com/watch?v=EWfujNzSUmw
'best' 카테고리의 다른 글
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |
---|---|
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(2) (0) | 2023.11.03 |
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(1) (1) | 2023.11.03 |
TypeScript , JavaScript의 접근 제한자 '#' Deep Dive (1) | 2023.08.29 |
클로저가 뭐냐?라는 일상적인 질문에 잘 아는 것처럼 행동하는 방법 (6) | 2023.02.23 |