본문 바로가기
Development

[21.01.08] YDKJSY - The (Not so) Secret Lifecycle of Variables

by igy95 2024. 1. 7.

When Can I Use a Variable?

변수를 사용할 수 있는 시기에 대한 명확(해 보이는 듯)한 답변은: 변수가 선언된 이후라고 생각할 수 있다. 하지만 이것은 정답이 아니다.

greeting(); // Hello!

function greeting(){
    console.log("Hello!");
}

위의 코드는 정상적으로 실행되는 것을 볼 수 있다. 이전 챕터에서 확인했다시피, 모든 식별자들은 각각의 스코프들에 대해서 컴파일 과정에서 등록되고, 스코프에 진입하는 순간 그 시작 지점에서 식별자들은 생성된다. 이와 같이 변수 / 함수 선언이 스코프의 아래 부분에서 이루어졌다 하더라도, 인접한 스코프의 시작 지점에서 확인이 가능한 현상을 호이스팅이라고 한다.

이 현상에 대해서는 두 가지 케이스로 나누어 생각해볼 수 있다.

  • 변수 키워드로 선언
  • 함수의 선언

보통 변수를 선언할 때는 var, let, const 를 같이 사용하는데, 함수 표현식의 경우도 결국 var func = function () {...} 처럼 함수를 어떤 변수에 할당하는 것이니 굳이 호이스팅 케이스에 있어 함수 선언식과 함수 표현식에 대해 차이를 나누지 않아도 된다.

 

첫 번째로 함수 선언식에 대한 호이스팅을 function hoisting 이라고 하는데, 이 케이스의 경우 함수의 이름 식별자가 스코프의 최상단에 등록될 때 부가적으로 해당 함수 참조에 대한 초기화까지 자동으로 이루어진다. 그래서 식별자 검색 뿐만 아니라 직접 호출까지 할 수 있는 것이다.

 

function hoisting 과 var hoisting은 block scope이 아닌 function scope를 기준으로 동작한다. (let, const 또한 호이스팅은 이루어지지만 TDZ 라는 일종의 매커니즘으로 인하여 변수의 할당 전까지 참조가 불가능하다. 이는 밑에서 다룰 것이다.)

Hoisting: Yet Another Metaphor

이전 챕터에서 한 개념을 직관적으로 이해하기 위해 비유적 표현을 사용한 것처럼, 호이스팅에 대해서도 몇 가지 비유를 들어 설명하는 방식이 있다. 말 그대로 JS 엔진이 선언을 위로 '끌어 올려' 코드를 재배치 한다는 가정이다. 이러한 가정은 코드 내의 호이스팅 과정을 이해하는 것을 단순화 시켜줄 수 있지만, 이것은 정확하지 않은 해석이다.

 

JS 엔진은 정확하게 선언부만 콕 찝어서 위로 끌어올릴 수 있는 능력이 없다. 정확히 말하자면 엔진은 선언부와 프로그램 내의 모든 스코프 범위를 찾기 위해서 결국 모든 코드를 파싱하는 것이다. 때문에 호이스팅을 런타임에서 발생하는 과정이라고 인식하기 보다는, 컴파일 과정에서 이루어지는 작업이라고 보는 시각이 좀 더 명확하다.

Re-declaration?

같은 스코프 내부에서 동일한 이름을 가진 식별자를 변수로 반복 선언할 때의 케이스를 알아 보았다.

var

var의 경우, 해당 키워드로 선언된 변수가 두 개 이상이면 재선언된 변수부터는 아무런 동작을 하지 않는다. 이 말은 단지 var a; 라는 선언을 한 번 더 한다고 한들, 실제적인 할당이 이루어지지 않는다면 undefined 로 초기화하지도 않고 넘어간다는 얘기다.

var studentName = "Frank";
console.log(studentName); // Frank
var studentName;
console.log(studentName); // Frank

파싱 과정에서 엔진은 해당 스코프에 대한 변수를 수집할 것이다. 첫 번째 studentName 변수에 대해 undefined로 초기화하고 나면, 두 번째 studentName 은 사실상 할 일이 없어진다. 이는 호이스팅 관점에서도 끌어 올려진 두 개의 변수 중 첫 번째만 역할을 수행하는 것으로 볼 수 있고, 초기화된 변수는 Frank 라는 값을 할당 받게 되니 결국 모든 출력은 해당 변수의 값이다.

var greeting;

function greeting() {
    console.log("Hello!");
}

// basically, a no-op
var greeting;

typeof greeting;        // "function"

var greeting = "Hello!";

typeof greeting;        // "string"

위의 코드의 경우에서 첫 번째 var 선언이 호이스팅으로 인해 undefined 로 초기화 되어야 하지만, 변수 객체가 변수를 수집하는 순서 상 함수 호이스팅이 먼저 시행되기 때문에 동일한 이름 식별자를 가진 함수 greeting() 이 이를 무시하고 해당 함수를 참조한다. 두 번째 var 선언은 당연히 아무런 동작을 하지 않고, 세 번째 var 에서는 직접적인 할당이 이루어지기 때문에 변수의 타입이 변한다.

Loops

일정하게 반복되는 루프 연산 안에서 변수의 재선언 문제는 어떻게 다루어질까? 다음 코드를 확인해 보자.

var keepGoing = true;
while (keepGoing) {
    let value = Math.random();
    if (value > 0.5) {
        keepGoing = false;
    }
}

반복문 안에 let 으로 선언된 변수는 반복 선언된다고 해도 동일한 스코프에서 시행되는 것이 아니다. 새로운 반복문이 실행될 때마다 별개의 스코프를 생성하게 되고 선언은 그 안에서 매번 새롭게 이루어지기 때문이다.

또한 해당 변수를 var 로 선언했다면, valuekeepGoing과 같은 스코프를 공유한다. 블록 범위로 선언되지 않았기 때문이다. 그래서 같은 변수에 대해 할당만 수행하게 되니 이 또한 에러는 발생하지 않는다.

TDZ

변수는 프로그램 내에서 대부분 크게 세 가지 단계로 이루어진다.

  1. 선언 : 파싱 과정에서 변수 객체가 각종 변수들을 수집한다.
  2. 초기화 : 해당 식별자에 대한 메모리를 부여하고 undefined 상태를 유지한다.
  3. 할당 : 변수 안에 직접 값을 넘겨 준다.

var 는 호이스팅이 발생하면, 해당 변수의 선언과 함께 초기화가 자동으로 이루어진다. 때문에 선언부 상단에서 참조도 가능하지만 직접 할당도 할 수 있다. 하지만 let, constvar 와 같은 단계에서 선언은 이루어지지만 키워드로 선언한 식별자를 만나기 전까지는 초기화 되지 않는다. 초기화되지 않은 변수는 참조도, 값의 할당도 불가능하다.

 

조금 더 깊이 들어가자면, 컴파일러는 변수를 수집하는 과정에서 var, let, const 키워드를 없애고 적절한 식별자를 각 스코프의 상단에 재배치하는데 이는 컴파일러가 let, const 로 선언된 변수의 초기화를 위해 프로그램의 중간 부분에서 명령을 추가할 수 있음을 나타낸다.

 

이렇게 스코프의 진입 지점부터 변수의 실질적인 초기화가 이루어지는 부분 사이를 TDZ (Temporal Dead Zone), 즉 '일시적 사각지대'라고 명명한다. TDZ 구간에서 변수는 존재하지만 초기화가 되어있지 않은데, 이 초기화는 오직 선언이 실제로 이루어진 지점에서 컴파일러의 남은 명령들이 실행되어야만 가능하다. 그래서 TDZ가 종료되고 나면 var 로 선언된 변수처럼 참조, 할당이 가능해진다. 사실 이론적으로 따지자면 var 도 TDZ 를 가지고 있다고 할 수 있지만 선언과 초기화가 동시에 이루어지면 TDZ 는 사실상 없다고 봐도 무방하기에 해당 개념은 let, const 에서만 확인이 가능한 것이다. Temporal(일시적인) 의 의미는 해당 코드의 위치가 아닌 실행 시점을 이야기한다.

askQuestion();
// ReferenceError

let studentName = "Suzy";

function askQuestion() {
    console.log(`${ studentName }, do you know?`);
}

studentName 변수는 분명 TDZ rule 에 맞게 작성되어있지만, 변수를 담고 있는 함수가 선언부 전에서 호출되기 때문에 TDZ 가 끝나지 않았음에도 불구하고 변수를 참조한다는 점에서 에러를 반환한다.

TDZ 에러를 피하는 방법은 간단하다. let, const의 변수들을 해당 스코프 상단에 모두 선언해주면 된다. 이러한 방법은 안정성과 가독성을 모두 높일 수 있다.