JavaScript var vs let vs const: 스코프·호이스팅 차이와 선택 기준
이 글의 핵심
for 루프 안에서 var로 선언한 변수를 setTimeout 콜백이 참조하면 모두 같은 값이 찍히는 버그가 대표적인 출발점입니다. 기본은 const, 재할당이 필요할 때만 let을 쓰는 원칙이 왜 합리적인지, const가 참조만 고정하고 객체 내용은 막지 못한다는 점까지 짚어 선언 방식을 고민 없이 고를 수 있게 합니다.
들어가며
“var를 써도 되나요?” JavaScript를 배울 때 자주 나오는 질문입니다. 이 글에서는 var, let, const의 차이를 명확히 이해하며, 실전에서 어떤 것을 써야 하는지 선택 기준을 제시합니다.
비유로 말씀드리면, var는 건물 전체가 하나의 방처럼 들리는 스피커, let은 회의실 칸막이, const는 한 번 붙인 이름표를 못 바꾸는 자리에 가깝습니다. 루프·블록 단위로 변수를 나누려면 let과 const가 맞습니다.
언제 let을, 언제 const를 쓰나요? (var는?)
| 관점 | const | let | var |
|---|---|---|---|
| 성능 | 동일 계열에서 차이는 미미 | 동일 | 호이스팅·스코프로 버그 유발이 잦음 |
| 사용성 | 재할당이 없을 때 기본 | 재할당이 필요할 때 | 레거시·특수 호환 외에는 비권장 |
| 적용 시나리오 | 대부분의 바인딩 | 카운터·스왑 등 | 구형 코드 유지 |
세 키워드가 공존하는 이유는 역사 때문입니다. JavaScript는 1995년부터 20년 동안 var 하나만 있었고, 그 동작(함수 스코프, 선언 전 접근 허용, 재선언 허용)이 대규모 코드에서 버그를 계속 만들었습니다. 2015년 ES6(ES2015)가 let과 const를 추가하면서도 var를 바꾸지 않은 것은 기존 웹사이트를 깨뜨리지 않기 위해서입니다. 브라우저는 20년 전에 작성된 스크립트도 그대로 실행해야 하므로, var의 동작은 앞으로도 바뀌지 않습니다. 그래서 새 코드에서는 var를 피하고, 오래된 코드를 읽을 때는 var의 규칙을 알아야 하는 상황이 계속됩니다.
빠른 비교표
| 특성 | var | let | const |
|---|---|---|---|
| 스코프 | 함수 스코프 | 블록 스코프 | 블록 스코프 |
| 호이스팅 | 선언 + 초기화 (undefined) | 선언만 (TDZ) | 선언만 (TDZ) |
| 재할당 | ✅ 가능 | ✅ 가능 | ❌ 불가능 |
| 재선언 | ✅ 가능 | ❌ 불가능 | ❌ 불가능 |
| 전역 객체 | window에 추가 | 추가 안 됨 | 추가 안 됨 |
| 권장 사용 | ❌ 사용 금지 | ✅ 재할당 필요 시 | ✅ 기본 선택 |
스코프 차이
var: 함수 스코프
function testVar() {
if (true) {
var x = 10;
}
console.log(x); // ✅ 10 (블록 밖에서도 접근 가능)
}
testVar();
C, Java, C#처럼 중괄호가 스코프를 만드는 언어에 익숙하다면 이 동작이 가장 먼저 놀라운 부분입니다. var는 if, for, while의 중괄호를 무시하고 가장 가까운 함수 전체를 스코프로 삼습니다. 함수 바깥에서 선언하면 전역 변수가 됩니다. 그래서 과거에는 스코프를 나누려고 (function () { var x = 10; })(); 같은 즉시 실행 함수(IIFE)를 흔히 썼습니다. 오래된 라이브러리 코드 전체가 (function(){ ... })()로 감싸져 있는 이유가 이것입니다.
let/const: 블록 스코프
function testLet() {
if (true) {
let x = 10;
const y = 20;
}
console.log(x); // ❌ ReferenceError: x is not defined
console.log(y); // ❌ ReferenceError: y is not defined
}
(실제로는 첫 번째 console.log에서 예외가 나므로 두 번째 줄은 실행되지 않습니다.)
블록 스코프는 변수의 수명과 가시성을 필요한 범위로 좁혀 줍니다. 긴 함수 안에서 if 블록 하나에서만 쓰는 임시 변수가 함수 전체에 보이지 않으므로, 같은 이름을 다른 블록에서 다른 용도로 써도 충돌하지 않습니다. switch 문의 case는 블록이 아니라는 점은 주의해야 합니다. 두 case에서 let result를 각각 선언하면 Identifier 'result' has already been declared 에러가 나므로, case 1: { let result = ...; break; }처럼 중괄호로 감싸야 합니다.
실전 문제: 루프 변수
// var의 함정
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 3, 3, 3 (예상: 0, 1, 2)
}, 100);
}
// let으로 해결
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2 ✅
}, 100);
}
이유: var는 함수 스코프이므로 루프가 끝난 후 i = 3이 됩니다. let은 각 반복마다 새로운 블록 스코프를 생성합니다.
조금 더 정확히 말하면 두 가지 사실이 겹쳐서 생기는 현상입니다. 첫째, 화살표 함수는 i의 값을 복사하는 것이 아니라 변수 자체를 참조하는 클로저입니다. 둘째, setTimeout 콜백은 루프가 모두 끝난 뒤에 실행됩니다. var i는 루프 전체에 변수가 하나뿐이라, 세 콜백이 같은 i를 보고 그 시점의 값인 3을 출력합니다. for (let i ...)는 명세상 반복마다 새 i 바인딩을 만들고 이전 반복의 값을 복사해 증가시키도록 정의되어 있어서, 각 콜백이 자기 반복의 i를 붙잡게 됩니다. let을 루프 바깥에 선언하고(let i; for (i = 0; ...)) 쓰면 다시 변수가 하나가 되어 3, 3, 3이 나옵니다.
이 문제는 setTimeout뿐 아니라 이벤트 핸들러 등록에서도 똑같이 나타납니다. 버튼 목록을 돌며 buttons[i].onclick = () => alert(i)를 var로 등록하면 모든 버튼이 마지막 인덱스를 알립니다. let 이전에는 (function (j) { ... })(i)처럼 즉시 실행 함수로 값을 고정하거나 forEach의 콜백 인자를 쓰는 것이 해결책이었습니다.
호이스팅
var: 선언 + 초기화 호이스팅
console.log(x); // undefined (에러 아님!)
var x = 10;
// 실제 동작
var x = undefined; // 호이스팅
console.log(x); // undefined
x = 10;
“호이스팅(끌어올림)“은 코드가 실제로 위로 옮겨진다는 뜻이 아니라, JavaScript 엔진이 코드를 실행하기 전에 스코프 안의 선언을 먼저 등록한다는 것을 비유한 말입니다. var는 등록과 동시에 undefined로 초기화되므로 선언보다 앞에서 읽어도 에러가 나지 않습니다. 이 “에러가 나지 않는다”는 점이 문제입니다. 오타나 순서 실수가 있어도 undefined가 조용히 흘러가서, 한참 뒤에 Cannot read properties of undefined 같은 전혀 다른 위치의 에러로 드러납니다.
함수 선언(function foo() {})은 var보다 한 단계 더 끌어올려져 본문까지 등록되므로 선언 전에 호출할 수 있습니다. 반면 const foo = () => {}처럼 변수에 담은 함수는 const 규칙을 따라 선언 전에 호출하면 에러가 납니다. var foo = function () {}로 선언했다면 선언 전 호출 시 TypeError: foo is not a function(값이 아직 undefined)이 납니다.
let/const: TDZ (Temporal Dead Zone)
console.log(x); // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 10;
// TDZ: 스코프 시작부터 선언까지의 구간
{
// TDZ 시작
console.log(x); // ❌ ReferenceError
let x = 10; // TDZ 끝
console.log(x); // ✅ 10
}
let과 const도 호이스팅은 됩니다. 다만 초기화되지 않은 상태로 등록되어, 선언문에 도달하기 전까지 접근하면 ReferenceError가 납니다. 이 구간을 TDZ(Temporal Dead Zone, 일시적 사각지대)라고 부릅니다. “호이스팅이 안 된다”고 설명하는 자료도 많지만, 다음 코드를 보면 호이스팅이 된다는 것을 알 수 있습니다.
let x = 'outer';
{
console.log(x); // ❌ ReferenceError (outer가 아니라!)
let x = 'inner';
}
호이스팅이 없다면 블록 안의 첫 줄은 바깥의 x를 읽어 'outer'를 출력해야 합니다. 하지만 블록 안의 let x가 이미 블록 전체에 등록되어 바깥 x를 가리고, 아직 초기화 전이라 에러가 납니다. “Temporal(시간적)“이라는 이름은 이 구간이 코드 위치가 아니라 실행 시점으로 결정된다는 뜻입니다. 함수 안에서 나중에 선언될 let 변수를 참조하더라도, 그 함수가 선언문 실행 이후에 호출되면 문제가 없습니다. TDZ는 “선언 전에 쓰는 코드는 대부분 실수”라는 판단을 언어가 강제하는 장치입니다.
실전 예제
// var의 문제
let user = cachedUser; // 바깥(전역)에 캐시된 사용자가 있다고 가정
function getUser() {
if (!user) { // 함수 안의 var user(undefined)를 읽음 → 항상 true
var user = fetchUser(); // 호이스팅으로 전체 함수 스코프
}
return user; // 바깥 캐시를 무시하고 매번 fetchUser() 결과 반환
}
// let으로 바꾸면
function getUserLet() {
if (!user) { // 블록 밖이므로 바깥 user를 읽음 (의도한 동작)
let user = fetchUser(); // 블록 스코프: 이 블록 안에서만 존재
}
return user; // 바깥 user 반환 (블록 안에서 가져온 값은 버려짐)
}
var 버전의 문제는 함수 안의 var user가 호이스팅되어 바깥의 user를 가린다는 점입니다. if (!user)는 함수 안의 undefined를 검사하므로 항상 참이고, 캐시가 있어도 매번 fetchUser()를 호출합니다. 에러가 나지 않으니 성능 저하로만 드러나서 원인을 찾기 어렵습니다.
let 버전은 이 가림 현상은 없지만 다른 버그가 드러납니다. 블록 안에서 가져온 user가 블록 밖으로 나오지 않아, 캐시가 비었을 때 fetchUser() 결과를 버리고 undefined를 반환합니다. 블록 스코프는 버그를 없애 주는 것이 아니라 변수가 어디까지 살아 있는지 눈에 보이게 해 줄 뿐입니다. 올바른 코드는 선언을 한 곳에 모으는 것입니다. function getUser() { if (!user) user = fetchUser(); return user; }처럼 바깥 변수에 대입하거나, 캐시를 명시적인 모듈 변수로 분리합니다.
재할당과 재선언
var: 재할당, 재선언 모두 가능
var x = 10;
var x = 20; // ✅ 재선언 가능 (위험!)
x = 30; // ✅ 재할당 가능
let: 재할당 가능, 재선언 불가
let x = 10;
let x = 20; // ❌ SyntaxError: Identifier 'x' has already been declared
x = 30; // ✅ 재할당 가능
const: 재할당, 재선언 모두 불가
const x = 10;
const x = 20; // ❌ SyntaxError
x = 30; // ❌ TypeError: Assignment to constant variable
에러 종류가 다르다는 점에 주목할 만합니다. 재선언은 SyntaxError라서 코드를 실행하기 전, 파싱 단계에서 스크립트 전체가 거부됩니다. 그 파일의 다른 코드도 한 줄도 실행되지 않습니다. 반면 const 재할당은 TypeError라서 그 줄에 도달했을 때 실행 중에 발생합니다. 거의 실행되지 않는 분기 안에 const 재할당이 숨어 있으면 테스트를 통과하고 운영 환경에서 터질 수 있으므로, 뒤의 ESLint no-const-assign으로 미리 잡는 것이 좋습니다.
const는 선언과 동시에 초기화해야 합니다. const x;는 Missing initializer in const declaration SyntaxError입니다. 조건에 따라 값이 달라지는 경우 let x; if (a) x = 1; else x = 2; 대신 const x = a ? 1 : 2;처럼 식 하나로 만들면 const를 유지할 수 있고, 분기가 복잡하면 값을 반환하는 작은 함수로 뽑아내는 편이 읽기 좋습니다.
var 재선언이 허용되는 것은 여러 스크립트 파일이 같은 전역 공간을 공유하던 시절, 파일마다 var config를 선언해도 에러가 나지 않게 하려던 설계입니다. 지금은 거의 항상 실수를 숨기는 역할만 합니다.
const의 불변성
주의: const는 참조만 불변
// 기본 타입: 완전 불변
const x = 10;
x = 20; // ❌ TypeError
// 객체: 참조는 불변, 내용은 가변
const obj = { name: 'Alice' };
obj = { name: 'Bob' }; // ❌ TypeError (참조 변경 불가)
obj.name = 'Bob'; // ✅ 가능 (내용 변경 가능)
// 배열: 참조는 불변, 요소는 가변
const arr = [1, 2, 3];
arr = [4, 5, 6]; // ❌ TypeError
arr.push(4); // ✅ 가능
arr[0] = 10; // ✅ 가능
const가 고정하는 것은 변수와 값 사이의 연결(바인딩)이지 값 자체가 아닙니다. 숫자나 문자열 같은 원시 값은 원래 바꿀 수 없는 값이라 결과적으로 완전히 불변이 되지만, 객체와 배열은 변수에 참조가 들어 있으므로 참조가 가리키는 객체의 내용은 얼마든지 바뀝니다. 이 점을 혼동하면 두 방향으로 실수합니다. “const니까 안전하다”고 믿고 공유 설정 객체를 여러 모듈에 넘겼다가 어딘가에서 내용이 바뀌는 경우, 그리고 반대로 “배열에 push해야 하니 let으로 선언해야 한다”고 오해하는 경우입니다. push는 재할당이 아니므로 const로 충분합니다.
React 같은 프레임워크에서는 이 차이가 실제 버그로 이어집니다. const [items, setItems] = useState([]) 다음 items.push(x); setItems(items);처럼 쓰면 배열의 참조가 그대로라 React가 변경을 감지하지 못해 화면이 갱신되지 않습니다. setItems([...items, x])처럼 새 배열을 만들어야 합니다.
완전 불변 객체
// Object.freeze로 완전 불변
const obj = Object.freeze({ name: 'Alice' });
obj.name = 'Bob'; // ❌ 무시됨 (strict mode에서는 TypeError)
// 깊은 불변성 (중첩 객체)
function deepFreeze(obj) {
Object.freeze(obj);
Object.values(obj).forEach(value => {
if (typeof value === 'object' && value !== null) {
deepFreeze(value);
}
});
return obj;
}
const config = deepFreeze({
api: {
url: 'https://api.example.com',
timeout: 5000,
},
});
config.api.url = 'https://evil.com'; // ❌ 무시됨
Object.freeze는 얕은(shallow) 동결입니다. 객체의 직접 속성만 잠그므로 Object.freeze({ api: { url } })에서 config.api = ...는 막히지만 config.api.url = ...는 여전히 바뀝니다. 그래서 중첩 객체까지 막으려면 위의 deepFreeze처럼 재귀적으로 적용해야 합니다. 동결된 객체에 쓰기를 시도하면 비엄격 모드에서는 조용히 무시되고, 엄격 모드(ES 모듈과 클래스 본문은 자동으로 엄격 모드)에서는 TypeError: Cannot assign to read only property 'name' of object가 납니다. 조용히 무시되는 쪽이 더 위험하므로, freeze를 쓸 때는 엄격 모드 환경인지 확인하는 것이 좋습니다.
deepFreeze에도 한계가 있습니다. Map, Set, Date처럼 내부 슬롯에 데이터를 저장하는 객체는 freeze해도 map.set(), date.setFullYear()로 내용이 바뀝니다. 순환 참조가 있는 객체라면 이미 동결된 객체를 다시 방문할 때 Object.isFrozen(value)로 건너뛰지 않으면 무한 재귀에 빠집니다. 실무에서 불변성이 중요한 상태 관리에는 동결보다 새 객체를 만드는 습관(스프레드 연산자, structuredClone)이나 Immer 같은 라이브러리가 더 흔히 쓰이고, 타입 수준에서는 TypeScript의 readonly와 as const가 컴파일 시점에 쓰기를 막아 줍니다.
실전 선택 가이드
기본 원칙
// 1순위: const (기본)
const API_URL = 'https://api.example.com';
const users = [];
// 2순위: let (재할당 필요 시)
let count = 0;
for (let i = 0; i < 10; i++) {
count += i;
}
// 3순위: var (사용 금지!)
// 레거시 코드에서만 볼 수 있음
상황별 선택
| 상황 | 선택 | 이유 |
|---|---|---|
| 상수 | const | 재할당 방지 |
| 루프 변수 | let | 블록 스코프 |
| 카운터 | let | 재할당 필요 |
| 객체/배열 | const | 참조 불변 (내용은 가변) |
| 함수 | const | 재할당 방지 |
| 레거시 | var | 기존 코드 유지보수만 |
ESLint 설정
// .eslintrc.json
{
"rules": {
"no-var": "error", // var 금지
"prefer-const": "warn", // 재할당 없으면 const 권장
"no-const-assign": "error" // const 재할당 금지
}
}
prefer-const는 재할당되지 않는 let을 찾아 const로 바꾸라고 알려 주는 규칙이고, eslint --fix로 자동 수정도 됩니다. 기존 코드베이스에 처음 켜면 경고가 수백 개 나올 수 있지만, 대부분 자동 수정으로 한 번에 정리됩니다. no-var도 자동 수정을 지원하지만, 위에서 본 호이스팅이나 함수 스코프에 의존하는 코드는 let으로 바꾸는 순간 동작이 달라질 수 있어서 ESLint가 안전하다고 판단할 수 있는 경우에만 수정합니다. 레거시 코드에 적용할 때는 테스트를 돌리면서 파일 단위로 진행하는 편이 안전합니다.
위 설정은 예전 .eslintrc.json 형식입니다. ESLint 9부터는 eslint.config.js(flat config)가 기본이며, 같은 규칙을 export default [{ rules: { "no-var": "error", "prefer-const": "warn" } }]; 형태로 적습니다. .eslintrc.json은 JSON이라 원래 주석을 허용하지 않는다는 점도 알아 두세요(ESLint는 주석을 관대하게 읽어 주지만, 다른 도구가 이 파일을 읽으면 파싱 에러가 날 수 있습니다). 참고로 no-const-assign은 eslint:recommended에 이미 포함되어 있습니다.
흔한 실수
실수 1: var 재선언
var user = 'Alice';
// ... 100줄 후 ...
var user = 'Bob'; // ✅ 에러 없음 (의도치 않은 재선언!)
// let으로 방지
let user = 'Alice';
let user = 'Bob'; // ❌ SyntaxError
(두 예제를 한 파일에 두면 var user와 let user가 충돌해 그것만으로 SyntaxError가 나므로, 따로 실행해야 합니다.)
실무에서 이 실수가 가장 자주 나오는 곳은 여러 <script> 태그가 전역을 공유하는 오래된 웹 페이지입니다. 한 파일의 var user를 다른 파일이 모르고 var user로 다시 선언하면 에러 없이 앞의 값을 덮어쓰고, 어느 스크립트가 먼저 로드되느냐에 따라 동작이 달라집니다. let과 const는 전역에서도 같은 이름을 두 번 선언하면 에러를 내므로 이런 충돌을 즉시 드러냅니다. 브라우저 개발자 도구 콘솔에서 코드를 여러 번 붙여 넣으며 테스트할 때 let이 already been declared 에러를 내는 것도 같은 이유입니다(최근 Chrome 콘솔은 편의상 이를 허용합니다).
실수 2: 루프 클로저
// var의 함정
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() {
return i;
});
}
console.log(funcs[0]()); // 3 (예상: 0)
console.log(funcs[1]()); // 3 (예상: 1)
console.log(funcs[2]()); // 3 (예상: 2)
// let으로 해결
for (let i = 0; i < 3; i++) {
funcs.push(function() {
return i;
});
}
console.log(funcs[0]()); // 0 ✅
console.log(funcs[1]()); // 1 ✅
console.log(funcs[2]()); // 2 ✅
(두 번째 루프 전에 funcs = []로 비워야 인덱스 02가 새 함수를 가리킵니다. 그대로 이어서 실행하면 funcs[0][2]는 여전히 첫 루프의 함수라 3을 반환합니다.)
앞의 setTimeout 예제와 원리는 같지만, 이 예제는 비동기가 없어도 문제가 생긴다는 점을 보여 줍니다. 핵심은 함수가 만들어지는 시점과 호출되는 시점이 다르다는 것입니다. 클로저는 만들어질 때의 값이 아니라 변수를 기억하고, 호출될 때 그 변수의 현재 값을 읽습니다. 콜백 배열, 이벤트 핸들러, 지연 실행 작업처럼 “나중에 실행할 함수를 루프에서 만드는” 코드라면 루프 변수는 반드시 let이어야 합니다. for...of와 for...in도 let/const로 선언하면 반복마다 새 바인딩이 만들어지므로 for (const item of items)처럼 const를 쓸 수 있습니다.
실수 3: const 객체 수정 시도
const config = { debug: true };
// ❌ 잘못된 이해
config = { debug: false }; // TypeError
// ✅ 올바른 이해
config.debug = false; // 가능
전역 객체와의 관계
var는 전역 객체에 추가
var globalVar = 'I am global';
console.log(window.globalVar); // 'I am global' (브라우저)
let globalLet = 'I am global';
console.log(window.globalLet); // undefined
const globalConst = 'I am global';
console.log(window.globalConst); // undefined
왜 문제인가?
// 기존 전역 변수 덮어쓰기
var alert = 'oops';
alert('Hello'); // ❌ TypeError: alert is not a function
일반 스크립트의 최상위에서 선언한 var는 전역 객체(window)의 속성이 됩니다. 그래서 alert, name, status, top 같은 브라우저 내장 전역과 이름이 겹치면 그것을 덮어쓰거나, 반대로 이상한 동작을 합니다. 특히 var name = 123;은 window.name이 항상 문자열로 변환되는 특수 속성이라 typeof name이 'string'이 되는 알려진 함정이 있습니다. 서드파티 스크립트와 전역 이름이 충돌하는 문제도 같은 원인입니다.
최상위 let과 const는 전역 스코프에 존재하지만 window의 속성은 되지 않습니다. 또 <script type="module">이나 번들러로 빌드한 ES 모듈에서는 최상위 var도 모듈 스코프에 머물러 window에 붙지 않습니다. 요즘 코드에서 전역 오염 문제가 덜한 이유가 이것이지만, 모듈 시스템 없이 로드되는 레거시 스크립트나 인라인 <script>에서는 여전히 위 문제가 그대로 적용됩니다. 전역 객체에 정말 무언가를 붙여야 한다면 globalThis.myLib = ...처럼 명시적으로 쓰는 편이 의도가 분명합니다.
마무리
JavaScript 변수 선언의 핵심:
- const를 기본으로 사용하세요
- 재할당이 필요하면 let 사용
- var는 절대 사용하지 마세요
- ESLint로 자동 검사 설정 핵심: const > let > var 순서로 고려하며, var는 레거시 코드에서만 보게 될 것입니다.
FAQ
Q1. const를 쓰면 성능이 더 좋나요? 미미한 차이입니다. const의 주요 이점은 의도 표현과 실수 방지입니다.
Q2. 모든 변수를 const로 선언해야 하나요? 재할당이 필요 없다면 const를 사용하세요. 코드 리뷰어가 “이 변수는 바뀌지 않는구나”를 바로 알 수 있습니다.
Q3. 기존 코드의 var를 모두 let/const로 바꿔야 하나요? 점진적으로 바꾸세요. 새 코드는 let/const만 사용하며, 기존 코드는 수정할 때 함께 바꾸면 됩니다.
같이 보면 좋은 글
var·let·const 선택 체크리스트
변수 선언 체크리스트
- 기본적으로 const 사용
- 재할당 필요 시 let 사용
- var 사용 금지
- ESLint no-var 규칙 활성화
- 객체/배열도 const 사용 (참조 불변)
코드 리뷰 체크리스트
- var 사용 여부 확인
- let인데 재할당이 없으면 const로 변경
- 루프 변수는 let 사용
- 전역 변수 최소화