스코프와 스코프 체인, 렉시컬 스코프, 클로저

스코프란?

스코프란 식별자의 유효 범위이다. 자신이 어디서 + 어떻게 선언되었는지에 따라 스코프가 결정된다.

자바스크립트에서 변수(식별자)를 선언하는 방식은 세 가지 var, let, const가 있다. var은 함수 스코프를 따르며 이 말은 즉슨 자신이 선언된 곳과 가장 가까운 함수를 유효 범위로 가진다는 의미이다. let과 const는 블록 스코프를 따르는데, 자신이 선언된 곳과 가장 가까운 블록을 유효 범위로 가진다.

var x = 0;
{
  var x = 1;
  console.log(x); // 1
}
console.log(x);   // 1

let y = 0;
{
  let y = 1;
  console.log(y); // 1
}
console.log(y);   // 0
출처: https://poiemaweb.com/js-scope


let으로 선언한 변수 y를 찍는 실행 결과는 당연하게 보였지만, var로 선언한 x를 찍는 두 번째 console.log 의 실행 결과는 순간적으로 헷갈릴 때가 있다. var은 함수 스코프를 따르기 때문에 첫 번째로 선언된 x와 두 번째로 선언된 x 모두 메인 함수의 전역 공간에 선언되어 있는 것 이다. 두 번째로 선언된 x가 값을 덮어 씌운 것.

그 외에도 var은 재선언과 재할당이 모두 가능, let은 재할당만 가능, const는 재선언과 재할당이 모두 불가능하다. var은 ES6 등장 이후 더이상 쓰지 않으며 변수에 저장된 값이 변경되는 값이라면 let을, 저장만하고 사용하는 값이라면 const를 이용해 선언한다.

(부록) var의 저주 : a.js 파일에서 var i = 0; 으로 선언하고, b.js 파일에서 var i = 1로 선언했다고 해보자. 각각의 파일을 단독적으로 사용한다면 문제가 없겠지만 index.html에서 a.js와 b.js를 같이 로드해서 사용한다면? 변수 i는 같은 전역 공간에 선언된 것으로 나중에 로드된 b.js에 의해 a.js에 있는 변수 i의 값은 1로 변경된다. 따라서 a.js에서 변수 i를 사용하는 코드가 내 의도와 다르게 동작할 수 있다.


스코프 체인이란?

자바스크립트 엔진은 식별자를 찾을 때 일단 자신이 속한 스코프에서 찾고 그 스코프에 식별자가 없으면 상위 스코프에서 다시 찾아 나간다. 이 현상을 스코프 체인 이라고 하며 스코프가 중첩되어있는 모든 상황에서 발생한다.

var x = 1;

function foo(){
    console.log(x); // -> 현재 자신의 스코프(foo 함수 내부)에 x가 없지만 상위 스코프인 전역에서 x를 찾는다.
}

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

이 원리에 대해 조금 더 자세히 알고 싶을 때는 실행 컨텍스트 키워드를 조금 더 살펴보자.


렉시컬 스코프란?

개인적으로 가장 헷갈리는 부분은 렉시컬 스코프이다. 만약 함수가 중첩되어 있을 때, 앞서 설명한 스코프 체인때문에 내부 함수에 찾는 식별자가 없다면 상위 스코프에서 식별자를 찾아 나간다. 아래 예시 코드의 실행 결과를 예측해보자.

function outer(){
    var x = 1;

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

    inner();
}

outer(); // 1

outer();의 실행 결과는 1이다. outer 함수 내부에서 inner 함수를 호출하는데, inner함수에는 x가 없기 때문에 상위 스코프인 outer함수에서 x를 찾는다.

여기서 inner함수의 상위 스코프가 outer함수라는 것은 어떻게 결정된 것일까? inner함수가 outer함수 내부에 선언되어 있기 때문일까 아니면 inner함수를 호출한 곳이 outer함수 내부이기 때문일까

함수의 상위 스코프를 결정하는데에는 두 가지 방법이 있다. 첫 번째 방법은 동적 스코프함수가 어디서 호출했는지에 따라 상위 스코프를 결정하고, 두 번째 방법은 렉시컬 스코프함수가 어디서 선언되었는지에 따라 상위 스코프를 결정한다. 아래의 예시에서 동적 스코프라면 foo()의 실행 결과는 10이고, 렉시컬 스코프라면 foo()의 실행 결과는 1이다.

var x = 1;

function foo() {
  var x = 10;
  bar();
}

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

foo(); // 10? 1?
bar(); // 1
출처: https://poiemaweb.com/js-scope


실행해보면 렉시컬 스코프를 따른다는 것을 확인할 수 있다. bar함수를 호출한 곳은 foo함수 내부였지만, bar함수가 선언된 곳은 전역이기 때문에 x는 전역에 선언된 1의 값을 가진다.


클로저란?

function outer(){
	let name = 'michelle';

	function inner(){
		console.log(`hello! ${name}`);
	}

	inner();

	return inner;
}

let greeting = outer();
greeting();

outer함수 내부에서 inner 함수를 호출했을 때, 렉시컬 스코프에 따라서 inner함수의 상위 스코프는 outer함수 이다. 따라서 outer함수에 있는 name 변수에 접근할 수 있고 hello! michelle을 찍을 수 있다.

greeting 변수에는 outer함수의 리턴값인 inner함수가 담긴다. outer함수는 이미 종료되어 콜스택에서 빠져 나갔는데, greeting()을 실행해보면 여전히 name 변수에 접근해 hello! michelle을 찍는 것을 확인할 수 있다. 이처럼 어떤 함수를 렉시컬 스코프 밖에서 호출해도, 원래 선언되었던 렉시컬 스코프를 기억하고 접근할 수 있도록 하는 특성클로저라고 한다.

  • 예시
function hello(name){
  setTimeout(() => {
    console.log(`hello, ${name}!`);
  }, 1000)
}

hello('michelle')

위의 첫 번째 예시를 실행해보면 우리가 기대했던 것과 같이 1초 후에 hello, michelle!이 실행된다. 콜 스택에 hello, setTimeout 함수가 순차적으로 올라가고 setTimeout의 콜백 함수는 비동기로 처리되기 때문에 event queue에 들어간다. 콜스택이 비었을 때 이 콜백 함수가 콜스택에 올라가서 실행되는데, 이 당시에 hello 함수는 종료되었지만 콜백 함수는 여전히 클로저 때문에 hello 함수 내부에 있던 name에 접근할 수 있다.

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

두 번째 예시의 실행 결과는 1초 간격으로 6을 5번 출력한다. 1초 간격으로 1부터 5을 5번 출력하는 것을 기대했을 수도 있지만 var로 선언한 변수는 함수 스코프를 따르기 때문에 전역에 선언하는 것과 같다. setTimeout의 콜백 함수가 콜스택에 올라왔을 당시에 참조하는 i의 값6으로 6을 5번 출력하게 되는 것이다. var을 let으로 변경하여 변수 i의 스코프를 블록 범위로 변경해준다면 우리가 예상했던 것처럼 동작한다. 아래에서 var을 사용하면서도 예상처럼 실행되는 코드를 살펴보자.

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

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


참조

  • https://poiemaweb.com/js-scope

Table of contents