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

한눈에 보는 차이
| 항목 | var | let | const |
|---|---|---|---|
| 스코프 | 함수 스코프 | 블록 스코프 | 블록 스코프 |
| 호이스팅 | ✅ (undefined로 초기화) | ✅ (TDZ, 접근 불가) | ✅ (TDZ, 접근 불가) |
| 재선언 | ✅ 가능 | ❌ 불가 | ❌ 불가 |
| 재할당 | ✅ 가능 | ✅ 가능 | ❌ 불가 |
| 전역 객체 프로퍼티 | ✅ (window.x) | ❌ | ❌ |
스코프: 가장 근본적인 차이
var는 함수 스코프다. if, for, while 같은 블록을 무시하고 가장 가까운 함수 경계까지 살아남는다.
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-var,prefer-const규칙이 켜져 있는가?
다음으로 깊게 파볼 만한 주제는 실행 컨텍스트와 스코프 체인이다. var, let, const의 동작이 엔진 레벨에서 어떻게 구현되는지 이해하면, 클로저나 비동기 코드에서 만나는 변수 관련 버그를 훨씬 빠르게 잡을 수 있다.