var·let·const, 스코프, 실행 컨텍스트
1. var, let, const
1.1 선언, 초기화, 할당
변수를 만드는 일은 코드로는 선언과 할당 두 단계로 보이지만, 엔진은 그 사이에 초기화를 한 단계 더 둡니다.
let count; // 선언
count = 1; // 할당
| 단계 | 하는 일 |
|---|---|
| 선언 | 이름을 그 범위에 등록합니다 |
| 초기화 | 등록한 이름에 메모리 자리를 주고 undefined를 넣습니다 |
| 할당 | 그 자리에 실제 값을 넣습니다 |
세 키워드의 차이는 대부분 이 세 단계가 언제 일어나느냐에서 갈립니다.
1.2 var
함수 스코프입니다. var로 선언한 변수는 그 변수를 감싼 함수 안이면 어디서나 읽힙니다. if나 for의 중괄호는 경계가 되지 않습니다.
function run() {
if (true) {
var a = 1;
}
console.log(a); // 1
}
선언 전에 읽어도 에러가 나지 않습니다. 엔진이 코드를 실행하기 전에 그 범위의 선언을 먼저 훑어 이름을 등록하고 undefined까지 넣어 두기 때문입니다.
console.log(a); // undefined
var a = 1;
1.1의 세 단계 중 선언과 초기화가 실행 전에 끝나고 할당만 제자리에 남는 셈입니다. 이름이 미리 등록되는 이 현상을 호이스팅이라고 합니다. 코드가 위로 옮겨지는 것이 아니라 이름이 먼저 등록되는 것입니다.
그 밖의 특징
- 같은 이름을 다시 선언해도 에러가 없습니다.
- 전역에 선언하면
window의 프로퍼티가 되어 다른 스크립트와 이름이 부딪힐 수 있습니다.
1.3 let과 const
ES6에서 도입된 let과 const는 var의 특징을 모두 뒤집습니다.
블록 스코프입니다. 감싼 중괄호 안에서만 읽힙니다.
function run() {
if (true) {
let b = 2;
}
console.log(b); // ReferenceError: b is not defined
}
선언 전에 읽으면 에러가 납니다. 실행 전에 이름이 등록되는 것은 var와 같습니다. 다른 점은 초기화입니다. var는 등록하면서 undefined를 넣지만, let과 const는 등록만 하고 선언문이 실행될 때 초기화합니다.
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 1;
이름은 있지만 아직 읽을 수 없는 이 구간을 TDZ(Temporal Dead Zone)라고 합니다.
재선언은 SyntaxError입니다. 컴파일 단계에서 걸리므로 그 파일의 코드는 한 줄도 실행되지 않습니다.
let a = 1;
let a = 2; // SyntaxError: Identifier 'a' has already been declared
const는 재할당을 막습니다. 변수가 들고 있는 것은 값 자체가 아니라 값이 있는 자리를 가리키는 주소이고, const는 이 주소를 바꾸지 못하게 합니다. 주소가 가리키는 객체의 내부는 바뀝니다.
const list = [];
list.push(1); // 됩니다. 주소는 그대로고 배열 안만 바뀝니다
list = [1]; // TypeError: Assignment to constant variable.
주소를 나중에 바꿀 수 없으므로 선언과 동시에 값을 줘야 합니다.
반복문에서는 반복마다 새 바인딩이 생깁니다.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 3 3 3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 0 1 2
var i는 함수 스코프라 i 칸이 하나뿐이고, 콜백이 실행될 때는 반복이 끝나 3이 들어 있습니다. let i는 반복마다 새 칸이 생겨 콜백이 각자 자기 차례의 값을 붙잡습니다.
1.4 비교
var | let | const | |
|---|---|---|---|
| 재선언 | 됩니다 | SyntaxError | SyntaxError |
| 재할당 | 됩니다 | 됩니다 | TypeError |
| 스코프 | 함수 | 블록 | 블록 |
| 호이스팅 | 이름 등록 + undefined 초기화 | 이름 등록만 | 이름 등록만 |
| TDZ | 없습니다 | 선언문 실행 전까지 | 선언문 실행 전까지 |
| 선언만 하고 두기 | 됩니다 | 됩니다 | SyntaxError |
| 전역에 선언하면 | window의 프로퍼티 | 아닙니다 | 아닙니다 |
호이스팅과 TDZ는 별개가 아니라 한 이야기의 앞뒤입니다. 셋 다 이름은 미리 등록됩니다. var만 그때 undefined까지 들어가서 읽어도 에러가 안 날 뿐입니다.
1.5 무엇을 쓰는가
기본은 const, 재할당이 필요한 자리만 let입니다. const를 먼저 쓰면 이 이름은 끝까지 이 값이라는 사실이 코드에 드러나 읽는 사람이 추적할 대상이 줄어듭니다.
var는 쓰지 않습니다. 블록을 무시하고, 재선언이 조용히 통과하고, 선언 전에 읽어도 undefined를 돌려주고, 전역을 오염시킵니다. 모두 실수를 드러내지 않고 덮는 방향입니다.
2. 스코프(Scope)
2.1 스코프란
스코프는 이름이 통하는 범위입니다. 선언한 이름이 어디까지 보이는지를 정합니다.
const user = 'global';
function run() {
const temp = 'local';
console.log(user); // global
}
run();
console.log(temp); // ReferenceError: temp is not defined
가장 바깥이 전역 스코프, 함수나 블록 안이 지역 스코프입니다. 지역에서는 전역이 보이지만 반대는 아닙니다.
범위가 나뉘어 있어서 같은 이름을 다른 곳에 두고도 부딪히지 않습니다. 반대로 전역은 어디서나 보이는 만큼 어느 코드에서든 바꿀 수 있으므로, 변수는 쓰이는 자리에서 가장 가까운 범위에 두는 것이 원칙입니다.
2.2 함수 스코프와 블록 스코프
지역 스코프를 만드는 단위가 둘입니다.
| 단위 | 경계 | 키워드 |
|---|---|---|
| 함수 스코프 | 함수 몸통의 중괄호만 | var |
| 블록 스코프 | 모든 중괄호 (if, for, while, try, 단독 블록) | let, const |
객체 리터럴의 중괄호는 블록이 아니라 값이므로 스코프를 만들지 않습니다.
2.3 스코프 체인(Scope Chain)
스코프는 중첩됩니다. 안쪽에서 이름을 못 찾으면 한 단계 바깥을 보고, 전역까지 올라가도 없으면 ReferenceError입니다. 이 탐색 경로가 스코프 체인입니다.
const a = 'global';
function outer() {
const b = 'outer';
function inner() {
const c = 'inner';
console.log(a, b, c); // global outer inner
}
inner();
}
탐색은 찾는 순간 멈춥니다. 그래서 안쪽에 같은 이름이 있으면 바깥 이름은 가려집니다. 이것을 섀도잉이라고 합니다.
const name = 'global';
function print() {
const name = 'local';
console.log(name); // local
}
2.4 렉시컬 스코프(Lexical Scope)
스코프 체인이 어떻게 이어질지는 함수를 어디서 호출했는지가 아니라 어디에 썼는지로 정해집니다.
const name = 'global';
const printName = () => console.log(name);
const callIt = () => {
const name = 'local';
printName();
};
callIt(); // global
printName은 callIt 안에서 불렸지만 바로 옆에 있는 'local'을 보지 못합니다. 쓰인 자리가 전역이기 때문입니다.
코드에 적힌 위치로 범위가 정해지는 이 방식을 렉시컬 스코프라고 합니다. 실행하지 않아도 코드만 읽으면 범위가 확정되므로 정적 스코프라고도 부릅니다.
3. 실행 컨텍스트(Execution Context)
3.1 실행 컨텍스트란
실행 컨텍스트는 코드 한 덩어리를 실행하는 데 필요한 정보를 모아 둔 것입니다. 이 안에서 어떤 이름이 어떤 값인지, 여기 없으면 어디로 가서 찾을지, this는 무엇인지가 들어 있습니다.
스코프와 나란히 놓으면 역할이 분명해집니다.
| 스코프 | 실행 컨텍스트 | |
|---|---|---|
| 무엇인가 | 이름이 어디까지 보이는지에 대한 규칙 | 그 규칙대로 실제 값을 담아 둔 그릇 |
| 언제 정해지는가 | 코드를 쓸 때 한 번 | 실행할 때마다 새로 |
함수 호출 한 번을 작업 하나라고 보면, 실행 컨텍스트는 그 작업을 위해 편 작업대입니다. 같은 함수를 두 번 부르면 작업대도 둘이라 변수 값이 섞이지 않습니다.
3.2 언제 만들어지는가
| 언제 | 무엇이 |
|---|---|
| 스크립트를 시작할 때 | 전역 실행 컨텍스트 하나. 끝날 때까지 남습니다 |
| 함수를 부를 때 | 호출마다 하나. 함수가 끝나면 사라집니다 |
만들어질 때 두 단계를 거칩니다.
| 단계 | 하는 일 |
|---|---|
| 생성 단계 | 그 범위의 선언을 먼저 훑어 이름을 등록합니다. 코드는 아직 실행하지 않습니다 |
| 실행 단계 | 코드를 한 줄씩 실행하며 값을 넣습니다 |
1장의 호이스팅이 바로 생성 단계입니다. var는 이때 undefined까지 들어가고, let과 const는 이름만 등록됩니다. 실행 단계에서 그 선언문을 만날 때까지가 TDZ입니다.
3.3 안에 무엇이 들어 있는가
| 칸 | 담는 것 |
|---|---|
| Lexical Environment | let과 const 선언, 그리고 바깥 환경으로 가는 링크 |
| Variable Environment | var 선언과 함수 선언문 |
| This Binding | 이 컨텍스트에서 this가 가리키는 값 |
Lexical Environment가 핵심입니다. 변수 보관함이라고 보면 되고, 두 부분으로 되어 있습니다.
| 부분 | 역할 |
|---|---|
| 환경 레코드 | 이름과 값의 표. 변수를 찾을 때 먼저 보는 곳 |
| 바깥 환경 참조 | 환경 레코드에 없을 때 가서 찾을 바깥 Lexical Environment의 주소 |
2장에서 본 두 가지가 여기서 실물이 됩니다.
- 바깥 환경 참조를 따라 올라가는 것이 스코프 체인입니다. 전역의 바깥 환경 참조는
null이라 거기까지 없으면ReferenceError입니다. - 바깥 환경 참조는 함수를 부를 때가 아니라 함수를 만들 때 정해집니다. 이것이 렉시컬 스코프입니다.
Variable Environment가 따로 있는 이유는 블록에 들어갈 때 새로 만드는 쪽이 Lexical Environment뿐이기 때문입니다. let과 const는 블록마다 새 보관함에 들어가서 블록을 나오면 사라지고, var는 함수 단위로 한 번만 만드는 보관함에 들어가서 블록을 무시하는 것처럼 보입니다.
This Binding은 this가 정해지는 규칙이 그 자체로 한 편이라 여기서는 다루지 않습니다.
3.4 콜스택(Call Stack)
실행 컨텍스트는 쌓입니다. 맨 위가 지금 실행 중인 것이고, 함수가 끝나면 위에서부터 빠집니다.
function outer() {
inner();
}
function inner() {
console.log('안쪽');
}
outer();
빠지지 않고 계속 쌓이면 RangeError: Maximum call stack size exceeded가 납니다. 끝나지 않는 재귀에서 보는 에러입니다.
3.5 한 번에 따라가기
const greeting = 'hi';
function greet(name) {
const msg = greeting + ' ' + name;
return msg;
}
greet('Kim');
- 스크립트가 시작되면 전역 실행 컨텍스트가 만들어집니다. 생성 단계에서 환경 레코드에
greeting과greet이 등록됩니다.greet은 함수 선언문이라 함수 전체가 바로 들어가고,greeting은const라 아직 비어 있습니다. 바깥 환경 참조는null입니다. - 실행 단계에서
greeting에'hi'가 들어갑니다. greet('Kim')을 부르면 새 실행 컨텍스트가 만들어져 전역 위에 쌓입니다. 생성 단계에서name은'Kim',msg는 비어 있음으로 등록됩니다. 바깥 환경 참조는 전역입니다.greet이 전역에 쓰였기 때문입니다.- 실행 단계에서
greeting을 찾습니다.greet의 환경 레코드에 없으니 바깥 환경 참조를 따라 전역으로 가서'hi'를 찾습니다.msg에'hi Kim'이 들어갑니다. return이 끝나면greet의 실행 컨텍스트는 스택에서 빠지고 전역만 남습니다.
1장의 호이스팅과 TDZ는 1번과 3번의 "이름부터 등록하고 값은 비워 두기"이고, 2장의 스코프 체인은 4번의 "바깥 환경 참조 따라가기"입니다.
정리
- 변수는 선언, 초기화, 할당 세 단계를 거치고, 세 키워드의 차이는 이 단계가 언제 일어나느냐입니다.
- 셋 다 이름은 실행 전에 등록됩니다.
var만 그때undefined까지 들어가고,let과const는 선언문이 실행될 때까지 TDZ에 있습니다. var는 함수 스코프,let과const는 블록 스코프입니다.const는 재할당만 막고 객체 내부는 바뀝니다.- 스코프 체인은 안에서 밖으로만 올라가고, 어떻게 이어질지는 호출 위치가 아니라 코드에 쓴 위치로 정해집니다.
- 스코프가 규칙이라면 실행 컨텍스트는 그 규칙대로 실제 값을 담은 그릇입니다. 함수를 부를 때마다 새로 만들어져 콜스택에 쌓이고, 끝나면 빠집니다.
- 만들 때 생성 단계에서 이름부터 등록하고 실행 단계에서 값을 넣습니다. 호이스팅과 TDZ가 여기서 나옵니다.
- 변수는 Lexical Environment의 환경 레코드에서 먼저 찾고, 없으면 바깥 환경 참조를 따라갑니다. 이것이 스코프 체인이고, 그 참조는 함수를 만들 때 정해집니다.