스코프란 식별자에 접근할 때 규칙이 있는데, 이 식별자 접근 규칙에 유효한 범위를 의미한다.
이러한 스코프는 중첩될 수 있다는 특징이 있고 이 경우에 표현식이나 문(statement)은 해당 레벨의 스코프나 더 바깥 레벨의 변수에만 접근할 수 있고 안쪽 레벨에 있는 변수에는 접근할 수 없다.
마트료시카와 같은 상자가 있을 때 같은 레벨의 상자나 이미 열린, 즉 더 큰 상자에 있는 물건은 확인할 수 있지만 아직 열리지 않은 더 작은 상자에 있는 물건은 확인할 수 없다는 것과 같다.
내가 공부하고 있는 JS 역시 렉시컬 스코프 규칙을 따르고 있는데 렉시컬 스코프와 JS만의 독특한 특징인 호이스팅, 지금은 별로 사용하지 않는var에 대해서 좀 더 알아보자.
렉시컬 스코프란 식별자를 호출한 곳이 아닌 선언한 곳에서 스코프가 결정된다는 규칙을 의미한다.
그렇기 때문에 한 번 선언이 되면 스코프가 결정되기 때문에 정적 스코프라고 불리기도 한다.
우선 이해가 쉽도록 예제를 하나 보자
let x = 1;
function first() {
let x = 10;
second();
}
function second() {
console.log(x);
}
first(); // ?
second(); // ?
위에서 first 함수와 second 함수를 각각 호출했을 때 결과가 어떻게 나올까?
결과는 둘 다 1이 나오게 된다. 왜 그럴까?
여기서 내가 헷갈렸던 점은 두 가지다.
첫 번째는 second 함수를 선언하기 전에 first에서 호출을 했다는거, 두 번째는 firt 함수를 호출하면 second 함수가 호출되는데 이때 first 함수의 스코프에서는 x가 10이기 때문에 second 함수가 받는 x는 10이 될 것 같다는거.
첫 번째 궁금증은 밑에 호이스팅이에서 자세히 다루기로 하고 우선은 렉시컬 스코프의 영향을 먼저 보자.
위에서 렉시컬 스코프의 정의를 설명했듯이 중요한건 함수를 어디서 호출하는지가 아니라 어디서 선언하였는지이기 때문에 second 함수는 같은 레벨의 스코프인 let x = 1을 참조하는 것이고 낮은 레벨에 있는 let x = 10은 확인할 수 없는 것이다.
그렇다면 왜 JS는 렉시컬 스코프 규칙을 가지게 되었을까?
이유 1. JS가 개발되었을 당시 널리 쓰이던 C, Java와 같은 언어들이 렉시컬 스코프를 사용하고 있었기 때문에 JS도 이 전통을 따랐다.
이유 2. 렉시컬 스코프는 코드가 선언된 위치만 보면 변수 참조를 추적할 수 있지만 동적 스코프는 실행 흐름을 따라가야 해서 디버깅이 더 어렵고 혼란스러울 수 있기 때문에 코드의 가독성과 유지 보수 측면에서 더 유리함이 있다.
이러한 정적 스코프 구조를 극복하고 동적 스코프처럼 사용하고 싶다면 eval(), new Funcion(), this 등을 사용하는 방법이 있지만, 사용을 지양해야하며 절대로 렉시컬 스코프 규칙을 바꾸는게 아닌 비슷하게 활용하는 것이다.
참조 : 렉시컬 스코프
스코프는 실행 단계가 아닌 그 전, 컴파일 단계에서 정해진다고 말한다.
하지만 컴파일 과정도 토크나이징, 파싱, 컴파일 등 세세한 과정이 있는데 정확히 어디서 정해지는건지 좀 더 자세히 알아보고자 한다.
소스코드를 분해해서 의미있는 토큰으로 만들고(렉싱) 토큰의 의미를 기준으로 구조화 하여 AST를 생성한다.
이 다음에 AST를 순회하며 노드를 읽고 식별자와 선언 위치를 바탕으로 스코프 체인을 구성하는 것이다.
그럼 스코프 체인을 구성하는 과정을 예시로 좀 더 자세히 알아보자.
function outer() {
let a = 1;
function inner() {
console.log(a);
}
}
위와 같은 코드가 있을 때 이걸 AST로 생성하면 다음과 같은 구조를 띄게 된다.
{
"type": "Program",
"body": [
{
"type": "FunctionDeclaration", ... 1️⃣
"id": {
"type": "Identifier",
"name": "outer"
},
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "VariableDeclaration", ... 2️⃣
"kind": "let",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1
}
}
]
},
{
"type": "FunctionDeclaration", ... 3️⃣
"id": {
"type": "Identifier",
"name": "inner"
},
...
이 AST 구조도에 맞춰서 스코프 트리가 정해지는 것이다.
1️⃣ : outer 함수가 전역 스코프 안에 선언됨 → outer 식별자는 전역 스코프에 바인딩
2️⃣ : outer 내부에 선언된 식별자 a는 outer의 함수 스코프에 바인딩
3️⃣ : 식별자 inner도 역시 outer의 함수 스코프에 바인딩
이와 동시에 inner 함수의 conosle.log(a);에서 a가 어떤 스코프에 있는 식별자를 참조할 것인지도 이 단계에서 결정된다.
그렇기 때문에 렉시컬 스코프를 설명할 때 봤던 예시처럼 함수 호출 위치에 상관하지 않고 선언 위치에 맞춰서 참조하게 되는 것이다.
스코프는 선언 위치를 기준으로 정해진다는 렉시컬 스코프 때문에 자동으로 발생하는 JS의 특징인 클로저에 대해서
📌 var를 사용한 변수 재선언이나 값의 재할당 같은 경우에는 컴파일 단계가 아닌 실행 단계에서 발생한다.
위에 스코프 정의 설명과 렉시컬 스코프의 예제를 설명하면서 나온 호이스팅과 TDZ(Temporal Dead Zone)에 대해서 알아보자.
호이스팅이란 특정 스코프 내에 선언한 변수가 선언된 위치와 상관없이 해당 스코프 시작 부분에서 변수의 가시성이 확보되는 현상을 말한다.
이러한 호이스팅은 파싱으로 AST가 생성된 이후 실행 컨텍스트를 만들면서 선언부를 먼저 등록하게 되면서 발생한다.
호이스팅 단계에서 값의 초기화는 선언 키워드에 따라 다르기 때문에 밑에서 확인하도록 하고 실제 값은 실행 단계에서 할당되게 된다.
우선 예제를 한 번 보자.
function inner() {
console.log(a);
console.log(b);
var a = 1;
let c = 2;
}
inner();
위와 같이 코드가 있을 때 inner 함수의 호출 결과는 어떻게 될까?
a에 대한 값은 undefined가 나오고 b에 대한 값은 ReferenceError: Cannot access 'b' before initialization라는 오류가 발생한다.
코드로 보자면 아래와 같다
function inner() {
var a; // undefined가 값으로 할당됨
let c; // 변수가 선언만 되고 할당된 값 없음 (TDZ 시작)
console.log(a);
console.log(b);
a = 1;
c = 2;
}
var를 사용한 변수 a는 호이스팅과 동시에 자동으로 undefined가 값으로 할당되는데 let(const도 같음)를 사용한 변수 b는 아무런 값이 할당되지 않아서 TDZ에 빠지게 된다.
그렇다면 ES6에서 도입된 let과 const는 왜 TDZ가 생기게 됐을까?
주된 이유는 역시 오류 발생을 방지하고 안정성을 높이기 위해서이다.
이유 1. 초기화되기 전의 변수 접근을 막아줌으로써, 실수를 빨리 감지할 수 있다
이유 2. 변수는 명확한 시점 이후에만 사용할 수 있게 강제되므로, 코드가 더 안정적이다
함수 스코프인 var를 잘 사용한다면 함수 스코프 내 어디서든 접근 가능하다는 장점이 있겠지만 나중에 알아볼 let, const 중 const의 사용을 지향하는 이유(상태 줄이기)와 오류 발생을 줄이기 위해서 사용을 지양하는게 맞는거 같다.
호이스팅은 리터럴 값에서만 존재하는 기능이 아닌 함수에서도 작동을 한다.
함수를 작성할 때는 아래와 같이 함수 표현식과 함수 선언문 두 가지를 사용할 수 있다.
1️⃣
const outer = () => {
console.log(inner());
const inner = () => { // 함수 표현식으로 작성
return 1;
};
}
outer(); // Uncaught ReferenceError: Cannot access 'inner' before initialization
2️⃣
const outer = () => {
console.log(inner());
function inner () { // 함수 선언문으로 작성
return 1;
};
}
outer(); // 1
3️⃣
askQeustion();
// ReferenceError
// 여기서 ReferenceError가 발생하는 위치는 askQuestion이지만
// askQuestion 함수 떄문이 아닌 let을 사용한 studentName 변수 호이스팅의 TDZ 때문에 발생한다.
let studentName = "지수";
function askQuestion () {
console.log(`${studentName}님, 안녕하세요!`)
}
inner 함수가 선언된 후 console.log가 실행되는 outer 함수였다면 표현식이나 선언문이나 동일하게 작동한다.
하지만 위의 경우에는 inner 함수가 선언되기 전에 console.log가 실행되며 내부 함수 inner가 호이스팅되는 상황이다.
위에서 봤던 것처럼 let과 const는 런타임 이전에는 TDZ에 존재하기 때문에 함수 표현식의 경우 에러가 발생한다.
하지만 함수 선언문은 호이스팅에서 식별자뿐만 아니라 함수 객체 전체가 호이스팅 되기 때문에 오류가 발생하지 않고 정상 작동하는 것이다.
📌 함수와 var의 호이스팅은 블록 스코프가 아닌 가장 가까운 함수 스코프에 등록되고, 함수 스코프가 없다면 전역 스코프에 등록된다.
📌 호이스팅에도 우선 순위가 있다. 함수 선언문 -> var -> let/const의 순서를 가지고 있으며 코드 상에서 var를 사용한 변수 선언이 함수 선언문 보다 먼저 작성되더라도 호이스팅은 함수 선언문이 먼저 선언된다.
하지만 실행 단계에서 값의 할당은 코드 순서대로 할당된다.
함수 호이스팅을 확인하면 궁금한 점이 하나 생긴다.
변수 선언에서 호이스팅이 발생하면 변수 선언만 가능하고 값은 할당되지 않는데 함수 선언문은 함수 전체가 정의된다는 것이다.
함수는 항상 정적인 코드 구조로 미리 확정된 정의를 갖고 있어서 호이스팅 시 전체를 올려도 부작용이 적지만 변수는 실행 시 동적으로 값이 변할 수 있어 미리 값을 호이스팅하면 예측 불가능한 결과나 혼란이 생길 수 있기 때문이다.
이러한 이유 때문에 JS에서는 유연성을 제공하지만 오류 발생을 줄이기 위해서 함수 선언문은 함수 객체 전체를 메모리에 할당하고 변수(함수 표현식 포함)는 선언만 될 뿐 값이 할당되지 않는다.