JS #14. 스코프(Scope)
Prog. Langs & Tools/JavaScript

JS #14. 스코프(Scope)

이번 포스팅에서는 자바스크립트의 스코프에 대해서 기본적인 개념을 알아 보려고 한다.

변수의 값은 어디에 저장되는지, 그리고 필요할 때 변수를 어떻게 찾아서 써야 하는지는 프로그래밍에서 중요한 요소이다. 따라서 특정 장소에 변수를 저장하고 나중에 그 변수를 찾는 데는 규칙이 필요하다. 이러한 규칙을 스코프(scope)라고 한다.

자바스크립트를 일반적으로 '동적' 또는 '인터프리터' 언어로 분류하지만 자바스크립트는 사실 '컴파일러' 언어이다. 물론 자바스크립트가 전통적인 많은 컴파일러 언어처럼 코드를 미리 컴파일하거나 컴파일한 결과를 분산 시스템에서 이용할 수 있는 것은 아니다. 하지만 자바스크립트 엔진은 전통적인 컴파일러 언어에서 컴파일러가 하는 일의 상당 부분을 우리가 아는 것보다 더 멋있게(?) 처리한다.

일반적인 컴파일러 언어의 처리과정(컴파일레이션, compilation)은 보통 3단계로 나뉘는데 다음과 같다. (더 복잡하지만 세부적인 내용들은 생략한다)

  • 토크나이징(tokenizing)/렉싱(lexing) : 문자열을 나누어서 토큰이라는 조각으로 나눈다.
  • 파싱(parsing) : 토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형태(AST, 추상 구문 트리)로 바꾸는 과정이다.
  • 코드 생성(code-generation) : AST를 컴퓨터에서 실행 코드로 바꾸는 과정이다.

자바스크립트 엔진이 기존 컴파일러와 다른 점은 자바스크립트 컴파일레이션을 미리 수행하지 않아서 최적화할 시간이 많지 않다는 점이다. 자바스크립트 컴파일레이션은 가능한 빠른 성능을 내기 위한 여러가지 트릭을 사용한다. 그래서 컴파일레이션은 코드 실행 전 수백만분의 일초 전에 수행한다.

 

엔진 & 컴파일러

var bar = 3;

이와 같은 간단한 프로그램을 엔진, 컴파일러가 어떻게 인식하는지 설명해 보려고 한다. 이 프로그램은 얼핏 보면 하나의 구문으로 보인다. 하지만, 엔진은 서로 다른 두 개의 구문으로 본다. 하나는 컴파일러가 컴파일레이션 과정에서 처리할 구문이고, 다른 하나는 실행 과정에서 엔진이 처리할 구문으로 말이다.

먼저 컴파일러가 첫 번째로 할 일은 렉싱(Lexing, 토크나이저가 상태 유지 파싱 규칙을 적용해 bar가 별개의 토큰인지 다른 토큰의 일부인지를 파악)을 통해 구문을 토큰으로 잘게 쪼개는 것이다. 그 후 토큰을 파싱해 트리 구조로 만든다. 그러나 코드 생성 과정에 들어가면 컴파일러는 우리의 추측과 다르게 동작한다. 아마 대부분은 "변수를 위해 메모리를 할당하고 할당된 메모리를 bar라고 명명한 후 그 변수에 값 3을 넣는다."라고 의사코드를 추측했었을 것이다. 실제는 조금 다르다. 컴파일러가 하는 일은 다음과 같다.

  • 컴파일러가 'var bar'를 만나면 스코프에게 변수 bar가 특정한 스코프 컬렉션 안에 있는지 묻는다. 변수 bar가 이미 있다면 컴파일러는 선언을 무시하고 지나가고, 그렇지 않으면 컴파일러는 새로운 변수 bar를 스코프 컬렉션 내에 선언하라고 요청한다.
  • 그 후 컴파일러는 'bar = 3' 대입문을 처리하기 위해 나중에 엔진이 실행할 수 있는 코드를 작성한다. 엔진이 실행하는 코드는 먼저 스코프에게 bar라는 변수가 현재 스코프 컬렉션에서 접근할 수 있는지 확인한다. 가능하면 엔진은 변수 bar를 사용하고, 아니라면 엔진은 다른 곳(중첩 스코프 부분)을 살핀다.

엔진이 변수를 찾으면 변수에 값을 넣고, 변수를 못 찾는다면 에러가 발생했다고 말할 것이다. 정리하면 자바스크립트는 별개의 두 가지 동작을 취하여 변수 대입문을 처리한다. 1. 컴파일러가 변수를 선언(현재 스코프에 변수가 선언되지 않은 경우) 2. 엔진이 스코프에서 변수를 찾고 변수가 있다면 값을 대입.

 

LHS, RHS 검색

컴파일러가 코드를 실행할 때 엔진은 해당 변수가 선언된 적이 있는지를 스코프에서 검색한다. 여기에 두 가지 방법이 있는데 LHS(Left-Hand Side) 검색과 RHS(Right-Hand Side) 검색이 그것이다. 이 방향은 대입 연산의 방향을 말한다. LHS 검색은 변수가 대입 연산자의 왼쪽에 있을 때 수행하고, RHS 검색은 변수가 대입 연산자의 오른쪽에 있을 때 수행한다.(정확하는 RHS는 왼편이 아닌 쪽에 가깝다) LHS는 변수에 값을 대입할 때, RHS는 변수의 값을 얻어올 때 사용한다.

// RHS
console.log(a);

// LHS
a = 2;

// 예제 코드
function foo(a) {
  var b = a;
  return a + b;
}

var c = foo(2);

위의 예제에서 LHS 검색은 c = ..., a = 2, b = ... 이렇게 세 군데에서 쓰였다. 반면 RHS 검색은 foo(2 ..., = a;, a ..., ... b 이렇게 네 군데에서 쓰였다.

 

중첩 스코프

스코프는 앞서 말한 것처럼 변수를 찾기 위한 규칙의 집합이다. 그리고 보통 고려해야 하는 스코프가 여러개인 경우가 많다. 하나의 블록이나 함수는 다른 블록이나 함수 안에 중첩될 수 있으므로 스코프도 다른 스코프 안에 중첩(nested) 될 수 있다. 따라서 대상 변수를 현재 스코프에서 발견하지 못하면 엔진은 다음 바깥의 스코프로 넘어가는 식으로 변수를 찾거나 글로벌 스코프라 부를 가장 바깥 스코프에 도달할 때 까지 계속한다. 중첩 스코프를 탐사할 때 사용하는 규칙은 다음과 같다.

  • 엔진은 현재 스코프에서 변수를 찾기 시작하고, 찾지 못하면 한 단계씩 올라간다.
  • 최상위 글로벌 스코프에 도달하면 변수를 찾았든, 못 찾았든 검색을 멈춘다.

비유를 하자면 마치 고층 호텔에서 한 층씩 모든 방을 찾아다는 것과 비슷하다. 같은 층 안에서 돌아다는 건 LHS/RHS 검색이고 위층으로 올라가는 건 스코프를 한 단계씩 올라가는 것을 의미한다. 그리고 꼭대기 층의 모든 방까지 돌아다녔는데도 못 찾으면 찾기를 중단한다.

LHS와 RHS는 변수가 선언되지 않았을 때 서로 다르게 동작한다는 점에서 구분해 주는 것이 중요하다. RHS 검색이 중첩 스코프 안 어디에서도 변수를 찾지 못하면 엔진이 'Reference Error'를 반환한다. 하지만 LHS 검색은 'Strict Mode'로 동작하는 것이 아니라는 전제하에, 찾는 변수가 없으면 엔진이 검색하는 이름을 가진 새로운 변수를 생성해서 엔진에게 넘겨준다.

그리고 변수를 찾은 후, 함수가 아닌 값을 함수처럼 실행하거나, null 또는 undefined 값을 참조할 때 엔진은 TypeError를 발생시킨다.

 

렉시컬 스코프(Lexical Scope)

스코프는 크게 두 가지 방식으로 작동한다. 첫 번째는 렉시컬 스코프, 두 번째는 동적 스코프이다. 동적 스코프의 경우 Bash Scripting이나 Perl 등의 일부 모드 또는 언어에서만 사용하므로 이번에는 렉시컬 스코프에 대해 집중적으로 알아보려고 한다. 

렉시컬 스코프는 렉싱 타임(Lexing Time)에 정의되는 스코프다. 바꿔 말해 렉시컬 스코프는 프로그래머가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초에서 렉서(lexer)가 코드를 처리할 때 확정된다. 함수를 어디에 선언하였는지에 따라 정의되는 스코프를 의미한다.

function foo(a) {
  var b = a*2;
  function bar(c) {
    console.log(a,b,c);
  }
  bar(b*3);
}
foo(2); // 2, 4, 12

위의 예제에서는 버블이 3개가 있는데 글로벌 스코프, foo 스코프, bar 스코프 이렇게 나누어 볼 수 있다. 여기서 우리는 중첩 버블의 경계가 엄밀하게 나누어져 있다는 사실을 알 수 있다. 어떠한 함수도 동시에 다른 두 스코프 버블 안에 존재할 수가 없다. 

엔진은 스코프 버블의 구조와 상대적 위치를 통해 어디서 검색해야 확인자를 찾을 수 있는지 안다. 예를 들어 변수 c는 foo 스코프와 bar 스코프에 모두 존재하는데 이 경우 console.log() 구문은 가까운 bar()에서 c를 찾아서 사용하고, foo()에는 c를 찾으러 가지 않는다. 스코프는 목표와 일치하는 대상을 찾는 즉시 검색을 중단한다. 따라서 여러 중첩 스코프 층에 걸쳐 같은 확인자 이름을 정의할 수 있는데, 더 안쪽의 확인자가 더 바깥쪽의 확인자를 가리는 것을 섀도잉(shadowing)이라고 한다.

어떤 함수가 어디서 또는 어떻게 호출되는지에 상관없이 함수의 렉시컬 스코프는 함수가 선언된 위치에 따라 정의된다. 만약 코드에서 foo.bar.baz의 참조를 찾는다고 하면 렉시컬 스코프 검색은 foo 확인자를 찾는데 사용되지만, 일단 foo를 찾고 나서는 객체 속성 접근 규칙을 통해 bar와 baz의 속성을 각각 가져온다.

렉시컬 스코프를 런타임에 속일 수 있는 방법은 eval() 함수가 있는데 권장하는 방법은 아니며, 성능을 떨어트린다는 점을 명심하자. 자바스크립트의 eval() 함수는 문자열을 인자로 받아 실행시점에 문자열의 내용을 코드의 일부분처럼 처리한다. 처음 작성한 코드에 프로그램에서 생성한 코드를 집어넣어 마치 처음 작성될 때부터 있었던 것처럼 실행하는 것이다.

function foo(str, a) {
  eval(str); // cheating
  console.log(a, b);
} 
var b = 2;
foo("var b = 3;", 1); // 1, 3

예제에서 문자열 "var b = 3;"은 eval()이 호출되는 시점에 원래 있던 코드처럼 처리된다. 이 코드는 새로운 변수 b를 선언하면서 이미 존재하는 foo()의 렉시컬 스코프를 수정한다. 그러면서 foo() 안에 변수 b를 생성하여 바깥(글로벌) 스코프에 선언된 변수 b를 가리는 것이다.

하지만 앞서 말한 것 처럼 eval()을 사용하면 성능에서 불이익을 감수해야 한다. 자바스크립트 엔진은 컴파일레이션 단계에서 상당수의 최적화 작업을 진행하는데, 이 최적화의 일부분은 렉싱된 코드를 분석하여 모든 변수와 함수 선언문이 어디에 있는지 파악하고 실행 과정에서 확인자 검색을 더 빠르게 하는 것이다. 만약 eval()이 코드에 있다면 엔진은 미리 확인해 둔 확인자의 위치가 틀릴 수도 있다고 가정해야 한다. 엔진은 렉싱 타임일 때 eval()에 어떤 코드가 전달되어 렉시컬 스코프가 수정될 지 정확하게 알 수 없기 때문이다. 따라서 대다수 최적화가 의미 없게 되어 버린다.

 

다음 포스팅에서는 함수/블록 스코프와 호이스팅에 대해서 정리 보도록 한다.

 

참고자료

  1. <YOU DON'T KNOW JS (타입과 문법, 스코프와 클로저)> 카일 심슨 저