변수가 참조될 수 있는 유효범위를 스코프라고 한다.
자바스크립트를 비롯한 대부분의 언어에서 변수는 자신이 정의된 위치에 따라 다른 코드가 자신을 참조할 수 있는 범위가 결정되는데 이를 렉시컬 스코프(Lexical scope)=정적 스코프라고 한다.
var x = 1;
function foo() {
var x = 10;
bar();
}
function bar() {
console.log(x);
}
foo(); // ? foo()는 전역에 정의되었으니 1
bar(); // ? bar()도 전역에 정의되었으니 1
var는 같은 스코프 내에서 같은 변수를 중복 선언할 수 있지만 let, const는 선언할 수 없다.
전역(코드의 가장 바깥 영역)에 위치하면 전역 스코프, 지역(함수 내부)에 위치하면 지역 스코프이다.
전역은 어디서든지 참조할 수 있고 지역은 자신의 지역 스코프와 그 하위 스코프에서만 참조할 수 있다.
스코프가 계층적으로 연결된 것을 스코프 체인이라고 한다.
변수를 참조할 때 자바스크립트 엔진은 스코프 체인을 통해 변수를 참조하는 코드의 스코프에서 시작하여 상위 스코프로 이동하며 선언된 변수를 검색한다. 가장 하위의 스코프가 상위 스코프 변수를 사용할 수 있거나 전역 스코프의 변수는 어디에서나 사용할 수 있는 원리이다.
(사실 변수뿐 아니라 함수도 해당되기 때문에 변수 참조보다는 식별자 참조라고 말하는 편이 맞다.)
var는 함수의 영역으로 스코프가 정해지는 함수 레벨 스코프, const, let은 {} 괄호로 범위가 정해지는 블록 레벨 스코프이다.
함수 레벨 스코프의 문제점:
var x = 1;
if (true) {
// var 키워드로 선언된 변수는 함수의 코드 블록(함수 몸체)만을 지역 스코프로 인정한다.
// 함수 밖에서 var 키워드로 선언된 변수는 코드 블록 내에서 선언되었다 할지라도 모두 전역 변수다.
// 따라서 x는 전역 변수다. 이미 선언된 전역 변수 x가 있으므로 x 변수는 중복 선언된다.
// 이는 의도치 않게 변수 값이 변경되는 부작용을 발생시킨다.
var x = 10;
}
console.log(x); // 10
var i = 10;
// for 문에서 선언한 i는 전역 변수다. 이미 선언된 전역 변수 i가 있으므로 중복 선언된다.
for (var i = 0; i < 5; i++) {
console.log(i); // 0 1 2 3 4
}
// 의도치 않게 변수의 값이 변경되었다.
console.log(i); // 5
따라서 의도치 않은 동작을 피하기 위해 var는 지양하고 let, const를 사용해야 한다.
자바스크립트 엔진은 소스코드를 4가지 타입으로 구분해서 각각 다른 방식으로 실행 컨텍스트를 생성한다.
전역 코드: 파일 최상위에 위치한 코드. var로 선언한 변수와 함수는 전역 객체(브라우저는 window)의 프로퍼티가 된다.
함수 코드: 함수 내부의 코드. 독립적인 지역 스코프를 형성하며, 이 지역 스코프는 상위 스코프와 이어진 스코프 체인의 일부가 된다.
eval 코드: eval 함수에 인수로 전달되어 실행되는 코드. strict mode에서 동작하며 실무에서는 거의 사용하지 않는다.
모듈 코드: import/export를 사용하는 모듈 파일 단위의 코드. 독립적인 모듈 스코프를 가진다.
자바스크립트 엔진은 소스코드를 한 번에 실행하지 않고 평가와 실행 두 단계로 나눠 처리한다.
console.log(x); // undefined
var x = 5;
평가 단계: 실행에 앞서 var 선언, 함수 선언문 등을 미리 등록한다. (호이스팅) var로 선언된 변수는 이 시점에 undefined로 초기화된다.
실행 단계: 코드를 순차적으로 실행하며 변수 값 참조 및 할당이 이루어진다. 필요한 식별자는 평가 단계에서 등록된 것을 참조한다.
실행 컨텍스트 스택 (실행 컨텍스트가 쌓인 스택)
└─ 실행 컨텍스트들을 호출 순서대로 관리
│
└─ 실행 컨텍스트 (코드가 실행되는 데에 필요한 환경들을 모아놓은 객체)
│
├─ 구성
│ └─ 렉시컬 환경 (Lexical Environment)
│ ↓
│ ├─ 환경 레코드 (Environment Record)
│ │ ├─ 변수/함수 등의 바인딩 관리
│ │ └─ this 바인딩 (this 관련 정보)
│ │
│ └─ 외부 렉시컬 환경에 대한 참조 (Outer Lexical Environment Reference)
│ → 상위 렉시컬 환경
│
└─ 역할
├─ 코드 실행 상태 관리
├─ 식별자 관련 환경 관리
└─ this 등 실행에 필요한 정보 관리
var x = 1;
const y = 2;
function foo (a) {
var x = 3;
const y = 4;
function bar (b) {
const z = 5;
console.log(a + b + x + y + z);
}
bar(10);
}
foo(20); // 42
전역 객체 생성: 코드 실행 전 console 등 빌트인 포함해서 먼저 만들어짐
전역 코드 평가: 전역 실행 컨텍스트 push, 전역 렉시컬 환경 생성
var x → 즉시 undefined
const y → 등록만, 값은 미할당(TDZ)
외부 참조 → null
전역 코드 실행: x=1, y=2 대입 → foo(20) 호출
foo 평가: foo 실행 컨텍스트 push (스택: [전역, foo]). a, x, y, bar 등록. 외부 참조 = 정의된 곳인 전역
foo 실행: a=20, x=3, y=4 → bar(10) 호출
→ bar 평가 — bar 실행 컨텍스트 push (스택: [전역, foo, bar]). b, z 등록. 외부 참조 = 정의된 곳인 foo
bar 실행 → 식별자 검색 (console.log(a+b+x+y+z))
bar → 없으면 foo → 없으면 전역, 순서로 검색
→ 20+10+3+4+5 = 42
소멸: bar 종료 → pop (스택: [전역, foo]) → foo 종료 → pop (스택: [전역])
실행 컨텍스트 내려감 ≠ 렉시컬 환경 내려감. 참조가 남아있으면 실행 컨텍스트는 유지됨 → 클로저의 원리
한줄 요약: 전역 생성 → 전역 평가/실행 → foo 호출 시 평가(외부참조=전역)/실행 → bar 호출 시 평가(외부참조=foo)/실행 → 식별자는 자기 스코프부터 위로 검색 → 종료 시 bar부터 역순 pop
var는 함수 단위로만 스코프가 구분되는 함수 레벨 스코프이며 let/const는 if, for, while 등 {} 블록 단위로 구분되는 블록 레벨 스코프다.
let x = 1;
if (true) {
let x = 10;
console.log(x); // 10
}
console.log(x); // 1
let/const는 블록 진입 시 해당 블록만을 위한 독립된 렉시컬 환경이 새로 생성되며 부 참조는 기존 렉시컬 환경(전역)을 가리킨다. 블록 종료 시 원래 렉시컬 환경으로 복귀한다.
자바스크립트는 함수의 상위 스코프를 호출 위치가 아닌 정의 위치로 결정한다(렉시컬 스코프). 함수 정의 시점에 [[Environment]] 슬롯에 상위 스코프 정보가 저장되기 때문에 실행 컨텍스트가 스택에서 제거된 이후에도 해당 렉시컬 환경은 참조가 남아있는 한 유지된다. 이것이 클로저가 성립하는 메커니즘이다.
function foo() {
var a = 10;
function bar() {
console.log(a);
}
return bar;
}
const myFunc = foo(); // 여기서 foo,bar는 이미 스택에서 내려간 시점
myFunc(); // 렉시컬 환경이 소멸되지 않은 덕분에 10을 제대로 불러올 수 있다.
bar는 정의되는 시점에 [[Environment]]에 foo의 렉시컬 환경을 저장해둔다. foo의 실행이 종료되어 실행 컨텍스트가 스택에서 제거되어도 bar(myFunc)가 그 렉시컬 환경을 계속 참조하고 있으므로 해당 렉시컬 환경은 소멸되지 않는다. 따라서 이후 myFunc() 호출 시에도 foo의 렉시컬 환경에서 a=10을 정상적으로 찾아낼 수 있다.
선언이 코드 실행 전 평가단계에서 먼저 처리되어서, 선언문보다 앞에서도 식별자를 참조할 수 있는 것처럼 보이는 현상이다.
호이스팅은 왜 일어나나? (실행 컨텍스트와의 연관)
실행 컨텍스트가 만들어질 때 엔진은 코드를 바로 실행하지 않고, 먼저 평가 단계에서 그 스코프의 선언들을 스캔해 렉시컬 환경의 환경 레코드에 식별자를 등록한다. 그다음 실행 단계에서 코드를 한 줄씩 실행하며 값을 할당한다. 그래서 실행 단계가 시작될 때는 이미 식별자가 등록되어 있다. 호이스팅은 이 "평가 → 실행" 2단계 구조의 부수 효과이다.
🔍 "let/const는 호이스팅이 안 되는 거 아니야?"
된다. 선언문을 실행하기 전까지는 초기화되지 않은 상태라 접근하면 에러가 나는데 이 구간이 TDZ(Temporal Dead Zone)이다.
let x = 1;
{
console.log(x); // ReferenceError (바깥 x=1이 아니라, 이 블록의 x가 TDZ 상태)
let x = 2;
}