
호이스팅(Hoisting): 자바스크립트의 특이한 동작
변수 선언이 코드 꼭대기로 끌어올려진 것처럼 보이는 마법. 성격 급한 자바스크립트 엔진의 '미리 읽기' 습관.

변수 선언이 코드 꼭대기로 끌어올려진 것처럼 보이는 마법. 성격 급한 자바스크립트 엔진의 '미리 읽기' 습관.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

몇 년 전, 간단한 토이 프로젝트를 만들 때의 일이다. 폼 검증 로직을 짰는데 계속 undefined가 튀어나왔다. 콘솔에 찍힌 값을 보면서 "내가 이 변수 분명 선언했는데?"라고 중얼거렸다. 코드는 이랬다.
function validateForm() {
console.log(isValid); // undefined
if (username.length > 0) {
var isValid = true;
}
return isValid;
}
나는 isValid를 if 블록 안에서 만들었으니까, 밖에서 접근하면 에러가 나야 맞다고 생각했다. Python이나 Java였다면 틀림없이 에러였을 것이다. 근데 자바스크립트는 그냥 undefined를 반환했다. 에러도 안 나니 문제를 찾기가 더 힘들었다.
다음날 아침에 스택오버플로우를 뒤적이다가 "호이스팅(Hoisting)"이라는 단어를 처음 봤다. 솔직히 처음엔 무슨 뜻인지 1도 이해가 안 갔다. "끌어올린다"는 게 뭘 어디로 끌어올린다는 건지, 코드가 실행 중에 순서가 바뀐다는 건지 혼란스러웠다. 그 날 이후로 나는 호이스팅을 제대로 이해하기로 마음먹었다. 이 글은 그때부터 지금까지 내가 정리해온 호이스팅에 대한 기록이다.
호이스팅을 설명하는 글들은 대부분 "변수 선언이 스코프 최상단으로 끌어올려지는 현상"이라고 한다. 처음엔 나도 그렇게 받아들였다. 근데 실제로 코드가 물리적으로 움직이는 건 아니다. 이건 자바스크립트 엔진이 코드를 실행하는 방식 때문에 생기는 착시에 가깝다.
자바스크립트 엔진은 코드를 실행하기 전에 두 단계를 거친다.
나한테 와닿았던 비유는 "시험 전날 교과서 훑어보기"였다. 시험 보기 전에 미리 문제 목차를 쭉 보면서 "아 이런 게 나오는구나" 하고 훑는 것처럼, 자바스크립트 엔진도 실행 전에 미리 "아 여기 var name 있네" 하고 메모해둔다. 그래서 실행 시점에는 이미 그 변수의 존재를 알고 있는 것이다.
가장 기본적인 예제부터 보자.
console.log(name); // undefined
var name = "김철수";
console.log(name); // "김철수"
첫 번째 줄에서 name을 출력하면 에러가 아니라 undefined가 나온다. 엔진 입장에서 이 코드는 이렇게 해석된다.
// 생성 단계: var name 발견 -> 메모리 할당, 값은 undefined로 초기화
var name = undefined;
// 실행 단계
console.log(name); // undefined (이미 메모리에 있음)
name = "김철수"; // 값 할당
console.log(name); // "김철수"
실제로 코드가 이렇게 변환되는 건 아니지만, 엔진이 내부적으로 이런 식으로 처리한다는 뜻이다. var 선언은 생성 단계에서 미리 메모리에 공간을 만들어두고, 값은 undefined로 초기화해둔다. 그래서 선언 전에 접근해도 에러가 안 나는 것이다.
이게 내가 겪었던 버그의 원인이었다. var isValid는 if 블록 안에 있었지만, 함수 스코프 전체에 호이스팅되어버렸다. 내가 의도한 건 블록 스코프였는데, var는 함수 스코프를 따르니까 함수 전체에서 접근 가능했던 것이다.
var의 이런 이상한 동작 때문에 ES6에서 let과 const가 나왔다. 처음엔 "let과 const는 호이스팅이 안 된다"는 설명을 봤다. 근데 이건 정확하지 않다. let과 const도 호이스팅은 된다. 다만 접근 방식이 다를 뿐이다.
console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 25;
let으로 선언한 변수도 생성 단계에서 메모리에 등록된다. 근데 var와 다르게 초기화를 하지 않는다. 선언문에 도달하기 전까지는 접근이 금지되는데, 이 구역을 Temporal Dead Zone(TDZ, 일시적 사각지대)라고 부른다.
나는 TDZ를 "공사 중인 건물"에 비유한다. 건물 틀은 이미 세워져 있지만(메모리 할당됨), 아직 내부 공사가 끝나지 않아서 출입금지 테이프가 쳐져 있는 상태다. 선언문(let age = 25)에 도달해야 비로소 출입이 허가된다.
// TDZ 시작
console.log(x); // ReferenceError
console.log(y); // ReferenceError
let x = 10; // x의 TDZ 종료
console.log(x); // 10
const y = 20; // y의 TDZ 종료
console.log(y); // 20
이 TDZ 개념 덕분에 나는 변수를 선언하기 전에 실수로 쓰는 버그를 많이 줄일 수 있었다. var 시절에는 에러도 안 나서 디버깅이 지옥이었는데, let/const는 명확하게 에러를 던져준다.
변수만 호이스팅되는 게 아니라 함수도 호이스팅된다. 근데 함수 선언(Function Declaration)과 함수 표현식(Function Expression)의 호이스팅 동작이 다르다.
greet(); // "안녕하세요" - 에러 안 남!
function greet() {
console.log("안녕하세요");
}
함수 선언문은 전체가 통째로 호이스팅된다. 선언 전에 호출해도 문제없다. 이건 편리할 때도 있지만, 코드 순서를 무시하게 만들어서 가독성을 해칠 수 있다.
sayHello(); // TypeError: sayHello is not a function
var sayHello = function() {
console.log("Hello");
};
함수 표현식은 변수에 함수를 할당하는 것이기 때문에, 변수 호이스팅 규칙을 따른다. var sayHello만 호이스팅되고 값은 undefined로 초기화된다. 그래서 호출하면 "undefined는 함수가 아니다"라는 에러가 나온다.
const를 쓰면 더 명확해진다.
greet(); // ReferenceError: Cannot access 'greet' before initialization
const greet = function() {
console.log("안녕");
};
const는 TDZ 때문에 선언 전에는 접근조차 할 수 없다. 나는 이게 더 안전하다고 생각해서 요즘은 함수 표현식에 const를 많이 쓴다.
ES6 클래스도 호이스팅되지만, let/const와 마찬가지로 TDZ에 걸린다.
const user = new Person(); // ReferenceError
class Person {
constructor(name) {
this.name = name;
}
}
클래스 선언 전에 인스턴스를 만들려고 하면 에러가 난다. 이것도 TDZ 때문이다. 클래스는 호이스팅은 되지만 초기화 전까지는 접근이 금지된다.
실제로 내가 겪었던 디버깅 상황 몇 가지를 정리해본다.
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// 출력: 3, 3, 3
기대한 건 0, 1, 2였는데 3만 세 번 나온다. var i는 함수 스코프라서 루프가 끝나면 i는 3이 되어 있고, setTimeout 콜백이 실행될 때는 모두 같은 i를 참조하기 때문이다.
let으로 바꾸면 해결된다.
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// 출력: 0, 1, 2
let은 블록 스코프라서 각 루프마다 별도의 i가 생성된다.
var name = "김철수";
console.log(name); // "김철수"
var name = "이영희"; // 에러 안 남!
console.log(name); // "이영희"
var는 같은 스코프에서 같은 변수를 여러 번 선언해도 에러가 안 난다. 이게 팀 프로젝트에서 진짜 골치 아프다. 누가 이미 만든 변수를 모르고 또 만들어도 에러가 안 나니까.
let/const는 중복 선언을 막아준다.
let age = 25;
let age = 30; // SyntaxError: Identifier 'age' has already been declared
switch (option) {
case 1:
let value = "A";
console.log(value);
break;
case 2:
let value = "B"; // SyntaxError!
console.log(value);
break;
}
switch 문 전체가 하나의 블록 스코프라서 let value를 두 번 선언하면 에러가 난다. 각 case마다 중괄호로 블록을 만들어야 한다.
switch (option) {
case 1: {
let value = "A";
console.log(value);
break;
}
case 2: {
let value = "B";
console.log(value);
break;
}
}
호이스팅을 완전히 이해하려면 스코프 체인(Scope Chain)도 알아야 한다. 자바스크립트는 변수를 찾을 때 현재 스코프부터 시작해서 바깥 스코프로 차례대로 올라간다.
var x = "global";
function outer() {
console.log(x); // undefined (not "global")
var x = "outer";
function inner() {
console.log(x); // "outer"
}
inner();
}
outer();
outer 함수 안에서 console.log(x)를 했을 때 "global"이 나올 것 같지만 undefined가 나온다. var x = "outer" 선언이 함수 스코프 최상단으로 호이스팅되기 때문이다. 엔진 입장에서는 이렇게 본다.
function outer() {
var x; // 호이스팅됨, undefined로 초기화
console.log(x); // undefined
x = "outer";
function inner() {
console.log(x); // "outer"
}
inner();
}
로컬 스코프에 x가 있으니까 글로벌 x를 찾으러 가지 않는다. 이게 스코프 체인과 호이스팅이 상호작용하는 방식이다.
ES6 모듈도 호이스팅이 적용된다. import 문은 항상 모듈의 최상단으로 호이스팅된다.
console.log(add(2, 3)); // 5
import { add } from './math.js'; // 실제로는 맨 위로 올라감
이게 편리한 점은, import 순서를 신경 쓰지 않아도 된다는 것이다. 하지만 나는 가독성을 위해 습관적으로 import는 파일 맨 위에 쓴다.
호이스팅을 완전히 이해한 지금, 내가 따르는 규칙들은 이렇다.
var는 절대 쓰지 않는다. let과 const만 쓴다. 호이스팅 때문에 생기는 혼란을 원천 차단할 수 있다.
변수는 사용하기 직전에 선언한다. 옛날 C 스타일로 함수 맨 위에 변수를 몰아서 선언하지 않는다. 필요한 곳에서 선언하는 게 가독성도 좋고 스코프도 명확해진다.
const를 기본으로, 재할당이 필요할 때만 let을 쓴다. 이건 호이스팅과 직접 관련은 없지만, 불변성을 지향하면 코드 추론이 쉬워진다.
함수는 선언문보다 표현식을 선호한다. 요즘은 화살표 함수를 const에 할당해서 많이 쓴다.
const greet = (name) => {
console.log(`Hello, ${name}`);
};
호이스팅을 "엔진이 코드를 위로 옮긴다"라고 이해하는 건 초보 단계입니다. 고수라면 실행 컨텍스트로 설명해야 합니다.
var name, function greet 같은 식별자가 키-값 쌍으로 저장됩니다. (이게 바로 호이스팅의 실체!)즉, 호이스팅은 "Environment Record에 식별자를 수집(Binding)하는 과정"입니다. 코드가 이동하는 게 아니라, 기록지에 이름을 미리 적어두는 행위인 거죠.
다음 코드의 결과를 3초 안에 맞혀보세요.
var a = 1;
function outer() {
console.log(a); // (1)
var a = 2;
console.log(a); // (2)
}
outer();
console.log(a); // (3)
undefined: outer 함수 내부의 var a가 호이스팅되었기 때문입니다. 전역 변수 a=1을 가립니다.2: 할당이 일어난 후이므로 2가 출력됩니다.1: 함수 스코프 밖이므로 전역 변수 1이 출력됩니다.하나 더! 함수와 변수 이름이 같으면 누가 이길까요?
var foo;
function foo() {}
console.log(typeof foo);
function입니다.
함수 선언은 변수 선언보다 우선순위가 높습니다. (Function Hoisting takes precedence over Variable Hoisting). 변수 foo가 초기화(값 할당)되지 않았으므로, 함수 foo가 자리를 차지합니다. 만약 var foo = 1; 처럼 값을 할당했다면 변수가 이깁니다.
이 글을 읽은 당신은 이제 호이스팅이 "마법"이 아니라 "메모리 할당 시점의 차이"라는 것을 명확히 설명할 수 있습니다. 누군가 물어보면 자신 있게 대답하세요!
import 구문이 호이스팅되는 이유는 자바스크립트 엔진의 동작 방식 때문이 아니라, 모듈 시스템의 설계 때문입니다.
ES6 모듈은 정적 분석(Static Analysis)을 전제로 합니다. 코드를 실행하기 전에 컴파일러(또는 엔진)가 파일의 의존성을 파악하고, 모듈 그래프를 먼저 그립니다. 이 단계에서 import와 export를 모두 처리합니다.
그래서 import 문이 코드 중간에 있어도, 논리적으로는 최상단에서 먼저 처리될 수밖에 없습니다. 이는 Dead Code Elimination (Tree Shaking)을 가능하게 하는 핵심 원리이기도 합니다. 실행 중에 동적으로 모듈을 불러오는 require()와 달리, import는 구조를 고정시키니까요.
호이스팅은 자바스크립트의 특이한 동작 중 하나다. 처음엔 "왜 이렇게 만들었을까?" 하고 불만스러웠는데, 이제는 그냥 "자바스크립트가 원래 그런 거지" 하고 받아들인다. 중요한 건 동작 원리를 이해하고, 그에 맞춰서 안전한 코딩 습관을 들이는 것이다.
새벽 2시에 undefined 때문에 삽질했던 그 날 이후로, 나는 var를 단 한 번도 쓰지 않았다. let과 const를 쓰면서 TDZ의 보호를 받고, 블록 스코프 덕분에 변수 충돌도 거의 없어졌다. 호이스팅을 이해하고 나니, 디버깅 시간이 확실히 줄어들었다.
결국 호이스팅이란 건, 자바스크립트 엔진이 "나 일 시작하기 전에 미리 준비 좀 할게"라고 말하는 것이다. 우리는 그 준비 과정을 이해하고, 예상 가능한 코드를 짜면 된다. 그게 내가 내린 결론이다.
A few years ago, I was building a simple toy project. I wrote some form validation logic, and undefined kept popping up everywhere. Staring at the console, I muttered to myself, "I swear I declared this variable." Here's what the code looked like:
function validateForm() {
console.log(isValid); // undefined
if (username.length > 0) {
var isValid = true;
}
return isValid;
}
I declared isValid inside the if block, so accessing it outside should've thrown an error. In Python or Java, it absolutely would have. But JavaScript just returned undefined. No error, which made it even harder to debug.
The next morning, digging through Stack Overflow, I stumbled upon a word I'd never seen before: "hoisting." Honestly, I had zero clue what it meant at first. "Hoisting" what, exactly? Where? Does code actually move around during execution? It was confusing as hell. That day, I decided to really understand hoisting. This post is the record of what I've learned since then.
Most explanations say hoisting is "when variable declarations are moved to the top of the scope." I started with that mental model too. But the code doesn't physically move. It's more like an optical illusion caused by how the JavaScript engine executes code.
The JavaScript engine goes through two phases before running your code:
The metaphor that clicked for me was "skimming the textbook before an exam." Before you take the test, you flip through the chapters and get a sense of what's there. Similarly, the JavaScript engine scans ahead and takes notes like "oh, there's a var name here" before actually executing anything. So by execution time, it already knows that variable exists.
Let's start with the classic example.
console.log(name); // undefined
var name = "John Doe";
console.log(name); // "John Doe"
The first line doesn't throw an error. It prints undefined. From the engine's perspective, this is what's happening:
// Creation phase: found var name -> allocate memory, initialize to undefined
var name = undefined;
// Execution phase
console.log(name); // undefined (already in memory)
name = "John Doe"; // assignment
console.log(name); // "John Doe"
The code doesn't literally transform, but the engine internally processes it this way. var declarations are registered in memory during the creation phase and initialized to undefined. That's why accessing it before the declaration doesn't error out.
This was exactly the bug I hit. var isValid was inside an if block, but it got hoisted to the entire function scope. I wanted block scope, but var follows function scope rules, making it accessible throughout the whole function.
Because of var's weirdness, ES6 introduced let and const. I initially read that "let and const aren't hoisted." That's not quite accurate. let and const ARE hoisted. The difference is in how you can access them.
console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 25;
Variables declared with let are registered in memory during the creation phase. But unlike var, they aren't initialized. The zone between the start of the scope and the actual declaration is called the Temporal Dead Zone (TDZ).
I think of the TDZ like a building under construction. The structure is already up (memory allocated), but there's caution tape blocking entry (no access allowed). You can't go in until the declaration statement (let age = 25) is reached, which is when construction finishes.
// TDZ starts
console.log(x); // ReferenceError
console.log(y); // ReferenceError
let x = 10; // x's TDZ ends
console.log(x); // 10
const y = 20; // y's TDZ ends
console.log(y); // 20
Thanks to the TDZ, I've avoided countless bugs from accidentally using variables before declaring them. With var, there was no error to guide me, making debugging a nightmare. With let/const, the engine throws a clear error.
Not just variables, functions get hoisted too. But function declarations and function expressions hoist differently.
greet(); // "Hello" - no error!
function greet() {
console.log("Hello");
}
Function declarations are hoisted in their entirety. You can call them before they appear in the code. This can be convenient, but it also lets you ignore code order, which can hurt readability.
sayHello(); // TypeError: sayHello is not a function
var sayHello = function() {
console.log("Hello");
};
Function expressions follow variable hoisting rules because they're assignments. Only var sayHello gets hoisted, initialized to undefined. Calling it gives you "undefined is not a function."
With const, it's even clearer.
greet(); // ReferenceError: Cannot access 'greet' before initialization
const greet = function() {
console.log("Hi");
};
const is in the TDZ, so you can't even access it before declaration. I find this safer, so I mostly use const for function expressions these days.
ES6 classes are hoisted, but like let/const, they're subject to the TDZ.
const user = new Person(); // ReferenceError
class Person {
constructor(name) {
this.name = name;
}
}
Trying to instantiate a class before its declaration throws an error because of the TDZ. Classes are hoisted, but access is blocked until initialization.
Here are some debugging situations I've actually encountered.
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// Output: 3, 3, 3
I expected 0, 1, 2. But it prints 3 three times. var i is function-scoped, so after the loop finishes, i is 3. When the setTimeout callbacks execute, they all reference the same i.
Using let fixes it.
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// Output: 0, 1, 2
let is block-scoped, so each iteration gets its own i.
var name = "John";
console.log(name); // "John"
var name = "Jane"; // No error!
console.log(name); // "Jane"
var doesn't complain about duplicate declarations in the same scope. This is a real pain in team projects. Someone can redeclare a variable without knowing it already exists.
let/const prevent duplicate declarations.
let age = 25;
let age = 30; // SyntaxError: Identifier 'age' has already been declared
switch (option) {
case 1:
let value = "A";
console.log(value);
break;
case 2:
let value = "B"; // SyntaxError!
console.log(value);
break;
}
The entire switch is one block scope, so declaring let value twice throws an error. You need to wrap each case in curly braces to create separate blocks.
switch (option) {
case 1: {
let value = "A";
console.log(value);
break;
}
case 2: {
let value = "B";
console.log(value);
break;
}
}
To fully grasp hoisting, you also need to understand the scope chain. JavaScript searches for variables starting from the current scope and moving outward.
var x = "global";
function outer() {
console.log(x); // undefined (not "global")
var x = "outer";
function inner() {
console.log(x); // "outer"
}
inner();
}
outer();
Inside outer, console.log(x) prints undefined, not "global". The declaration var x = "outer" is hoisted to the top of the function scope. The engine sees it like this:
function outer() {
var x; // hoisted, initialized to undefined
console.log(x); // undefined
x = "outer";
function inner() {
console.log(x); // "outer"
}
inner();
}
Since there's a local x, the engine doesn't look for the global x. This is how the scope chain and hoisting interact.
ES6 modules also have hoisting. Import statements are always hoisted to the top of the module.
console.log(add(2, 3)); // 5
import { add } from './math.js'; // actually hoisted to the top
The convenience here is you don't have to worry about import order. But for readability, I still put imports at the top of the file out of habit.
Now that I fully understand hoisting, here are the rules I follow:
Never use var. Only use let and const. This eliminates hoisting confusion entirely.
Declare variables right before use. I don't declare all variables at the top of a function like old-school C. Declaring where needed improves readability and scope clarity.
Default to const, use let only when reassignment is needed. This isn't directly about hoisting, but immutability makes code easier to reason about.
Prefer function expressions over declarations. These days, I mostly assign arrow functions to const.
const greet = (name) => {
console.log(`Hello, ${name}`);
};
Understanding hoisting as "moving code up" is beginner level. To be a pro, you must explain it with Execution Context.
var name, function greet as key-value pairs. (This is the reality of hoisting!)So hoisting is essentially "The process of Binding identifiers to the Environment Record". Code doesn't move; names are just written down in the register beforehand.
Guess the result of this code in 3 seconds.
var a = 1;
function outer() {
console.log(a); // (1)
var a = 2;
console.log(a); // (2)
}
outer();
console.log(a); // (3)
undefined: Because var a inside outer is hoisted. It shadows the global a=1.2: After assignment, it prints 2.1: Outside the function scope, it prints the global 1.One more! Variable vs Function, who wins?
var foo;
function foo() {}
console.log(typeof foo);
function.
Function Declaration takes precedence over Variable Declaration. Since variable foo wasn't initialized (assigned a value), function foo keeps the spot. If you did var foo = 1;, the variable would win.
Why are import statements hoisted? It's not just a quirk; it's by design for the ES6 Module System.
ES6 Modules are designed for Static Analysis. Before executing a single line of code, the engine (or tools like Webpack) parses the file to build a Module Graph. It identifies all import and export statements during this parsing phase.
Therefore, even if you write an import in the middle of a file, it is logically processed at the very beginning. This rigid structure enables powerful features like Tree Shaking (Dead Code Elimination). Unlike require(), which loads modules dynamically at runtime, import locks down the dependency structure beforehand.
This is why you get a syntax error if you try to put import inside an if block. It must be statically analyzable at the top level.