JavaScript - 스코프, 호이스팅, 클로저

김영한·2021년 9월 18일
1

JavaScript

목록 보기
1/2

참고, 참고


스코프

프로그래밍 언어에는 변수나 함수에 이름을 부여해 의미를 갖도록 하는데 이 때, 이름 충돌의 문제가 발생하게 된다. 그래서 충돌을 피하기 위해 스코프라는 규칙을 만들어 어디서 선언되었는지를 결정하게 되는데 우리말로 직역하면 범위라는 뜻을 가지고 있다.
즉, 변수에 접근할 수 있는 범위라고 할 수 있다.

자바스크립트의 스코프는 함수 레벨 스코프와 블록 레벨 스코프가 있고 이 둘은 렉시컬 스코프 규칙을 따른다.

함수 레벨 스코프와 블록 레벨 스코프

함수 레벨 스코프는 자신이 선언된 곳과 가장 가까운 함수를 유효 범위로 가진다. 는 의미이다. 즉, 함수 내부 전체에서 유효한 식별자가 된다는 뜻.
var 로 선언된 변수나 함수 선언식으로 만들어진 함수가 함수 스코프를 따른다.

블록 레벨 스코프는 자신이 선언된 곳과 가장 가까운 블록을 유효 범위로 가진다. 는 의미이다. 즉, 블록 내부에서만 유효한 식별자이고 그 밖의 영역에서는 참조할 수 없다.
letconst로 선언된 변수가 블록 스코프를 따른다.

아래 예시를 보면 쉽게 이해할 수 있다.

function foo() {
    if (true) {
        var color = 'blue';
    }
    console.log(color);
}

function foo2() {
    var color = 'blue';
    if (true) {
        var color = 'red';
        console.log(color);
    }
    console.log(color);
}

function foo3() {
    let color = 'blue';
    if (true) {
        let color = 'red';
      	console.log(color);
    }
    console.log(color);
}

foo(); // blue
foo2(); // red, red
foo3(); // red, blue

foo의 color는 var로 선언되어 있어서 함수 레벨 스코프이기 때문에 foo 함수 내부에서는 참조할 수 있다.
만약 블록 레벨 스코프였다면 if문 블록에서만 참조가 가능하기 때문에 잘못된 참조로 에러가 발생할 것이다.

foo2는 헷갈릴 수 있는데 color가 역시 var로 선언되어 함수 레벨 스코프이기 때문에 첫 번째로 선언된 color와 두 번째로 선언된 color 모두 foo2 함수의 전역 공간에 선언되어 있는 것이다. 따라서 두 번째로 선언된 color가 값을 덮어 씌우게 되서 red, red가 출력되는 것이다.

foo3는 let으로 선언되었기 때문에 우리가 흔히 알고 있는 흐름처럼 if문 안에서의 color는 red로 선언되고 해당 블록 밖에서 참조할 시 밖에 color가 있다면 해당 color를 참조하고 없다면 에러가 발생한다.

이 외에도

  • var : 재선언, 재할당 가능
  • let : 재할당 가능
  • const : 둘 다 불가능

의 특징을 가진다.

var vs let, const

ES6가 표준화 되면서 함수 레벨과 블록 레벨을 모두 지원하게 되었는데 varletconst로 모두 대체가 가능하고, var는 함수 레벨 스코프를 가지기 때문에 블록 레벨 스코프보다 많은 혼란을 야기하기 때문에 거의 사용하지 않는다고 한다.

그렇다면 이 두 스코프가 렉시컬 스코프 규칙을 따른다는 것은 무슨 말일까?

렉시컬 스코프

렉시컬 스코프(함수를 둘러싼 환경)를 말하기 전에 먼저 스코프 체인에 대해 알 필요가 있다.

스코프 체인이란?

자바스크립트는 식별자를 찾을 때 자신이 속한 스코프에서 찾고 그 스코프에 식별자가 없으면 상위 스코프에서 다시 찾아나가는데 이런식으로 스코프가 중첩되어 있는 상황에서 식별자를 찾는 것을 스코프 체인이라고 한다.

var globalColor = 'red';

function foo() {
    var fooColor = 'blue';
  
    function bar() {
      var barColor = 'yellow';
      
      console.log(barColor);
      console.log(fooColor);
      console.log(globalColor);
    }
  
    bar();
}

foo();

위 실행 결과는 실행 컨텍스트에 의해 yellow -> blue -> red 순으로 bar 안에 fooColor가 없으니 상위 스코프인 foo에서 찾는 식이다.

여기서 궁금한 점은 bar 함수의 상위 스코프가 foo 함수라는 것을 어떻게 아는 것일까?

이 때 상위 스코프를 결정하는데 렉시컬 스코프 규칙을 따르냐동적 스코프 규칙을 따르냐에 따라 다르게 되는 것이다.

  • 동적 스코프 규칙 : 함수가 어디서 호출했는지에 따라 상위 스코프를 결정(즉, bar 함수를 호출한 곳이 foo 함수이기 때문에 상위 스코프가 foo)
  • 렉시컬 스코프 규칙 : 함수가 어디서 선언되었는지에 따라 상위 스코프를 결정(즉, bar 함수가 foo 함수의 내부에 선언되어 있기 때문에 상위 스코프가 foo)

자바스크립트는 렉시컬 스코프 규칙을 따르기 때문에 호출 스택과 관계없이 소스코드 기준으로 대응표를 정의하고 런타임에 그 대응표를 변경하지 않는다.

var x = 'global';

function foo() {
    var x = 'local';
    bar();
}

function bar() {
    console.log(x);
}

foo(); // global
bar(); // global

위 예시에서 동적 스코프라면 foo에서 bar를 호출하므로 bar의 상위 스코프는 foo기 때문에 local, global을 출력하지만 렉시컬 스코프라면 bar가 선언된 곳이 전역(global)이기 때문에 global, global을 호출하게 된다.

호이스팅

자바스크립트는 코드를 인터프리팅 하기 전에 먼저 컴파일 하는데 var a = 2를 하나의 구문으로 생각할 수 있지만 자바스크립트에서는 var a, a = 2 2개의 구문으로 분리한다.

이렇게 선언과 초기화 단계를 나누고 선언 단계에서 선언하는 코드가 어디에 위치하든 해당 스코프의 컴파일 단계에서 처리해버린다.(단, 서로 다른 스코프에서는 해당되지 않는다.)

즉, 아래와 같은 상황이 가능한다.

function foo() {
    a = 2;
    var a;
    console.log(a);
}
foo(); // 정상적으로 2가 출력된다.

실행 순서가 소스코드의 위에서 아래로 순서이긴 하지만 선언된 부분을 먼저 처리한다.
위 소스코드에서 var a;를 먼저 처리한 후 a=2;, console.log(a)가 처리되는 것

이런 결과처럼 선언 단계가 스코프의 가장 처음으로 올라가는 것처럼 보이는 것을 호이스팅이라고 한다.

클로저

자바스크립트 클로저의 정의

함수가 생성될 때 그 함수의 렉시컬 환경을 포섭(closure)하여 실행 될 때 이용한다.

즉, 클로저는 함수와 렉시컬 스코프를 합친 것으로 함수를 만들고 그 함수 내부의 코드가 탐색하는 스코프를 함수 생성 당시의 렉시컬 스코프로 고정하는 것이다. (생성한 함수를 상위 스코프에 할당)

개념적으로는 자바스크립트의 모든 함수는 클로저이지만 실제로는 그렇게 부르진 않는다.

아래 예시를 보면 더 쉽게 이해할 수 있다.

function foo() {
    var color = 'blue';
    function bar() {
        console.log(color);
    }
    bar();
}
foo();

barfoo안에 선언되었기 때문에 foo를 상위 스코프로 알고 있다. 그리고 bar는 렉시컬 스코프 체인에 의해 foocolor를 참조한다.

여기서 barfoo 안에서 정의되고 실행될 뿐 foo밖으로 나오지 않았기 때문에 클로저라 부르지 않는다.

따라서 다음과 같은 코드로 변경해야한다.

var color = 'red';
function foo() {
    var color = 'blue';
    function bar() {
        console.log(color);
    }
    return bar;
}
var baz = foo();
baz(); // blue
  1. barfoo를 상위 스코프로 한다.
  2. bar를 전역 변수인 baz에 할당한다.
  3. foo는 콜스택에서 비워진다.
  4. baz를 호출하게되면 bar는 자신의 스코프에서 color를 찾는다.
  5. color가 없으니 자신의 상위 스코프인 foo에서 color를 찾는다.
  6. color를 찾고 출력한다.
  7. blue 출력

foo 함수는 이미 종료되어 콜스택을 빠져나갔는데 baz를 호출하면 여전히 foo의 color 변수에 접근하여 blue를 출력한다.

즉, 어떤 함수를 렉시컬 스코프 밖에서 호출해도, 원래 선언되었던 렉시컬 스코프를 기억하고 접근할 수 있도록 하는 특성클로저라고 한다.

⭐️ 클로저의 특성을 볼 수 있는 예시

for (var i=1; i<=5; i++) {
    setTimeout(() => {
        console.log(i);
    }, i*1000)
}

1초 간격으로 1부터 5까지 출력하는 것을 생각했을지도 모르지만 위 실행 결과는 6을 5번 출력한다.

그 이유는 var로 선언된 i는 함수 스코프를 따르기 때문에 setTimeout이 1초 기다리는 사이에 6까지 올라가게된다. 1초 뒤에 task queue로 5개의 setTimeout의 콜백 함수가 쌓이게 되고 콜스택이 비워지게 되면 5개의 setTimeout의 콜백 함수가 1초마다 콜스택에 올라오게 되는데 그 당시 참조하는 i의 값은 6이기 때문에 6을 5번 출력하는 것이다.
(task queue를 모른다면setTimeout 비동기 동작)

위 코드에서 var를 let으로 변경하면 처음에 예상한데로 1~5까지 출력된다.

또는

for (var i=1; i<=5; i++){
    (function(j){
        setTimeout(() => {
            console.log(j)
        }, j*1000)
    })(i);
}

이렇게 즉시 실행 함수로 감싸주면 반복문을 돌 때마다 새로운 스코프를 만들어진다. 따라서 하나씩 콜스택에 올라올 때 렉시컬 스코프와 클로저 때문에 이전에 받았던 j를 기억하고 접근할 수 있다.

0개의 댓글