개요
이번 글에서는 JavaScript 언어의 핵심 개념 중 하나인 스코프(Scope)에 대해 알아본다.
스코프(Scope) 란?
오늘날 대부분의 프로그래밍 언어는 변수를 선언하여 값을 저장하고 필요한 경우 참조할 수도 있다. 이처럼 변수는 메모리 상에 할당된 공간을 나타내는 추상적인 개념이자 프로그래밍 언어가 제공하는 핵심 기능 중 하나이다.
이렇게 선언된 변수는 메모리 상에 할당되어 있지만, 일반적으로 프로그래밍 언어를 통해 코드를 작성할 때에는 변수가 어느 메모리 위치에 존재하는지 확인이 필요한 경우는 매우 드물다. 왜냐하면 메모리 공간을 추상화한 것이 변수이고, 우리는 이를 제대로 식별할 수 있기만 하면 되기 때문이다.
보다 추상화 수준이 올라간 "프로그래밍 언어" 수준에서 생각해 보자.
프로그램에서 변수를 선언해 두었고, 이를 참조하려고 한다. 선언한 변수는 프로그램 상에서 어떻게 찾아내는 것일까?
두 가지 모두 비슷한 이야기 일 수 있지만 실제 메모리 주소(저수준)의 위치는 우리가 알 필요가 없지만, 프로그램 상에서 어느 위치(고수준)에 선언된 변수를 찾게 될지에 대한 부분은 꽤나 중요하다.
결국 프로그래밍 언어를 통해 메모리 공간이 추상화되어있더라도 어느 위치에 선언된 변수에 접근할지에 대한 고민은 필요하다. 이처럼 선언된 변수를 찾기 위한 규칙을 스코프(Scope)라고 한다.
지금부터 예제 코드를 확인하며 스코프에 대해 살펴보도록 하자.
전역 스코프(Global Scope)
전역에 변수를 선언하면 어디서든 접근 가능한 전역 스코프 범위에 선언된다. 함수나 블록이 아닌 곳에서 선언한 변수가 이에 해당한다.
if (true) {
var value = 0;
}
console.log(value);
value
라는 변수가 if 문 블록 내부에 선언되어 있다. var
키워드로 선언된 변수는 함수 레벨 스코프를 따르며 예제에서는 함수 내에 선언되지 않았지만, 이런 경우에는 기본적으로 전역 스코프에 변수가 선언된다.
함수 레벨 스코프(Function-level Scope)
함수 레벨 스코프는 이름과 동일하게 함수 수준의 스코프를 의미한다.
var
키워드를 통해 선언한 변수는 함수 레벨 스코프를 따르게 되는데, 전역 스코프에서 살펴본 예제 코드의 value
변수도 함수 레벨 스코프를 따르고 있다.
함수 레벨 스코프에 대해 조금 더 알아보기 위해 아래 코드를 추가로 살펴보자.
var globalValue = 0;
function myFunc() {
var localValue = 1;
console.log(globalValue);
console.log(localValue);
}
myFunc();
console.log(globalValue);
console.log(localValue);
전역 스코프(Global Scope)와 함수 레벨 스코프(Function-level Scope)를 시각적으로 나타낸 그림이다. myFunc
함수의 스코프는 지역 스코프(Local Scope)라고 부르기도 한다.
코드를 살펴보면 전역에 선언한 globalValue
변수와 함수 내부에서 선언한 localValue
변수가 있다.
함수 스코프에는 globalValue
변수가 선언되어있지 않지만, 정상적으로 접근할 수 있다. 하위 스코프에서는 상위 스코프레 접근할 수 있는데, globalValue
변수는 전역 스코프에 선언되어 있으므로 문제없이 접근이 가능하다.
함수 외부에서는 함수 내부에서 선언한 localValue
변수에 접근할 수 없어 에러가 발생하는데, 이는 상위 스코프에서 하위 스코프에 접근할 수 없는 스코프의 특징 때문이다. 즉, 하위 스코프에서는 상위 스코프에 접근이 가능하지만 반대의 경우에는 불가능하다는 의미이다.
아래 예제 코드를 추가로 확인해 보자.
var value = 0;
function myFunc() {
var value = 1;
console.log(value);
}
myFunc();
console.log(value);
전역에 선언된 value
변수가 있고, 함수 내에 선언된 value
변수가 있다.
변수 이름이 동일한데, 코드 실행 결과를 보면 동일한 식별자(여기서는 변수의 이름)에 접근을 시도할 경우 가장 인접한 스코프를 우선 참조하는 것을 확인할 수 있다.
이러한 동작 매커니즘을 섀도잉(Shadowing) 이라고 하는데, 지금처럼 상위 스코프의 식별자자 하위 스코프의 식별자에 가려지는 것을 의미한다.
만약 인접한 스코프에 찾고자 하는 식별자가 존재하지 않을 경우에는 상위 스코프로 이동하여 다시 탐색을 하게 된다. 이 과정은 식별자를 찾을 때 까지 반복되며, 전역 스코프에 도달하면 종료된다. 전역 스코프까지 반복하여 탐색했으나, 찾지 못할 경우에는 ReferenceError 가 발생한다.
지금까지 살펴본 예제 코드를 통해 알 수 있는 추가적인 사실은 스코프는 중첩이 가능하다는 것이다. 이는 스코프의 특징 중 하나이며, 인접한 스코프부터 최상위 스코프인 전역 스코프까지 탐색을 이어나가는 것을 스코프 체이닝(Scope Chaining)이라고 한다.
블록 레벨 스코프(Block-level Scope)
앞서 var
키워드로 선언한 변수는 함수 레벨 스코프를 따른다는 것을 배웠다.
ES6에 추가된 let
, const
키워드를 통해 선언한 변수는 블록 레벨 스코프를 따른다.
블록이란, 중괄호({}
)로 묶여진 영역을 의미하며 아래와 같이 if
, for
문 등에서 사용하는 중괄호가 여기에 포함된다. 또한 자바스크립트에서는 5~7행처럼 아무런 구문 없이 중괄호만 사용해도 블럭 범위로 취급한다.
블럭에 대한 이해는 여기까지 하고, 아래 예제 코드를 살펴보자.
if (true) {
let value = 0;
console.log(value);
}
console.log(value);
코드 실행 결과를 보면 if
문 블록 내에 선언된 value
변수는 블록 내부에서는 유효하지만, 블록 바깥쪽에서는 접근이 불가하여 에러가 발생한다.
아래 그림을 보면 함수 레벨 스코프와 블록 레벨 스코프가 동작하는 방식의 차이를 쉽게 이해할 수 있을 것이다.
위 두 개의 예제 코드는 함수 레벨 스코프를, 아래 예제 코드는 블록 레벨 스코프를 시각적으로 나타낸 그림이다.
함수 레벨 스코프는 말 그대로 "함수" 를 기준으로 범위를 갖고, 블록 레벨 스코프는 "블록" 을 기준으로 범위를 갖게 된다.
마지막으로 예제 코드를 하나 더 살펴보자.
var res = {};
for (var i = 0; i < 10; i++) {
Object.defineProperty(res, i, {
get() {
return i;
},
});
}
res[0];
res[5];
res[9];
해당 코드를 실행하면 어떤 값들이 출력될지 예상해보자.
코드의 의도대로라면 접근한 키 값과 동일한 0, 5, 9 가 출력될 것으로 기대된다. 하지만 코드를 실행해보면 10이 3번 출력된다. 무슨 문제 때문일까?
반복은 0부터 i < 10
까지 즉, 10보다 작을 때 까지만 반복하고, 그 이외의 조건에서는 반복을 종료하게 된다. (10보다 작지 않은 10이 되었을 때 반복 종료)
i
변수는 var
키워드로 선언되었고, 함수 레벨 스코프를 따르고 있기 때문에 최종적으로 반복이 끝나는 시점에는 10 이라는 값을 지니게 된다.res
에 정의된 getter 를 통해 i
값을 꺼내보면 전역 스코프에 선언된 i
변수의 값인 10이 반환된다.
그러면 블록 레벨 스코프를 따르도록 코드를 변경했을 때 의도한대로 동작하는지 한 번 확인해보자.
함수 레벨 스코프를 따르는 var
키워드 대신 블록 레벨 스코프를 따를 수 있도록 let
키워드로 코드를 수정했다.
실행시켜보면 의도한 것처럼 0, 5, 9 가 정상적으로 출력되는 모습을 확인할 수 있다.
이는 변수 i
가 블록 레벨 스코프를 따르기 때문인데, 매 반복마다 개별적인 블록 수준의 스코프가 생성되고, 여기에 i
값이 할당되기 때문에 반복이 끝난 시점이 아닌 반복 중인 시점의 값을 유지할 수 있기 때문이다.
스코프는 일반적으로 두 가지 방식으로 동작하는데, 자바스크립트는 이 중 첫 번째 방식을 따른다.
- 렉시컬 스코프 (Lexical Scope)
- 동적 스코프 (Dynamic Scope)
렉시컬(Lexical)의 사전적 정의는 "어휘" 라고 하며, 작성한 코드 그리고 컴파일레이션 과정과 밀접한 관계가 있다.
렉시컬 스코프에 대해 알아보기 전에, 우리가 작성한 코드가 실제로 엔진에서 실행되기까지 거치게 되는 과정을 우선 간략히 살펴보도록 하자.
컴파일레이션(Compilation)
자바스크립트는 흔히 동적 언어 혹은 인터프리터 언어라고 하지만 사실은 컴파일러 언어라고 볼 수 있다.
전통적인 컴파일러 언어와 동일한 방식으로 컴파일 되는것은 아니지만, 코드 실행 직전에 컴파일 과정을 거친다.
이 일련의 과정을 컴파일레이션(Compilation)이라고 하고, 자바스크립트 엔진에서는 아래와 같은 과정들을 거친다.
토큰화(Tokenizing)
우리가 작성한 소스코드는 단순한 문자열에 불과하다. 문자열을 구문 분석하고 의미를 부여하기 전에 문자열을 토큰(Token)이라는 조각으로 나누는데 이 과정을 토큰화 라고 한다.
아래와 같은 코드를 작성했다고 하자. 해당 코드는 토큰화를 거쳐 개별 토큰으로 분리된다.
렉싱(Lexing)
렉싱은 토큰화와 밀접하게 연관되어있다. 앞서 잘게 나눠진 토큰에 의미를 부여하는 과정이 렉싱(Lexing)이다.
토큰이 의미하는 바가 무엇인지 렉싱 과정을 통해 정리된다.
파싱(Parsing)
토큰화 & 렉싱을 통해 의미가 부여된 토큰의 집합을 프로그래밍 언어의 문법 구조에 따라 구문 분석을 진행하게 된다. 구문 분석을 통해 트리 형태로 변환하는 과정을 파싱(Parsing)이라고 한다.
본 과정을 통해 생성된 트리는 흔히 이야기 하는 추상 구문 트리(AST, Abstract Syntax Tree)라고 한다.
아래는 추상 구문 트리를 시각적으로 나타낸 그림이다.
코드 생성(Code Generation)
추상 구문 트리를 실행 가능한 코드(기계어)로 변환하는 과정을 의미한다.
이 과정은 실행하는 환경(엔진)에 따라 다르게 처리되며, 저수준의 영역에서 처리되기 때문에 개발자가 관여할 일은 거의 없다.
이러한 과정을 거친다는 것 정도만 짚고 넘어가도록 하자.
최적화(Optimization)
지금까지 살펴본 과정 외에도 자바스크립트 엔진에서는 코드 실행 전 여러가지 최적화를 진행한다. 엔진에 따라 지원하는 최적화 기법이 조금씩 다르겠지만 코드 실행 속도를 향상시키는 JIT(Just In Time) 컴파일레이션 과정을 지원하는 등 여러가지 최적화 과정이 아주 빠른 속도로 수행된다.
지금까지 살펴본 내용을 정리하자면 비록 자바스크립트는 전통적인 컴파일 언어처럼 미리 컴파일레이션 과정을 거치지는 않지만, 실행 직전에 컴파일레이션과 최적화 과정을 아주 빠른 속도로 처리(인지하지 못할 정도로 엄청 빠르다)하여 코드를 실행 가능한 형태로 준비한다는 점이다.
렉시컬 스코프(Lexical Scope)
컴파일레이션 과정에 대해 간략히 살펴보았다.
렉시컬 스코프와 컴파일레이션은 도대체 무슨 관계가 있을까?
렉시컬 스코프는 컴파일레이션 중 렉싱(Lexing) 과정에서 결정되는 스코프이다.
쉽게 설명하자면 우리가 작성한 코드에서 변수를 어떻게 그리고 어디서 선언하고 사용하는가에 따라 결정된다.
아래 예제 코드를 보며 렉시컬 스코프의 개념에 대해 살펴보자.
var value = 0;
function foo() {
var value = 1;
console.log('foo', value);
bar();
}
function bar() {
console.log('bar', value);
}
foo();
코드를 실행시켜보면 foo 1
, bar 0
이 콘솔에 출력된다.
전역에 선언된 value
변수가 있고 0이 할당되어있다.
foo
함수가 호출되고 foo
함수에서 동일한 이름의 식별자를 가진 value
변수를 선언하고 1을 할당했기 때문에 인접한 스코프(foo
의 스코프)의 값인 1 이 출력된다.
bar
함수에서는 value
를 어느 스코프에서 찾게 될까? foo
함수에서 bar
함수를 호출했으니 아래와 같이 foo
함수 스코프를 따르게 되는 것일까?
렉시컬 스코프는 우리가 작성한 코드를 기준으로 결정된다고 이야기 했다.
이 말을 머릿속에 각인 시키고 다시 코드를 살펴보면, 비록 bar
함수는 foo
에서 호출되었지만 코드 상으로 foo
함수와 bar
함수는 전역 스코프에 선언 되어있다. 즉, 둘 다 동등한 수준에 위치하고 있다.
foo
와 bar
는 개별적인 스코프를 갖고 있고, 상위에는 전역 스코프가 위치한 상태이다. 그렇기 때문에 코드를 실행시켜보면 bar
함수에서는 전역 스코프의 value
를 참조하게 되고 해당 변수의 값인 0이 출력된다.
정리하자면 자바스크립트는 렉시컬 스코프를 따르고, 우리가 작성한 코드를 기준으로 스코프를 정의하게 된다. 이 과정은 컴파일레이션 중 렉싱 과정에서 처리된다.
마무리하며
지금까지 자바스크립트의 핵심 개념인 스코프에 대해 알아보았다.
스코프는 다른 개념들을 이해하기 위해 숙지하고 있어야 하는 개념 중 하나이다.
다음 글에서는 호이스팅(Hoisting)과 클로저(Closure) 그리고 this 와 실행 컨텍스트(Execution Context)에 대한 내용을 정리해보도록 하겠다.