티스토리 뷰

반응형

사실, JS는 "적당히" 코드를 컴퓨터에 던져주면, "적당히" 실행시켜주고, 어느정도 사용자가 기대하는 만큼 작동하는 언어이다. 따라서, JS 진영은 다른 언어 진영에 비해 JS에 대한 탐구를 깊게 하는 경우를 보지 못하였고, 특히 호이스팅과 같이 JS 관련 여러 개념은 알고 있지만, 이에 대한 탐구는 깊게 하는 사용자들이 그렇게 많아 보이지 않았다.

 

이번 글에서는 프로그래밍의 가장 근간이 되지만, 가장 알기 어렵고 헷갈리기 쉬운 변수에 대하여 알아보고, 이에 대한 생명 주기를 알아보자. (You don't know JS를 기반으로 작성되었습니다!)

JS에서 변수의 생명주기에 대하여 알아보자.


1.변수는 언제 사용 가능한가?

자명한 답변이지만, 어느 언어에서나 변수는 언제 사용가능한가라고 물어보면 선언된 이후라 할 수 있다. 다음과 같은 예제를 보자.

sayHello();
// "Hello!"

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

이에 대하여 아무생각없이 실행하면 실행이 된다! 근데, 생각해보면, 1번째 줄에서 함수 sayHello를 호출하였는데, 함수 sayHello는 4번째 줄에 선언되어 있다. 왜 그럴까?

 

JS에서는 컴파일 타임에 모든 식별자를 각자 해당하는 스코프 안에 등록시킨다. 그리고, 실행되어 각 스코프의 영역에 진입할 때, 모든 식별자는 생성된다. 이때 진입한 시점에 JS는 모든 변수들을 위로 등록시켜 인식하는데, 이는 우린 호이스팅(hoisting)이라 한다.

 

모든 변수는 호이스팅이 된다고 치고, 어떻게 함수가 선언되기만 한 것을 인식하였지만 함수 호출이 작동하는 지는 다른 이야기이다. JS에서는 함수를 호이스팅할 때, 함수 레퍼런스에 대한 정보도 같이 초기화한다. 따라서, 함수 자체는 어디에 써도 작동이 된다는 이야기이다!

 

2.  표현식 vs 선언문

JS에는 함수를 선언하는 방법이 세 가지가 있다. 생성자를 활용한 방법은 일단 제쳐두고, 첫 번째로 함수 선언식이 있고, 두 번째로 함수 표현식, 세 번째로 화살표 함수가 있다. 같은 기능을 하는 함수를 세 가지 방법으로 써보면 다음과 같이 작성할 수 있다.

// 1. 함수 선언식
function sayHello1() {
  console.log("Hello!");
}

// 2. 함수 표현식
var hello2 = function sayHello2() {
  console.log("Hello!");
}

// 3. 화살표 함수
const hello3 = () => {
  console.log("Hello!");
}

이 중, 함수 호이스팅은 함수 선언식에서만 발생한다. 다른 말로, 다음과 같이 작성하면 다음과 같은 결과를 각각 얻을 수 있다.

sayHello1(); // "Hello!"
sayHello2(); // TypeError
sayHello3(); // TypeError

// 1. 함수 선언식
function sayHello1() {
  console.log("Hello!");
}

// 2. 함수 표현식
var hello2 = function sayHello2() {
  console.log("Hello!");
}

// 3. 화살표 함수
var hello3 = () => {
  console.log("Hello!");
}

첫 번째 함수 선언식으로 선언한 식은 함수 호이스팅이 발생하였지만, 두 번째 함수 표현식과 세 번째 화살표 함수는 함수 호이스팅이 발생하지 않은 모습을 볼 수 있다. 에러 메시지를 들여다 보면, TypeError를 throw한 것을 볼 수 있다. 다른 말로, 허용되지 않은 값에 접근하려 하였다는 의미이다. JS 환경에 따라 다르지만, TypeError에 자세한 안내말로 "undefined is not a function(undefined는 함수가 아닙니다.)" 혹은 "'sayHello2' is not a function('sayHello2'는 함수가 아닙니다.)"라는 문구를 볼 수 있을 것이다.

 

이것이 어떤 흥미로운 점을 유발하냐면, ReferenceError가 아니라는 것이다. JS는 'sayHello2'를 스코프 안에서 찾을 수 없다는 의미가 아니다. JS는 sayHello2를 인식하고는 있지만, 함수 레퍼런스에 대한 정보를 변수에서 찾을 수 없다는 의미이다. 그럼 호이스팅되었을 때, sayHello2에는 어떤 값이 들어가 있는가?

 

호이스팅에 대해 알고있다면, var로 인하여 호이스팅된 변수는 초기화 전 undefined라는 값이 담겨있다고 할 수 있다. 다른 말로, 두 번째 함수 표현식과 세 번째 화살표 함수는 호이스팅 되어 undefined를 담고 있다고 할 수 있다. 따라서, 첫 번째 함수 선언식은 처음에 호이스팅되며 함수 레퍼런스가 자동으로 할당이 되지만, 두 번째 함수 표현식과 세 번째 화살표 함수는 처음에 호이스팅되어 undefined가 들어가고, 이후에 변수에 할당되면 그 함수 레퍼런스를 넣는다. 다시 말해, 함수 선언식은 컴파일 타임, 함수 표현식과 화살표 함수는 런타임에 할당된다고 할 수 있다.

 

3. 변수 호이스팅

변수 호이스팅에 관한 다음 예시를 보자.

hello = "Hello!";
console.log(hello); // "Hello!"

var hello = "Hi!";

위의 코드에서 hello는 4번째 줄이 실행되기 전에 할당되지 않은 변수이지만, 1번째 줄에서 할당이 가능하였다! 호이스팅이 되는 과정은 1. 식별자가 스코프의 맨 위로 끌어올려진다. 2. 동시에 식별자에 undefined가 넣어진다.

 

따라서, 위의 예시를 호이스팅시키면 다음과 같은 모습을 띄고 있다고 할 수 있다. 다르게 표현하지면 JS 엔진은 위의 코드 실행 전 전처리를 통해 다음과 같이 재배치한다고 할 수 있다.

var hello;          // 호이스팅 발생
hello = "Hello!";   // 1번째 줄의 할당 실행
console.log(hello); // "Hello!"

hello = "Hi!";      // 'var'를 날림

만약 함수와 변수가 섞여있다면? JS는 모든 함수를 먼저 호이스팅 한 다음 변수를 호이스팅한다. 함수와 변수가 섞여 있는 경우 다음과 같이 호이스팅된다.

// 호이스팅 전
/////////////////
student = "Junho";
sayHello(); // Hello Junho!

function sayHello() {
  console.log(`Hello ${student}!`);
}
var student;


// 호이스팅 후
/////////////////
function sayHello() {
  console.log(`Hello ${student}!`);
}
var student;

student = "Junho";
sayHello(); // Hello Junho!

호이스팅은 한다는 것 자체가 JS는 위에서부터 아래로, 단방향으로 실행한다는 것을 암시한다. 그러나, JS는 사실 코드를 실제로 코드를 재배치하지 않는다. (사용자가 프로그램을 어떻게 짰을 줄 알고!). 그래서, 필자는 호이스팅은 런타임 개념보단 컴파일 타임에 알맞는 개념이라 생각한다... 저자와 나 모두 같은 생각을 하는 모먼트이다.

 

4. 변수 재선언?

만약 같은 스코프 안에서 변수에 재할당하게 된다면 어떤 일이 발생할까? 다음 예제를 살펴보자.

var name = "Junho";
console.log(name); // Junho

var name;
console.log(name); // ???

만약 다른 언어에 익숙하거나, JS에 익숙하지 않는다면  var name; 때문에 할당이 초기화되고, 마지막 console.log에 undefined가 출력되리라 생각할 수 있다. 하지만, 호이스팅이 있다면 같은 스코프 안에서 "재선언"같은 행위가 가능할까? 당연히 되지 않는다.

 

위의 코드를 JS 엔진이 호이스팅한다면 다음과 같이 작성할 수 있다.

var name;
var name; // 의미가 없다!

name = "Junho";
console.log(name); // Junho

console.log(name); // Junho

따라서, 아래에 적은 var name;은 의미가 없는 식이 되고, 실제 프로그램에선 무시되거나 컴파일 시 의도적으로 이 줄을 지워버릴 수 있다. (컴파일러는 스코프 관리 코드에 "name"이라는 식별자가 이미 있는지 확인한다. 만약 있으면 컴파일러는 아무 행동도 하지 않는다.)

 

또한 눈여겨볼만한 점은 var name; 은 var name = undefined;과 다르다는 점이다. 위의 코드에서 console.log(name)에 undefined라는 출력을 얻고 싶다면, 명시적으로 var name = undefined;를 할당해주어야 한다.

 

만약, ES6에서 추가된 let과 const를 사용하여 변수 재선언을 하면 어떤 일이 발생할까? 다음과 같은 코드를 작성하고, 직접 실행하여보자.

let name = "Junho";

console.log(name);

let name = "Hyeryun";

이를 실제로 실행한다면, SyntaxError가 발생한다. JS 환경에 따라 다르겠지만, 보통 "name has already been declared" 라는 에러메시지를 받을 수 있다. 재선언은 불가능하다고 명시적으로 알려주는 것이다!! 이는 let - let 뿐만 아니라 let - var, var -let에도 똑같이 적용된다.

 

어떤 기술적인 이유보다, 그냥 버그를 줄이기 위해서 재선언을 막아놓은 것이다.

 

또한, const 키워드는 같은 스코프안에 재선언 & 재할당이 불가능하다. 실제로 재할당을 시도한다면, SyntaxError가 아닌 TypeError가 발생한다. 이는 컴파일 타임에서 발생한 오류가 아닌, 런타임에서 발생한 오류라는 의미이다. 

 

5. Loops

JS는 우리가 "재선언"하는 것을 원치 않는다는 것을 위에서 알았다. 그럼, 제어문 혹은 반복문인 경우 "재선언"의 의미를 가진 구문은 JS가 싫어할까? 한번 보자.

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

이 경우 value는 반복문을 돌 때마다 할당되는가? 답은 상식적으로 아닐 것이다. 다른 말로, 그 스코프를 나갔다가 다시 스코프에 들어온 경우, 선언 컨택스트는 초기화되어있다. 따라서, 반복문이어도 변수 재선언 아닌 재선언에 문제가 생기지 않는 것이다. 그럼, 만약 let 대신 var를 쓴다면 안될까?

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

var의 경우 전역 스코프 개념으로 호이스팅되기 때문에, "재선언" 자체가 발생하지 않는다! 따라서, 이에 대하여 생각할 때는 하나의 스코프에 하나의 인스턴스가 있는지 확인만 하면 된다.  for..in 문 혹은 for .. of도 마찬가지이다. 이 점은 const일 때도 적용된다. (많은 사람들이 착각하는 부분이기도 하다. const는 같은 스코프 내 재할당이 불가능한거지, 재할당 자체가 불가능한 것은 아니다.)

var flag = true;
while (flag) {
  const value = Math.random(); // 문제없음!
  if (value > 0.5) {
    flag = false;
  }
}

그러나, 다음과 같이 for 반복문을 짠다면 문제가 생길 것이다. 실제로 코드를 실행시키면 오류가 발생한다.

for (const i = 0; i < 3; i++) {
  console.log("I cannot execute!");
}

왜 그럴까? "재할당" 처럼 보이지도 않고, "재선언"으로 보이지도 않는 이 코드는 왜 실행되지 않는 것일까? 이 for 반복문을 풀어써보면 다음과 같이 쓸 수 있다.

{
  const $i = 0; // 가상의 변수
  
  for (; $i < 3; $i++) {
    const i = $i;
    console.log("I cannot execute!");
  }
}

i는 for 스코프안에서 재할당이 발생한다. 따라서, 이는 오류가 발생하고, for 문에서 순회할때는 let 혹은 var를 써야 하는 이유이다. 이렇게 for문 안에서 어떤 코드가 작동될 지 안 될지는 이렇게 for문을 풀어써보면 쉽게 답을 얻을 수 있는 경우가 많다.

 

6. 초기화되지 않은 변수들 & TDZ

var의 경우 전역 스코프로 호이스팅된다는 사실은 자명하다. 그러나, 처음에 undefined로 초기화되고 모든 스코프에서 접근이 가능하므로, 이러한 특성이 문제가 되리라는 뻔하다.

 

그럼, let과 const는 다르게 동작하는가? 많은 블로그 글들이 let과 const는 블록 단위로 호이스팅 된다고 말한다. 그럼, 전역에 선언한 let과 const는 호이스팅이 되는가? 다음 코드를 보자. 호이스팅이 되었다면, 이 코드는 정상적으로 실행이 되었을 것이다.

console.log(student); // ReferenceError

let student = "Junho";

이 코드를 실행하면 ReferenceError를 맞닥뜨릴 수 있다. JS엔진에 따라 세부 에러메시지는 다르겠지만, 대략  "초기화 전에 student에 접근할 수 없다."라는 맥락을 가진 메시지를 띄울 것이다. 그럼, 초기화를 하면 모든 문제가 해결될까? 초기화할 수 있도록 코드를 수정하여보자.

student = "Junho"
console.log(student); // ReferenceError

let student;

초기화했는데도 ReferenceError가 났다! 그럼 어떻게 해주어야 하는가? 초기화까지 해주었는데! let과 const에게는 변수에 값을 할당하는 유일한 방법이 선언 단계에서 동시에 할당을 해주는 것이다. 혹은 선언과 할당을 순차적으로 하여 다른 코드가 이 사이에 들어오지 못하게 하는 것이다. 따라서, 이렇게 쓰면 적어도 작동은 한다!

let student;
// 또는, let student = undefined;
student = "Junho";

console.log(student); // Junho

이전의 var의 경우와 비교하면, var name은 var name = undefined와 같지 않다고 하였다. 그러나 let의 경우, let student는 let student = undefined와 완벽하게 동일한 역할을 할 수 있다고 할 수 있다. 이는 var의 호이스팅과 let의 호이스팅이 다른 방법으로 작동한다는 완벽한 예시이다.

 

전에 컴파일러는 결국 var/let/const를 떼서 컴파일한다. 어떻게? 이들을 각 스코프에 알맞은 위치로 호이스팅하여 알맞은 instruction을 생성한다. let과 const도 마찬가지로 알맞은 위치까지 호이스팅되어, 나중에 할당을 해주는 instruction이 생성된다. 따라서, 블록 스코프안에서 할당과 변수 인식 사이에 시차가 생기는데, 이를 우린 TDZ(Temporal Dead Zone)이라 부른다.(기억해야 하는게, 코드 안 위치의 문제가 아니라 시간의 문제이다!)

 

다음과 같은 코드를 보자.

sayHello(); // ReferenceError

let name = "Junho";

function sayHello() {
  console.log(`Hello ${name}!`);
}

코드의 위치 상 sayHello 함수는 name 변수 할당 다음에 일어나지만, 함수 호이스팅으로 인하여 sayHello 함수는 let 선언보다 일찍 호출되고, name은 아직 TDZ안에 존재하므로 ReferenceError가 발생한다.

 

다시 말하지만, let과 const는 호이스팅을 한다! 호이스팅의 의미를 '스코프의 최상단에서의 변수 등록'이라고 하면, let과 const는 호이스팅이 발생한다고 할 수 있다. 또한, var이 자동으로 undefined가 할당되는 것과 다르게 let과 const는 값을자동으로 할당하지 않는다. 다음 예제를 보자.

var student = "Hyejin"

{
  console.log(student); // ReferenceError
  
  let student = "Junho";
  console.log(student); // Junho
}

위의 예시에서 let과 const가 호이스팅되지 않는다면 ReferenceError대신 Hyejin이 출력되었을 것이다. 그러나, 실제 실행하여보면 에러가 발생하고, 이는 let의 TDZ때문이라 할 수 있다.

 

이러한 TDZ 에러를 피하기 위해선, 가장 좋은 것은 let과 const를 최상단에 놓는 것이다. TDZ 시차를 최대한 줄여, 실행에 문제가 없도록 하면 된다. 그럼, let과 const에도 undefined로 자동 초기화를 하면 되지 않느냐란 말이 나올 수 있는데 걱정마라. 별도의 글로 찾아뵙겠다.

 

7. 마무리

변수에 대하여 알아보았는데 생각보다 되게 복잡하고, 그 안의 메커니즘은 우리가 상상했던 것 이상으로 어렵다는 것을 알 수 있었다. 다른 개발자가 처음 JS에 입문할 때 호이스팅, 재선언, TDZ와 같은 생소한 개념들은 멘탈을 터뜨리기 충분하다고 생각한다. 스코프에 대한 이해와 변수에 대한 이해가 합쳐지면, 적어도 JS의 변수 생명주기에 관한 문제에서는 자유로우리라 생각한다.

반응형
댓글
Total
Today
Yesterday
공지사항
최근에 올라온 글
최근에 달린 댓글
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함