프론트엔드/JavaScript

(JavaScript) var vs let vs const 차이 (TDZ 포함)

그린티_ 2026. 2. 9. 15:02
반응형

서론

JavaScript로 처음 개발을 시작했을 때는 변수 선언에 크게 신경을 안 썼음
let이랑 const만 쓰면 된다고 해서 그렇게 하고 있었는데, 면접 준비를 하다 보니 "var, let, const의 차이를 설명해보세요"라는 질문이 정말 자주 나옴

단순히 "var는 옛날 거고 let은 변수, const는 상수"라고 답하면 부족하고, 스코프, 호이스팅, TDZ, 재선언/재할당 네 가지 관점에서 정확하게 설명할 수 있어야 함

이전에 호이스팅 글에서 살짝 다뤘는데, 이번에는 var vs let vs const를 중심으로 깊게 정리해봄


본론

1. 스코프(Scope) 차이 — 가장 핵심적인 차이

var: 함수 스코프

var함수 스코프(Function Scope)를 가짐. if, for, while 같은 블록({})을 무시하고, 오직 함수만 스코프의 경계가 됨

function example() {
  if (true) {
    var message = '안녕';
  }
  console.log(message); // '안녕' — if 블록 밖인데 접근 가능!
}

example();
for (var i = 0; i < 3; i++) {
  // ...
}
console.log(i); // 3 — for 블록 밖인데 접근 가능!

블록 밖에서도 접근이 되니까, 의도치 않은 변수 충돌이 발생하기 쉬움

let / const: 블록 스코프

letconst블록 스코프(Block Scope)를 가짐. {} 중괄호가 스코프의 경계가 됨

function example() {
  if (true) {
    let message = '안녕';
    const greeting = '반가워';
  }
  console.log(message);  // ❌ ReferenceError
  console.log(greeting); // ❌ ReferenceError
}
for (let i = 0; i < 3; i++) {
  // ...
}
console.log(i); // ❌ ReferenceError — 블록 안에서만 존재

블록 밖에서 접근이 안 되니까 변수가 예상한 범위 안에서만 살아있음. 훨씬 안전함

실전 예제: for문에서의 차이

이건 면접에서도 자주 나오고, 실제로 버그를 만들기도 하는 패턴

// ❌ var — 의도대로 안 됨
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 100);
}
// 출력: 3, 3, 3

// ✅ let — 정상 동작
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 100);
}
// 출력: 0, 1, 2

var는 함수 스코프라서 i가 하나만 존재하고, 루프가 끝난 후 i = 3을 세 콜백이 공유함
let은 블록 스코프라서 매 반복마다 새로운 i가 생성되고, 각 콜백이 자기만의 i를 클로저로 기억함


2. 호이스팅과 TDZ (Temporal Dead Zone)

세 키워드 모두 호이스팅됨. 하지만 동작 방식이 완전히 다름

var: 호이스팅 + undefined로 초기화

console.log(name); // undefined (에러가 아님)
var name = '그린티';
console.log(name); // '그린티'

JavaScript 엔진이 내부적으로 이렇게 처리함:

var name;            // 1단계: 선언 + undefined로 초기화 (호이스팅)
console.log(name);   // undefined
name = '그린티';      // 2단계: 할당 (원래 위치)
console.log(name);   // '그린티'

선언과 동시에 undefined로 초기화되기 때문에, 선언 전에 접근해도 에러가 아닌 undefined가 나옴. 이게 오히려 버그를 숨기는 문제가 됨

let / const: 호이스팅 + TDZ

console.log(name); // ❌ ReferenceError: Cannot access 'name' before initialization
let name = '그린티';

let도 호이스팅은 됨. 하지만 undefined로 초기화되지 않고, 선언문에 도달하기 전까지 접근 금지 구간(TDZ)에 놓임

// ===== TDZ 시작 =====
// 이 구간에서 name에 접근하면 ReferenceError!

let name = '그린티'; // ← TDZ 끝, 여기서 초기화됨
console.log(name);   // ✅ '그린티'

TDZ가 존재하는 이유

TDZ는 "실수로 초기화 전에 변수를 쓰는 것"을 막아주는 안전장치

// var의 경우 — 버그가 숨겨짐
function getUser() {
  console.log(user); // undefined... 에러가 아니라서 모를 수 있음
  // ... 100줄의 코드 ...
  var user = fetchUser();
}

// let의 경우 — 즉시 에러 발생
function getUser() {
  console.log(user); // ❌ ReferenceError! 바로 알 수 있음
  // ... 100줄의 코드 ...
  let user = fetchUser();
}

에러가 나는 게 더 좋은 거임. 문제를 빨리 발견할 수 있으니까

let이 호이스팅된다는 증거

"let은 호이스팅이 안 되는 것 아닌가?"라고 착각할 수 있는데, 이 코드로 확인 가능

let x = '전역';

function test() {
  console.log(x); // ❌ ReferenceError (전역 x가 출력되지 않음!)
  let x = '지역';
}

test();

만약 let이 호이스팅이 안 됐다면, 전역 x'전역'이 출력돼야 함. 하지만 함수 내부의 let x가 호이스팅되어 TDZ에 걸렸기 때문에 에러가 발생함. 즉, 호이스팅은 되지만 TDZ 때문에 접근이 차단되는 것


3. 재선언과 재할당

var: 재선언 ✅, 재할당 ✅

var name = '그린티';
var name = '녹차';  // ✅ 같은 이름으로 다시 선언 가능 (위험!)
name = '말차';      // ✅ 재할당도 가능

console.log(name); // '말차'

같은 변수를 두 번 선언해도 에러가 안 남. 대형 프로젝트에서 실수로 같은 이름의 변수를 선언하면 기존 값이 덮어씌워지는데, 에러가 안 나니까 찾기가 어려움

let: 재선언 ❌, 재할당 ✅

let name = '그린티';
let name = '녹차';  // ❌ SyntaxError: Identifier 'name' has already been declared
name = '말차';      // ✅ 재할당은 가능

console.log(name); // '말차'

같은 스코프에서 재선언하면 바로 에러가 남. 변수 충돌을 방지할 수 있음

const: 재선언 ❌, 재할당 ❌

const name = '그린티';
const name = '녹차';  // ❌ SyntaxError
name = '말차';        // ❌ TypeError: Assignment to constant variable

const는 선언과 동시에 반드시 값을 할당해야 하고, 이후 재할당이 불가능함

const name; // ❌ SyntaxError: Missing initializer in const declaration

const의 함정: 객체/배열은 내부 값 변경 가능

const user = { name: '그린티', age: 25 };

// ❌ 참조 자체를 바꾸는 건 불가능
user = { name: '녹차' }; // TypeError

// ✅ 내부 프로퍼티 변경은 가능!
user.name = '녹차';      // OK
user.job = '개발자';      // OK

console.log(user); // { name: '녹차', age: 25, job: '개발자' }
const numbers = [1, 2, 3];

// ❌ 참조 변경 불가
numbers = [4, 5, 6]; // TypeError

// ✅ 내부 요소 변경 가능
numbers.push(4);     // OK
numbers[0] = 99;     // OK

console.log(numbers); // [99, 2, 3, 4]

const변수의 재할당(참조 변경)을 막는 것이지, 값 자체를 불변(immutable)으로 만드는 것이 아님. 객체를 완전히 불변으로 만들고 싶으면 Object.freeze()를 사용해야 함

const user = Object.freeze({ name: '그린티', age: 25 });
user.name = '녹차'; // 무시됨 (strict mode에서는 TypeError)
console.log(user.name); // '그린티'

4. 전역 객체 바인딩

또 하나 중요한 차이가 있음. 전역 스코프에서 선언했을 때의 동작

var globalVar = '나는 var';
let globalLet = '나는 let';
const globalConst = '나는 const';

console.log(window.globalVar);   // '나는 var' — window 객체에 붙음
console.log(window.globalLet);   // undefined — window에 안 붙음
console.log(window.globalConst); // undefined — window에 안 붙음

var로 전역 선언하면 window(브라우저) 또는 global(Node.js) 객체의 프로퍼티가 됨. 이건 전역 오염의 원인이 될 수 있음

letconst는 전역에서 선언해도 전역 객체에 바인딩되지 않음


5. 한눈에 비교

구분 var let const
스코프 함수 스코프 블록 스코프 블록 스코프
호이스팅 ✅ (undefined로 초기화) ✅ (TDZ) ✅ (TDZ)
TDZ
재선언 ✅ 가능 ❌ 불가 ❌ 불가
재할당 ✅ 가능 ✅ 가능 ❌ 불가
초기화 필수 ✅ (선언과 동시에)
전역 객체 바인딩 ✅ (window에 붙음)

6. 실전에서의 사용 가이드

기본 원칙: const > let > var

// ✅ 1순위: const (기본값)
const API_URL = 'https://api.example.com';
const user = { name: '그린티' };
const numbers = [1, 2, 3];

// ✅ 2순위: let (재할당이 필요할 때만)
let count = 0;
let isLoading = true;

for (let i = 0; i < 10; i++) {
  count += i;
}

// ❌ var는 쓰지 말자

React 프로젝트에서의 예시

// 컴포넌트 안에서 대부분 const를 씀
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);      // const — 변수 자체를 재할당하지 않음
  const [loading, setLoading] = useState(true); // const — useState 반환값을 한 번만 받음

  const fetchUser = async () => {               // const — 함수도 재할당 안 하니까
    const response = await fetch(`/api/users/${userId}`); // const
    const data = await response.json();         // const
    setUser(data);
  };

  useEffect(() => {
    fetchUser();
  }, [userId]);

  // let은 반복문이나 조건에 따라 값이 변할 때
  let statusText = '';
  if (loading) {
    statusText = '로딩 중...';
  } else if (user) {
    statusText = `${user.name}님 환영합니다`;
  } else {
    statusText = '유저를 찾을 수 없습니다';
  }

  return <div>{statusText}</div>;
}

실제로 gacha-picker 프로젝트에서도 거의 const만 쓰고, for문이나 조건부 값 변경에서만 let을 씀


7. 면접 예상 질문 & 답변

Q1. var, let, const의 차이를 설명해주세요.

var는 함수 스코프이고 재선언/재할당이 가능하며, 호이스팅 시 undefined로 초기화됩니다. let은 블록 스코프이고 재할당만 가능하며, TDZ가 적용됩니다. const는 블록 스코프이고 재선언/재할당 모두 불가하며, 선언 시 반드시 초기화해야 합니다.

Q2. TDZ(Temporal Dead Zone)란 무엇인가요?

letconst가 호이스팅되었지만, 실제 선언문에 도달하기 전까지 접근할 수 없는 구간입니다. 이 구간에서 변수에 접근하면 ReferenceError가 발생합니다. 초기화 전에 변수를 사용하는 실수를 방지하기 위한 안전장치입니다.

Q3. const로 선언한 객체의 프로퍼티를 변경할 수 있는 이유는?

const는 변수의 재할당(참조 변경)을 막는 것이지, 값 자체를 불변으로 만드는 것이 아닙니다. 객체나 배열은 참조 타입이므로 참조가 가리키는 대상은 동일하게 유지하면서 내부 프로퍼티나 요소를 변경할 수 있습니다.

Q4. var를 쓰면 안 되는 이유는?

함수 스코프라서 블록을 무시하고 변수가 유출되며, 같은 이름으로 재선언이 가능해 변수 충돌 위험이 있습니다. 또한 호이스팅 시 undefined로 초기화되어 버그가 숨겨질 수 있습니다. letconst를 사용하면 이 문제들을 대부분 방지할 수 있습니다.

Q5. for문에서 var를 쓰면 3, 3, 3이 출력되는 이유는?

var는 함수 스코프라서 for문 전체에서 하나의 i 변수만 존재합니다. setTimeout 콜백이 실행될 때는 이미 루프가 끝나 i = 3이 된 상태이므로, 세 콜백 모두 같은 i(= 3)를 참조합니다. let은 블록 스코프라서 매 반복마다 새로운 i가 생성되어 각 콜백이 고유한 값을 기억합니다.


결론

var, let, const의 차이는 스코프, 호이스팅/TDZ, 재선언/재할당, 전역 객체 바인딩 네 가지 관점에서 정리하면 됨

  • var: 함수 스코프, 호이스팅 시 undefined 초기화, 재선언/재할당 가능 → 버그 유발 가능
  • let: 블록 스코프, TDZ 적용, 재할당만 가능 → 값이 변하는 변수에 사용
  • const: 블록 스코프, TDZ 적용, 재선언/재할당 불가 → 기본으로 사용
사용 시점 키워드
기본값 (대부분의 경우) const
값이 변해야 할 때 (반복문, 조건부 할당 등) let
레거시 코드에서만 봄 (새 코드에서는 사용 X) var

실무에서는 ESLint의 no-var 규칙을 켜두면 var를 원천적으로 차단할 수 있음
앞으로도 const를 기본으로 쓰고, 정말 재할당이 필요한 경우에만 let을 쓰는 습관을 유지해야겠음!

반응형