var로 선언한 변수는 왜 선언 전에 접근해도 에러가 나지 않을까요? 반대로 let은 왜 에러가 날까요?

같은 변수 선언인데 동작이 다른 이유는, 자바스크립트 엔진이 실행 컨텍스트를 생성하는 과정에서 각 키워드를 다르게 처리하기 때문입니다.

var — 함수 스코프와 호이스팅

var는 자바스크립트 초창기부터 있던 변수 선언 키워드입니다. 두 가지 특징이 있습니다.

함수 스코프

var함수 단위 로 스코프가 결정됩니다. 블록(if, for)은 스코프 경계가 아닙니다.

JS
function example() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 — if 블록 밖에서도 접근 가능
}

example();

이 동작이 직관적이지 않기 때문에 많은 버그의 원인이 됩니다.

var의 호이스팅

var로 선언한 변수는 실행 컨텍스트 생성 시 선언과 동시에 undefined로 초기화 됩니다.

JS
console.log(a); // undefined — 에러가 아님
var a = 5;
console.log(a); // 5

엔진 입장에서 위 코드는 개념적으로 이렇게 처리됩니다.

JS
// 실행 컨텍스트 생성 단계
var a = undefined; // 선언 + 초기화

// 실행 단계
console.log(a);    // undefined
a = 5;             // 할당
console.log(a);    // 5

"코드가 위로 올라간다"는 표현은 비유입니다. 실제로는 환경 레코드에 먼저 등록되는 것입니다.

var의 중복 선언

같은 스코프에서 var를 여러 번 선언해도 에러가 나지 않습니다.

JS
var x = 1;
var x = 2; // 에러 없음
console.log(x); // 2

이것도 버그를 찾기 어렵게 만드는 요인 중 하나입니다.

let — 블록 스코프와 TDZ

ES2015에서 도입된 letvar의 문제점을 해결합니다.

블록 스코프

let블록({}) 단위 로 스코프가 결정됩니다.

JS
function example() {
  if (true) {
    let y = 20;
  }
  console.log(y); // ReferenceError — 블록 밖에서 접근 불가
}

TDZ (Temporal Dead Zone)

let도 호이스팅됩니다. 하지만 var와 다르게 **선언만 되고 초기화되지 않습니다 **. 선언문이 실행되기 전까지 변수에 접근하면 ReferenceError가 발생하는데, 이 구간을 TDZ 라고 합니다.

JS
{
  // TDZ 시작 — 스코프 진입 시점
  console.log(b); // ReferenceError
  let b = 10;     // TDZ 끝 — 여기서 초기화
  console.log(b); // 10
}

TDZ는 시간 기반이 아니라 코드 실행 순서 기반입니다.

JS
// 시간적으로 뒤에 있지만 실행 순서상 TDZ를 벗어남
function check() {
  console.log(value); // 정상 동작 — 함수 호출 시점에는 TDZ를 지남
}

let value = 42;
check(); // 42

let은 중복 선언 불가

JS
let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declared

이 제약 덕분에 실수로 같은 변수를 다시 선언하는 버그를 방지할 수 있습니다.

const — 재할당 금지, 그러나 불변은 아니다

constlet과 동일한 블록 스코프와 TDZ를 가지지만, ** 재할당이 불가능 **합니다.

JS
const PI = 3.14159;
PI = 3.14; // TypeError: Assignment to constant variable

가장 흔한 오해 — "const는 불변이다"

const는 ** 바인딩(binding)을 고정 **하는 것이지, ** 값 자체를 불변으로 만드는 것이 아닙니다 **. 객체나 배열의 내부는 변경할 수 있습니다.

JS
const user = { name: 'Alice', age: 25 };

// 프로퍼티 변경 — 가능
user.name = 'Bob';
console.log(user.name); // 'Bob'

// 재할당 — 불가능
user = { name: 'Charlie' }; // TypeError
JS
const arr = [1, 2, 3];

// 배열 요소 변경 — 가능
arr.push(4);
console.log(arr); // [1, 2, 3, 4]

// 재할당 — 불가능
arr = [5, 6]; // TypeError

여기서 핵심은 ** 바인딩 고정과 값 불변의 차이 **입니다. const는 변수가 가리키는 대상(바인딩)을 바꿀 수 없게 만들 뿐, 대상 자체의 내용은 제한하지 않습니다.

진짜 불변 객체를 만들려면

JS
const frozen = Object.freeze({ name: 'Alice', age: 25 });
frozen.name = 'Bob'; // 무시됨 (strict mode에서는 TypeError)
console.log(frozen.name); // 'Alice'

하지만 Object.freeze()는 ** 얕은 동결(shallow freeze)**입니다. 중첩 객체는 동결되지 않습니다.

JS
const config = Object.freeze({
  db: { host: 'localhost', port: 3306 }
});

config.db.port = 5432; // 변경됨!
console.log(config.db.port); // 5432

깊은 동결이 필요하면 structuredClone + Object.freeze를 조합하거나 재귀적으로 동결하는 유틸리티를 만들어야 합니다.

호이스팅 — 진짜 메커니즘

호이스팅을 "선언이 위로 끌어올려진다"고 설명하는 경우가 많지만, 정확한 메커니즘은 다릅니다.

실행 컨텍스트의 두 단계

자바스크립트 엔진은 코드를 실행할 때 두 단계를 거칩니다.

  1. ** 생성 단계(Creation Phase)**: 선언을 환경 레코드에 등록
  2. ** 실행 단계(Execution Phase)**: 코드를 한 줄씩 실행

각 키워드별 생성 단계 동작이 다릅니다.

키워드생성 단계접근 가능 시점
var선언 + undefined로 초기화스코프 어디서든 (undefined)
let선언만 (초기화 X)선언문 이후 (TDZ)
const선언만 (초기화 X)선언문 이후 (TDZ)
함수 선언문선언 + 초기화 + 할당스코프 어디서든
함수 표현식변수 규칙을 따름변수 규칙을 따름

함수 호이스팅

함수 선언문은 생성 단계에서 ** 선언과 동시에 함수 객체로 초기화 **됩니다.

JS
// 함수 선언문 — 선언 전에 호출 가능
greet(); // 'hello'
function greet() {
  console.log('hello');
}

// 함수 표현식 — var이므로 undefined
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
  console.log('hi');
};
JS
// let으로 함수 표현식 — TDZ 적용
run(); // ReferenceError
let run = () => console.log('running');

for 루프와 변수 — 스코프 차이가 드러나는 순간

JS
// var 사용 — 의도와 다른 결과
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 출력: 3, 3, 3

var는 함수 스코프이므로 루프 전체에서 하나의 i를 공유합니다. setTimeout 콜백이 실행될 때 루프는 이미 끝나서 i는 3입니다.

JS
// let 사용 — 의도대로 동작
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 출력: 0, 1, 2

let은 블록 스코프이므로 ** 매 반복마다 새로운 바인딩 **이 생깁니다. 각 콜백이 자신만의 i를 캡처합니다.

var일 때 이 문제를 해결하는 고전적 방법은 IIFE로 각 반복의 값을 캡처하는 것입니다.

JS
// var + IIFE로 해결하는 고전적 방법
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 100);
  })(i);
}
// 출력: 0, 1, 2

전역 스코프에서의 차이

전역에서 var로 선언하면 window 객체의 프로퍼티가 됩니다. letconst는 그렇지 않습니다.

JS
var globalVar = 'hello';
let globalLet = 'world';

console.log(window.globalVar); // 'hello'
console.log(window.globalLet); // undefined

이것은 전역 오염(global pollution) 관점에서 let/const가 더 안전한 이유이기도 합니다.

실무에서의 선택 기준

JS
// 기본: const를 사용
const API_URL = 'https://api.example.com';
const config = { timeout: 3000 };

// 재할당이 필요한 경우만: let
let count = 0;
count += 1;

// var: 사용하지 않는다
// 레거시 코드 유지보수 시에만 만남

대부분의 스타일 가이드(Airbnb, Google 등)에서 const를 기본으로, 재할당이 필요할 때만 let을 사용 하라고 권장합니다. var는 새 코드에서 쓸 이유가 없습니다.

주의할 점

"호이스팅되지 않는다"는 오해

letconst가 "호이스팅되지 않는다"고 설명하는 자료가 많지만, 정확하지 않습니다. 호이스팅은 됩니다. 다만 TDZ 때문에 선언 전에 접근하면 에러가 발생하는 것입니다. 호이스팅이 안 된다면 아래 코드에서 전역 x가 출력되어야 하지만, 실제로는 ReferenceError가 발생합니다.

JS
const x = 'global';
{
  console.log(x); // ReferenceError — 블록 내 let x가 호이스팅됨
  let x = 'local';
}

블록 안의 let x가 호이스팅되어 TDZ를 형성하기 때문에, 전역 x에 접근하지 못합니다.

const 객체를 불변으로 착각하는 실수

const로 선언한 객체를 수정할 수 없다고 생각하고 방어 코드를 누락하면, 의도치 않은 상태 변경이 발생합니다. 특히 Object.freeze()를 사용해도 중첩 객체는 동결되지 않습니다.

var의 중복 선언으로 인한 덮어쓰기

같은 스코프에서 var를 여러 번 선언해도 에러가 나지 않기 때문에, 대규모 코드베이스에서 변수가 의도치 않게 덮어씌워지는 버그가 발생할 수 있습니다.

정리

항목varletconst
스코프함수 스코프블록 스코프블록 스코프
호이스팅선언 + undefined 초기화선언만 (TDZ)선언만 (TDZ)
재선언가능불가불가
재할당가능가능불가
전역 선언 시window 프로퍼티window에 안 붙음window에 안 붙음

새 코드에서는 const를 기본으로, 재할당이 필요할 때만 let을 사용합니다. var는 사용하지 않습니다.

댓글 로딩 중...