JavaScript에서 변수를 선언하는 방법은 세 가지입니다. var, let, const는 모두 변수를 만들지만, 각각이 변수를 어떤 범위에서 유효하게 만드는지, 즉 스코프(scope)를 다루는 방식이 근본적으로 다릅니다. 이 차이는 단순한 문법적 선택이 아니라, JavaScript 언어가 진화해 온 역사와 실무에서 반복적으로 발생하던 버그들에 대한 반성에서 비롯된 것입니다. var가 가진 함수 스코프 특성은 오랫동안 개발자들을 혼란에 빠뜨렸고, 이를 해결하기 위해 ES6(2015)에서 블록 스코프를 지원하는 let과 const가 도입되었습니다. 세 키워드의 차이를 정확히 이해하면, 코드의 흐름을 예측 가능하게 유지하고 의도치 않은 변수 누출이나 재할당 실수를 미리 방지할 수 있습니다.
🔍 핵심 문제점
var는 블록({})을 무시하고 함수 전체 또는 전역 범위에서 살아남아,if나for블록 밖에서도 접근이 가능합니다var로 선언된 변수는 같은 스코프 안에서 중복 선언이 허용되어, 기존 값을 실수로 덮어쓰는 버그가 조용히 발생합니다- 루프 안에서
var를 사용하면 반복문이 끝난 후에도 변수가 남아 있어 비동기 콜백이나 클로저와 조합될 때 예기치 않은 결과를 만듭니다 let은 블록 스코프를 가지므로 선언된{}안에서만 접근되지만, 선언 전 접근 시 일시적 사각지대(TDZ) 오류가 발생합니다const는 선언과 동시에 반드시 초기화해야 하며, 이후 재할당이 금지되어 값의 불변성을 보장하는 목적으로 사용됩니다
실무에서의 영향
세 키워드의 스코프 규칙을 이해하는 것은 버그 없는 코드를 작성하는 가장 기초적인 역량입니다. 실무에서는 const를 기본으로 사용하고, 재할당이 필요한 경우에만 let을 선택하는 것이 현재의 모범 사례로 자리 잡혀 있습니다. 이 원칙을 따르면 코드를 읽는 사람이 변수의 값이 이후에 바뀔 수 있는지 없는지를 선언부만 보고도 즉시 파악할 수 있어 가독성이 크게 향상됩니다. 반면 var는 레거시 코드베이스에서 여전히 등장하기 때문에, 그 특성을 정확히 알지 못하면 기존 코드를 유지보수하거나 리팩토링할 때 예상치 못한 동작을 마주치게 됩니다. 특히 for 루프와 비동기 처리가 결합된 코드에서 var와 let의 스코프 차이는 완전히 다른 실행 결과를 만들어내기 때문에, 이 개념은 단순한 이론이 아닌 현장에서 바로 적용해야 하는 실전 지식입니다.
핵심 개념
var의 함수 스코프
입문
var로 만든 변수는 중괄호({}) 블록을 무시하고 함수 전체에서 살아남아요. 왜 이게 문제인지 일상 비유로 알아봐요!
📦 스코프란 무엇인가요? 스코프는 변수가 살 수 있는 ‘범위’예요. 교실 안의 칠판에 적힌 글은 그 교실 학생들만 볼 수 있고, 복도에서는 볼 수 없잖아요? 변수도 마찬가지로 어디서든 볼 수 있는 게 아니라, 선언된 위치에 따라 볼 수 있는 범위가 정해져요.
🏠 var는 방이 아닌 집 전체를 사용해요 var로 선언한 변수는 작은 방(블록, {}) 안에 넣어도 집 전체(함수)에서 꺼낼 수 있어요. 마치 옷장 안에 물건을 넣었는데, 집 어디서나 그 물건이 보이는 것처럼요. if 문이나 for 반복문 안에서 만든 var 변수도 그 블록이 끝나면 없어지지 않고 함수 전체에 남아 있어요.
🚨 어디서 문제가 생기나요? 친구들과 모둠 활동을 할 때, 내 모둠에서만 쓰려고 만든 메모를 반 전체가 볼 수 있다면 어떨까요? 누군가 실수로 그 메모를 고칠 수도 있어요! var도 마찬가지예요. if 블록 안에서만 쓰려고 만든 변수가 함수 전체에 노출되면, 다른 코드가 실수로 그 값을 바꿀 수 있어요.
🔁 반복문에서는 더 큰 문제가 생겨요 for 반복문을 서랍 정리에 비유해볼게요. 1번 서랍, 2번 서랍, 3번 서랍을 순서대로 정리한다고 상상해요. 그런데 var로 만든 ‘현재 서랍 번호’ 변수는 반복문이 끝나도 사라지지 않고 마지막 값(3번)으로 남아 있어요. 나중에 이 변수를 확인하면 항상 3번만 보이는 황당한 상황이 생겨요.
중급
var는 함수 스코프(function scope)를 가집니다. 즉, if, for, while 같은 블록({})은 var에게 새로운 스코프를 만들어주지 않으므로, 블록 내부에서 선언된 var 변수라도 해당 함수 전체에서 접근할 수 있습니다.
함수 스코프의 동작 원리 var 선언은 자신이 속한 가장 가까운 함수 경계를 기준으로 스코프가 결정됩니다. 함수 밖에서 선언되면 전역(global) 스코프에 등록됩니다. if/for 블록은 var의 스코프에 영향을 주지 않습니다.
function example() {
if (true) {
var x = 10; // if 블록 안에서 선언
}
console.log(x); // 10 - 블록 밖에서도 접근 가능
}
// for 루프에서의 문제
function loopProblem() {
for (var i = 0; i < 3; i++) {
// 루프 내부 작업
}
console.log(i); // 3 - 루프 종료 후에도 i가 남아 있음
}
중복 선언 허용 문제 var는 같은 스코프 안에서 동일한 이름으로 중복 선언이 허용됩니다. 이로 인해 실수로 기존 변수를 덮어써도 에러가 발생하지 않아 버그를 잡기 어렵습니다.
var userName = 'Alice';
// ... 수백 줄의 코드 후 ...
var userName = 'Bob'; // 에러 없음 - 기존 값을 조용히 덮어씀
console.log(userName); // 'Bob'
심화
var의 함수 스코프는 ECMAScript 명세의 Variable Environment와 실행 컨텍스트(Execution Context) 모델에 의해 결정됩니다. 블록 레벨 바인딩(binding)이 없는 구조는 초기 JavaScript 설계에서 의도된 것이나, 이후 대규모 애플리케이션 개발 환경에서 심각한 결함으로 드러났습니다.
ECMAScript 명세 기반 스코프 결정 메커니즘
ECMAScript 2023 명세 14.3.2절(Variable Statement)에 따르면, var 선언은 VarDeclaredNames 추상 연산을 통해 수집되고, 함수 인스턴스화(FunctionDeclarationInstantiation, 명세 10.2.11절) 단계에서 Function Environment Record에 바인딩(binding, 변수와 메모리 주소의 연결)됩니다. 이때 모든 var 바인딩은 undefined로 초기화됩니다.
블록 문(Block Statement, {})은 ECMAScript 명세상 독립적인 Variable Environment를 생성하지 않습니다. var는 항상 현재 실행 컨텍스트의 Variable Environment를 참조하므로, 블록 내부의 var 선언은 블록을 탈출하여 상위 함수 스코프에 귀속됩니다.
V8 엔진 구현 관점 V8 엔진에서 var 변수는 함수 활성화 레코드(Activation Record)의 고정된 슬롯에 배치됩니다. 함수 최적화(TurboFan, Maglev 컴파일러)는 var 변수의 타입이 안정적일 때 Hidden Class Optimization과 Inline Caching을 적용하여 프로퍼티 접근을 O(1)으로 최적화합니다.
그러나 함수 스코프로 인한 변수 누출(variable leaking)은 가비지 컬렉터(GC)가 함수 종료 후에도 해당 스코프의 메모리를 회수하지 못하게 하는 클로저 캡처(closure capture) 문제를 유발할 수 있습니다. 특히 이벤트 루프와 결합된 콜백 패턴에서 var 변수가 예상보다 오래 메모리를 점유하는 현상이 관찰됩니다.
let과 const의 블록 스코프
입문
let과 const는 var와 달리 중괄호({}) 안에서만 사는 변수예요. 마치 각 방에 자물쇠가 달려 있어서 그 방 안에 들어가야만 볼 수 있는 것처럼요!
🔒 블록 스코프란 무엇인가요? 블록 스코프는 변수가 중괄호({}) 안에서만 존재하는 것을 말해요. let이나 const로 변수를 만들면, 그 중괄호 밖에서는 그 변수가 없는 것처럼 취급돼요. 교실 안 작은 모둠 칠판에 적은 내용은 그 모둠 안에서만 볼 수 있고, 다른 모둠은 볼 수 없는 것과 같아요.
🎯 let과 const는 어떻게 다른가요? let과 const 둘 다 블록 스코프를 가지지만, 하나 큰 차이가 있어요. let은 나중에 값을 바꿀 수 있는 연필로 쓴 메모예요. const는 한 번 적으면 지울 수 없는 펜으로 쓴 메모예요. const로 만든 변수는 처음에 정한 값을 나중에 바꾸려고 하면 오류가 나요!
📌 const는 왜 쓰나요? const로 변수를 만들면 “이 값은 절대 바뀌지 않아요”라는 약속이에요. 마치 학교 교칙처럼, 한 번 정해지면 바꾸기 어려운 것들이요. 코드를 읽는 사람이 const를 보면 ‘아, 이 값은 앞으로도 계속 똑같구나’라고 바로 알 수 있어서 코드를 이해하기 훨씬 쉬워져요.
✅ 왜 let과 const가 더 안전한가요? let과 const는 같은 이름으로 두 번 선언하려고 하면 바로 오류를 보여줘요. 실수로 이미 있는 변수를 또 만들려고 하면 “이미 있어요!”라고 알려주는 것이죠. 덕분에 var처럼 조용히 값이 덮어써지는 사고가 일어나지 않아요.
중급
let과 const는 블록 스코프(block scope)를 가집니다. 선언된 블록({}) 내에서만 유효하며, 블록 밖에서 접근하면 ReferenceError가 발생합니다. 두 키워드는 동일한 스코프에서 중복 선언을 허용하지 않습니다.
let과 const의 차이
- let: 재할당 가능, 선언 후 값 변경 가능
- const: 재할당 불가, 선언과 동시에 초기화 필수. 단, 객체나 배열의 경우 내부 프로퍼티 변경은 가능(참조 불변, 내용 가변)
function blockScopeExample() {
if (true) {
let blockVar = 'block';
const blockConst = 'constant';
console.log(blockVar); // 'block'
console.log(blockConst); // 'constant'
}
console.log(blockVar); // ReferenceError: blockVar is not defined
console.log(blockConst); // ReferenceError: blockConst is not defined
}
const name = 'Alice';
name = 'Bob'; // TypeError: Assignment to constant variable
// 객체의 경우: 참조는 불변, 내용은 가변
const user = { name: 'Alice' };
user.name = 'Bob'; // 정상 동작 - 내부 프로퍼티 수정은 허용
user = {}; // TypeError - 재할당은 불가
실무 권장 패턴 현대 JavaScript에서는 const를 기본으로 사용하고, 반드시 재할당이 필요한 경우에만 let을 사용하는 것이 모범 사례입니다. var는 새로운 코드에서 사용하지 않는 것이 권장됩니다.
심화
let과 const는 ECMAScript 2015(ES6) 명세에서 도입된 렉시컬 선언(Lexical Declaration)으로, 블록 레벨의 선언적 환경 레코드(Declarative Environment Record)에 바인딩됩니다. 이는 var의 Variable Environment와 명확히 분리된 별도의 Lexical Environment에서 관리됩니다.
ECMAScript 명세 기반 블록 스코프 구현 명세 14.3.3절(LexicalDeclaration)에 따르면, let/const 선언은 블록 문의 평가 시 생성되는 새로운 Declarative Environment Record에 바인딩됩니다. 이 레코드는 블록 실행이 완료되면 폐기되며, 외부 Environment Record는 이를 참조할 수 없습니다.
const의 경우 명세 6.1.7.1절의 프로퍼티 디스크립터(Property Descriptor) 개념과 유사하게, 바인딩 자체가 [[Writable]]: false에 해당하는 불변(immutable) 속성을 가집니다. 단, 이는 바인딩의 불변성이며, 바인딩이 참조하는 객체의 내부 상태(mutable state)와는 독립적입니다.
중복 선언 금지의 명세 근거
명세 10.2.11절(FunctionDeclarationInstantiation)과 14.2절(Block 평가 알고리즘)에 따르면, let/const 선언 시 HasBinding 추상 연산으로 동일 Environment Record 내 중복 여부를 확인합니다. 중복이 감지되면 SyntaxError를 즉시 발생시킵니다. 이는 파싱 단계가 아닌 인스턴스화(instantiation) 단계에서 검증됩니다.
메모리 및 최적화 관점 V8 TurboFan 컴파일러는 블록 스코프 변수를 스택 슬롯(stack slot)에 할당하여 힙(heap) 할당 없이 함수 종료 시 자동으로 해제합니다. const 변수는 단일 대입(SSA, Static Single Assignment) 형태이므로 컴파일러가 값 전파(constant folding)와 데드 코드 제거(dead code elimination) 최적화를 공격적으로 적용할 수 있습니다. 이는 let보다 미세하게 높은 최적화 가능성을 제공하지만, 현대 엔진에서 실측 성능 차이는 대부분 측정 불가 수준(< 0.1%)입니다.
호이스팅과 일시적 사각지대(TDZ)
입문
var로 만든 변수는 선언하기 전에 사용해도 오류가 안 나요. 하지만 let과 const는 선언 전에 쓰면 바로 오류가 나요. 이 차이가 왜 생기는지 알아봐요!
🎩 마술 같은 호이스팅이란? 호이스팅은 “끌어올리기”라는 뜻이에요. JavaScript는 코드를 실행하기 전에 먼저 훑어보면서 var로 선언된 변수들을 미리 메모해둬요. 마치 선생님이 수업 전에 출석부를 미리 만들어두는 것처럼요. 그래서 var 변수는 선언하기 전 줄에서 사용해도 오류가 안 나고, 대신 빈 값(undefined)이 나와요.
🚧 일시적 사각지대(TDZ)란 무엇인가요? TDZ는 “Temporal Dead Zone”의 줄임말로, 한국어로 “일시적 사각지대”예요. let과 const도 호이스팅은 되지만, 선언 줄에 도달하기 전까지는 절대로 접근할 수 없는 특별한 대기 상태에 있어요. 마치 택배가 배송 중이지만 아직 도착하지 않아서 열어볼 수 없는 것처럼요. 이 구간에서 변수에 접근하면 “아직 준비 안 됐어요!” 오류가 나요.
💡 왜 TDZ가 더 안전한가요? var처럼 빈 값(undefined)이 나오는 것보다, let/const처럼 오류를 바로 알려주는 게 사실은 더 안전해요. 마치 냉장고가 비어 있을 때 아무 반응이 없는 것과, “지금 비어 있어요!”라고 알림이 오는 것 중 어느 게 더 편한가요? 오류가 나는 편이 훨씬 빨리 실수를 알아챌 수 있어서 좋아요.
🔄 var와 let/const 호이스팅 비교 var는 메모해두고 바로 빈 값(undefined)으로 초기화해요. 반면 let과 const는 메모는 해두지만 선언 줄에 도달할 때까지 “아직 초기화 안 됨” 상태로 유지해요. 선언 줄 이전에 접근하면 TDZ 오류가 나는 이유가 바로 이것이에요.
중급
호이스팅(Hoisting)은 JavaScript 엔진이 코드 실행 전 선언문을 스코프 상단으로 끌어올리는 동작입니다. var, let, const 모두 호이스팅이 발생하지만, 초기화 시점이 다릅니다.
- var: 선언과 동시에
undefined로 초기화 → 선언 전 접근 시undefined반환 - let/const: 선언 호이스팅은 되지만 초기화는 선언 줄에서 발생 → 선언 줄 이전 구간(TDZ)에서 접근 시 ReferenceError 발생
console.log(x); // undefined (ReferenceError가 아님!)
var x = 10;
console.log(x); // 10
// 엔진이 실제로 해석하는 방식
var x; // 선언만 상단으로 끌어올림 (초기값: undefined)
console.log(x); // undefined
x = 10; // 대입은 원래 위치에서
console.log(x); // 10
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
// const도 동일
console.log(z); // ReferenceError: Cannot access 'z' before initialization
const z = 30;
TDZ가 존재하는 이유
TDZ는 개발자가 선언 전에 변수를 실수로 사용하는 것을 방지합니다. var처럼 조용히 undefined를 반환하면 버그를 찾기 어렵지만, TDZ는 즉시 오류를 발생시켜 문제 위치를 명확히 알려줍니다.
심화
호이스팅은 ECMAScript 명세의 실행 컨텍스트 생성 단계(Creation Phase)와 실행 단계(Execution Phase)의 분리에서 비롯됩니다. var와 let/const의 호이스팅 차이는 각각이 속한 Environment Record와 초기화 시점의 차이로 설명됩니다.
ECMAScript 명세 기반 호이스팅 메커니즘
명세 10.2.11절(FunctionDeclarationInstantiation)에 따르면, 함수 실행 컨텍스트 생성 시 모든 var 선언에 대해 CreateMutableBinding 추상 연산이 호출되고 즉시 InitializeBinding(undefined)으로 초기화됩니다. 이것이 var 호이스팅의 본질입니다.
반면 let/const(LexicalDeclaration)의 경우, 블록 평가 시 CreateMutableBinding(let) 또는 CreateImmutableBinding(const)이 호출되지만, InitializeBinding은 실제 선언 문(statement) 평가 시에만 호출됩니다. 선언 전 접근 시 GetBindingValue 추상 연산이 바인딩의 초기화 여부를 확인하고, 미초기화 상태이면 ReferenceError를 발생시킵니다. 이 구간이 Temporal Dead Zone(TDZ)입니다.
TDZ의 설계 의도와 typeof 연산자의 예외
TDZ 구간에서 typeof 연산자는 var와 다르게 동작합니다. 선언되지 않은 변수에 대한 typeof는 "undefined"를 반환하지만, TDZ 내의 let/const 변수에 typeof를 적용하면 여전히 ReferenceError가 발생합니다. 이는 TDZ가 “변수 미존재”가 아닌 “초기화 전 바인딩 존재” 상태임을 명세가 엄격히 구분하기 때문입니다.
엔진 최적화와 TDZ 검사 비용 V8 엔진은 TDZ 검사를 컴파일 단계에서 정적 분석으로 대부분 제거합니다. TurboFan이 변수 접근 시점과 선언 시점을 정적으로 추론 가능하면, 런타임 TDZ 검사 코드를 생성하지 않습니다. 그러나 클로저나 eval이 개입되면 정적 분석이 불가하여 런타임 TDZ 검사가 삽입됩니다. 이 경우 미세한 성능 오버헤드(overhead, 추가적인 연산 비용)가 발생하지만 실측 영향은 무시 가능한 수준입니다.