JavaScript 코드를 작성하면서 알아야 하는 기초 개념 중 하나로 스코프가 있다고 생각했다.
스코프에 대한 개념이 확실해야 다른 개념을 배울 때 도움이 될 것이라고 생각이 들었고,
모든 언어에 적용할 수 있는 개념이라 이번 기회에 정리를 하면 좋을 것이라 생각했다.
학습에 참고한 자료는 모던 자바스크립트 Deep Dive 책을 참고하였다.
모든 변수 이름, 함수 이름, 클래스 이름 등은 자신이 선언된 위치에 의해 다른 코드가 식별자 자신을 참조할 수 있는 유효 범위가 결정되고 이를 스코프라고 한다.
라고 주로 설명이 되어있다.
하지만 이 말만 보고는 확실하게 이해가 되는 것은 아니다. 한번 예제를 보면서 알아보도록 한다.
var var1 = 1;
if(true) {
var var2 = 2;
if(true) var var3 = 3;
}
function foo() {
var var4 = 4;
function bar() {
var var5 = 5;
}
}
console.log(var1); // => 1
console.log(var2); // => 2
console.log(var3); // => 3
console.log(var4); // => ReferenceError
console.log(var5); // => ReferenceError
다음과 같은 예제를 보면 어떤 것은 출력이 잘 동작하고 어떤 것은 출력이 잘 나오지 않는 것을 알 수 있다.
좀 더 살펴보면 함수 안에 있는 변수들만 출력이 안나오는 것을 알 수 있는데 이는 var 에 특성에 의해서 그런 것으로, 자바스크립트에서 모든 코드 블록(if, for, while, try/catch 등)이 지역 스코프를 만들며, 이러한 특성을 블록 레벨 스코프라 한다. 하지만 var 키워드로 선언된 변수는 오로지 함수의 코드 블록만을 지역 스코프로 인정한다. 이를 함수 레벨 스코프라 한다.
이 내용은 지금 다룰거는 아니지만 위에서 대략 느낄 수 있는 것을 정리하면
’아! 변수를 어디서 선언하는가에 따라서 못 쓸 수 있는 위치가 있구나! 이게 스코프 때문이구나!’
정도만 느끼고 넘어가면 될 것 같다. 다른 예제로 또 살펴보자
var x = 'global';
function foo() {
var x = 'local';
console.log(x);
}
foo(); // => local
console.log(x); // => global
동일한 console.log(x) 이지만 출력이 다른 것을 볼 수 있다. 어떠한 내부 동작이 이루어지고 있길래 이렇게 동작하는 것일까?
자바스크립트 엔진은 이름이 같은 두 개의 변수 중에서 어떤 변수를 참조해야 할 것인지를 결정해야 할 것이다. 이러한 과정을 식별자 결정이라고 한다. 자바스크립트 엔진은 스코프를 통해서 어떤 변수를 참조해야 할 것인지 결정한다. 따라서 스코프란 자바스크립트 엔진이 식별자를 검색할 때 사용하는 규칙이라고 볼 수 있다.
자바스크립트 엔진은 코드를 실행할 때 코드의 문맥을 고려한다.
코드가 어디서 실행되며 주변에 어떤 코드가 있는지에 따라 위 예제 코드 처럼 동일한 코드로 다른 결과를 만들어낸다.
한마디로 첫 번째 줄에 있는 x 변수의 범위와 foo 함수 내부에서 선언된 x 변수의 범위는 다르다는 것이고 이를 스코프라고 하는 것이다. 간단하게 생각해보면 우리는 동일 폴더에는 같은 이름의 파일을 생성할 수 없지만, 다른 폴더로 가면 같은 이름의 파일을 생성할 수 있다는 것을 알고있고, 이와 비슷한 일이 자바스크립트 내부에서도 일어나고 있는 것이다.
대략적으로 스코프라는 개념이 머리에 희미하게 생겼으니 종류에 대해서 알아보도록 한다.
코드는 전역과 지역으로 구분할 수 있다.
변수는 자신이 선언된 위치에 의해 자신의 유효한 범위인 스코프가 결정이 된다. 즉 전역에서 선언된 변수는 전역 스코프를 가지게 되는 전역 변수이고, 지역에서 선언된 변수는 지역 스코프를 갖는 지역 변수다.
전역 변수와 지역 변수의 특징을 알아보기 위해서 예제 코드를 살펴보면 아래와 같다.
var x = 'global x';
function outer() {
var y = 'outer y';
console.log(x); // => global x
console.log(y); // => outer y
function inner() {
var z = 'inner x';
console.log(x); // => global x
console.log(y); // => outer y
console.log(z); // => inner z
}
inner();
}
outer();
console.log(x); // => global x
console.log(y); // => ReferenceError
console.log(z); // => ReferenceError
결과에 대해서 살펴본다.
일단 전역으로 선언된 global x 에 대해서는 어느 공간이든지 출력이 잘 나오는 것을 확인 할 수 있다. 이는 전역 변수의 특징으로 어디서든 참조가 가능한 것을 볼 수 있다.
이번에는 outer y를 살펴보면 outer 와 inner 에서만 접근이 가능 한 것을 볼 수 있다. 이는 지역 변수의 특징으로 지역 변수는 자신의 지역 스코프와 하위 지역 스코프에서 유효하다는 것을 볼 수 있다.
정리를 하자면
이렇게 할 수 있을 것 같다.
그러면 여기서 드는 생각은 어떻게 함수 내부에서 전역 변수에 접근하는 것이 가능하고, 지역 변수는 어떻게 본인의 하위 지역 스코프에서도 접근이 가능한지에 대한 궁금증이 생긴다. 이를 다음 토픽인 스코프 체인에서 알아본다.
위에서 본 예제와 같이 함수는 중첩될 수 있다. 이렇게 함수가 중첩이 될 수 있기에 스코프도 중첩이 될 수 있다. 이는 스코프가 함수의 중첩에 의해 계층적 구조를 가진다는 것을 말한다. 이렇게 말로만 하니 어려워 보이는 것 같아서 모던 자바스크립트 Deep Dive 에서 참고자료로 올려준 그림과 같이 알아본다.
앞서서 본 outer, inner 예제의 구조를 대략적으로 그린 것이다. 코드를 약간 줄였기 때문에 몇몇 변수들은 없거나 다를 수 있다. 앞서서 중첩될 수 있다고 언급을 했었는데 위 그림처럼 계층 구조로 스코프가 설정이 되는 것을 볼 수 있다. 이때 외부 함수의 지역 스코프를 중첩 함수의 상위 스코프라고 한다. 또한 outer 함수의 지역 스코프의 상위 스코프는 전역 스코프인 것을 볼 수 있다.
이처럼 모든 스코프는 하나의 계층적인 구조로 연결되며, 모든 지역 스코프의 최상위 스코프는 전역 스코프이다. 이렇게 스코프가 계층적으로 연결된 것을 스코프 체인이라고 한다.
변수를 참조할 때 자바스크립트 엔진은 스코프 체인을 통해 변수를 참조하는 코드의 스코프에서 시작해서 상위 스코프 방향으로 이동하며 선언된 변수를 검색한다. 이러한 방법을 통해서 상위 스코프에서 선언한 변수를 하위 스코프에서도 참조할 수 있는 것이다.
또한 스코프 체인은 물리적인 실체로 존재한다. 자바스크립트 엔진은 코드를 실행하기에 앞서 위 그림과 유사한 자료구조인 렉시컬 환경을 실제로 생성한다. 변수 선언이 실행되면 변수 식별자가 이 자료구조에 키로 등록되고, 변수 할당이 일어나면 이 자료구조의 변수 식별자에 해당하는 값을 변경한다. 변수의 검색도 이 자료구조 상에서 이루어진다.
지역 스코프가 어떻게 혹은 어떠한 상황에서 생성되는지 살펴본다.
대부분의 프로그래밍 언어는 함수 몸체만이 아니라 모든 코드 블록, 예를 들어서 if, for, while, try/catch 등 에서도 지역 스코프를 생성한다. 이러한 특성을 블록 레벨 스코프라고 한다. 하지만 var 키워드로 선언된 변수는 앞서 살펴봤듯 오로지 함수의 코드 블록만을 지역 스코프로 인정한다. 이러한 특성을 함수 레벨 스코프이다.
이로 인해서 문제가 발생했고 이를 해결하기 위해서 ES6 에서 let 과 const 가 추가된 것이다. 이를 코드로 봐보자
var i = 10;
if(true) i = 20;
console.log(i) // => 20
이처럼 if 나 for 등에서 밖에 있는 값을 건드릴 수 있다는 것이 문제로 등장했다. 이를 의도했으면 문제가 아니였겠지만 이걸 의도하고 짠 사람은 없을 것이다. 이러한 문제를 해결해주는 let 과 const 는 블록 레벨 스코프를 지원하는데 이는 차후 살펴보도록 한다.
코드와 같이 살펴본다.
var x = 1;
function foo() {
var x = 10;
bar();
}
function bar() {
console.log(x);
}
foo();
bar();
이 예제의 결과를 예측해본다. 크게 두가지 접근이 가능할 것 같은데
1번 방식으로 상위 스코프를 결정한다면 bar 함수의 상위 스코프는 foo 함수의 지역 스코프와 전역 스코프일 것이고, 2번 방식으로 함수의 상위 스코프를 결정한다면 bar 함수의 상위 스코프는 전역 스코프일 것이다. 프로그래밍 언어에서는 이 두 가지 방식 중 한가지 방식으로 함수의 상위 스코프를 결정한다.
첫 번째 방식을 동적 스코프라고 한다. 함수를 정의하는 시점에는 함수가 어디서 호출될 지 알 수 없다. 따라서 함수가 호출되는 시점에 동적으로 상위 스코프를 결정해야 하기 때문에 동적 스코프라고 부른다.
두 번째 방식은 렉시컬 스코프 또는 정적 스코프라고 한다. 변하지 않고 함수 정의가 평가되는 시점에서 상위 스코프가 정적으로 결정되기 때문에 정적 스코프라고 부른다. 자바스크립트를 비롯한 대부분의 프로그래밍 언어는 렉시컬 스코프를 따른다.
자바스크립트는 두 번째 방식을 따른다. 따라서 함수를 어디서 호출했는지가 아니라 함수를 어디서 정의했는지에 따라 상위 스코프를 결정한다. 함수가 호출된 위치는 상위 스코프 결정에 어떠한 영향도 주지 않는다. 즉, 함수의 상위 스코프는 언제나 자신이 정의된 스코프이다.
그러면 저 결과를 예측해보자. bar 함수는 전역에서 정의된 함수이므로 상위 스코프로 전역 스코프만 가지게 된다. foo 에서 호출을 할 때 bar 함수는 본인의 내부를 확인하고 이후 상위 스코프인 전역 스코프로 이동해서 x를 찾을 것이다. 따라서 위 결과는 모두 1 이 나올 것이다.
코드를 짜면서 대략적으로 그냥 몸으로만 느끼던 것을 나름은 학습을 하면서 정리를 한 것 같아 마음이 편하다. 또한 이 개념을 알아야 실행 컨텍스트 같은 상위 개념들을 이해할 수 있었는데 자바스크립트의 심연? 에 첫 발걸음을 내딛은 것 같아서 나름은 기분이 좋다. 다음에 더 깊은 내용을 정리해보려고 한다.