var, let, const 아직도 헷갈린다면: 스코프·호이스팅·클로저까지 한 번에(1)

const를 쓰면 되는 거 아닌가요?

절반은 맞다. 실무에서 var를 새로 쓸 일은 거의 없고, 기본적으로 const → 재할당 필요하면 let 순서로 선택하면 된다. 그런데 이 원칙만 알고 이유를 모르면, 레거시 코드에서 var가 섞인 순간 예상 못 한 버그를 만나게 된다. 특히 클로저나 비동기 코드에서 변수 캡처가 엮이면 “분명히 맞게 짰는데 왜 이러지?” 하는 상황이 온다.

세 키워드의 차이는 스코프, 호이스팅, 재할당 가능 여부 세 축으로 나뉜다. 하나씩 짚어보자.

JavaScript var 함수 스코프 vs let const 블록 스코프 비교

한눈에 보는 차이

항목varletconst
스코프함수 스코프블록 스코프블록 스코프
호이스팅✅ (undefined로 초기화)✅ (TDZ, 접근 불가)✅ (TDZ, 접근 불가)
재선언✅ 가능❌ 불가❌ 불가
재할당✅ 가능✅ 가능❌ 불가
전역 객체 프로퍼티✅ (window.x)

스코프: 가장 근본적인 차이

var는 함수 스코프다. ifforwhile 같은 블록을 무시하고 가장 가까운 함수 경계까지 살아남는다.

js

function example() {
  if (true) {
    var x = 'var';
    let y = 'let';
  }
  console.log(x); // 'var' — 블록 밖에서도 접근됨
  console.log(y); // ReferenceError: y is not defined
}

let과 const는 블록 스코프다. {}로 감싸인 블록 안에서만 유효하다. 이게 직관적으로 맞는 동작이고, var의 함수 스코프는 의도치 않은 변수 노출로 이어지기 쉽다.

js

//  var의 함수 스코프가 문제를 일으키는 예
function processItems(items) {
  for (var i = 0; i < items.length; i++) {
    var result = items[i] * 2;
  }
  // i와 result가 for 블록 밖에서도 살아있음
  console.log(i);      // items.length
  console.log(result); // 마지막 반복의 결과값
}

//  let은 블록을 벗어나면 사라짐
function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    let result = items[i] * 2;
  }
  console.log(i);      // ReferenceError
  console.log(result); // ReferenceError
}

호이스팅: var와 let/const의 결정적 차이

세 키워드 모두 호이스팅은 된다. 다만 어떻게 호이스팅되느냐가 다르다.

var는 선언과 동시에 undefined로 초기화된다. 선언 전에 접근해도 에러가 나지 않고 undefined를 반환한다.

js

console.log(name); // undefined (에러 아님)
var name = '콜리';
console.log(name); // '콜리'

이게 왜 문제냐면, 실수로 선언 전에 변수를 사용해도 아무 경고 없이 통과되기 때문이다. 버그가 조용히 숨는다.

let과 const는 선언은 호이스팅되지만 일시적 사각지대(TDZ, Temporal Dead Zone) 에 놓인다. 초기화 전에 접근하면 ReferenceError가 발생한다.

js

console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = '콜리';

에러가 나는 게 더 낫다. 문제가 즉시 드러나기 때문이다.

js

// TDZ가 실제로 영향을 주는 상황
let x = 'outer';

{
  // 이 블록 안에서 let x가 호이스팅되어 TDZ에 진입
  console.log(x); // ReferenceError (outer를 출력하지 않음)
  let x = 'inner';
}

블록 안에 let x가 있으면, 그 블록 전체에서 x는 바깥 스코프가 아닌 안쪽 선언에 귀속된다. 선언 전 접근은 TDZ로 막힌다.


const: 불변이 아니라 재할당 불가

const에 대한 흔한 오해가 있다. “상수니까 값이 안 바뀐다”는 생각인데, 참조가 고정되는 것이지 값 자체가 불변이 아니다.

js

const user = { name: '콜리', age: 30 };

user.age = 31;       //  가능 — 객체 내부 속성은 변경 가능
user = { name: '다른사람' }; //  TypeError — 참조 자체는 변경 불가

js

const nums = [1, 2, 3];
nums.push(4);  // ✅ 가능 — 배열 내용 변경 가능
nums = [5, 6]; // ❌ TypeError

객체나 배열을 완전히 불변으로 만들려면 Object.freeze()를 쓰거나, TypeScript에서 as const를 활용한다.

js

const config = Object.freeze({ env: 'production', port: 3000 });
config.port = 8080; // 무시됨 (strict mode에서는 TypeError)

클로저와 루프: var가 만드는 고전적 버그

var의 함수 스코프 + 클로저 조합은 for 루프에서 유명한 버그를 만든다.

js

//  var로 인한 클로저 버그
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 0, 1, 2가 아니라 3, 3, 3 출력
  }, 100);
}
// var i는 루프 전체에서 하나만 존재 → 콜백 실행 시점엔 이미 i === 3

js

//  let으로 해결: 반복마다 새로운 블록 스코프 생성
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 0, 1, 2 정상 출력
  }, 100);
}

js

//  var 시절 해결책: IIFE로 스코프 격리 (레거시 코드에서 자주 보임)
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => {
      console.log(j); // 0, 1, 2
    }, 100);
  })(i);
}

레거시 코드에서 IIFE 패턴이 보이면 “여기 var 클로저 버그를 막은 흔적이구나”로 읽으면 된다.


실무 선택 기준

기본값: const
재할당이 필요할 때: let
var: 레거시 코드 읽을 때만 마주침

조금 더 구체적으로 나누면 이렇다.

js

// const: 참조가 바뀌지 않는 모든 것
const API_URL = 'https://api.example.com';
const user = { name: '콜리' }; // 내부 변경은 가능, 참조는 고정
const fetchUser = async (id) => { /* ... */ };

// let: 루프 카운터, 조건에 따라 바뀌는 값, 누적 계산
let count = 0;
let errorMessage = '';

for (let i = 0; i < items.length; i++) { /* ... */ }

// var: 쓰지 않는다

팁: TypeScript를 쓴다면 ESLint 규칙 no-var를 켜두면 var 사용 시 자동으로 경고가 뜬다. prefer-const 규칙도 함께 설정하면 재할당 없는 let을 const로 바꾸도록 안내해준다.


FAQ

Q. const를 쓰면 코드가 느려지나요? 아니다. 런타임 성능 차이는 없다. const는 엔진에게 재할당이 없다는 힌트를 줘서 오히려 최적화에 유리할 수 있다.

Q. 함수 선언도 호이스팅되나요? 된다. function 키워드로 선언한 함수는 선언 전에 호출해도 동작한다. 반면 const fn = () => {}처럼 변수에 할당한 함수 표현식은 TDZ 규칙을 따른다.

js

greet(); // '안녕' — 함수 선언은 완전히 호이스팅됨
function greet() { console.log('안녕'); }

hello(); //  ReferenceError
const hello = () => { console.log('안녕'); };

Q. 모듈 최상단의 let은 전역 변수가 되나요? ES 모듈(import/export) 환경에서는 모듈 스코프가 적용된다. 최상단에 선언해도 전역 객체(window)에 붙지 않는다. var도 마찬가지다. 전통적인 <script> 태그 환경에서만 var가 window 프로퍼티가 된다.

Q. Object.freeze()는 중첩 객체에도 적용되나요? 얕은(shallow) 동결만 된다. 중첩 객체의 내부 속성은 여전히 변경 가능하다. 완전한 불변이 필요하면 재귀적으로 freeze를 적용하거나 Immer 같은 라이브러리를 쓴다.

Q. 레거시 코드에서 var를 let/const로 일괄 교체해도 되나요? 기계적으로 교체하면 안 된다. var는 함수 스코프라 블록 밖에서 접근하는 코드가 있을 수 있다. let으로 바꾸면 그 접근이 ReferenceError로 바뀐다. 교체 전에 해당 변수가 블록 밖에서 쓰이는지 먼저 확인해야 한다.

Q. TDZ는 실제로 어디까지 영향을 미치나요? 블록이 시작되는 시점부터 변수 선언문이 실행되는 시점까지다. 같은 블록 안이라도 선언문 위쪽 코드에서 접근하면 TDZ에 걸린다. 특히 기본 매개변수에서도 TDZ가 적용된다.

js

// 기본 매개변수에서의 TDZ
function example(a = b, b = 1) { } // ReferenceError: b is 사용 before initialization

마무리 체크리스트

  •  새로 작성하는 코드에서 var를 쓰고 있지 않은가?
  •  재할당 없는 변수는 모두 const를 쓰고 있는가?
  •  for 루프 안에서 클로저를 쓴다면 let을 사용하고 있는가?
  •  const 객체가 “불변”이라고 착각하고 있지 않은가?
  •  레거시 코드의 var를 교체할 때 블록 스코프 차이를 확인했는가?
  •  ESLint에 no-varprefer-const 규칙이 켜져 있는가?

다음으로 깊게 파볼 만한 주제는 실행 컨텍스트와 스코프 체인이다. varletconst의 동작이 엔진 레벨에서 어떻게 구현되는지 이해하면, 클로저나 비동기 코드에서 만나는 변수 관련 버그를 훨씬 빠르게 잡을 수 있다.

댓글 남기기