Web: Hoisting, 호이스팅이란(JavaScript)

Heechan
HcleeDev
Published in
14 min readMar 4, 2022
Photo by Andrew Coop on Unsplash

코드를 짜다보면 가끔 흐름상 아직 정의하지 않은 변수 같은 것을 부를 때가 있다. 어떤 함수를 코드 상단에서 작성했는데, 이 코드 내에서 사용하고 있는 값이 이 함수보다 코드 하단에서 선언되고 있을 수 있다. 그냥 단순히 생각해보면, 컴퓨터가 코드를 위에서 아래로 읽어갈텐데, 아직 선언되지도 않은 것을 어떻게 알고 컴파일 단계에서 에러를 내지 않는건지 신기할 따름이다.

이번주는 JavaScript에서 이를 가능하게 해주는 Hoisting, 호이스팅에 대해서 간단히 알아보도록 하자.

Hoisting과 그 배경

결론부터 한 문장으로 말하자면 새로운 컨텍스트를 구성할 때, 사용되는 변수와 함수의 선언을 먼저 하는 것을 말한다.

이를 코드로 한 번 살펴보자면 아래와 같은 상황이다.

console.log(a);
var a = 1;

상식적으로 생각하면 첫 번째 줄에서 a 는 아직 선언되지도 않았는데 부를 수 있을 리가 없다. 아마 Python 같이 순서대로 실행하는 언어에서는 a 를 찾을 수 없다고 에러를 뱉어낼 것이다.

그런데 JavaScript에서는 undefined 를 출력할 것이다. 그러면 아직 정의되지 않았다는 뜻이니, 그게 맞는거 아닌가? 라고 생각할 수 있을텐데, 그러면 그냥 아예 a 를 정의하지 않고 console.log(a)만 적어서 한번 돌려보자.

바로 ReferenceError가 발생한다. 그렇다면 앞에서 undefined가 출력되었다는 것은var a = 1console.log(a) 보다 나중에 작성했음에도 a 가 있다는 것을 인지할 수 있다는 것이었다.

이를 가능하게 해주는 것이 바로 호이스팅이다.

Hoisting은 단어 그대로 끌어올린다는 뜻이 있는데, 코드 하단에서 선언되는 변수나 함수를 끌어올려서 코드 상단에서 미리 선언해준다는 것을 암시하고 있다.

코드가 실제로 이렇게 바뀌는 것은 아니지만… 아래 코드처럼 동작하게 한다고 생각할 수 있다.

var a;
console.log(a); // undefined 출력
a = 1;

이걸 보면 a 의 선언부를 미리 위로 끌고 오고, 초기화하는 부분은 기존의 위치에 그대로 둔다고 생각할 수 있다.

이에 대해서 조금 더 자세히 알아보자.

프로그래밍 언어를 실행하는 과정에서, 우리가 원하는 변수, 함수를 실행시키기 위해선 지금 이 영역에서 어떤 변수, 함수를 사용할 수 있는지에 대한 정보를 가지고 있어야 한다.

이 정보를 담고 있는 것을 환경이라고 부를 수 있을 것이다. JavaScript에서 이를 VariableEnvironment라고 부른다. 이 VariableEnvironment는 말그대로 환경을 조성해주는 정보를 담고 있는 것으로, 지금 이 영역(스코프)에서 사용할 수 있는 변수, 함수 등에 대한 식별자와 메모리 정보 등을 담고 있다. 스코프나 환경 이런 정보에 대해 더 자세히 알고 싶으면 추가적으로 검색해보면 좋을 것이다.

예를 들어 아래 코드를 컴파일러가 돌려야 하는 상황을 생각해보자.

function block(a) {
console.log(a);
console.log(b);
var a = 2;
var b = 1;
}
block(1); //이 줄이 실행된 상황부터 생각해보자.

이 경우 block(1) 이 호출될 때 새로운 실행 단위가 하나 생긴다. 이때 이 실행 단위, 실행 영역, 스코프를 위한 VariableEnvironment도 새롭게 생긴다. 이 상황에서 사용할 수 있는 변수나 메서드를 한번 생각해보자.

일단 block 이라는 메서드 그 자체도 호출할 수 있다. 그리고 Parameter로 받아온 a 가 있을 것이다. 그러면 일단 이 VariableEnvironment는 something , block , a 에 대한 정보를 가지고 있을 것이다. 그 중에서도 일단 Parameter로 받아왔던 a 를 새롭게 선언해서 사용한다고 생각해보면 아래 코드처럼 동작할 것이라고 생각할 수 있다.

function block(a) {
var a; // Parameter로 온 a를 선언하고 시작한다고 생각하자
a = (받아온 값)
console.log(a);
console.log(b);
var a = 2;
var b = 1;
}
block(1);

여기에다가 우리가 배운 Hoisting도 적용해보자. 호이스팅이 진행되면 해당 영역의 코드를 쓱 훑으면서 선언되는 변수나 메서드들을 위로 끌고 올라오게 된다. 중요한 것은 선언부만 가져와야 한다는 점이다. 초기화하고 할당하는 코드는 그 자리에 두고 선언부만 가지고 올라온다. 여기서 훑어볼 때는 var a = 2;var b = 1; 이 눈에 띈다.

function block(a) {
var a;
var a; // 밑에 있던 var a = 2; 에서 선언부만 끌고 올라왔다
var b; // 밑에 있던 var b = 1; 에서 선언부만 끌고 올라왔다
a = (받아온 값)
console.log(a);
console.log(b);
a = 2;
b = 1;
}
block(1);

이렇게 되면 우리가 작성했던 실제 코드인 console.log(a); 가 작동하기 전에 필요한 VariableEnvironment가 모두 구성된다. VariableEnvironment는 만들어지면서 block , a , b 라는게 존재하는구나! 라는 정보를 가지고 있을 것이다.

그러면 이제 실행했을 때 어떤 결과가 나올까? 한번 하나 하나 살펴가며 따라가보자.

function block(a) {
var a; // 1
var a; // 2
var b; // 3
a = (받아온 값, 여기서는 1) // 4
console.log(a); // 5
console.log(b); // 6
a = 2; // 7
b = 1; // 8
}
block(1); // 9
  • 9번 줄에서 block(1) 이 실행되면서 새로운 VariableEnvironment가 구성된다.
  • 1번에서는 argument인 a 를 우선 선언한다.
  • 2번과 3번에서는 Hoisting을 통해 끌고 올라온 ab 를 선언한다. 하지만 2번의 a 라는 이름은 이미 VariableEnvironment에 있다. 따라서 추가적인 메모리를 잡지 않고 무시한다.
  • 4번에서는 우리가 Parameter로 넘겨줬던 1이라는 값을 a 에 넣는다. 지금 VariableEnvironment는 {a: 1, b: undefined, ...} 대충 이런 식으로 되어있다고 생각할 수 있다.
  • 5번부터는 우리가 짠 코드다. 5번째 줄에서는 VariableEnvironment를 살펴보고, a: 1 이라는 정보가 들어있는 것을 확인할 수 있다. 따라서 1을 출력한다.
  • 6번에서는 VariableEnvironment를 살펴봐도, 아직 초기화되지 않은 undefined 라는 값을 가진 b 밖에 없다. 따라서 undefined 를 출력한다.
  • 7번과 8번은 VariableEnvironment에 있는 a: 1a: 2 로, b: undefinedb: 1 로 바꿔준다고 생각할 수 있다.

사실 정확히 말하면 한번 컨텍스트가 생성되고 나서 새롭게 뭔가 더 추가되는 경우는 VariableEnvironment가 아닌 LexicalEnvironment를 만들어서 사용한다. VariableEnvironment는 처음에 컨텍스트가 생길 때 딱 스냅샷처럼 생성되며, 그 이후 내용이 변화하지는 않는 것으로 알고 있다. 다만 여기선 그걸 자세히 설명하는 글이 아니라서 이해를 위해 VariableEnvironment만 언급했다.그냥 변수 이름을 가지고 값을 꺼내쓸 수 있도록 가지고 있는 녀석이 있다고만 생각하면 된다.

그래서 결국 1과 undefined 가 출력되게 된다는 점을 알 수 있었다.

몇몇 언어에서는 console.log(b) 를 실행할 때 b 가 아직 정의되지 않았다고 에러를 발생시킬 수 있겠지만, JavaScript는 위에서 설명한 Hoisting의 원리를 통해 제대로 정의되기 전에도 에러가 나지 않고 접근할 수 있는 것이다.

var, let, const, function에서의 Hoisting

그런데 위에서는 var 만 사용했을 때의 얘기였다. Hoisting도 let , const 일 때 적용되는 것이 다르고, function 일 때 적용되는 것이 다르다.

let , const

요즘은 var 를 잘 사용하지 않는 추세이긴 하다. 나의 경우 (웹을 시작한지 이제 반년이지만) var 를 사용한 적은 없는 것 같다.

그런데 letconst 의 경우에는 var의 경우와 호이스팅이 조금 다르게 적용된다. 이 글 가장 처음에 나왔던 코드를 살짝 변형시켜서 실행시켜보자.

console.log(a);
let a = 1;

이걸 돌려보면 바로 ReferenceError: a is not defined 에러가 나온다.

그러면 letconst 는 호이스팅이 일어나지 않는건가?

그렇지 않다. letconst 도 호이스팅의 대상이다. 하지만 그 목적에 차이가 있을 뿐이다.

letconst 의 경우에는 호이스팅된 선언부와, 초기화 및 할당 시점 사이에 시간상 사각지대, Temporal Dead Zone이 생긴다.

var 의 경우와 가장 큰 차이점은 호이스팅된 선언부에서 초기화를 해주지 않는다는 점이다. var 의 경우 선언 후 즉시 undefined 라는 값으로 초기화해준다. 하지만 letconst 의 경우 메모리 공간만 잡아두고 딱히 값을 초기화해주지는 않는다.

let a;          // (1)
console.log(a); // (2)
a = 1; // (3)

따라서 (1)과 (3) 사이는 TDZ라고 생각할 수 있고, (2)에서 a 에 접근하려고 했기 때문에 ReferenceError가 발생할 것이다.

근데 호이스팅은 선언되기 전에도 알아채고 사용하기 위함이면서, 호이스팅해봤자 에러가 나면 let , const 는 호이스팅을 할 필요가 없지 않나 싶을 수 있다.

하지만 letconst 가 왜 생겼는지 생각해보면 왜 이런 방식인지 이해할 수 있다.

var 는 그 특징과 호이스팅 때문에 몇가지 문제가 있었다.

  • 블록 레벨 스코프가 아닌 함수 레벨 스코프에 정의된다.
  • 변수를 중복으로 부를 수 있게 (덮어씌울 수 있게) 한다.
  • 호이스팅으로 인해 선언 전에도 변수를 참조할 수 있게 된다.
  • 위 문제로 인해 의도치 않게 전역 변수, 혹은 좀 더 넓은 스코프에서의 변수를 덮어씌우거나 해버려서 문제가 발생할 수 있다. 이런 경우 왜 문제가 발생하는지 고치기도 힘들 것이다.

이런 문제를 해결하기 위해 나온 것이 letconst 다. letconst 는 블록 레벨으로 정의되며, 중복된 이름의 변수나 상수를 만들 수도 없다. 또한 선언 전에 변수에 접근할 수 있는 것도 불가능하다.

그럼에도 호이스팅을 적용하는 이유는 무엇일까?

일단 당연하게도 필요한 메모리를 먼저 잡아놓기 위해서라는 생각이 든다. 그리고 아마 위에 작성한 문제를 해결하기 위해 let , const 로 선언되는 변수 및 상수의 경우에는 중복으로 불러지지 않도록, 초기화하지 않음으로써 할당 전에는 접근이 불가능하도록 확실히 확인하고자 호이스팅이 적용되는 것으로 보인다. var 의 경우와는 목적이 반대라고 생각할 수 있다.

function , 함수 선언문과 함수 표현식

함수를 정의하는 방법을 크게 2가지로 나누자면, 흔히 사용하는 function a() {}const a = () => {} , var a = function () {} 이렇게 변수나 상수에 함수를 할당하는 방식이 있을 것이다. 주로 전자를 함수 선언문, 후자를 함수 표현식이라고 부른다.

일단 function a() {} 같은 함수 선언문은 호이스팅할 때 최우선적으로 처리되며, 초기화하는 코드까지 전부 올라온다. var 는 호이스팅 때 undefined 로 초기화되고 나중에 할당하는 코드가 나오면 값이 들어오는데 비해, 함수 선언문은 코드 블럭까지 모두 가리키도록 할당된다고 볼 수 있다.

함수 표현식은 그렇지 않다. 기본적으로 함수를 표현하는 식을 var , let , const 에 할당하는 방식이므로 변수 선언과 크게 다른 것이 없다.

그러면 이를 코드 예제로 살펴보자.

sum(1, 2);
multiply(1, 2);
function sum(a, b) {
console.log(a + b);
}
const multiply = (a, b) => {
console.log(a * b);
}

이런 코드가 있다고 치자. 이 코드는 실행해보면 3과 ReferenceError가 출력된다.

이를 호이스팅이 적용되는 과정처럼 보이도록 코드를 살짝 고쳐보자.

function sum(a, b) {
console.log(a + b);
}
const multiply;
sum(1, 2);
multiply(1, 2);
multiply = (a, b) => {
console.log(a * b);
}

sum(a, b) 를 선언하는 함수 선언문은 선언 뿐만 아니라 초기화, 할당부까지 모두 호이스팅되어 처리되는 모습이다. 그래서 함수 선언문의 경우는 실제 코드에서 선언되기 전에도 호출이 문제 없이 이뤄진다.

multiply 는 일반적인 const 호이스팅과 같다고 볼 수 있다. 여기서 multiply(1, 2); 를 호출하면 ReferenceError가 발생할 것이다. 함수 표현식은 초기화와 할당까지 끝난 후에 제대로 사용이 가능할 것이다.

여기서 함수 선언문을 사용했을 때 생길 수 있는 문제는 무엇일까?

function sum(a, b) {
console.log(a + b);
}
sum(1, 2);function sum(a, b) {
console.log('' + a + b);
}
sum(1, 2);

이런 코드가 있다고 생각해보자. 상식적으로 어떤 결과가 나와야 할 것 같은가? 직관적으로 보았을 때 첫 번째 sum(1, 2); 에는 3이 나와야 할 것 같고, 두 번째 sum(1, 2); 에서는 ‘12’가 나와야 할 것 같다.

그런데 이를 실제로 실행해보면 12만 두 번이 나온다.

이것이 함수 선언문을 사용했을 때 호이스팅이 야기하는 위험한 점이다. 위 코드를 호이스팅 과정처럼 코드로 표현하면 아래와 같이 변한다.

function sum(a, b) {
console.log(a + b);
}
function sum(a, b) {
console.log('' + a + b);
}
sum(1, 2);sum(1, 2);

함수 선언문이 모두 위로 끌려 올라가고, 같은 이름의 함수를 다시 선언하는 것도 딱히 막지 않는다. 따라서 같은 이름의 함수를 정의하면 마지막에 정의한 것으로 결정이 되어, 이 경우에 두 번의 호출 모두 12가 출력되게 되는 것이다.

이건 짧은 코드라서 그렇지, 만약 몇백 줄, 혹은 몇천 줄짜리 코드에서 어쩌다 함수 이름이 겹쳐서 이런 일이 벌어지면… 혹시라도 그 실수를 눈치채지 못하고 배포라도 된다면… 그 개발자는 상당히 골치 아파질 것이다.

결론

호이스팅이 처음에 알아볼 때는 좋은걸로만 알았는데, 실제로는 문제를 많이 일으키는 녀석이라는 것을 알게 되었다. let , const 가 왜 나왔는지도 다시 한번 이해가 되었고…

그래도 이걸 알고 있음으로써 변수 환경에 대한 이해와 코드 동작에 대해 조금 더 잘 알 수 있게 된 것 같다.

앞으로는 혹시 모를 문제를 대비하기 위해 메서드들도 최대한 함수 표현식으로 해서 const 에 할당하는 방식으로 해야겠다. 괜히 다른 사람들이 그렇게 하는게 아니었군…

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science