0biglife.

자바스크립트 코드 안정성을 위한 호이스팅

Frontend/Javascript

· 2025-01-24

자바스크립트 코드 안정성을 위한 호이스팅

JS 동작 과정과 그 안에서 발생하는 호이스팅은 무엇인지, 그리고 호이스팅을 통해서 var, let, const에서 나타나는 차이점은 무엇인지를 설명한다. 나머지 게시글에서는 Javascript가 가지는 구조로 인하여 JS가 왜 싱글 스레드 언어이 필요한지와 논블라킹 언어인지에 대해 디테일하게 다뤄본다.

감히 단정지어 말하기는 어렵지만, 프론트엔드 개발자는 Javascript를 잘 다루는 개발자라고 해도 과언이 아니라고 생각한다. 컴포넌트 UI를 그려내고 데이터 처리를 하는 것은 조금 배운다면 마음 먹은 누구든 할 수 있지 않은가. 재사용성과 유지보수를 고려하고 대규모 프로젝트를 다룰수록 성능이 중요하고, 그 성능은 내가 다루는 언어와 프레임워크의 구조를 이해함에서 갈린다. 그러니까 "이걸 써야 좋다고 해요. 근데 왜?"에 대해 대답을 명로하게 해내는 개발자가 되어야한다.

호이스팅

타이틀에서 'JS 코드 안정성을 위한 호이스팅'이라고 했다. 호이스팅이 무엇이길래 JS의 안정성을 보장할까? 호이스팅은 변수와 함수의 메모리 공간이 선언 전에 미리 할당되는 것을 의미하는데, 여러 설명글들에서는 이를 '스코프의 최상단으로 끌어올려진 것 같은 현상'이라고 말한다. 호이스팅을 처음 접했을 때는 단어 발음도 웃기고 원리도 이해가 안갔기 때문에 최대한 순차적으로, 명료하게 정리해본다. '끌어올리다'를 의미하는 Hoisting은 이 현상이 벌어지는 자바스크립트가 어떻게 해석되는지를 알아야만 이해가 간다.

Javascript Engine

Javascript Engine의 코드 처리 절차Javascript Engine의 코드 처리 절차

가장 먼저, Javascript 코드는 Javascript Engine을 통해서 일어난다. 이 Javascript Engine은 각 브라우저마다 서로 다른 엔진을 가지고 있으며, 코드 처리 작업을 위한 엔진의 구성 요소는 크게 네 가지로 구성된다.

각 브라우저마다 내부에 JS Engine을 가진다. 즉, 브라우저마다 엔진이 서로 다르기 때문에 동일한 코드도 다르게 해석되거나 동작할 가능성이 있다. 우리가 여러 환경의 브라우저에서 테스트해야하는 이유가 여기서 발생한다. V8는 Chrome, Opera, Node.js의 JS 엔진이고 SpiderMonkey, JavascriptCore는 각각 Firefox, Safari의 엔진이다. 이를 해결하기 위한 해결방법으로는 CSS 벤더 프리픽스나 폴리필(Polyfill)이 있고 크로스 브라우저 테스트를 통해 추가적인 작업을 검토해야한다.(자세한 내용은 추후 새 게시글로 작성 예정)

  1. JS Parser : JS 코드를 읽고 추상 구문 트리(AST: Abstract Syntax Tree)로 변환시킨다.

  2. Interpreter : AST 코드를 실행 가능하도록 해석하여 바이트코드로 변환하고, 인터프리터가 이를 실행한다.

  3. JIT Compiler(Just-in-Time) : 성능을 높이기 위해 자주 사용되는 코드를 최적화한다. 예를 들어, 반복된 코드 실행시 해당 JIT가 해당 코드를 기계어로 컴파일하여 성능을 최적화한다.

  4. Garbage Collector : JS 엔진이 메모리를 자동으로 관리하기 위해 사용되지 않는 메모리를 자동 해제하여 누수를 방지한다.

구성요소를 알았으니 어떤 과정을 통하여 엔진이 코드를 분석하고 실행시키는지 살펴보자.

파싱 단계

이 단계는 JS Parser를 통해 일어난다. JS Parser는 엔진의 일부이며, 소스 코드를 해석하고 실행 가능한 형태로 변환하는 역할을 한다. 즉, JS 코드를 읽어 **추상 구문 트리(AST)**로 변환하는 과정을 하고, 이 과정에서 문법 오류를 체킹한다. 더 세부적으로는 네 가지 단계로 나뉘어진다.

  1. Tokenizing(토크나이징) : 머신러닝 분야에서도 많이 나오는 단어인데 단순히 말하면 코드를 더 작은 단위로 분리시키는 과정이다.(논외지만, 머신 러닝에서는 코드를 세분화하여 서로의 관계를 찾기 위해 사용되는 함수이기도 하다)
1let sample = 20 + 9;
2
3// 토크나이징 결과: let, sample, =, 20, +, 9, ;

위 코드를 토크나이징하면 'let (키워드), sample (변수명), = (할당 연산자), 20 (숫자 리터럴), + (덧셈 연산자), 9 (숫자 리터럴), ; (세미콜론)'와 같이 분리된다.

  1. Parsing : 쪼개진 토큰을 기반으로 추상 구문 트리(AST)를 생성하여 코드 구조를 트리 형태로 표현한다. 구문 트리 코드는 문법적 구조를 나타내고, 각 토큰이 서로 어떻게 연관되어 있는지 표현한다. 이 과정에서 문법 오류가 발생한다면 실행을 즉시 중단한다.
1// 토크나이징된 코드는 다음과 같은 트리 구조(AST)로 변환된다
2Program
3 └── VariableDeclaration (let)
4     └── VariableDeclarator
5         ├── Identifier (sample)
6         └── BinaryExpression (+)
7             ├── Literal (20)
8             └── Literal (9)
  1. 실행 컨텍스트 생성 : 파싱이 완료된 후, 실행이 되기 직전에 실행 컨텍스트(Execution Context)가 생성된다. 이 과정에서 JS는 변수와 함수 선언을 메모리에 할당하여 호이스팅을 처리한다.

  2. 해석 및 실행

실행 컨텍스트에 대한 자세한 내용은 새로운 게시글로 작성할 예정이다. 실행 컨텍스트의 구조와 해당 변수 바인딩, 그리고 스코프에 대해 어떻게 참조되는지를 살펴보면 이번 게시글에서 호이스팅과 연관지어 명확하게 이해가 될 것이다. (재밌지 않나요..?🤗)

컴파일링 단계

AST 최적화바이트코드가 생성되는 단계이다. 엔진은 AST를 기반으로 저수준 코드, 즉 바이트코드로 변환하거나 실행 준비를 위한 작업을 한다. 이 단계에서 엔진은 코드 최적화도 수행할 수 있다. 불필요한 코드를 제거하거나, 효율적으로 실행될 수 있도록 트리를 최적화하는 과정을 거친다.

실행 단계

이제 자바스크립트 엔진은 바이트 코드를 기반으로 실제로 코드를 실행시킨다. 추가적으로, 실행 단계가 지나고 나서 자바스크립트는 가비지 컬랙터를 통하여 메모리를 자동으로 관리한다. 실행 중 더 이상 사용되지 않는 객체나 메모리를 정리하여 시스템 자원을 효율적으로 사용한다.

호이스팅의 동작 방식

호이스팅은 '변수와 함수의 메모리 공간이 선언 전에 미리 할당되는 것'이라고 했다. 세 단계 중 언제 발생하는 것이 알맞아보이나? 바로 '파싱 단계'다. JS Parser를 통해 토크나이징을 진행하고 AST로 변환하는 과정에서 코드 실행 전 선언문을 스코프 최상단으로 끌어올린다. 여기서 '끌어올린다'는 것은 실제로 물리적으로 끌어올리는게 아니라 코드 실행 전 모든 선언문을 가장 먼저 취급하기 때문이라는 것을 명심하자. 호이스팅에는 변수 호이스팅과 함수 호이스팅이 있다. 말 그대로 각각 변수와 함수를 호이스팅하는 것인데 좀 더 자세히 살펴보자.

변수 호이스팅

가장 먼저, 변수는 호이스팅되지만 변수의 초기화는 호이스팅되지 않고 선언만 최상단으로 끌어올려진다. 선언된 변수를 호이스팅하기 때문에 선언되는 키워드(var, let, const)에 따라 다르게 동작한다. 셋 다 필요해서 동시에 생긴 것이 아닌 var가 먼저 생겼다가 단점을 보완하고자 let과 const가 도입되었다.

1. var로 선언된 변수

과거에는 JS에서 변수를 선언할 때 var만 사용할 수 있었다. var로 선언된 변수는 크게 두 가지 문제점을 야기한다. 첫 번째는, 함수 스코프를 가지기 때문에 함수 내부에서만 유효하다는 것이다. 블록 내부에서 선언된 변수는 블록 외부에서도 접근이 가능하기 때문에 예기치 못한 접근까지 허용하고 이는 독립적인 코드 설계에 부정적인 영향을 미친다. 두 번째 문제는 이미 선언된 변수를 동일한 스코프 내에서 재선언이 가능하다는 것이다.

1// 문제점1. 함수 스코프
2if (true) {
3  var x = 5;
4}
5console.log(x); // 5
6
7// 문제점2. 재선언, 재할당 가능
8var x = 1;
9var x = 2;
10console.log(x); // 2

2. let로 선언된 변수

이 문제점을 해결하고자 도입된 것이 ES6에서 생겨난 let키워드다. let블록 스코프를 가지기 때문에 변수가 선언된 블록 내부에서만 접근이 가능하다. 또한, let으로 선언된 변수들은 동일한 스코프 내에서 재선언하려고 한다면 오류가 발생한다.

1// 1. 블록 스코프
2if (true) {
3  let y = 8;
4}
5console.log(y); // ReferenceError: y is not defined
6
7// 2. 재선언 불가능, 재할당 가능
8let z = 1;
9let z = 2; // SyntaxError: Identifier 'z' has already been declared

3. const로 선언된 변수

const의 도입은 변경 불가한 값인 상수를 정의하기 위하여 let과 함께 도입되었다. let과 동일하게 블록 스코프를 따르고, 재할당과 재선언이 모두 불가능한 가장 엄격한 키워드다.

1// 재할당 불가능
2const a = 10;
3a = 20; // TypeError: Assignment to constant variable.

다만, const로 선언된 객체와 배열의 경우 참조값만 고정된다. 즉, 전체 객체나 배열 자체를 새로운 값으로 재할당하는 것은 불가하지만, 객체의 속성이나 배열의 요소는 변경할 수 있다는 것을 의미한다.

1// 예를 들어, obj의 내부 필드값은 변경이 가능하다.
2
3const sample = { name: "Kakao" };
4obj.name = "Toss"; // 객체의 속성 변경 가능
5obj = {}; // TypeError: Assignment to constant variable.

함수 호이스팅

길고 긴 변수 호이스팅을 살펴보았으니 호이스팅에 대해 조금은 익숙해졌으리라. 함수 호이스팅은 함수 전체가 호이스팅된다. 우리가 함수가 선언되기 전 코드에서 호출하려고 하면 해당 함수가 호출되는 것이 바로 이러한 이유 때문이다.

1// 선언 전 호출이 가능한 함수 호이싕
2
3console.log(add(2, 3)); // 5
4
5function add(a, b) {
6  return a + b;
7}

다만, 함수 표현식은 변수 호이스팅과 동일하게 동작한다. 그렇기 때문에 함수 표현식은 선언 . 전호출하려고 할 때 <func-name> is not a function이라는 에러를 뱉는다.

1console.log(subtract(5, 3)); // TypeError: subtract is not a function
2
3var subtract = function (a, b) {
4  return a - b;
5};

TDZ

이쯤에서 꼭 짚고 넘어가야 할 개념이 하나 있다. 바로 "일시적 사각지대"를 의미하는 TDZ(Temporary Dead Zone)는 let과 const의 호이스팅 동작을 이해하는 데 핵심적인 개념이다. let과 const는 선언은 호이스팅되지만 초기화되기 전까지는 TDZ라는 구간에 들어가게 된다. 변수는 존재하지만 아직 사용할 준비가 안 됐기 때문에, "얘 아직 초기화 안 됐어. 건드리지 마!"라는 브라우저의 일종의 보호 장치라고 생각하면 이해가 쉽다. 아래에서 더 자세히 살펴보자.

호이스팅 관점에서 var, let, const 차이점

let, var, const가 뭔지 알았으니 호이스팅과의 관점에서 슥 알아보자. var은 선언과 동시에 undefined로 초기화된다. 그렇기 때문에 var로 선언된 변수는 선언 전에 참조를 해도 에러가 발생하지 않는다. letconst는 선언과 초기화 단계가 별개로 동작한다. var과 달리 선언은 호이스팅을 통하여 상단으로 끌어올려지지만 초기화는 코드가 실행되는 과정에서 선언문을 만났을 때 동작한다.

다시 동일한 예제를 살펴보자.

1// var case
2var x; // 선언이 호이스팅됨 (초기값은 undefined)
3console.log(x); // undefined (초기화 전에 접근)
4x = 5; // 초기화
5console.log(x); // 5
6
7// let, const case
8let y; // 선언만 호이스팅되고 초기화하지 않음(초기화 전까지 TDZ에 보관)
9console.log(y); // ReferenceError(TDZ 상태이므로 참조 불가)
10let y = 10;
11console.log(y); // 10

letconst는 선언과 동시에 TDZ 라는 곳에 들어간다. **TDZ(Temporary Dead Zone₩)**는 글마다 표현되는 다양한 방식이 있지만 코드 실행 시점에 변수가 초기화될 때까지의 시간 동안만 존재하는 구간 정도로 생각하자. '초기화될 때까지'니까 선언과 동시에 undefined로 초기화되는 var은 TDZ에 들어갈 일이 없다.

TDZ의 존재로 인하여 우리는 선언만 된 변수를 이 곳에 보관한다. 이는 Javascript 코드의 안정성과 명확성을 높이기 위한 동작 방식이다. var 또는 const로 선언되었지만 초기화되지 않은 변수를 참조할시 오류를 발생시킨다! 그렇기 때문에 우리는 개발해오면서 ReferenceError라는 것을 종종 봤을테다. '초기화되지 않았기에 TDZ에 보관시켜뒀는데 이걸 꺼내서 쓰려고해?'라고 말하며 ReferenceError를 뱉는 것이다!


여기까지 'Javascript 코드 안정성을 위한 호이스팅'에 대해 알아봤다. 호이스팅을 알기 위해서 JS 엔진의 어느 단계에서 벌어지는지, 그리고 정확히 '변수가 최상단으로 끌어올려지는' 것 같아 보이는 동작이 어떻게 일어나는지 알아보았다. 그 과정에서 변수가 선언되고 초기화되는 키워드들의 차이점에 따라 Javascript가 마련해둔 TDZ를 통하여 안정성이 확보되는 것을 배웠다. 별거 아닌 것 같지만 왜 ReferenceError<function-name> is not a function이 뜨는지, 어느 코드를 먼저 의심해야하는지 예시들도 살펴봤다. 막상 정리를 와라락 하고 나니 정리에 비해 별거 아닌 것 같아보이지만, 이 개념들을 처음 살펴본 이들에게는 쉽게 읽혔으면 하는 글이 되었길 바란다.

Index

호이스팅Javascript Engine호이스팅의 동작 방식변수 호이스팅함수 호이스팅TDZ호이스팅 관점에서 var, let, const 차이점