var의 유해성에 대해서

호이스팅, 변수 스코프에 관해서 이야기를 해봅시다.

Featured image

들어가며

최근에 “모던 자바스크립트 딥 다이브”라는 책을 완독하고, 이런저런 공부들을 계속 하면서, 나중에 글을 쓸 클로저 라는 개념을 설명하기 위해서, 렉시컬 스코프라는 개념을 소개할 필요성을 느끼게 되었습니다. 근데 클로저라는 개념 자체가, 한번에 와닫는 쉬운 개념이 아니기에, 선수지식인 렉시컬 스코프 부터 이해해야하고, 렉시컬 스코프를 제대로 이해하기 위해서는 스코프에 대한 제대로 된 이해가 필요하고…

이 글을 여러분께 최대한 효율적으로 전해드리기 위해서, 제가 공부한 책의 구조를 조금 참고하기로 했습니다. 제가 언급한 “모던 자바스크립트 딥 다이브” 라는 책에서는 스코프에 대한 설명을 하는 장의 구조가 이렇게 되있었습니다.

한 글에 모든 주제를 국밥마냥 때려박으면, 읽기가 싫어지기 때문에 글을 분리하겠습니다. 여기서 상대적으로 쉽게 접근 할 수 있는, 스코프에 관한 이야기를 먼저 하는것이 옳을 것 같습니다. 우선 첫번째 주제는 변수 스코프에 대한 흥미 유발을 위해서 var의 유해성에 대해서 한번 다뤄보고자 합니다.

자바스크립트를 공부해보신 분이라면, var 사용을 최대한 지양하고, 대신 constlet을 사용해라는 말을 들어보셨을 것입니다. 근데 왜 그럴까요? JS에 대한 깊은 이해가 없었던 예전의 저는 단순히 구형 스펙이니 사용하지 말라는 것인가 정도로 그냥 넘어갔습니다. 하지만, 이제는 말할 수 있습니다. 왜 var를 쓰면 안되는지. var의 유해성에 대한 이야기, 지금부터 시작합니다.

어 이게 왜 돌아가지?

짤막한 코드를 하나 써봤습니다. 이 코드의 실행 결과는 무엇일까요?

var v1 = 1;

if (true) {
  var v2 = 2;
  if (true) {
    var v3 = 3;
  }
}
console.log(v1);
console.log(v2);
console.log(v3);

function add(x, y) {
  console.log(x, y); //2 4가 콘솔에 출력될것
  return x + y;
}

add(2, 4);

console.log(x, y); //ReferenceError : x is not defined

C, C++, Java, Kotlin 처럼 제대로 된 언어만 해보신 분은 당연히 v1은 출력이 되겠는데, v2,v3는 출력이 안되겠지 하는 생각을 하실 것 같습니다. 그러나 위 코드에서 v1,v2,v3 모두 출력이 됩니다. 그리고 x,y 는 또 출력이 안되네요. 참 혼란스러움 그자체입니다(python을 해보신 분이라면, ‘그럴 수 있지’ 하고 생각할 수 있겠지만, JS에서 변수 관련해서 주의해야 할 사항은 이게 끝이 아니니 지루함을 참고 읽어 주세요). 이게 왜 그렇게 되는지에 대해서 한 섹션에 다 욱여넣기에는 내용이 좀 길어질거 같으니 하나 하나 나누어서 설명을 해볼까 합니다.

var의 독특한 특징(문제점)

예전 javascript표준인 ES5까지는 변수를 선언하기 위해서는 var 키워드를 사용해서 선언하는 방법밖에 없었습니다. 근데, 이 var이라는 친구가 정말 골때리는 특징들을 가지고 있습니다. 하나 하나 찬찬히 뜯어보도록 하겠습니다.

1. 변수 중복 선언 허용

일반적으로 프로그래밍 언어에서 한번 선언한 변수를 다시 한번 선언하는 것은 일반적으로 의도되지 않은 작업입니다. 변수를 하나 만들고, 그 값을 갱신하려면, 변수를 새로 선언하는 것이 아니라, 해당 변수의 값을 수정하는것이 일반적이지요. 그래서 C나 java 같은 프로그래밍 언어에서는 중복 선언을 에러로 간주합니다. 하지만, JS에서 var 키워드로 생성된 변수는 그렇게 동작을 하지 않습니다. 아래의 코드를 확인해 보세요. 주석으로 설명을 달아 놓았습니다.

var x = 1;
var y = 1;

// var 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용한다
// 초기화문이 있는 변수 선언문은 자바스크립트 엔진에 의해 var 키워드가 없는 것처럼 동작한다
var x = 100;
// 초기화문이 없는 변수 선언문은 무시된다
var y;

console.log(x); // 100
console.log(y); // 1

위 예제처럼 동일한 변수가 이미 선언되어 있는 것을 모르고, 변수를 중복 선언하면서 값까지 변경되면, 의도치 않게 먼저 선언한 변수의 값이 바뀌는 결과가 일어나고, 여러분은 코드에서 뭐가 잘못되었나 찾느라 많은 시간을 허비하게 되겠죠

2. 함수 레벨 스코프만 허용한다.

C나 java 같은 언어에서는 중괄호로 둘러쌓인 ‘블록’이 하나의 스코프가 됩니다. 그래서, 특정 스코프에서 선언한 변수는 그 스코프 외부에서는 참조 할 수가 없지요. 하지만, var로 선언된 변수는 그렇게 동작하지 않습니다.

var x = 1;

if (true) {
  // if 블록 안에서 선언된 x 이지만, 블록 레벨 스코프가 아닌 전역변수로 동작한다
  // 이는 1. 에서 설명한것 처럼 의도치 않게 변수의 내용물이 바뀌는 결과를 초래한다
  var x = 10;
}

console.log(x); // 10

또, for문의 변수 선언문에서 var키워드로 선언한 변수도 전역 변수로 동작합니다.

var i = 10;
for (var i = 0; i < 5; i++) {
  console.log(i); // 0 1 2 3 4
}
console.log(i); // 5

함수 레벨 스코프만 지원하는 var 키워드의 특성 상, 변수가 전역변수로 선언될 가능성이 높아지고, 이로 인해서 전역 변수들이 재선언 되면서 값이 의도치않게 수정되는 일들이 발생할 가능성이 높아집니다.

3. 변수 호이스팅

사실 호이스팅은 var문에서만 일어나는것은 아닙니다. JS의 특징상, 모든 선언문은 호이스팅 되어, 스코프의 선두로 끌어올려 집니다. 이 현상을 직관적으로 알아보기 위해서 하단의 코드를 참조해 주세요.

// 이 시점에 변수 호이스팅에 의해서 이미 foo 변수가 선언되었다. (선언 단계)
// 변수 foo는 undefined로 초기화 된다. (초기화 단계)
console.log(foo); //undefined

foo = 123; // 변수에 값을 할당
console.log(foo); // 123

//변수 선언은 런타임 이전에 JS엔진에 의해서 암묵적으로 실행된다(===호이스팅)
var foo;

변수 선언문 이전에 변수를 참조하는 것은 호이스팅에 의해서 에러는 발생하지 않지만, 직관적이지도 않고, 가독성이 떨어져서 오류를 발생시킬 수 있는 코드를 만들 여지가 생깁니다.

그럼 let은 뭐가 다른데?

앞에서 var의 유해성에 대해서 알아봤다면, 이제 let은 어떻게 다른지에 대해서 알아보겠습니다.

1. 변수 중복 선언 금지

var 키워드로 동일한 변수를 중복 선언하면, 아무런 에러가 발생하지 않았습니다. 하지만, let 키워드로 이름이 같은 변수를 중복 선언하면 문법 에러(StntaxError)가 발생해서, 앞에서 서술한 그러한 문제를 예방 할 수 있습니다.

var foo = 123;
// var 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용한다.
// 아래 변수 선언문은 JS 엔진에 의해 var 키워드가 없는것처럼 동작하여, foo의 값을 갱신한다
var foo = 456;

let bar = 123;
// let이나 const로 선언한 변수는 같은 스코프 내에서 중복 선언 허용이 되지 않는다
let bar = 456; // SyntaxError: Identifier 'bar' has already been declared

2. 블록 레벨 스코프

var 키워드로 선언된 변수는 오로지 함수의 코드 블록만을 지역 스코프로 인정하고, 다른 블록은 인정하지 않아서, 전역 변수를 남발하게 되버리는 결과를 초래했습니다. 하지만, let은 C나 Java 같은 언어들의 블록 레벨 스코프를 지원합니다.

let foo = 1; // 전역변수

{
  let foo = 2;
  let bar = 3;
}
console.log(foo); // 1 var 였다면, 2를 반환했을 것
console.log(bar); // ReferenceError: bar is not defined

3. 변수 호이스팅

var키워드로 선언된 변수는, 선언부가 호이스팅되고, undefined로 초기화까지 되어서, 변수 선언보다 먼저 변수의 값을 할당하거나, 변수의 값을 사용할 수 있었습니다. 이 특징 때문에, 비직관적인 코드를 짤 수 있는 위험성이 있다고 아까 소개했었지요.

그럼 let, const로 선언된 변수는 호이스팅 되지 않을까요? 이 변수들도 호이스팅 됩니다. 다만, 변수를 선언하는 “선언 단계”와 undefined를 기본적으로 할당하는 “초기화 단계”가 함께 진행되는 var로 선언된 변수와는 다르게, 이 두 과정이 분리되어서 진행됩니다. 그리고, JS 에서는 초기화 단계가 실행되기 이전의 변수에 접근하려고 하면 참조 에러(ReferenceError)가 발생하여셔, 그런 비직관적인 코드를 작성하는것을 방지해 주지요. 스코프의 시작 부분에서 변수가 선언은 되지만, 참조 하지 못하는 이 구역을 일시적 사각지대(TDZ) 라고 부릅니다.

// 런타임 이전에 선언 단계가 실행된다. 아직 변수가 초기화 되지 않았다
// 초기화 되지 않은 TDZ 에서는 변수를 참조할 수 없다
console.log(foo); // ReferenceError : foo is not defined

let foo; // 변수 선언문에서 초기화가 실행된다
console.log(foo); // undefined

foo = 1;
console.log(foo); // 1

이것만 보면, let으로 선언된 키워드는 호이스팅 되지 않는것처럼 보입니다. 하지만, 이들도 호이스팅 된다는 것을 아래 예제를 통해서 확인 할 수 있습니다.

let foo = 1;
{
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  let foo = 2;
}

만약에 let으로 선언된 블록 내의 foo변수가 호이스팅 되지 않는다면 블록 문 내에서 전역 변수 foo의 값인 1이 출력되어야 할 것입니다. 하지만, foo변수를 초기화 전에 접근할 수 없다는 오류가 나오는것을 직접 확인함으로서, let으로 선언된 변수 또한 호이스팅 됨을 알 수 있지요.

마무리

varlet의 차이를 비교하면서 간단하게나마, 스코프의 종류, 함수 스코프에 대해서 알아봤습니다. 다음 글에서는 이제 스코프에 대한 본격적인 이야기들을 해볼까 합니다. 끝까지 읽어주셔서 감사하고, 다음 글에서 또 만날 수 있었으면 좋겠습니다. 안녕~

참고자료

모던 자바스크립트 Deep Dive(이용모 지음)